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..c00b250 --- /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%, 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; + } + } +} + +@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..b5a3ee4 --- /dev/null +++ b/resources/themes/bootstrap5/assets/scripts/admin/components/AutocompleteCreate.js @@ -0,0 +1,438 @@ +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 parseIntOrDefault(value, fallback) { + var parsed = parseInt(value, 10); + return Number.isNaN(parsed) ? fallback : parsed; +} + +function coerceToBoolean(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: 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: coerceToBoolean(dataset.readonly), + disabled: coerceToBoolean(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) { + var $button = $(' + + +
+ + + 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..3b2afae --- /dev/null +++ b/src/Library/Controllers/Traits/HasAutocompleteCreateTrait.php @@ -0,0 +1,191 @@ +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 + { + $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 $encodedPayload; + } +} diff --git a/src/Library/Controllers/Traits/HasAutocompleteSearchTrait.php b/src/Library/Controllers/Traits/HasAutocompleteSearchTrait.php new file mode 100644 index 0000000..11ebddf --- /dev/null +++ b/src/Library/Controllers/Traits/HasAutocompleteSearchTrait.php @@ -0,0 +1,199 @@ +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 + { + $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 $encodedPayload; + } +} 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; + } +}