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 ?? [] +); +?> + + + + + * + + + + +
+ + + +
+ + + /> + +
+ +
+ +
+ errors[$field]) && $item->errors[$field]) { ?> + errors[$field] ?> + + + + + + 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 = $('' - ); + var $createButton = $('