From b88593af9edbf892e6934ae9503e0e1723bc9510 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 14 Jun 2026 13:04:34 +0000
Subject: [PATCH 1/2] Add autocomplete traits and bootstrap5 component
scaffolding
---
README.md | 75 +++
.../assets/sass/ui/_autocomplete-create.scss | 45 ++
.../bootstrap5/assets/sass/ui/core.scss | 2 +-
.../bootstrap5/assets/scripts/admin/app.js | 2 +
.../admin/components/AutocompleteCreate.js | 428 ++++++++++++++++++
.../item-form/fields/autocomplete_create.php | 94 ++++
.../Traits/HasAutocompleteCreateTrait.php | 182 ++++++++
.../Traits/HasAutocompleteSearchTrait.php | 190 ++++++++
.../Traits/HasAutocompleteCreateTraitTest.php | 82 ++++
.../Traits/HasAutocompleteSearchTraitTest.php | 71 +++
10 files changed, 1170 insertions(+), 1 deletion(-)
create mode 100644 resources/themes/bootstrap5/assets/sass/ui/_autocomplete-create.scss
create mode 100644 resources/themes/bootstrap5/assets/scripts/admin/components/AutocompleteCreate.js
create mode 100644 resources/themes/bootstrap5/views/abstract/modules/item-form/fields/autocomplete_create.php
create mode 100644 src/Library/Controllers/Traits/HasAutocompleteCreateTrait.php
create mode 100644 src/Library/Controllers/Traits/HasAutocompleteSearchTrait.php
create mode 100644 tests/src/Library/Controllers/Traits/HasAutocompleteCreateTraitTest.php
create mode 100644 tests/src/Library/Controllers/Traits/HasAutocompleteSearchTraitTest.php
diff --git a/README.md b/README.md
index 72600bc..cd743c3 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,81 @@ The default modern color palette includes:
- **Warning**: Amber (#f59e0b)
- **Danger**: Modern red (#ef4444)
+## Autocomplete + Create Field (Bootstrap 5)
+
+This package includes reusable backend traits and a Bootstrap 5 field partial for "search and create if missing" flows.
+
+### 1) Controller traits
+
+Use one or both traits from:
+
+- `ByTIC\AdminBase\Library\Controllers\Traits\HasAutocompleteSearchTrait`
+- `ByTIC\AdminBase\Library\Controllers\Traits\HasAutocompleteCreateTrait`
+
+You must implement:
+
+- `autocompleteSearchFetchRecords(string $query, int $limit): iterable`
+- `autocompleteCreatePersistRecord(string $name): mixed`
+
+Optional extension points:
+
+- duplicate lookup: `autocompleteCreateFindDuplicateRecord(string $name): mixed`
+- request parameter names (`q`, `limit`, `name`)
+- limits, error messages, and record formatting
+
+### 2) Response contract
+
+Search success:
+
+```json
+{
+ "success": true,
+ "data": [{"id": 1, "label": "Example"}],
+ "meta": {"query": "exa", "limit": 10, "count": 1}
+}
+```
+
+Create success:
+
+```json
+{
+ "success": true,
+ "data": {"id": 1, "label": "Example"},
+ "meta": {"created": true}
+}
+```
+
+Error:
+
+```json
+{
+ "success": false,
+ "error": {"code": "duplicate", "message": "...", "details": {}}
+}
+```
+
+### 3) View partial
+
+Use field partial:
+
+- `/abstract/modules/item-form/fields/autocomplete_create`
+
+Expected variables:
+
+- required: `field`, `searchUrl`
+- optional: `createUrl`, `selectedId`, `selectedLabel`, `label`, `placeholder`, `minChars`, `limit`, `required`, `readonly`
+
+### 4) Frontend behavior
+
+The Bootstrap 5 admin bundle now includes an autocomplete component that:
+
+- debounces search requests
+- renders results dropdown
+- supports keyboard navigation and selection
+- shows inline "create new" when no results
+- creates and selects new record via AJAX
+- handles Turbo navigation lifecycle cleanup
+
#### UI INSPIRATION
https://coreui.io/
https://github.com/coreui/coreui-free-bootstrap-admin-template
diff --git a/resources/themes/bootstrap5/assets/sass/ui/_autocomplete-create.scss b/resources/themes/bootstrap5/assets/sass/ui/_autocomplete-create.scss
new file mode 100644
index 0000000..63086ae
--- /dev/null
+++ b/resources/themes/bootstrap5/assets/sass/ui/_autocomplete-create.scss
@@ -0,0 +1,45 @@
+.btc-autocomplete-create-field {
+ .btc-autocomplete-create-menu {
+ max-height: 260px;
+ overflow-y: auto;
+ margin-top: .125rem;
+ border: 1px solid var(--bs-border-color);
+ box-shadow: 0 .5rem 1rem rgba(0, 0, 0, .12);
+ }
+
+ .btc-autocomplete-create-option.active,
+ .btc-autocomplete-create-option:active {
+ background-color: var(--bs-primary);
+ color: var(--bs-white);
+ }
+
+ .btc-autocomplete-create-new {
+ border-top: 1px solid var(--bs-border-color-translucent);
+ font-weight: 500;
+ }
+
+ .btc-autocomplete-create-feedback {
+ margin-top: .35rem;
+ }
+
+ .btc-autocomplete-create-reset {
+ min-width: 2.5rem;
+ }
+
+ &.is-loading {
+ .btc-autocomplete-create-input {
+ background-image: linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(13, 110, 253, .1) 50%, rgba(255, 255, 255, 0) 100%);
+ background-size: 200% 100%;
+ animation: btc-autocomplete-loading 1s linear infinite;
+ }
+ }
+}
+
+@keyframes btc-autocomplete-loading {
+ from {
+ background-position: 200% 0;
+ }
+ to {
+ background-position: -200% 0;
+ }
+}
diff --git a/resources/themes/bootstrap5/assets/sass/ui/core.scss b/resources/themes/bootstrap5/assets/sass/ui/core.scss
index e15ca3d..830d4f7 100644
--- a/resources/themes/bootstrap5/assets/sass/ui/core.scss
+++ b/resources/themes/bootstrap5/assets/sass/ui/core.scss
@@ -1,7 +1,7 @@
@import "cards";
+@import "autocomplete-create";
@import "content";
@import "content-header";
@import "header";
@import "sidebar";
@import "modern-enhancements";
-
diff --git a/resources/themes/bootstrap5/assets/scripts/admin/app.js b/resources/themes/bootstrap5/assets/scripts/admin/app.js
index ee3ea06..07a4eb1 100644
--- a/resources/themes/bootstrap5/assets/scripts/admin/app.js
+++ b/resources/themes/bootstrap5/assets/scripts/admin/app.js
@@ -7,6 +7,7 @@ import { TopMenuComponent } from './components/top-menu';
import { PanelComponent } from './components/panel';
import { ThemeComponent } from './components/theme';
import { AjaxComponent, applyPageOption } from './components/ajax';
+import { AutocompleteCreateComponent } from './components/AutocompleteCreate';
/**
* ByticAdminApp
@@ -34,6 +35,7 @@ class ByticAdminApp {
.register(new TopMenuComponent())
.register(new PanelComponent())
.register(new ThemeComponent())
+ .register(new AutocompleteCreateComponent())
.register(new AjaxComponent());
}
diff --git a/resources/themes/bootstrap5/assets/scripts/admin/components/AutocompleteCreate.js b/resources/themes/bootstrap5/assets/scripts/admin/components/AutocompleteCreate.js
new file mode 100644
index 0000000..60aa546
--- /dev/null
+++ b/resources/themes/bootstrap5/assets/scripts/admin/components/AutocompleteCreate.js
@@ -0,0 +1,428 @@
+import $ from 'jquery';
+import { BaseComponent } from './base-component';
+
+const FIELD_SELECTOR = '[data-autocomplete-create-field="true"]';
+
+function toInt(value, fallback) {
+ var parsed = parseInt(value, 10);
+ return Number.isNaN(parsed) ? fallback : parsed;
+}
+
+function toBool(value) {
+ return value === true || value === 'true' || value === 1 || value === '1';
+}
+
+function getConfig($field) {
+ var dataset = $field.data();
+
+ return {
+ searchUrl: dataset.searchUrl || '',
+ searchMethod: (dataset.searchMethod || 'GET').toUpperCase(),
+ createUrl: dataset.createUrl || '',
+ createMethod: (dataset.createMethod || 'POST').toUpperCase(),
+ queryParam: dataset.queryParam || 'q',
+ limitParam: dataset.limitParam || 'limit',
+ nameParam: dataset.nameParam || 'name',
+ minChars: toInt(dataset.minChars, 2),
+ limit: toInt(dataset.limit, 10),
+ messageEmpty: dataset.messageEmpty || 'No matches found',
+ messageCreate: dataset.messageCreate || 'Create',
+ messageLoading: dataset.messageLoading || 'Loading',
+ messageError: dataset.messageError || 'Something went wrong',
+ readonly: toBool(dataset.readonly),
+ disabled: toBool(dataset.disabled),
+ };
+}
+
+function getState($field) {
+ var state = $field.data('btcAutocompleteCreateState');
+ if (!state) {
+ state = {
+ debounceTimer: null,
+ activeXhr: null,
+ activeIndex: -1,
+ items: [],
+ };
+ $field.data('btcAutocompleteCreateState', state);
+ }
+ return state;
+}
+
+function getElements($field) {
+ return {
+ input: $field.find('.btc-autocomplete-create-input'),
+ idInput: $field.find('.btc-autocomplete-create-id'),
+ resetBtn: $field.find('.btc-autocomplete-create-reset'),
+ menu: $field.find('.btc-autocomplete-create-menu'),
+ feedback: $field.find('.btc-autocomplete-create-feedback'),
+ };
+}
+
+function clearFeedback($elements) {
+ $elements.feedback.text('').addClass('d-none');
+}
+
+function showFeedback($elements, message) {
+ $elements.feedback.text(message).removeClass('d-none');
+}
+
+function openMenu($elements) {
+ $elements.menu.addClass('show');
+}
+
+function closeMenu($elements) {
+ $elements.menu.removeClass('show').empty();
+}
+
+function normalizeItems(response) {
+ if (!response || response.success !== true || !Array.isArray(response.data)) {
+ return [];
+ }
+
+ return response.data
+ .map(function (item) {
+ if (!item) {
+ return null;
+ }
+ return {
+ id: item.id,
+ label: item.label || item.name || item.title || '',
+ };
+ })
+ .filter(function (item) {
+ return item && item.label !== '';
+ });
+}
+
+function setSelected($field, selectedId, selectedLabel) {
+ var $elements = getElements($field);
+
+ $elements.idInput.val(selectedId || '');
+ $elements.input.val(selectedLabel || '');
+ $elements.resetBtn.toggleClass('d-none', !(selectedLabel || selectedId));
+
+ clearFeedback($elements);
+ closeMenu($elements);
+}
+
+function setLoading($field, loading) {
+ var $elements = getElements($field);
+ $field.toggleClass('is-loading', loading);
+ $elements.input.attr('aria-busy', loading ? 'true' : 'false');
+}
+
+function renderMenu($field, items, query) {
+ var config = getConfig($field);
+ var state = getState($field);
+ var $elements = getElements($field);
+
+ state.items = items;
+ state.activeIndex = -1;
+
+ if (items.length > 0) {
+ var rows = items.map(function (item, index) {
+ return (
+ ''
+ );
+ });
+ $elements.menu.html(rows.join(''));
+ openMenu($elements);
+ return;
+ }
+
+ var html = '' + $('').text(config.messageEmpty).html() + '';
+ if (config.createUrl) {
+ html += (
+ ''
+ );
+ }
+ $elements.menu.html(html);
+ openMenu($elements);
+}
+
+function searchRecords($field, query) {
+ var config = getConfig($field);
+ var state = getState($field);
+ var $elements = getElements($field);
+
+ if (!config.searchUrl) {
+ showFeedback($elements, config.messageError);
+ return;
+ }
+
+ if (state.activeXhr && typeof state.activeXhr.abort === 'function') {
+ state.activeXhr.abort();
+ }
+
+ setLoading($field, true);
+ clearFeedback($elements);
+
+ var data = {};
+ data[config.queryParam] = query;
+ data[config.limitParam] = config.limit;
+
+ state.activeXhr = $.ajax({
+ url: config.searchUrl,
+ type: config.searchMethod,
+ dataType: 'json',
+ data: data,
+ })
+ .done(function (response) {
+ var items = normalizeItems(response);
+ renderMenu($field, items, query);
+ })
+ .fail(function () {
+ showFeedback($elements, config.messageError);
+ closeMenu($elements);
+ })
+ .always(function () {
+ state.activeXhr = null;
+ setLoading($field, false);
+ });
+}
+
+function createRecord($field, name) {
+ var config = getConfig($field);
+ var state = getState($field);
+ var $elements = getElements($field);
+
+ if (!config.createUrl) {
+ return;
+ }
+
+ if (state.activeXhr && typeof state.activeXhr.abort === 'function') {
+ state.activeXhr.abort();
+ }
+
+ setLoading($field, true);
+ clearFeedback($elements);
+
+ var data = {};
+ data[config.nameParam] = name;
+
+ state.activeXhr = $.ajax({
+ url: config.createUrl,
+ type: config.createMethod,
+ dataType: 'json',
+ data: data,
+ })
+ .done(function (response) {
+ if (!response || response.success !== true || !response.data) {
+ showFeedback($elements, config.messageError);
+ return;
+ }
+
+ var item = {
+ id: response.data.id,
+ label: response.data.label || name,
+ };
+ setSelected($field, item.id, item.label);
+ })
+ .fail(function (xhr) {
+ var message = config.messageError;
+ if (xhr && xhr.responseJSON && xhr.responseJSON.error && xhr.responseJSON.error.message) {
+ message = xhr.responseJSON.error.message;
+ }
+ showFeedback($elements, message);
+ })
+ .always(function () {
+ state.activeXhr = null;
+ setLoading($field, false);
+ });
+}
+
+function highlightOption($field, nextIndex) {
+ var state = getState($field);
+ var $elements = getElements($field);
+ var $options = $elements.menu.find('.btc-autocomplete-create-option');
+
+ if ($options.length === 0) {
+ return;
+ }
+
+ if (nextIndex < 0) {
+ nextIndex = $options.length - 1;
+ }
+ if (nextIndex >= $options.length) {
+ nextIndex = 0;
+ }
+
+ state.activeIndex = nextIndex;
+ $options.removeClass('active');
+ $options.eq(nextIndex).addClass('active');
+}
+
+export class AutocompleteCreateComponent extends BaseComponent {
+ constructor() {
+ super();
+ this._mounted = false;
+ }
+
+ onSetup(/* app */) {
+ if (this._mounted) {
+ return;
+ }
+
+ this._bindHandlers();
+ this._mounted = true;
+ }
+
+ onInit(/* app */) {
+ $(FIELD_SELECTOR).each(function () {
+ var $field = $(this);
+ var $elements = getElements($field);
+ $elements.resetBtn.toggleClass('d-none', !$elements.input.val() && !$elements.idInput.val());
+ });
+ }
+
+ onBeforeCache(/* app */) {
+ $(FIELD_SELECTOR).each(function () {
+ var $field = $(this);
+ var state = getState($field);
+ var $elements = getElements($field);
+
+ if (state.debounceTimer) {
+ clearTimeout(state.debounceTimer);
+ state.debounceTimer = null;
+ }
+ if (state.activeXhr && typeof state.activeXhr.abort === 'function') {
+ state.activeXhr.abort();
+ }
+
+ closeMenu($elements);
+ clearFeedback($elements);
+ setLoading($field, false);
+ });
+ }
+
+ _bindHandlers() {
+ var self = this;
+
+ $(document)
+ .off('input.colorAdminAutocompleteCreate', FIELD_SELECTOR + ' .btc-autocomplete-create-input')
+ .on('input.colorAdminAutocompleteCreate', FIELD_SELECTOR + ' .btc-autocomplete-create-input', function () {
+ var $input = $(this);
+ var $field = $input.closest(FIELD_SELECTOR);
+ var config = getConfig($field);
+ var state = getState($field);
+ var $elements = getElements($field);
+ var query = ($input.val() || '').toString().trim();
+
+ if (config.readonly || config.disabled) {
+ return;
+ }
+
+ $elements.idInput.val('');
+ $elements.resetBtn.toggleClass('d-none', query.length < 1);
+
+ if (state.debounceTimer) {
+ clearTimeout(state.debounceTimer);
+ }
+
+ if (query.length < config.minChars) {
+ closeMenu($elements);
+ clearFeedback($elements);
+ return;
+ }
+
+ state.debounceTimer = setTimeout(function () {
+ searchRecords($field, query);
+ }, 250);
+ });
+
+ $(document)
+ .off('keydown.colorAdminAutocompleteCreate', FIELD_SELECTOR + ' .btc-autocomplete-create-input')
+ .on('keydown.colorAdminAutocompleteCreate', FIELD_SELECTOR + ' .btc-autocomplete-create-input', function (event) {
+ var $field = $(this).closest(FIELD_SELECTOR);
+ var state = getState($field);
+ var $elements = getElements($field);
+
+ if (!$elements.menu.hasClass('show')) {
+ return;
+ }
+
+ if (event.key === 'ArrowDown') {
+ event.preventDefault();
+ highlightOption($field, state.activeIndex + 1);
+ return;
+ }
+ if (event.key === 'ArrowUp') {
+ event.preventDefault();
+ highlightOption($field, state.activeIndex - 1);
+ return;
+ }
+ if (event.key === 'Enter' && state.activeIndex > -1) {
+ event.preventDefault();
+ $elements.menu.find('.btc-autocomplete-create-option').eq(state.activeIndex).trigger('click');
+ return;
+ }
+ if (event.key === 'Escape') {
+ closeMenu($elements);
+ }
+ });
+
+ $(document)
+ .off('click.colorAdminAutocompleteCreate', FIELD_SELECTOR + ' .btc-autocomplete-create-option')
+ .on('click.colorAdminAutocompleteCreate', FIELD_SELECTOR + ' .btc-autocomplete-create-option', function () {
+ var $option = $(this);
+ var $field = $option.closest(FIELD_SELECTOR);
+ var index = toInt($option.data('index'), -1);
+ var state = getState($field);
+ var item = state.items[index];
+
+ if (!item) {
+ return;
+ }
+
+ setSelected($field, item.id, item.label);
+ });
+
+ $(document)
+ .off('click.colorAdminAutocompleteCreate', FIELD_SELECTOR + ' .btc-autocomplete-create-new')
+ .on('click.colorAdminAutocompleteCreate', FIELD_SELECTOR + ' .btc-autocomplete-create-new', function () {
+ var $action = $(this);
+ var $field = $action.closest(FIELD_SELECTOR);
+ var $elements = getElements($field);
+ var name = ($action.data('name') || $elements.input.val() || '').toString().trim();
+ if (!name) {
+ return;
+ }
+ createRecord($field, name);
+ });
+
+ $(document)
+ .off('click.colorAdminAutocompleteCreate', FIELD_SELECTOR + ' .btc-autocomplete-create-reset')
+ .on('click.colorAdminAutocompleteCreate', FIELD_SELECTOR + ' .btc-autocomplete-create-reset', function () {
+ var $field = $(this).closest(FIELD_SELECTOR);
+ setSelected($field, '', '');
+ });
+
+ $(document)
+ .off('click.colorAdminAutocompleteCreateOutside')
+ .on('click.colorAdminAutocompleteCreateOutside', function (event) {
+ $(FIELD_SELECTOR).each(function () {
+ var $field = $(this);
+ var $elements = getElements($field);
+ if (!$(event.target).closest($field).length) {
+ closeMenu($elements);
+ }
+ });
+ });
+
+ $(document)
+ .off('blur.colorAdminAutocompleteCreate', FIELD_SELECTOR + ' .btc-autocomplete-create-input')
+ .on('blur.colorAdminAutocompleteCreate', FIELD_SELECTOR + ' .btc-autocomplete-create-input', function () {
+ var $field = $(this).closest(FIELD_SELECTOR);
+ window.setTimeout(function () {
+ closeMenu(getElements($field));
+ }, 200);
+ });
+ }
+}
+
+export default AutocompleteCreateComponent;
diff --git a/resources/themes/bootstrap5/views/abstract/modules/item-form/fields/autocomplete_create.php b/resources/themes/bootstrap5/views/abstract/modules/item-form/fields/autocomplete_create.php
new file mode 100644
index 0000000..e00c712
--- /dev/null
+++ b/resources/themes/bootstrap5/views/abstract/modules/item-form/fields/autocomplete_create.php
@@ -0,0 +1,94 @@
+item ?? ($this->clone ?? null));
+$field = $field ?? 'record_id';
+$label = $label ?? ucfirst(str_replace('_', ' ', $field));
+$readonly = (bool)($readonly ?? false);
+$required = (bool)($required ?? false);
+
+$searchUrl = $searchUrl ?? '';
+$createUrl = $createUrl ?? '';
+$searchMethod = strtoupper((string)($searchMethod ?? 'GET'));
+$createMethod = strtoupper((string)($createMethod ?? 'POST'));
+
+$queryParam = $queryParam ?? 'q';
+$limitParam = $limitParam ?? 'limit';
+$nameParam = $nameParam ?? 'name';
+$minChars = (int)($minChars ?? 2);
+$limit = (int)($limit ?? 10);
+$disabled = (bool)($disabled ?? false);
+
+$selectedId = $selectedId ?? ($item && isset($item->{$field}) ? $item->{$field} : '');
+$selectedLabel = $selectedLabel ?? ($item && isset($item->{$field . '_label'}) ? $item->{$field . '_label'} : '');
+$placeholder = $placeholder ?? translator()->trans('Search or create');
+
+$messages = array_merge(
+ [
+ 'empty' => translator()->trans('No matches found'),
+ 'create' => translator()->trans('Create'),
+ 'loading' => translator()->trans('Loading'),
+ 'error' => translator()->trans('Something went wrong'),
+ ],
+ $messages ?? []
+);
+?>
+
+ |
+ = $label; ?>
+
+ *
+
+ |
+
+
+
+
+
+
+
+
+ = $required ? 'required' : ''; ?>
+ />
+
+
+
+
+
+
+ errors[$field]) && $item->errors[$field]) { ?>
+ = $item->errors[$field] ?>
+
+
+ = htmlspecialchars((string)($selectedLabel ?: $selectedId), ENT_QUOTES, 'UTF-8'); ?>
+
+ |
+
diff --git a/src/Library/Controllers/Traits/HasAutocompleteCreateTrait.php b/src/Library/Controllers/Traits/HasAutocompleteCreateTrait.php
new file mode 100644
index 0000000..3636cd3
--- /dev/null
+++ b/src/Library/Controllers/Traits/HasAutocompleteCreateTrait.php
@@ -0,0 +1,182 @@
+autocompleteCreateNormalizeName(
+ $this->autocompleteCreateRequestValue($this->autocompleteCreateNameParameter())
+ );
+
+ if ($name === '') {
+ $this->autocompleteCreateRespondJson(
+ $this->autocompleteCreateErrorPayload('invalid_name', 'Please provide a valid name.'),
+ 422
+ );
+ return;
+ }
+
+ try {
+ $duplicate = $this->autocompleteCreateFindDuplicateRecord($name);
+ if ($duplicate !== null && !$this->autocompleteCreateAllowDuplicates()) {
+ $this->autocompleteCreateRespondJson(
+ $this->autocompleteCreateErrorPayload(
+ 'duplicate',
+ 'An item with the same name already exists.',
+ ['record' => $this->autocompleteCreateFormatRecord($duplicate)]
+ ),
+ 409
+ );
+ return;
+ }
+
+ $record = $this->autocompleteCreatePersistRecord($name);
+ if ($record === null) {
+ $this->autocompleteCreateRespondJson(
+ $this->autocompleteCreateErrorPayload('create_failed', 'The item could not be created.'),
+ 500
+ );
+ return;
+ }
+
+ $this->autocompleteCreateRespondJson(
+ [
+ 'success' => true,
+ 'data' => $this->autocompleteCreateFormatRecord($record),
+ 'meta' => [
+ 'created' => true,
+ ],
+ ],
+ 201
+ );
+ } catch (Throwable $exception) {
+ $this->autocompleteCreateRespondJson(
+ $this->autocompleteCreateErrorPayload(
+ 'create_failed',
+ $this->autocompleteCreateErrorMessage($exception)
+ ),
+ 500
+ );
+ }
+ }
+
+ /**
+ * Must be implemented by consumer controller.
+ */
+ abstract protected function autocompleteCreatePersistRecord(string $name): mixed;
+
+ protected function autocompleteCreateNameParameter(): string
+ {
+ return 'name';
+ }
+
+ protected function autocompleteCreateAllowDuplicates(): bool
+ {
+ return false;
+ }
+
+ protected function autocompleteCreateErrorMessage(Throwable $exception): string
+ {
+ return 'The item could not be created.';
+ }
+
+ protected function autocompleteCreateFindDuplicateRecord(string $name): mixed
+ {
+ return null;
+ }
+
+ protected function autocompleteCreateRequestValue(string $key): mixed
+ {
+ if (isset($_REQUEST[$key])) {
+ return $_REQUEST[$key];
+ }
+
+ if (method_exists($this, 'request')) {
+ $request = $this->request();
+ if (is_object($request) && method_exists($request, 'get')) {
+ return $request->get($key);
+ }
+ }
+
+ if (method_exists($this, 'getRequest')) {
+ $request = $this->getRequest();
+ if (is_object($request) && method_exists($request, 'get')) {
+ return $request->get($key);
+ }
+ }
+
+ return null;
+ }
+
+ protected function autocompleteCreateNormalizeName(mixed $name): string
+ {
+ if ($name === null) {
+ return '';
+ }
+ return trim((string) $name);
+ }
+
+ protected function autocompleteCreateFormatRecord(mixed $record): array
+ {
+ $id = $this->autocompleteCreateExtractValue($record, ['id', 'value']);
+ $label = $this->autocompleteCreateExtractValue($record, ['label', 'name', 'title', 'value']);
+
+ return [
+ 'id' => $id,
+ 'label' => (string) $label,
+ ];
+ }
+
+ protected function autocompleteCreateExtractValue(mixed $record, array $keys): mixed
+ {
+ foreach ($keys as $key) {
+ if (is_array($record) && array_key_exists($key, $record)) {
+ return $record[$key];
+ }
+ if (is_object($record) && isset($record->{$key})) {
+ return $record->{$key};
+ }
+ if (is_object($record)) {
+ $method = 'get' . ucfirst($key);
+ if (method_exists($record, $method)) {
+ return $record->{$method}();
+ }
+ }
+ }
+
+ return null;
+ }
+
+ protected function autocompleteCreateErrorPayload(string $code, string $message, array $details = []): array
+ {
+ return [
+ 'success' => false,
+ 'error' => [
+ 'code' => $code,
+ 'message' => $message,
+ 'details' => $details,
+ ],
+ ];
+ }
+
+ protected function autocompleteCreateRespondJson(array $payload, int $statusCode = 200): void
+ {
+ if (!headers_sent()) {
+ http_response_code($statusCode);
+ header('Content-Type: application/json; charset=utf-8');
+ }
+
+ echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ }
+}
diff --git a/src/Library/Controllers/Traits/HasAutocompleteSearchTrait.php b/src/Library/Controllers/Traits/HasAutocompleteSearchTrait.php
new file mode 100644
index 0000000..172cca7
--- /dev/null
+++ b/src/Library/Controllers/Traits/HasAutocompleteSearchTrait.php
@@ -0,0 +1,190 @@
+autocompleteSearchNormalizeQuery(
+ $this->autocompleteSearchRequestValue($this->autocompleteSearchQueryParameter())
+ );
+ $limit = $this->autocompleteSearchNormalizeLimit(
+ $this->autocompleteSearchRequestValue($this->autocompleteSearchLimitParameter())
+ );
+
+ if ($query === '') {
+ $this->autocompleteSearchRespondJson(
+ $this->autocompleteSearchSuccessPayload([], $query, $limit)
+ );
+ return;
+ }
+
+ try {
+ $records = $this->autocompleteSearchFetchRecords($query, $limit);
+ $results = [];
+ foreach ($records as $record) {
+ $results[] = $this->autocompleteSearchFormatRecord($record);
+ }
+
+ $this->autocompleteSearchRespondJson(
+ $this->autocompleteSearchSuccessPayload($results, $query, $limit)
+ );
+ } catch (Throwable $exception) {
+ $this->autocompleteSearchRespondJson(
+ $this->autocompleteSearchErrorPayload(
+ 'search_failed',
+ $this->autocompleteSearchErrorMessage($exception)
+ ),
+ 500
+ );
+ }
+ }
+
+ /**
+ * Must be implemented by consumer controller.
+ */
+ abstract protected function autocompleteSearchFetchRecords(string $query, int $limit): iterable;
+
+ protected function autocompleteSearchQueryParameter(): string
+ {
+ return 'q';
+ }
+
+ protected function autocompleteSearchLimitParameter(): string
+ {
+ return 'limit';
+ }
+
+ protected function autocompleteSearchDefaultLimit(): int
+ {
+ return 10;
+ }
+
+ protected function autocompleteSearchMaxLimit(): int
+ {
+ return 50;
+ }
+
+ protected function autocompleteSearchErrorMessage(Throwable $exception): string
+ {
+ return 'Search could not be completed.';
+ }
+
+ protected function autocompleteSearchNormalizeQuery(mixed $query): string
+ {
+ if ($query === null) {
+ return '';
+ }
+ return trim((string) $query);
+ }
+
+ protected function autocompleteSearchNormalizeLimit(mixed $limit): int
+ {
+ $default = $this->autocompleteSearchDefaultLimit();
+ $max = max(1, $this->autocompleteSearchMaxLimit());
+ $normalized = (int) ($limit ?: $default);
+ if ($normalized < 1) {
+ return $default;
+ }
+ return min($normalized, $max);
+ }
+
+ protected function autocompleteSearchRequestValue(string $key): mixed
+ {
+ if (isset($_REQUEST[$key])) {
+ return $_REQUEST[$key];
+ }
+
+ if (method_exists($this, 'request')) {
+ $request = $this->request();
+ if (is_object($request) && method_exists($request, 'get')) {
+ return $request->get($key);
+ }
+ }
+
+ if (method_exists($this, 'getRequest')) {
+ $request = $this->getRequest();
+ if (is_object($request) && method_exists($request, 'get')) {
+ return $request->get($key);
+ }
+ }
+
+ return null;
+ }
+
+ protected function autocompleteSearchFormatRecord(mixed $record): array
+ {
+ $id = $this->autocompleteSearchExtractValue($record, ['id', 'value']);
+ $label = $this->autocompleteSearchExtractValue($record, ['label', 'name', 'title', 'value']);
+
+ return [
+ 'id' => $id,
+ 'label' => (string) $label,
+ ];
+ }
+
+ protected function autocompleteSearchExtractValue(mixed $record, array $keys): mixed
+ {
+ foreach ($keys as $key) {
+ if (is_array($record) && array_key_exists($key, $record)) {
+ return $record[$key];
+ }
+ if (is_object($record) && isset($record->{$key})) {
+ return $record->{$key};
+ }
+ if (is_object($record)) {
+ $method = 'get' . ucfirst($key);
+ if (method_exists($record, $method)) {
+ return $record->{$method}();
+ }
+ }
+ }
+
+ return null;
+ }
+
+ protected function autocompleteSearchSuccessPayload(array $results, string $query, int $limit): array
+ {
+ return [
+ 'success' => true,
+ 'data' => $results,
+ 'meta' => [
+ 'query' => $query,
+ 'limit' => $limit,
+ 'count' => count($results),
+ ],
+ ];
+ }
+
+ protected function autocompleteSearchErrorPayload(string $code, string $message, array $details = []): array
+ {
+ return [
+ 'success' => false,
+ 'error' => [
+ 'code' => $code,
+ 'message' => $message,
+ 'details' => $details,
+ ],
+ ];
+ }
+
+ protected function autocompleteSearchRespondJson(array $payload, int $statusCode = 200): void
+ {
+ if (!headers_sent()) {
+ http_response_code($statusCode);
+ header('Content-Type: application/json; charset=utf-8');
+ }
+
+ echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ }
+}
diff --git a/tests/src/Library/Controllers/Traits/HasAutocompleteCreateTraitTest.php b/tests/src/Library/Controllers/Traits/HasAutocompleteCreateTraitTest.php
new file mode 100644
index 0000000..c58783c
--- /dev/null
+++ b/tests/src/Library/Controllers/Traits/HasAutocompleteCreateTraitTest.php
@@ -0,0 +1,82 @@
+ ' '];
+
+ $controller = new AutocompleteCreateControllerFixture();
+ $controller->autocompleteCreateAction();
+
+ self::assertSame(422, $controller->statusCode);
+ self::assertFalse($controller->payload['success']);
+ self::assertSame('invalid_name', $controller->payload['error']['code']);
+ }
+
+ public function test_duplicate_name_returns_duplicate_error(): void
+ {
+ $_REQUEST = ['name' => 'Existing'];
+
+ $controller = new AutocompleteCreateControllerFixture();
+ $controller->duplicate = ['id' => 9, 'name' => 'Existing'];
+ $controller->autocompleteCreateAction();
+
+ self::assertSame(409, $controller->statusCode);
+ self::assertFalse($controller->payload['success']);
+ self::assertSame('duplicate', $controller->payload['error']['code']);
+ self::assertSame(9, $controller->payload['error']['details']['record']['id']);
+ }
+
+ public function test_successful_create_returns_created_payload(): void
+ {
+ $_REQUEST = ['name' => 'New item'];
+
+ $controller = new AutocompleteCreateControllerFixture();
+ $controller->created = ['id' => 12, 'name' => 'New item'];
+ $controller->autocompleteCreateAction();
+
+ self::assertSame(201, $controller->statusCode);
+ self::assertTrue($controller->payload['success']);
+ self::assertSame(12, $controller->payload['data']['id']);
+ self::assertSame('New item', $controller->payload['data']['label']);
+ self::assertTrue($controller->payload['meta']['created']);
+ }
+}
+
+class AutocompleteCreateControllerFixture
+{
+ use HasAutocompleteCreateTrait;
+
+ public mixed $duplicate = null;
+ public mixed $created = null;
+ public array $payload = [];
+ public int $statusCode = 0;
+
+ protected function autocompleteCreatePersistRecord(string $name): mixed
+ {
+ return $this->created;
+ }
+
+ protected function autocompleteCreateFindDuplicateRecord(string $name): mixed
+ {
+ return $this->duplicate;
+ }
+
+ protected function autocompleteCreateRespondJson(array $payload, int $statusCode = 200): void
+ {
+ $this->payload = $payload;
+ $this->statusCode = $statusCode;
+ }
+}
diff --git a/tests/src/Library/Controllers/Traits/HasAutocompleteSearchTraitTest.php b/tests/src/Library/Controllers/Traits/HasAutocompleteSearchTraitTest.php
new file mode 100644
index 0000000..d2e55b6
--- /dev/null
+++ b/tests/src/Library/Controllers/Traits/HasAutocompleteSearchTraitTest.php
@@ -0,0 +1,71 @@
+ ' '];
+
+ $controller = new AutocompleteSearchControllerFixture();
+ $controller->autocompleteSearchAction();
+
+ self::assertSame(200, $controller->statusCode);
+ self::assertTrue($controller->payload['success']);
+ self::assertSame([], $controller->payload['data']);
+ self::assertSame('', $controller->payload['meta']['query']);
+ }
+
+ public function test_search_maps_results_and_respects_limit(): void
+ {
+ $_REQUEST = ['q' => 'ab', 'limit' => 2];
+
+ $controller = new AutocompleteSearchControllerFixture();
+ $controller->records = [
+ ['id' => 10, 'name' => 'Alpha'],
+ (object)['id' => 11, 'label' => 'Beta'],
+ ['id' => 12, 'name' => 'Gamma'],
+ ];
+ $controller->autocompleteSearchAction();
+
+ self::assertSame(200, $controller->statusCode);
+ self::assertTrue($controller->payload['success']);
+ self::assertCount(2, $controller->payload['data']);
+ self::assertSame(10, $controller->payload['data'][0]['id']);
+ self::assertSame('Alpha', $controller->payload['data'][0]['label']);
+ self::assertSame(11, $controller->payload['data'][1]['id']);
+ self::assertSame('Beta', $controller->payload['data'][1]['label']);
+ self::assertSame(2, $controller->payload['meta']['limit']);
+ self::assertSame(2, $controller->payload['meta']['count']);
+ }
+}
+
+class AutocompleteSearchControllerFixture
+{
+ use HasAutocompleteSearchTrait;
+
+ public array $records = [];
+ public array $payload = [];
+ public int $statusCode = 0;
+
+ protected function autocompleteSearchFetchRecords(string $query, int $limit): iterable
+ {
+ return array_slice($this->records, 0, $limit);
+ }
+
+ protected function autocompleteSearchRespondJson(array $payload, int $statusCode = 200): void
+ {
+ $this->payload = $payload;
+ $this->statusCode = $statusCode;
+ }
+}
From 9171c1e6c99c9501526eec0816d6d3dc21d8211c Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 14 Jun 2026 13:10:04 +0000
Subject: [PATCH 2/2] Harden autocomplete JSON handling and refine component
behavior
---
.../assets/sass/ui/_autocomplete-create.scss | 2 +-
.../admin/components/AutocompleteCreate.js | 50 +++++++++++--------
.../item-form/fields/autocomplete_create.php | 4 ++
.../Traits/HasAutocompleteCreateTrait.php | 19 +++++--
.../Traits/HasAutocompleteSearchTrait.php | 19 +++++--
5 files changed, 63 insertions(+), 31 deletions(-)
diff --git a/resources/themes/bootstrap5/assets/sass/ui/_autocomplete-create.scss b/resources/themes/bootstrap5/assets/sass/ui/_autocomplete-create.scss
index 63086ae..c00b250 100644
--- a/resources/themes/bootstrap5/assets/sass/ui/_autocomplete-create.scss
+++ b/resources/themes/bootstrap5/assets/sass/ui/_autocomplete-create.scss
@@ -28,7 +28,7 @@
&.is-loading {
.btc-autocomplete-create-input {
- background-image: linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(13, 110, 253, .1) 50%, rgba(255, 255, 255, 0) 100%);
+ background-image: linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, color-mix(in srgb, var(--bs-primary) 10%, transparent) 50%, rgba(255, 255, 255, 0) 100%);
background-size: 200% 100%;
animation: btc-autocomplete-loading 1s linear infinite;
}
diff --git a/resources/themes/bootstrap5/assets/scripts/admin/components/AutocompleteCreate.js b/resources/themes/bootstrap5/assets/scripts/admin/components/AutocompleteCreate.js
index 60aa546..b5a3ee4 100644
--- a/resources/themes/bootstrap5/assets/scripts/admin/components/AutocompleteCreate.js
+++ b/resources/themes/bootstrap5/assets/scripts/admin/components/AutocompleteCreate.js
@@ -2,13 +2,15 @@ import $ from 'jquery';
import { BaseComponent } from './base-component';
const FIELD_SELECTOR = '[data-autocomplete-create-field="true"]';
+const DEBOUNCE_DELAY = 250;
+const BLUR_CLOSE_DELAY = 200;
-function toInt(value, fallback) {
+function parseIntOrDefault(value, fallback) {
var parsed = parseInt(value, 10);
return Number.isNaN(parsed) ? fallback : parsed;
}
-function toBool(value) {
+function coerceToBoolean(value) {
return value === true || value === 'true' || value === 1 || value === '1';
}
@@ -23,14 +25,16 @@ function getConfig($field) {
queryParam: dataset.queryParam || 'q',
limitParam: dataset.limitParam || 'limit',
nameParam: dataset.nameParam || 'name',
- minChars: toInt(dataset.minChars, 2),
- limit: toInt(dataset.limit, 10),
+ minChars: parseIntOrDefault(dataset.minChars, 2),
+ limit: parseIntOrDefault(dataset.limit, 10),
+ debounceDelay: parseIntOrDefault(dataset.debounceDelay, DEBOUNCE_DELAY),
+ blurCloseDelay: parseIntOrDefault(dataset.blurCloseDelay, BLUR_CLOSE_DELAY),
messageEmpty: dataset.messageEmpty || 'No matches found',
messageCreate: dataset.messageCreate || 'Create',
messageLoading: dataset.messageLoading || 'Loading',
messageError: dataset.messageError || 'Something went wrong',
- readonly: toBool(dataset.readonly),
- disabled: toBool(dataset.disabled),
+ readonly: coerceToBoolean(dataset.readonly),
+ disabled: coerceToBoolean(dataset.disabled),
};
}
@@ -121,12 +125,14 @@ function renderMenu($field, items, query) {
if (items.length > 0) {
var rows = items.map(function (item, index) {
- return (
- ''
- );
+ var $button = $('', {
+ type: 'button',
+ class: 'dropdown-item btc-autocomplete-create-option',
+ });
+ $button.attr('data-index', String(index));
+ $button.attr('data-id', String(item.id));
+ $button.text(item.label);
+ return $button.prop('outerHTML');
});
$elements.menu.html(rows.join(''));
openMenu($elements);
@@ -135,11 +141,14 @@ function renderMenu($field, items, query) {
var html = '' + $('').text(config.messageEmpty).html() + '';
if (config.createUrl) {
- html += (
- ''
- );
+ var $createButton = $('', {
+ type: 'button',
+ class: 'dropdown-item btc-autocomplete-create-new',
+ });
+ $createButton.attr('data-name', query);
+ $createButton.append('');
+ $createButton.append(document.createTextNode(config.messageCreate + ' "' + query + '"'));
+ html += $createButton.prop('outerHTML');
}
$elements.menu.html(html);
openMenu($elements);
@@ -332,7 +341,7 @@ export class AutocompleteCreateComponent extends BaseComponent {
state.debounceTimer = setTimeout(function () {
searchRecords($field, query);
- }, 250);
+ }, config.debounceDelay);
});
$(document)
@@ -371,7 +380,7 @@ export class AutocompleteCreateComponent extends BaseComponent {
.on('click.colorAdminAutocompleteCreate', FIELD_SELECTOR + ' .btc-autocomplete-create-option', function () {
var $option = $(this);
var $field = $option.closest(FIELD_SELECTOR);
- var index = toInt($option.data('index'), -1);
+ var index = parseIntOrDefault($option.data('index'), -1);
var state = getState($field);
var item = state.items[index];
@@ -418,9 +427,10 @@ export class AutocompleteCreateComponent extends BaseComponent {
.off('blur.colorAdminAutocompleteCreate', FIELD_SELECTOR + ' .btc-autocomplete-create-input')
.on('blur.colorAdminAutocompleteCreate', FIELD_SELECTOR + ' .btc-autocomplete-create-input', function () {
var $field = $(this).closest(FIELD_SELECTOR);
+ var config = getConfig($field);
window.setTimeout(function () {
closeMenu(getElements($field));
- }, 200);
+ }, config.blurCloseDelay);
});
}
}
diff --git a/resources/themes/bootstrap5/views/abstract/modules/item-form/fields/autocomplete_create.php b/resources/themes/bootstrap5/views/abstract/modules/item-form/fields/autocomplete_create.php
index e00c712..8ac71fd 100644
--- a/resources/themes/bootstrap5/views/abstract/modules/item-form/fields/autocomplete_create.php
+++ b/resources/themes/bootstrap5/views/abstract/modules/item-form/fields/autocomplete_create.php
@@ -16,6 +16,8 @@
$nameParam = $nameParam ?? 'name';
$minChars = (int)($minChars ?? 2);
$limit = (int)($limit ?? 10);
+$debounceDelay = (int)($debounceDelay ?? 250);
+$blurCloseDelay = (int)($blurCloseDelay ?? 200);
$disabled = (bool)($disabled ?? false);
$selectedId = $selectedId ?? ($item && isset($item->{$field}) ? $item->{$field} : '');
@@ -52,6 +54,8 @@
data-name-param="= htmlspecialchars((string)$nameParam, ENT_QUOTES, 'UTF-8'); ?>"
data-min-chars="= (int)$minChars; ?>"
data-limit="= (int)$limit; ?>"
+ data-debounce-delay="= (int)$debounceDelay; ?>"
+ data-blur-close-delay="= (int)$blurCloseDelay; ?>"
data-readonly="= $readonly ? 'true' : 'false'; ?>"
data-disabled="= $disabled ? 'true' : 'false'; ?>"
data-message-empty="= htmlspecialchars((string)$messages['empty'], ENT_QUOTES, 'UTF-8'); ?>"
diff --git a/src/Library/Controllers/Traits/HasAutocompleteCreateTrait.php b/src/Library/Controllers/Traits/HasAutocompleteCreateTrait.php
index 3636cd3..3b2afae 100644
--- a/src/Library/Controllers/Traits/HasAutocompleteCreateTrait.php
+++ b/src/Library/Controllers/Traits/HasAutocompleteCreateTrait.php
@@ -2,14 +2,11 @@
namespace ByTIC\AdminBase\Library\Controllers\Traits;
+use JsonException;
use Throwable;
trait HasAutocompleteCreateTrait
{
- protected function bootHasAutocompleteCreateTrait(): void
- {
- }
-
/**
* Generic endpoint for inline create in autocomplete components.
*/
@@ -172,11 +169,23 @@ protected function autocompleteCreateErrorPayload(string $code, string $message,
protected function autocompleteCreateRespondJson(array $payload, int $statusCode = 200): void
{
+ $encodedPayload = null;
+ try {
+ $encodedPayload = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
+ } catch (JsonException $exception) {
+ if (!headers_sent()) {
+ http_response_code(500);
+ header('Content-Type: application/json; charset=utf-8');
+ }
+ echo '{"success":false,"error":{"code":"json_encode_failed","message":"Response encoding failed."}}';
+ return;
+ }
+
if (!headers_sent()) {
http_response_code($statusCode);
header('Content-Type: application/json; charset=utf-8');
}
- echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ echo $encodedPayload;
}
}
diff --git a/src/Library/Controllers/Traits/HasAutocompleteSearchTrait.php b/src/Library/Controllers/Traits/HasAutocompleteSearchTrait.php
index 172cca7..11ebddf 100644
--- a/src/Library/Controllers/Traits/HasAutocompleteSearchTrait.php
+++ b/src/Library/Controllers/Traits/HasAutocompleteSearchTrait.php
@@ -2,14 +2,11 @@
namespace ByTIC\AdminBase\Library\Controllers\Traits;
+use JsonException;
use Throwable;
trait HasAutocompleteSearchTrait
{
- protected function bootHasAutocompleteSearchTrait(): void
- {
- }
-
/**
* Generic endpoint for autocomplete search.
*/
@@ -180,11 +177,23 @@ protected function autocompleteSearchErrorPayload(string $code, string $message,
protected function autocompleteSearchRespondJson(array $payload, int $statusCode = 200): void
{
+ $encodedPayload = null;
+ try {
+ $encodedPayload = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
+ } catch (JsonException $exception) {
+ if (!headers_sent()) {
+ http_response_code(500);
+ header('Content-Type: application/json; charset=utf-8');
+ }
+ echo '{"success":false,"error":{"code":"json_encode_failed","message":"Response encoding failed."}}';
+ return;
+ }
+
if (!headers_sent()) {
http_response_code($statusCode);
header('Content-Type: application/json; charset=utf-8');
}
- echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ echo $encodedPayload;
}
}