forked from fa/breadcrumb-the-shire
feat(ui): token-select primitive for large multi-selects
Adds tokenSelectForm() in core/Support/helpers/ui.php: an inline typeahead combobox + flat alphabetical removable list, designed for selections that outgrow the chip-header of the vendor MultiSelect (roles, permissions, and similar admin pickers). Contract: - Hidden <select multiple name="<name>[]"> is the form submission source — consumers read via $request->body() verbatim, no parsing - Items are ['id' => int, <labelKey> => string, 'key' => string?] where labelKeys default to ['description', 'label', 'name']; 'key' is an invisible fuzzy-match hint - $labelOverrides lets callers swap emptyState / removeTooltip / noMatches / clearAll / countSuffix / errorMessage with domain copy - $disabled renders pure-presentation list (no combobox, no remove) Runtime: - initTokenSelect in web/js/components/app-token-select.js is registered as 'token-select' in app-init.js; destroy()/cleanupFns contract - Syncs the hidden <select> on every mutation and dispatches 'change' so dependent UI can react Wired up: pages/admin/permissions/_form.phtml (assigned roles), pages/admin/roles/_form.phtml (permissions + assignable roles). Helper contract lives in tests/Support/Helpers/TokenSelectFormHelperTest.php; runtime contract + entrypoint registration + host usage are enforced by FrontendRuntime*ContractTest.php. Coexists with multiSelectForm() — pick tokenSelectForm() when the selected set can grow beyond ~10 items or typeahead is expected. Docs in CLAUDE.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,11 @@ class FrontendRuntimeComponentContractTest extends TestCase
|
||||
|
||||
$this->assertStringContainsString('return {', $multiSelect);
|
||||
$this->assertStringContainsString('destroy: () => {', $multiSelect);
|
||||
|
||||
$tokenSelect = $this->readProjectFile('web/js/components/app-token-select.js');
|
||||
|
||||
$this->assertStringContainsString('const destroy = () => {', $tokenSelect);
|
||||
$this->assertStringContainsString('cleanupFns.push(', $tokenSelect);
|
||||
}
|
||||
|
||||
public function testRuntimeStorageContractUsesSharedUiStorageHelper(): void
|
||||
|
||||
@@ -20,6 +20,7 @@ class FrontendRuntimeEntrypointContractTest extends TestCase
|
||||
|
||||
$this->assertStringContainsString("runtime.register('sidebar-toggle', initSidebarToggle", $defaultEntry);
|
||||
$this->assertStringContainsString("runtime.register('global-search', initGlobalSearch", $defaultEntry);
|
||||
$this->assertStringContainsString("runtime.register('token-select', initTokenSelect", $defaultEntry);
|
||||
$this->assertStringContainsString("await mountModuleComponentsByPhase('late');", $defaultEntry);
|
||||
$this->assertStringContainsString('moduleRuntimeComponents', $defaultEntry);
|
||||
$this->assertStringContainsString("selector: '[data-app-component=\"tabs\"]'", $defaultEntry);
|
||||
|
||||
@@ -57,4 +57,10 @@ class FrontendRuntimeHostContractTest extends TestCase
|
||||
$this->assertStringContainsString('data-app-component="tabs"', $content, $file);
|
||||
}
|
||||
}
|
||||
|
||||
public function testRolesFormUsesTokenSelectHost(): void
|
||||
{
|
||||
$rolesForm = $this->readProjectFile('pages/admin/roles/_form.phtml');
|
||||
$this->assertStringContainsString('tokenSelectForm(', $rolesForm);
|
||||
}
|
||||
}
|
||||
|
||||
373
tests/Support/Helpers/TokenSelectFormHelperTest.php
Normal file
373
tests/Support/Helpers/TokenSelectFormHelperTest.php
Normal file
@@ -0,0 +1,373 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Support\Helpers;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Contract tests for the `tokenSelectForm()` PHP helper (GR-TEST-001).
|
||||
*
|
||||
* The helper emits a hidden <select multiple> (source of truth for POST) plus
|
||||
* a progressive-enhancement host that the `token-select` runtime component
|
||||
* upgrades into an interactive combobox lookup with a flat removable list.
|
||||
*/
|
||||
class TokenSelectFormHelperTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @return list<array{id: int, key: string, description: string}>
|
||||
*/
|
||||
private function itemsFixture(): array
|
||||
{
|
||||
return [
|
||||
['id' => 1, 'key' => 'admin.users.view', 'description' => 'View users'],
|
||||
['id' => 2, 'key' => 'admin.users.edit', 'description' => 'Edit users'],
|
||||
['id' => 3, 'key' => 'billing.invoices.read', 'description' => 'Read invoices'],
|
||||
];
|
||||
}
|
||||
|
||||
private function render(callable $call): string
|
||||
{
|
||||
ob_start();
|
||||
try {
|
||||
$call();
|
||||
} finally {
|
||||
$output = ob_get_clean();
|
||||
}
|
||||
|
||||
return $output === false ? '' : $output;
|
||||
}
|
||||
|
||||
public function testRendersHiddenSelectWithSelectedOptions(): void
|
||||
{
|
||||
$items = $this->itemsFixture();
|
||||
$html = $this->render(function () use ($items): void {
|
||||
tokenSelectForm(
|
||||
'role-permissions',
|
||||
'permission_ids',
|
||||
'Assigned permissions',
|
||||
'Select permissions',
|
||||
$items,
|
||||
[1, 3],
|
||||
false,
|
||||
['description', 'key']
|
||||
);
|
||||
});
|
||||
|
||||
self::assertStringContainsString('<select', $html);
|
||||
self::assertStringContainsString('name="permission_ids[]"', $html);
|
||||
self::assertStringContainsString('multiple', $html);
|
||||
self::assertStringContainsString('value="1"', $html);
|
||||
self::assertStringContainsString('value="2"', $html);
|
||||
self::assertStringContainsString('value="3"', $html);
|
||||
|
||||
// Only ids 1 and 3 must carry `selected`.
|
||||
self::assertMatchesRegularExpression('/value="1"[^>]*selected/', $html);
|
||||
self::assertMatchesRegularExpression('/value="3"[^>]*selected/', $html);
|
||||
self::assertDoesNotMatchRegularExpression('/value="2"[^>]*selected/', $html);
|
||||
}
|
||||
|
||||
public function testRendersHostDataAttributeAndCombobox(): void
|
||||
{
|
||||
$html = $this->render(function (): void {
|
||||
tokenSelectForm(
|
||||
'role-permissions',
|
||||
'permission_ids',
|
||||
'Assigned permissions',
|
||||
'Select permissions',
|
||||
$this->itemsFixture(),
|
||||
[1],
|
||||
false,
|
||||
['description', 'key']
|
||||
);
|
||||
});
|
||||
|
||||
self::assertStringContainsString('data-app-component="token-select"', $html);
|
||||
self::assertStringContainsString('data-readonly="0"', $html);
|
||||
self::assertStringContainsString('data-token-select-id="role-permissions"', $html);
|
||||
self::assertStringContainsString('data-token-select-field="permission_ids"', $html);
|
||||
// Combobox input + listbox must be present in non-readonly mode.
|
||||
self::assertStringContainsString('role="combobox"', $html);
|
||||
self::assertStringContainsString('data-token-select-input', $html);
|
||||
self::assertStringContainsString('data-token-select-listbox', $html);
|
||||
self::assertStringContainsString('id="role-permissions-listbox"', $html);
|
||||
self::assertStringContainsString('aria-controls="role-permissions-listbox"', $html);
|
||||
}
|
||||
|
||||
public function testReadonlyModeHidesInteractiveAffordances(): void
|
||||
{
|
||||
$html = $this->render(function (): void {
|
||||
tokenSelectForm(
|
||||
'role-permissions',
|
||||
'permission_ids',
|
||||
'Assigned permissions',
|
||||
'Select permissions',
|
||||
$this->itemsFixture(),
|
||||
[1, 2],
|
||||
true,
|
||||
['description', 'key']
|
||||
);
|
||||
});
|
||||
|
||||
self::assertStringContainsString('data-readonly="1"', $html);
|
||||
self::assertStringContainsString('app-token-select--readonly', $html);
|
||||
// No combobox input, no clear, no remove in readonly.
|
||||
self::assertStringNotContainsString('data-token-select-input', $html);
|
||||
self::assertStringNotContainsString('data-token-select-clear', $html);
|
||||
self::assertStringNotContainsString('data-token-select-remove', $html);
|
||||
self::assertStringNotContainsString('role="combobox"', $html);
|
||||
}
|
||||
|
||||
public function testSelectedRowsAreFlatAndAlphabeticallySorted(): void
|
||||
{
|
||||
$items = [
|
||||
['id' => 1, 'key' => 'zulu.task', 'description' => 'Zeta action'],
|
||||
['id' => 2, 'key' => 'alpha.task', 'description' => 'Alpha action'],
|
||||
['id' => 3, 'key' => 'mike.task', 'description' => 'Mike action'],
|
||||
];
|
||||
$html = $this->render(function () use ($items): void {
|
||||
tokenSelectForm(
|
||||
'role-permissions',
|
||||
'permission_ids',
|
||||
'Assigned permissions',
|
||||
'Select permissions',
|
||||
$items,
|
||||
[1, 2, 3],
|
||||
false,
|
||||
['description', 'key']
|
||||
);
|
||||
});
|
||||
|
||||
// Alphabetical by label: Alpha (id=2) < Mike (id=3) < Zeta (id=1).
|
||||
// The `data-token-select-row` attribute only appears on the visible selected rows
|
||||
// (not on the hidden <option>s), so its positions reflect the rendered row order.
|
||||
$posAlpha = strpos($html, 'data-token-select-row="2"');
|
||||
$posMike = strpos($html, 'data-token-select-row="3"');
|
||||
$posZeta = strpos($html, 'data-token-select-row="1"');
|
||||
self::assertIsInt($posAlpha);
|
||||
self::assertIsInt($posMike);
|
||||
self::assertIsInt($posZeta);
|
||||
self::assertLessThan($posMike, $posAlpha);
|
||||
self::assertLessThan($posZeta, $posMike);
|
||||
|
||||
// No grouping markers in output.
|
||||
self::assertStringNotContainsString('app-token-select-group', $html);
|
||||
self::assertStringNotContainsString('data-token-select-group', $html);
|
||||
}
|
||||
|
||||
public function testLabelFallbackChainUsesKeyWhenDescriptionMissing(): void
|
||||
{
|
||||
$items = [
|
||||
['id' => 7, 'key' => 'admin.users.view', 'description' => ''],
|
||||
];
|
||||
$html = $this->render(function () use ($items): void {
|
||||
tokenSelectForm(
|
||||
'role-permissions',
|
||||
'permission_ids',
|
||||
'Assigned permissions',
|
||||
'Select permissions',
|
||||
$items,
|
||||
[7],
|
||||
false,
|
||||
['description', 'key']
|
||||
);
|
||||
});
|
||||
|
||||
// The row should display the key, since description is empty.
|
||||
self::assertStringContainsString('admin.users.view', $html);
|
||||
}
|
||||
|
||||
public function testInvalidItemsRendersErrorCard(): void
|
||||
{
|
||||
$html = $this->render(function (): void {
|
||||
tokenSelectForm(
|
||||
'role-permissions',
|
||||
'permission_ids',
|
||||
'Assigned permissions',
|
||||
'Select permissions',
|
||||
null,
|
||||
[],
|
||||
false,
|
||||
['description', 'key']
|
||||
);
|
||||
});
|
||||
|
||||
self::assertStringContainsString('app-token-select--error', $html);
|
||||
// t() returns the input key when no translation is loaded.
|
||||
self::assertStringContainsString('token_select.error_invalid_items', $html);
|
||||
self::assertStringNotContainsString('data-app-component="token-select"', $html);
|
||||
}
|
||||
|
||||
public function testLabelOverridesReplaceDefaultI18nStrings(): void
|
||||
{
|
||||
$html = $this->render(function (): void {
|
||||
tokenSelectForm(
|
||||
'custom',
|
||||
'tag_ids',
|
||||
'Assigned tags',
|
||||
'Search tags…',
|
||||
$this->itemsFixture(),
|
||||
[1],
|
||||
false,
|
||||
['description', 'key'],
|
||||
null,
|
||||
[
|
||||
'emptyState' => 'No tags selected',
|
||||
'noMatches' => 'No matching tags',
|
||||
'removeTooltip' => 'Remove tag',
|
||||
'clearAll' => 'Drop all tags',
|
||||
'countSuffix' => 'tags applied',
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
self::assertStringContainsString('data-label-remove="Remove tag"', $html);
|
||||
self::assertStringContainsString('data-label-empty-state="No tags selected"', $html);
|
||||
self::assertStringContainsString('data-label-no-matches="No matching tags"', $html);
|
||||
self::assertStringContainsString('>Drop all tags<', $html);
|
||||
self::assertStringContainsString('tags applied', $html);
|
||||
// Default i18n key must NOT leak through when an override is provided.
|
||||
self::assertStringNotContainsString('token_select.remove', $html);
|
||||
self::assertStringNotContainsString('token_select.empty_state_title', $html);
|
||||
}
|
||||
|
||||
public function testLabelOverridesForErrorCardReplaceDefault(): void
|
||||
{
|
||||
$html = $this->render(function (): void {
|
||||
tokenSelectForm(
|
||||
'custom',
|
||||
'tag_ids',
|
||||
'Assigned tags',
|
||||
'Search tags…',
|
||||
null,
|
||||
[],
|
||||
false,
|
||||
['description', 'key'],
|
||||
null,
|
||||
['errorMessage' => 'Tags could not be loaded.']
|
||||
);
|
||||
});
|
||||
|
||||
self::assertStringContainsString('Tags could not be loaded.', $html);
|
||||
self::assertStringNotContainsString('token_select.error_invalid_items', $html);
|
||||
}
|
||||
|
||||
public function testAcceptsMinimalItemShape(): void
|
||||
{
|
||||
// Generic data contract: id + one labelKey is enough; `key` is optional.
|
||||
$items = [
|
||||
['id' => 10, 'label' => 'First item'],
|
||||
['id' => 20, 'label' => 'Second item'],
|
||||
];
|
||||
$html = $this->render(function () use ($items): void {
|
||||
tokenSelectForm(
|
||||
'generic',
|
||||
'item_ids',
|
||||
'Items',
|
||||
'Search…',
|
||||
$items,
|
||||
[10],
|
||||
false,
|
||||
['label']
|
||||
);
|
||||
});
|
||||
|
||||
self::assertStringContainsString('value="10"', $html);
|
||||
self::assertStringContainsString('value="20"', $html);
|
||||
self::assertMatchesRegularExpression('/value="10"[^>]*selected/', $html);
|
||||
self::assertStringContainsString('First item', $html);
|
||||
// Without a `key`, hidden options carry an empty `data-token-key` attribute.
|
||||
self::assertMatchesRegularExpression('/value="10"[^>]*data-token-key=""/', $html);
|
||||
}
|
||||
|
||||
public function testEscapesHostileLabelsAndKeys(): void
|
||||
{
|
||||
// Protection against GR-SEC-010: every user-facing value must flow through e().
|
||||
$items = [
|
||||
['id' => 1, 'key' => '<img src=x onerror=alert(1)>', 'description' => '"><svg onload=alert(1)>'],
|
||||
];
|
||||
$html = $this->render(function () use ($items): void {
|
||||
tokenSelectForm(
|
||||
'hostile',
|
||||
'ids',
|
||||
'Things',
|
||||
'Search…',
|
||||
$items,
|
||||
[1],
|
||||
false,
|
||||
['description']
|
||||
);
|
||||
});
|
||||
|
||||
// Raw attack payloads must NOT appear unescaped.
|
||||
self::assertStringNotContainsString('<img src=x', $html);
|
||||
self::assertStringNotContainsString('<svg onload', $html);
|
||||
// Escaped forms must be present.
|
||||
self::assertStringContainsString('<img src=x', $html);
|
||||
self::assertStringContainsString('<svg onload', $html);
|
||||
}
|
||||
|
||||
public function testTwoInstancesOnSamePageProduceUniqueListboxIds(): void
|
||||
{
|
||||
$html = $this->render(function (): void {
|
||||
tokenSelectForm('perms', 'permission_ids', 'Permissions', 'Search…', $this->itemsFixture(), [1], false, ['description']);
|
||||
tokenSelectForm('roles', 'role_ids', 'Roles', 'Search…', $this->itemsFixture(), [2], false, ['description']);
|
||||
});
|
||||
|
||||
self::assertStringContainsString('id="perms-listbox"', $html);
|
||||
self::assertStringContainsString('id="roles-listbox"', $html);
|
||||
self::assertStringContainsString('aria-controls="perms-listbox"', $html);
|
||||
self::assertStringContainsString('aria-controls="roles-listbox"', $html);
|
||||
self::assertStringContainsString('data-token-select-id="perms"', $html);
|
||||
self::assertStringContainsString('data-token-select-id="roles"', $html);
|
||||
}
|
||||
|
||||
public function testServerRendersTranslatedLabelsOnHost(): void
|
||||
{
|
||||
// The `data-label-*` attributes must carry server-translated strings so
|
||||
// JS re-renders (remove, empty-state, no-matches) read them back verbatim
|
||||
// instead of rendering i18n key literals.
|
||||
$html = $this->render(function (): void {
|
||||
tokenSelectForm(
|
||||
'token',
|
||||
'ids',
|
||||
'Assigned things',
|
||||
'Search things…',
|
||||
$this->itemsFixture(),
|
||||
[1],
|
||||
false,
|
||||
['description'],
|
||||
null,
|
||||
[
|
||||
'emptyState' => 'No things here',
|
||||
'removeTooltip' => 'Drop this thing',
|
||||
'noMatches' => 'Nothing matches',
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
self::assertStringContainsString('data-label-remove="Drop this thing"', $html);
|
||||
self::assertStringContainsString('data-label-empty-state="No things here"', $html);
|
||||
self::assertStringContainsString('data-label-no-matches="Nothing matches"', $html);
|
||||
}
|
||||
|
||||
public function testEmptyItemsRendersEmptyState(): void
|
||||
{
|
||||
$html = $this->render(function (): void {
|
||||
tokenSelectForm(
|
||||
'role-permissions',
|
||||
'permission_ids',
|
||||
'Assigned permissions',
|
||||
'Select permissions',
|
||||
[],
|
||||
[],
|
||||
false,
|
||||
['description', 'key']
|
||||
);
|
||||
});
|
||||
|
||||
self::assertStringContainsString('data-token-select-empty', $html);
|
||||
self::assertStringContainsString('data-app-component="token-select"', $html);
|
||||
// Combobox input is still rendered so the user can add items from the empty state.
|
||||
self::assertStringContainsString('data-token-select-input', $html);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user