diff --git a/app/Controllers/CollaneController.php b/app/Controllers/CollaneController.php
index 41d38a0e..d730e882 100644
--- a/app/Controllers/CollaneController.php
+++ b/app/Controllers/CollaneController.php
@@ -430,11 +430,47 @@ public function removeBook(Request $request, Response $response, mysqli $db): Re
}
/**
- * API: search collane names for autocomplete.
+ * API: autocomplete suggestions for the book-form series fields.
+ *
+ * Query params:
+ * - `q` string the typed text (suggestions returned only for >= 2 chars)
+ * - `field` string which book-form field is asking; one of `collana`,
+ * `serie_padre`, `gruppo_serie`, `ciclo_serie`. Unknown values
+ * fall back to `collana`. The field selects the suggestion source
+ * (table + column) from an internal whitelist — never built from
+ * input — while only the search value and tipo are bound.
+ *
+ * @return Response JSON array of up to 10 distinct matching names.
*/
public function searchApi(Request $request, Response $response, mysqli $db): Response
{
- $q = trim((string) ($request->getQueryParams()['q'] ?? ''));
+ $params = $request->getQueryParams();
+ $q = trim((string) ($params['q'] ?? ''));
+
+ // Each book-form series field suggests from its own existing values (the
+ // denormalized libri column) plus, when available, curated collane of the
+ // matching tipo — so e.g. the "universe" field proposes existing universes
+ // instead of forcing the user to retype them (#179). The column is
+ // whitelisted via $fieldMap (never taken from user input) so it can be
+ // embedded in the SQL safely; everything else is bound.
+ // Each book-form series field maps to one or more (table, column[, tipo])
+ // suggestion sources. Tables/columns are whitelisted here (never taken
+ // from user input) so they can be embedded in the SQL safely; the query
+ // value and any tipo filter are bound. This lets e.g. the "universe"
+ // field propose existing universes (collane.nome WHERE tipo='universo')
+ // instead of forcing the user to retype them (#179).
+ $sources = [
+ 'collana' => [['t' => 'collane', 'c' => 'nome', 'tipos' => ['serie']],
+ ['t' => 'libri', 'c' => 'collana', 'tipos' => []]],
+ 'serie_padre' => [['t' => 'collane', 'c' => 'nome', 'tipos' => ['universo']]],
+ 'gruppo_serie' => [['t' => 'collane', 'c' => 'gruppo_serie', 'tipos' => []]],
+ 'ciclo_serie' => [['t' => 'collane', 'c' => 'ciclo', 'tipos' => []]],
+ ];
+ $field = (string) ($params['field'] ?? 'collana');
+ if (!isset($sources[$field])) {
+ $field = 'collana';
+ }
+
$results = [];
// SEC2-1 + SEC1-5 (review): require min 2 chars and escape LIKE
@@ -443,31 +479,40 @@ public function searchApi(Request $request, Response $response, mysqli $db): Res
if (mb_strlen($q) >= 2) {
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $q);
$search = '%' . $escaped . '%';
- if ($this->hasCollaneTable($db)) {
- $stmt = $db->prepare("
- SELECT DISTINCT nome FROM (
- SELECT nome FROM collane WHERE nome LIKE ?
- UNION
- SELECT collana AS nome FROM libri WHERE collana LIKE ? AND collana IS NOT NULL AND collana != '' AND deleted_at IS NULL
- ) AS combined ORDER BY nome LIMIT 10
- ");
- if ($stmt) {
- $stmt->bind_param('ss', $search, $search);
- $stmt->execute();
- $res = $stmt->get_result();
- while ($row = $res->fetch_assoc()) {
- $results[] = $row['nome'];
+
+ $hasCollane = $this->hasCollaneTable($db);
+ $sqlParts = [];
+ $types = '';
+ $binds = [];
+
+ foreach ($sources[$field] as $src) {
+ if ($src['t'] === 'collane' && !$hasCollane) {
+ continue; // no collane table — skip its sources
+ }
+ $col = $src['c'];
+ $where = "`{$col}` LIKE ? AND `{$col}` IS NOT NULL AND `{$col}` != ''";
+ $types .= 's';
+ $binds[] = $search;
+ if ($src['t'] === 'libri') {
+ $where .= ' AND deleted_at IS NULL';
+ }
+ if (!empty($src['tipos'])) {
+ $placeholders = implode(',', array_fill(0, count($src['tipos']), '?'));
+ $where .= " AND tipo IN ({$placeholders})";
+ $types .= str_repeat('s', count($src['tipos']));
+ foreach ($src['tipos'] as $t) {
+ $binds[] = $t;
}
- $stmt->close();
}
- } else {
- $stmt = $db->prepare("
- SELECT DISTINCT collana AS nome FROM libri
- WHERE collana LIKE ? AND collana IS NOT NULL AND collana != '' AND deleted_at IS NULL
- ORDER BY collana LIMIT 10
- ");
+ $sqlParts[] = "SELECT `{$col}` AS nome FROM {$src['t']} WHERE {$where}";
+ }
+
+ if (!empty($sqlParts)) {
+ $sql = 'SELECT DISTINCT nome FROM (' . implode(' UNION ', $sqlParts)
+ . ') AS combined ORDER BY nome LIMIT 10';
+ $stmt = $db->prepare($sql);
if ($stmt) {
- $stmt->bind_param('s', $search);
+ $stmt->bind_param($types, ...$binds);
$stmt->execute();
$res = $stmt->get_result();
while ($row = $res->fetch_assoc()) {
diff --git a/app/Views/libri/partials/book_form.php b/app/Views/libri/partials/book_form.php
index 8c2fd57b..7710ed21 100644
--- a/app/Views/libri/partials/book_form.php
+++ b/app/Views/libri/partials/book_form.php
@@ -504,13 +504,23 @@
= __("Serie e collana") ?>
-
= __("Gruppo serie") ?>
-
+
= __("Gruppo serie") ?>
+
+
+
+ = HtmlHelper::e($gruppoSerieVal) ?>
+
+
= __('Etichetta "ombrello" per spin-off (es. tutto il franchise di Fairy Tail).') ?>
-
= __("Serie padre / universo") ?>
-
+
= __("Serie padre / universo") ?>
+
+
+
+ = HtmlHelper::e($seriePadreVal) ?>
+
+
= __("Serie superiore nella gerarchia (es. l'universo che contiene cicli e stagioni).") ?>
@@ -523,8 +533,13 @@
= __('Tassonomia: serie / universo / ciclo / stagione / spin-off / arco / collana editoriale.') ?>
-
= __("Serie principale") ?>
-
+
= __("Serie principale") ?>
+
+
+
+ = HtmlHelper::e($collanaVal) ?>
+
+
= __("Nome specifico della serie a cui appartiene il libro.") ?>
@@ -535,8 +550,13 @@
- = __("Ciclo / stagione") ?>
-
+ = __("Ciclo / stagione") ?>
+
+
+
+ = HtmlHelper::e($cicloSerieVal) ?>
+
+
= __("Ordine ciclo") ?>
@@ -1125,6 +1145,7 @@ function toggleLibraryThingAccordion() {
initializeUppy();
initializeChoicesJS();
initializePublishersChoices();
+ initializeSeriesAutocompletes();
initializeSweetAlert();
initializeGeneriDropdowns();
initializeFormValidation();
@@ -2129,6 +2150,96 @@ function initializeSweetAlert() {
*
* @returns {void}
*/
+/**
+ * Single-value series autocompletes (#179): give the "universe / group / cycle /
+ * series" book-form fields the same Choices.js shape as the author/publisher
+ * pickers, so existing values are proposed after a couple of letters instead of
+ * being retyped (a typo no longer silently spawns a new universe). Each control
+ * is a mirrored into a hidden input
+ * named that carries the value to the form unchanged.
+ */
+function initializeSeriesAutocompletes() {
+ if (typeof Choices === 'undefined') return;
+ document.querySelectorAll('select[data-series-autocomplete]').forEach(function (sel) {
+ try {
+ const field = sel.getAttribute('data-series-autocomplete');
+ const hidden = document.getElementById(field);
+ if (!hidden) return;
+
+ const choice = new Choices(sel, {
+ searchEnabled: true,
+ shouldSort: false,
+ searchResultLimit: -1,
+ searchFloor: 2,
+ placeholder: true,
+ placeholderValue: sel.getAttribute('data-placeholder') || '',
+ itemSelectText: = json_encode(__("Clicca per selezionare"), JSON_HEX_TAG) ?>,
+ noChoicesText: = json_encode(__("Digita almeno 2 lettere per cercare o creare"), JSON_HEX_TAG) ?>,
+ classNames: { containerInner: 'choices__inner' }
+ });
+
+ const wrapper = sel.closest('.choices');
+ const input = wrapper ? wrapper.querySelector('.choices__input--cloned') : null;
+
+ const syncHidden = function () { hidden.value = choice.getValue(true) || ''; };
+ syncHidden();
+
+ // Commit a typed value (existing OR brand-new): a select-one Choices
+ // otherwise only lets the user pick pre-existing options.
+ const commitTyped = function (raw) {
+ const v = (raw || '').trim();
+ if (!v) return;
+ choice.setChoices([{ value: v, label: v, selected: true }], 'value', 'label', false);
+ hidden.value = v;
+ if (input) input.value = '';
+ choice.hideDropdown();
+ };
+
+ // Expose a setter so the ISBN scraper can fill this field visibly
+ // (it goes through Choices, not the now-hidden raw input).
+ window.__seriesAutocomplete = window.__seriesAutocomplete || {};
+ window.__seriesAutocomplete[field] = commitTyped;
+
+ let searchTimer = null;
+ sel.addEventListener('search', function (e) {
+ const q = (e.detail && e.detail.value ? e.detail.value : '').trim();
+ clearTimeout(searchTimer);
+ if (q.length < 2) return;
+ searchTimer = setTimeout(async function () {
+ try {
+ const resp = await fetch(`${window.BASE_PATH}/api/collane/search?field=${encodeURIComponent(field)}&q=${encodeURIComponent(q)}`, { credentials: 'same-origin' });
+ if (!resp.ok) return;
+ const names = await resp.json();
+ const opts = (Array.isArray(names) ? names : []).map(function (n) { return { value: n, label: n }; });
+ // Always offer the typed value so a NEW name can be created.
+ if (!opts.some(function (o) { return String(o.value).toLowerCase() === q.toLowerCase(); })) {
+ opts.unshift({ value: q, label: q });
+ }
+ choice.setChoices(opts, 'value', 'label', true);
+ } catch (err) { console.error('series autocomplete failed:', err); }
+ }, 300);
+ });
+
+ sel.addEventListener('change', syncHidden);
+ // Don't lose a typed-but-not-picked value: commit it on blur.
+ if (input) {
+ input.addEventListener('blur', function () { if (input.value.trim()) commitTyped(input.value); });
+ }
+ // Enter on typed text with nothing highlighted creates/commits it.
+ if (typeof choice._onEnterKey === 'function') {
+ const origEnter = choice._onEnterKey.bind(choice);
+ choice._onEnterKey = function (event, hasActiveDropdown) {
+ const typed = input ? input.value.trim() : '';
+ const dd = wrapper ? wrapper.querySelector('.choices__list--dropdown') : null;
+ const hl = dd ? dd.querySelector('.choices__item--selectable.is-highlighted') : null;
+ if (typed && !hl) { event.preventDefault(); commitTyped(typed); return; }
+ return origEnter(event, hasActiveDropdown);
+ };
+ }
+ } catch (err) { console.error('initializeSeriesAutocompletes:', err); }
+ });
+}
+
function initializePublishersChoices() {
try {
const element = document.getElementById('editori_select');
@@ -3869,9 +3980,14 @@ function initializeIsbnImport() {
// Handle series (collana)
try {
if (data.series) {
- const seriesInput = document.querySelector('input[name="collana"]');
- if (seriesInput) {
- seriesInput.value = data.series;
+ // collana is now a Choices autocomplete (#179) — set it via the
+ // exposed setter so the value shows in the control; fall back to
+ // the hidden input if the autocomplete hasn't initialised.
+ if (window.__seriesAutocomplete && window.__seriesAutocomplete.collana) {
+ window.__seriesAutocomplete.collana(data.series);
+ } else {
+ const seriesInput = document.getElementById('collana');
+ if (seriesInput) seriesInput.value = data.series;
}
const scrapedSeries = document.getElementById('scraped_series');
if (scrapedSeries) {
diff --git a/locale/de_DE.json b/locale/de_DE.json
index de6d510e..137ff433 100644
--- a/locale/de_DE.json
+++ b/locale/de_DE.json
@@ -5411,5 +5411,6 @@
"Dispositivo non trovato.": "Gerät nicht gefunden.",
"Impossibile recuperare lo stato del servizio.": "Dienststatus konnte nicht abgerufen werden.",
"Il file caricato supera il limite di caricamento del server (post_max_size = %s). Aumenta post_max_size e upload_max_filesize nella configurazione PHP del server e riprova. Su hosting con php-fpm o CGI le direttive php_value in .htaccess vengono ignorate: modifica php.ini o la configurazione del pool php-fpm.": "Die hochgeladene Datei überschreitet das Upload-Limit des Servers (post_max_size = %s). Erhöhen Sie post_max_size und upload_max_filesize in der PHP-Konfiguration des Servers und versuchen Sie es erneut. Bei php-fpm- oder CGI-Hosting werden php_value-Direktiven in der .htaccess ignoriert: Bearbeiten Sie php.ini oder die php-fpm-Pool-Konfiguration.",
- "Il file caricato supera il limite di upload del server (upload_max_filesize = %s). Aumenta upload_max_filesize e post_max_size nella configurazione PHP del server e riprova. Su hosting con php-fpm o CGI le direttive php_value in .htaccess vengono ignorate: modifica php.ini o la configurazione del pool php-fpm.": "Die hochgeladene Datei überschreitet das Upload-Limit des Servers (upload_max_filesize = %s). Erhöhen Sie upload_max_filesize und post_max_size in der PHP-Konfiguration des Servers und versuchen Sie es erneut. Bei php-fpm- oder CGI-Hosting werden php_value-Direktiven in der .htaccess ignoriert: Bearbeiten Sie php.ini oder die php-fpm-Pool-Konfiguration."
+ "Il file caricato supera il limite di upload del server (upload_max_filesize = %s). Aumenta upload_max_filesize e post_max_size nella configurazione PHP del server e riprova. Su hosting con php-fpm o CGI le direttive php_value in .htaccess vengono ignorate: modifica php.ini o la configurazione del pool php-fpm.": "Die hochgeladene Datei überschreitet das Upload-Limit des Servers (upload_max_filesize = %s). Erhöhen Sie upload_max_filesize und post_max_size in der PHP-Konfiguration des Servers und versuchen Sie es erneut. Bei php-fpm- oder CGI-Hosting werden php_value-Direktiven in der .htaccess ignoriert: Bearbeiten Sie php.ini oder die php-fpm-Pool-Konfiguration.",
+ "Digita almeno 2 lettere per cercare o creare": "Mindestens 2 Buchstaben eingeben, um zu suchen oder zu erstellen"
}
diff --git a/locale/en_US.json b/locale/en_US.json
index 7715cc6d..46a66b10 100644
--- a/locale/en_US.json
+++ b/locale/en_US.json
@@ -5411,5 +5411,6 @@
"Dispositivo non trovato.": "Device not found.",
"Impossibile recuperare lo stato del servizio.": "Could not retrieve the service status.",
"Il file caricato supera il limite di caricamento del server (post_max_size = %s). Aumenta post_max_size e upload_max_filesize nella configurazione PHP del server e riprova. Su hosting con php-fpm o CGI le direttive php_value in .htaccess vengono ignorate: modifica php.ini o la configurazione del pool php-fpm.": "The uploaded file exceeds the server upload limit (post_max_size = %s). Increase post_max_size and upload_max_filesize in the server's PHP configuration and try again. On php-fpm or CGI hosting, php_value directives in .htaccess are ignored: edit php.ini or the php-fpm pool configuration.",
- "Il file caricato supera il limite di upload del server (upload_max_filesize = %s). Aumenta upload_max_filesize e post_max_size nella configurazione PHP del server e riprova. Su hosting con php-fpm o CGI le direttive php_value in .htaccess vengono ignorate: modifica php.ini o la configurazione del pool php-fpm.": "The uploaded file exceeds the server upload limit (upload_max_filesize = %s). Increase upload_max_filesize and post_max_size in the server's PHP configuration and try again. On php-fpm or CGI hosting, php_value directives in .htaccess are ignored: edit php.ini or the php-fpm pool configuration."
+ "Il file caricato supera il limite di upload del server (upload_max_filesize = %s). Aumenta upload_max_filesize e post_max_size nella configurazione PHP del server e riprova. Su hosting con php-fpm o CGI le direttive php_value in .htaccess vengono ignorate: modifica php.ini o la configurazione del pool php-fpm.": "The uploaded file exceeds the server upload limit (upload_max_filesize = %s). Increase upload_max_filesize and post_max_size in the server's PHP configuration and try again. On php-fpm or CGI hosting, php_value directives in .htaccess are ignored: edit php.ini or the php-fpm pool configuration.",
+ "Digita almeno 2 lettere per cercare o creare": "Type at least 2 letters to search or create"
}
diff --git a/locale/fr_FR.json b/locale/fr_FR.json
index d8ddbee0..dfe829d7 100644
--- a/locale/fr_FR.json
+++ b/locale/fr_FR.json
@@ -5411,5 +5411,6 @@
"Dispositivo non trovato.": "Appareil introuvable.",
"Impossibile recuperare lo stato del servizio.": "Impossible de récupérer l'état du service.",
"Il file caricato supera il limite di caricamento del server (post_max_size = %s). Aumenta post_max_size e upload_max_filesize nella configurazione PHP del server e riprova. Su hosting con php-fpm o CGI le direttive php_value in .htaccess vengono ignorate: modifica php.ini o la configurazione del pool php-fpm.": "Le fichier envoyé dépasse la limite de téléversement du serveur (post_max_size = %s). Augmentez post_max_size et upload_max_filesize dans la configuration PHP du serveur, puis réessayez. Sur un hébergement php-fpm ou CGI, les directives php_value du fichier .htaccess sont ignorées : modifiez php.ini ou la configuration du pool php-fpm.",
- "Il file caricato supera il limite di upload del server (upload_max_filesize = %s). Aumenta upload_max_filesize e post_max_size nella configurazione PHP del server e riprova. Su hosting con php-fpm o CGI le direttive php_value in .htaccess vengono ignorate: modifica php.ini o la configurazione del pool php-fpm.": "Le fichier envoyé dépasse la limite de téléversement du serveur (upload_max_filesize = %s). Augmentez upload_max_filesize et post_max_size dans la configuration PHP du serveur, puis réessayez. Sur un hébergement php-fpm ou CGI, les directives php_value du fichier .htaccess sont ignorées : modifiez php.ini ou la configuration du pool php-fpm."
+ "Il file caricato supera il limite di upload del server (upload_max_filesize = %s). Aumenta upload_max_filesize e post_max_size nella configurazione PHP del server e riprova. Su hosting con php-fpm o CGI le direttive php_value in .htaccess vengono ignorate: modifica php.ini o la configurazione del pool php-fpm.": "Le fichier envoyé dépasse la limite de téléversement du serveur (upload_max_filesize = %s). Augmentez upload_max_filesize et post_max_size dans la configuration PHP du serveur, puis réessayez. Sur un hébergement php-fpm ou CGI, les directives php_value du fichier .htaccess sont ignorées : modifiez php.ini ou la configuration du pool php-fpm.",
+ "Digita almeno 2 lettere per cercare o creare": "Tapez au moins 2 lettres pour rechercher ou créer"
}
diff --git a/locale/it_IT.json b/locale/it_IT.json
index 8dc6a820..6d1156ab 100644
--- a/locale/it_IT.json
+++ b/locale/it_IT.json
@@ -5411,5 +5411,6 @@
"Dispositivo non trovato.": "Dispositivo non trovato.",
"Impossibile recuperare lo stato del servizio.": "Impossibile recuperare lo stato del servizio.",
"Il file caricato supera il limite di caricamento del server (post_max_size = %s). Aumenta post_max_size e upload_max_filesize nella configurazione PHP del server e riprova. Su hosting con php-fpm o CGI le direttive php_value in .htaccess vengono ignorate: modifica php.ini o la configurazione del pool php-fpm.": "Il file caricato supera il limite di caricamento del server (post_max_size = %s). Aumenta post_max_size e upload_max_filesize nella configurazione PHP del server e riprova. Su hosting con php-fpm o CGI le direttive php_value in .htaccess vengono ignorate: modifica php.ini o la configurazione del pool php-fpm.",
- "Il file caricato supera il limite di upload del server (upload_max_filesize = %s). Aumenta upload_max_filesize e post_max_size nella configurazione PHP del server e riprova. Su hosting con php-fpm o CGI le direttive php_value in .htaccess vengono ignorate: modifica php.ini o la configurazione del pool php-fpm.": "Il file caricato supera il limite di upload del server (upload_max_filesize = %s). Aumenta upload_max_filesize e post_max_size nella configurazione PHP del server e riprova. Su hosting con php-fpm o CGI le direttive php_value in .htaccess vengono ignorate: modifica php.ini o la configurazione del pool php-fpm."
+ "Il file caricato supera il limite di upload del server (upload_max_filesize = %s). Aumenta upload_max_filesize e post_max_size nella configurazione PHP del server e riprova. Su hosting con php-fpm o CGI le direttive php_value in .htaccess vengono ignorate: modifica php.ini o la configurazione del pool php-fpm.": "Il file caricato supera il limite di upload del server (upload_max_filesize = %s). Aumenta upload_max_filesize e post_max_size nella configurazione PHP del server e riprova. Su hosting con php-fpm o CGI le direttive php_value in .htaccess vengono ignorate: modifica php.ini o la configurazione del pool php-fpm.",
+ "Digita almeno 2 lettere per cercare o creare": "Digita almeno 2 lettere per cercare o creare"
}
diff --git a/tests/full-test.spec.js b/tests/full-test.spec.js
index b90f3723..d99e5bbe 100644
--- a/tests/full-test.spec.js
+++ b/tests/full-test.spec.js
@@ -687,11 +687,16 @@ test.describe.serial('Phase 3: Manual Book Creation', () => {
await copies.fill('2');
}
- // Series (collana)
- const collana = page.locator('#collana, input[name="collana"]');
- if (await collana.isVisible({ timeout: 1000 }).catch(() => false)) {
- await collana.fill(`TestSeries_${RUN_ID}`);
- }
+ // Series (collana) — now a Choices.js autocomplete (#179) whose submitted
+ // value lives in a hidden input set via the exposed setter.
+ await page.evaluate((v) => {
+ if (window.__seriesAutocomplete && window.__seriesAutocomplete.collana) {
+ window.__seriesAutocomplete.collana(v);
+ } else {
+ const h = document.getElementById('collana');
+ if (h) h.value = v;
+ }
+ }, `TestSeries_${RUN_ID}`);
// Notes
const note = page.locator('#note_varie, textarea[name="note_varie"]');
diff --git a/tests/issue-179-series-autocomplete.spec.js b/tests/issue-179-series-autocomplete.spec.js
new file mode 100644
index 00000000..91baf2a7
--- /dev/null
+++ b/tests/issue-179-series-autocomplete.spec.js
@@ -0,0 +1,82 @@
+// @ts-check
+// Issue #179 — the book-form "universe / group / cycle / series" fields must
+// propose EXISTING values (Choices.js autocomplete) so a typo no longer spawns a
+// duplicate universe, while still letting the user create a brand-new value.
+const { test, expect } = require('@playwright/test');
+test.describe.configure({ mode: 'serial' });
+
+const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081';
+const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || '';
+const ADMIN_PASS = process.env.E2E_ADMIN_PASS || '';
+
+let page;
+
+test.beforeAll(async ({ browser }) => {
+ test.skip(!ADMIN_EMAIL, 'creds not configured (use /tmp/run-e2e.sh)');
+ page = await browser.newPage();
+ await page.goto(`${BASE}/accedi`);
+ await page.fill('input[name="email"]', ADMIN_EMAIL);
+ await page.fill('input[name="password"]', ADMIN_PASS);
+ await page.click('button[type="submit"]');
+ await page.waitForLoadState('networkidle');
+});
+test.afterAll(async () => { await page?.close(); });
+
+// The serie_padre (universe) Choices wrapper + its hidden value input.
+const universeWrapper = () => page.locator('.choices', { has: page.locator('#serie_padre_select') });
+
+test('Universe field suggests an EXISTING universe and writes it to the hidden input', async () => {
+ await page.goto(`${BASE}/admin/books/create`);
+ await page.waitForSelector('#serie_padre_select', { state: 'attached', timeout: 10000 });
+
+ // Open the single-select Choices and type into its search box.
+ await universeWrapper().click();
+ const search = universeWrapper().locator('input[type="search"], .choices__input--cloned').first();
+ await search.fill('seed');
+
+ // An existing universe (seeded: "Seed: Fairy Tail Universe") must appear.
+ const suggestion = page.locator('.choices__list--dropdown .choices__item--selectable', {
+ hasText: 'Seed: Fairy Tail Universe',
+ }).first();
+ await expect(suggestion).toBeVisible({ timeout: 8000 });
+ await suggestion.click();
+
+ // The hidden field that submits to the form carries the picked value.
+ await expect(page.locator('#serie_padre')).toHaveValue('Seed: Fairy Tail Universe');
+});
+
+test('Typing a brand-new universe name commits it (create-new path)', async () => {
+ await page.goto(`${BASE}/admin/books/create`);
+ await page.waitForSelector('#serie_padre_select', { state: 'attached', timeout: 10000 });
+
+ const NEW = 'Universo Test 179 ' + Date.now();
+ await universeWrapper().click();
+ const search = universeWrapper().locator('input[type="search"], .choices__input--cloned').first();
+ await search.fill(NEW);
+ // The typed value is always offered as a selectable option; Enter commits it.
+ await search.press('Enter');
+
+ await expect(page.locator('#serie_padre')).toHaveValue(NEW);
+});
+
+// All four series fields share the same generic autocomplete: each suggests its
+// own existing values (from the right collane source) and writes the picked
+// value to its hidden input.
+for (const f of [
+ { sel: '#gruppo_serie_select', hidden: '#gruppo_serie', q: 'fairy', expect: 'Fairy Tail' },
+ { sel: '#ciclo_serie_select', hidden: '#ciclo_serie', q: 'ciclo', expect: 'Ciclo 1' },
+ { sel: '#collana_select', hidden: '#collana', q: 'fairy', expect: 'Seed: Fairy Tail' },
+]) {
+ test(`Field ${f.sel} suggests existing values and writes the pick to ${f.hidden}`, async () => {
+ await page.goto(`${BASE}/admin/books/create`);
+ await page.waitForSelector(f.sel, { state: 'attached', timeout: 10000 });
+ const wrapper = page.locator('.choices', { has: page.locator(f.sel) });
+ await wrapper.click();
+ const search = wrapper.locator('input[type="search"], .choices__input--cloned').first();
+ await search.fill(f.q);
+ const item = page.locator('.choices__list--dropdown .choices__item--selectable', { hasText: f.expect }).first();
+ await expect(item).toBeVisible({ timeout: 8000 });
+ await item.click();
+ await expect(page.locator(f.hidden)).toHaveValue(f.expect);
+ });
+}
diff --git a/tests/series-cycles.spec.js b/tests/series-cycles.spec.js
index ebeb8484..ff197372 100644
--- a/tests/series-cycles.spec.js
+++ b/tests/series-cycles.spec.js
@@ -151,16 +151,29 @@ async function submitBookForm(page, expectedId = null) {
return Number(match?.[1]);
}
+// The universe/group/cycle/series fields are Choices.js autocompletes (#179);
+// their submitted value lives in a hidden input set via the exposed setter.
+async function setSeriesAutocomplete(page, field, value) {
+ await page.evaluate(([f, v]) => {
+ if (window.__seriesAutocomplete && window.__seriesAutocomplete[f]) {
+ window.__seriesAutocomplete[f](v);
+ } else {
+ const h = document.getElementById(f);
+ if (h) h.value = v;
+ }
+ }, [field, value || '']);
+}
+
async function createBookWithSeries(page, data) {
await page.goto(`${BASE}/admin/books/create`);
await page.waitForLoadState('domcontentloaded');
await page.fill('#titolo', data.title);
- await page.fill('#gruppo_serie', data.group || '');
- await page.fill('#serie_padre', data.parent || '');
+ await setSeriesAutocomplete(page, 'gruppo_serie', data.group || '');
+ await setSeriesAutocomplete(page, 'serie_padre', data.parent || '');
await page.selectOption('#tipo_collana', data.type || 'serie');
- await page.fill('#collana', data.series || '');
+ await setSeriesAutocomplete(page, 'collana', data.series || '');
await page.fill('#numero_serie', data.number || '');
- await page.fill('#ciclo_serie', data.cycle || '');
+ await setSeriesAutocomplete(page, 'ciclo_serie', data.cycle || '');
await page.fill('#ordine_ciclo', data.cycleOrder ? String(data.cycleOrder) : '');
await page.fill('#altre_collane', data.otherSeries || '');
return submitBookForm(page);
@@ -335,7 +348,7 @@ test.describe.serial('Series groups and cycles', () => {
test('11. editing a book updates its series cycle metadata', async () => {
const id = ids.get('cycle2book');
await page.goto(`${BASE}/admin/books/edit/${id}`);
- await page.fill('#ciclo_serie', 'Cycle 3 - Betelgeuse revised');
+ await setSeriesAutocomplete(page, 'ciclo_serie', 'Cycle 3 - Betelgeuse revised');
await page.fill('#ordine_ciclo', '3');
await expect(page.locator('#serie_padre')).toHaveValue(GROUP_WORLDS);
await expect(page.locator('#tipo_collana')).toHaveValue('ciclo');
@@ -379,7 +392,7 @@ test.describe.serial('Series groups and cycles', () => {
test('13. series detail can add group metadata to the bulk-assigned series', async () => {
await page.goto(`${BASE}/admin/series/detail?nome=${encodeURIComponent(HAPPY_SERIES)}`);
- await page.fill('#gruppo_serie', GROUP_FAIRY);
+ await setSeriesAutocomplete(page, 'gruppo_serie', GROUP_FAIRY);
await page.fill('#ciclo', 'Happy spin-off');
await page.fill('#ordine_ciclo', '4');
await page.click('button:has-text("Salva descrizione")');