Skip to content

Commit fe84779

Browse files
authored
Merge pull request #10 from ConductionNL/wip/build-quick-search-2026-07-23
feat(tile-quick-search): on-dashboard quick-search launcher + archive
2 parents 5a4cfbc + c837555 commit fe84779

21 files changed

Lines changed: 2527 additions & 37 deletions

File tree

lib/Controller/AdminController.php

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,15 @@ public function getSettings(): JSONResponse
380380
* unlimited).
381381
* dashboard-quota-limits
382382
* REQ-QUOTA-001.
383+
* @param string|null $quicksearchFallbackTarget On-dashboard quick-search
384+
* no-match fallback:
385+
* `'none'`,
386+
* `'unified-search'`, or
387+
* an `https` URL
388+
* template containing
389+
* `{query}`.
390+
* tile-quick-search
391+
* REQ-QSEARCH-004.
383392
*
384393
* @return JSONResponse The update confirmation.
385394
*
@@ -397,7 +406,8 @@ public function updateSettings(
397406
?array $forcedShareGroups=null,
398407
?bool $legacyWidgetBridgeEnabled=null,
399408
?int $maxDashboardsPerUser=null,
400-
?int $maxWidgetsPerDashboard=null
409+
?int $maxWidgetsPerDashboard=null,
410+
?string $quicksearchFallbackTarget=null
401411
): JSONResponse {
402412
try {
403413
$this->settingsService->updateSettings(
@@ -411,7 +421,8 @@ public function updateSettings(
411421
forcedShareGroups: $forcedShareGroups,
412422
legacyWidgetBridgeEnabled: $legacyWidgetBridgeEnabled,
413423
maxDashboardsPerUser: $maxDashboardsPerUser,
414-
maxWidgetsPerDashboard: $maxWidgetsPerDashboard
424+
maxWidgetsPerDashboard: $maxWidgetsPerDashboard,
425+
quicksearchFallbackTarget: $quicksearchFallbackTarget
415426
);
416427

417428
return ResponseHelper::success(data: ['status' => 'ok']);

lib/Controller/PageController.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
use OCA\LaunchPad\AppInfo\Application;
2929
use OCA\LaunchPad\Db\Dashboard;
30+
use OCA\LaunchPad\Service\AdminSettingsService;
3031
use OCA\LaunchPad\Service\AdminTemplateService;
3132
use OCA\LaunchPad\Service\DashboardService;
3233
use OCA\LaunchPad\Service\DashboardTreeService;
@@ -85,6 +86,13 @@ class PageController extends Controller
8586
* path doesn't
8687
* resolve to a
8788
* visible dashboard.
89+
* @param AdminSettingsService $adminSettingsService Source of the
90+
* quick-search
91+
* no-match
92+
* fallback-target
93+
* admin setting
94+
* (tile-quick-search
95+
* REQ-QSEARCH-004).
8896
*/
8997
public function __construct(
9098
IRequest $request,
@@ -97,6 +105,7 @@ public function __construct(
97105
private readonly RoleFeaturePermissionService $roleFeaturePerm,
98106
private readonly DashboardTreeService $treeService,
99107
private readonly LoggerInterface $logger,
108+
private readonly AdminSettingsService $adminSettingsService,
100109
) {
101110
parent::__construct(appName: Application::APP_ID, request: $request);
102111
}//end __construct()
@@ -320,9 +329,18 @@ public function index(string $deepLink=''): TemplateResponse
320329
);
321330
}
322331

332+
// tile-quick-search REQ-QSEARCH-004: read the admin-configured
333+
// no-match fallback target. `getSettings()` already resolves the
334+
// safe 'none' default when unset/invalid, so this never throws.
335+
$quicksearchFallbackTarget = (string) (
336+
$this->adminSettingsService->getSettings()['quicksearchFallbackTarget']
337+
?? AdminSettingsService::DEFAULT_QUICKSEARCH_FALLBACK_TARGET
338+
);
339+
323340
$builder
324341
->setAllowedWidgets($allowedWidgets)
325342
->setDeepLinkPath($deepLinkPath)
343+
->setQuicksearchFallbackTarget($quicksearchFallbackTarget)
326344
->apply();
327345

328346
// REQ-SHELL-001: pass the chrome slot ids so Nextcloud treats

lib/Db/AdminSetting.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,15 @@ class AdminSetting extends Entity implements JsonSerializable
213213
*/
214214
public const KEY_MAX_WIDGETS_PER_DASHBOARD = AdminSettingKey::MAX_WIDGETS_PER_DASHBOARD->value;
215215

216+
/**
217+
* BC alias for AdminSettingKey::QUICKSEARCH_FALLBACK_TARGET (tile-quick-search).
218+
*
219+
* @var string
220+
*
221+
* @see AdminSettingKey::QUICKSEARCH_FALLBACK_TARGET
222+
*/
223+
public const KEY_QUICKSEARCH_FALLBACK_TARGET = AdminSettingKey::QUICKSEARCH_FALLBACK_TARGET->value;
224+
216225
/**
217226
* The setting key.
218227
*

lib/Db/AdminSettingKey.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
* - Footer: FOOTER_ENABLED, FOOTER_HTML, FOOTER_CONFIG,
1717
* FOOTER_BACKGROUND_COLOR, FOOTER_TEXT_COLOR
1818
* - Setup wizard: SETUP_WIZARD_COMPLETE, CONTENT_STORAGE
19+
* - Quick search: QUICKSEARCH_FALLBACK_TARGET (tile-quick-search)
1920
*
2021
* @category Db
2122
* @package OCA\LaunchPad\Db
@@ -61,4 +62,5 @@ enum AdminSettingKey: string
6162
case LEGACY_WIDGET_BRIDGE_ENABLED = 'legacy_widget_bridge_enabled';
6263
case MAX_DASHBOARDS_PER_USER = 'max_dashboards_per_user';
6364
case MAX_WIDGETS_PER_DASHBOARD = 'max_widgets_per_dashboard';
65+
case QUICKSEARCH_FALLBACK_TARGET = 'quicksearch_fallback_target';
6466
}//end enum

lib/Service/AdminSettingsService.php

Lines changed: 121 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,17 @@ public function getSettings(): array
100100
$maxDashKey = AdminSetting::KEY_MAX_DASHBOARDS_PER_USER;
101101
$maxWidgetKey = AdminSetting::KEY_MAX_WIDGETS_PER_DASHBOARD;
102102

103+
// tile-quick-search REQ-QSEARCH-004: fall back to the safe
104+
// 'none' default when unset, OR when a previously-stored value no
105+
// longer validates (e.g. an admin-edited DB row) — never surface
106+
// a value the frontend can't safely act on.
107+
$fallbackKey = AdminSetting::KEY_QUICKSEARCH_FALLBACK_TARGET;
108+
$storedFallbackTarget = $settings[$fallbackKey] ?? null;
109+
$quicksearchFallbackTarget = is_string($storedFallbackTarget) === true
110+
&& $this->isValidQuicksearchFallbackTarget(value: $storedFallbackTarget) === true
111+
? $storedFallbackTarget
112+
: self::DEFAULT_QUICKSEARCH_FALLBACK_TARGET;
113+
103114
return [
104115
'defaultPermissionLevel' => $settings[$permKey] ?? $permDef,
105116
// REQ-ASET-003 (extended): default `false` — admins MUST opt in
@@ -122,6 +133,9 @@ public function getSettings(): array
122133
// quotas. `0` = unlimited (no enforcement).
123134
'maxDashboardsPerUser' => $this->clampQuota(value: $settings[$maxDashKey] ?? 0),
124135
'maxWidgetsPerDashboard' => $this->clampQuota(value: $settings[$maxWidgetKey] ?? 0),
136+
// tile-quick-search REQ-QSEARCH-004: 'none' | 'unified-search'
137+
// | an https URL template containing '{query}'.
138+
'quicksearchFallbackTarget' => $quicksearchFallbackTarget,
125139
];
126140
}//end getSettings()
127141

@@ -181,6 +195,83 @@ public function clampQuota(mixed $value): int
181195
*/
182196
public const VALID_CONTENT_STORAGE_VALUES = ['database', 'groupfolder'];
183197

198+
/**
199+
* tile-quick-search REQ-QSEARCH-004: no-match fallback disabled — Enter
200+
* on a zero-match query takes no navigation action.
201+
*
202+
* @var string
203+
*/
204+
public const QUICKSEARCH_FALLBACK_NONE = 'none';
205+
206+
/**
207+
* tile-quick-search REQ-QSEARCH-004: no-match fallback hands the query
208+
* to Nextcloud unified search.
209+
*
210+
* @var string
211+
*/
212+
public const QUICKSEARCH_FALLBACK_UNIFIED_SEARCH = 'unified-search';
213+
214+
/**
215+
* Default `quicksearch_fallback_target` when no admin setting row
216+
* exists — no navigation on no-match Enter (REQ-QSEARCH-004 "No
217+
* fallback configured" scenario is the safe out-of-the-box default).
218+
*
219+
* @var string
220+
*/
221+
public const DEFAULT_QUICKSEARCH_FALLBACK_TARGET = self::QUICKSEARCH_FALLBACK_NONE;
222+
223+
/**
224+
* Validate a `quicksearch_fallback_target` value (REQ-QSEARCH-004
225+
* "Fallback template validation" scenario). Valid values are the two
226+
* literal targets, or an `https` URL template containing the
227+
* `{query}` placeholder.
228+
*
229+
* @param string|null $value the candidate value.
230+
*
231+
* @return bool true when `$value` is a valid fallback target.
232+
*
233+
* @spec openspec/specs/tile-quick-search/spec.md
234+
*/
235+
public function isValidQuicksearchFallbackTarget(?string $value): bool
236+
{
237+
if ($value === null) {
238+
return false;
239+
}
240+
241+
if ($value === self::QUICKSEARCH_FALLBACK_NONE || $value === self::QUICKSEARCH_FALLBACK_UNIFIED_SEARCH) {
242+
return true;
243+
}
244+
245+
return $this->isValidQuicksearchFallbackUrlTemplate(value: $value);
246+
}//end isValidQuicksearchFallbackTarget()
247+
248+
/**
249+
* Validate the `https` + `{query}`-placeholder shape of a web-search
250+
* fallback URL template (REQ-QSEARCH-004). Substitutes a harmless
251+
* probe string for `{query}` before validating so a template like
252+
* `https://example.org/search?q={query}` — not a valid URL as
253+
* written — validates correctly.
254+
*
255+
* @param string $value the candidate URL template.
256+
*
257+
* @return bool true when `$value` is a valid `https` URL template
258+
* containing `{query}`.
259+
*/
260+
private function isValidQuicksearchFallbackUrlTemplate(string $value): bool
261+
{
262+
if (trim($value) === '' || str_contains(haystack: $value, needle: '{query}') === false) {
263+
return false;
264+
}
265+
266+
$probe = str_replace(search: '{query}', replace: 'quicksearch-validation-probe', subject: $value);
267+
268+
if (parse_url(url: $probe, component: PHP_URL_SCHEME) !== 'https') {
269+
return false;
270+
}
271+
272+
return filter_var(value: $probe, filter: FILTER_VALIDATE_URL) !== false;
273+
}//end isValidQuicksearchFallbackUrlTemplate()
274+
184275
/**
185276
* Update admin settings.
186277
*
@@ -220,11 +311,21 @@ public function clampQuota(mixed $value): int
220311
* `[0, 10000]`).
221312
* dashboard-quota-limits
222313
* REQ-QUOTA-001.
314+
* @param string|null $quicksearchFallbackTarget No-match fallback for
315+
* the on-dashboard quick
316+
* search: `'none'`,
317+
* `'unified-search'`, or
318+
* an `https` URL
319+
* template containing
320+
* `{query}`.
321+
* tile-quick-search
322+
* REQ-QSEARCH-004.
223323
*
224324
* @return void
225325
*
226-
* @throws \InvalidArgumentException When `$contentStorage` or
227-
* `$defaultSharePermissionLevel` is not
326+
* @throws \InvalidArgumentException When `$contentStorage`,
327+
* `$defaultSharePermissionLevel`, or
328+
* `$quicksearchFallbackTarget` is not
228329
* a valid value.
229330
*
230331
* @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-2
@@ -240,7 +341,8 @@ public function updateSettings(
240341
?array $forcedShareGroups=null,
241342
?bool $legacyWidgetBridgeEnabled=null,
242343
?int $maxDashboardsPerUser=null,
243-
?int $maxWidgetsPerDashboard=null
344+
?int $maxWidgetsPerDashboard=null,
345+
?string $quicksearchFallbackTarget=null
244346
): void {
245347
if ($defaultPermLevel !== null) {
246348
$this->settingMapper->setSetting(
@@ -307,6 +409,22 @@ public function updateSettings(
307409
);
308410
}
309411

412+
// tile-quick-search REQ-QSEARCH-004 "Fallback template validation"
413+
// scenario: reject an invalid target at save time rather than
414+
// silently storing something the frontend can't act on.
415+
if ($quicksearchFallbackTarget !== null) {
416+
if ($this->isValidQuicksearchFallbackTarget(value: $quicksearchFallbackTarget) === false) {
417+
throw new InvalidArgumentException(
418+
message: "Invalid value for quicksearchFallbackTarget. Must be 'none', 'unified-search', or an https URL template containing '{query}'."
419+
);
420+
}
421+
422+
$this->settingMapper->setSetting(
423+
key: AdminSetting::KEY_QUICKSEARCH_FALLBACK_TARGET,
424+
value: $quicksearchFallbackTarget
425+
);
426+
}
427+
310428
$this->persistSharingAndBridgeSettings(
311429
defaultSharePermissionLevel: $defaultSharePermissionLevel,
312430
forcedShareGroups: $forcedShareGroups,

lib/Service/InitialStateBuilder.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,30 @@ public function setDeepLinkPath(string $deepLinkPath): self
332332
return $this;
333333
}//end setDeepLinkPath()
334334

335+
/**
336+
* Set the admin-configured quick-search no-match fallback target
337+
* (workspace). `'none'` | `'unified-search'` | a validated `https`
338+
* URL template containing `{query}`.
339+
*
340+
* Optional key — mirrors {@see self::setDeepLinkPath()}'s pattern:
341+
* NOT in {@see self::REQUIRED_KEYS}, so an older deploy that hasn't
342+
* called this setter yet still passes {@see self::apply()}'s
343+
* required-key check, and the JS reader's `'none'` default keeps the
344+
* frontend typed either way (tile-quick-search REQ-QSEARCH-004).
345+
*
346+
* @param string $quicksearchFallbackTarget The current fallback-target
347+
* setting value.
348+
*
349+
* @return self Fluent.
350+
*
351+
* @spec openspec/specs/tile-quick-search/spec.md
352+
*/
353+
public function setQuicksearchFallbackTarget(string $quicksearchFallbackTarget): self
354+
{
355+
$this->values['quicksearchFallbackTarget'] = $quicksearchFallbackTarget;
356+
return $this;
357+
}//end setQuicksearchFallbackTarget()
358+
335359
/**
336360
* Set every Nextcloud group (admin).
337361
*

openspec/changes/tile-quick-search/proposal.md renamed to openspec/changes/archive/2026-07-23-tile-quick-search/proposal.md

File renamed without changes.

openspec/changes/tile-quick-search/specs/tile-quick-search/spec.md renamed to openspec/changes/archive/2026-07-23-tile-quick-search/specs/tile-quick-search/spec.md

File renamed without changes.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Tasks: On-dashboard quick-search / launcher bar
2+
3+
## Backend
4+
- [~] Add app config `quicksearch_fallback_target` (`none` / `unified-search` / web-search URL template) with an admin default and a per-user override. **Admin default DONE** (`AdminSettingKey::QUICKSEARCH_FALLBACK_TARGET`, `AdminSettingsService::getSettings()`/`updateSettings()`, `AdminController::getSettings()`/`updateSettings()` — no new route, existing endpoint extended). **Per-user override NOT implemented** — the setting is currently admin/instance-wide only.
5+
- [x] Provide `quicksearch_fallback_target` to the frontend via `IInitialState::provideInitialState()` (no new controller/endpoint). `InitialStateBuilder::setQuicksearchFallbackTarget()` (optional key, mirrors `deepLinkPath`) + `PageController::index()` wiring.
6+
- [x] Validate a web-search URL template on save (must be `https`, must contain the `{query}` placeholder); reject otherwise. `AdminSettingsService::isValidQuicksearchFallbackTarget()` / `isValidQuicksearchFallbackUrlTemplate()`, enforced in `updateSettings()` (throws `InvalidArgumentException` → HTTP 400 via the existing `AdminController::updateSettings()` catch block).
7+
8+
## Frontend
9+
- [x] `src/composables/useTileSearch.js` — filter+rank the reactive tile list by query, track active-selection index, own global keydown listeners (`/`, `Ctrl+K`, arrows, Enter, Esc), and decide the no-match fallback route. (Global-listener *attachment* lives in `RuntimeShellSearch.vue`'s mounted/beforeDestroy per Vue idiom in this codebase; the composable owns the pure shortcut/ranking/fallback *decisions*, unit-tested independently of any DOM.)
10+
- [x] `src/components/RuntimeShellSearch.vue` — labelled search input (`role="search"`), result/selection affordance, WCAG AA keyboard handling and `aria-activedescendant`, no-match fallback affordance.
11+
- [x] Mount `RuntimeShellSearch` in the runtime-shell page component; wire `filter`/`open` events to dim non-matching tiles, scroll-to and activate the selected tile. (`WorkspaceApp.vue`; DOM reach into the sibling `Views.vue` grid via `data-placement-id` + a plain `querySelector`, since the grid lives in a different component's tree.)
12+
- [x] Read `quicksearch_fallback_target` via `loadState` from `@nextcloud/initial-state`; dispatch to NC unified search (existing `nc-unified-search-integration`) or open the web-search template on no-match Enter. Web-search: `window.open(...)`. Unified-search: best-effort `window.dispatchEvent(new CustomEvent('nextcloud:unified-search.search', {detail:{query}}))` — no documented public JS API to open NC's unified-search UI programmatically was found in this repo's `@nextcloud/*` dependencies, so this dispatches a plausible hook a listener could pick up without navigating on its own (satisfies the "MUST NOT navigate away on its own" clause either way).
13+
14+
## Testing
15+
- [x] Vitest: `useTileSearch` filtering/ranking (prefix > substring > subsequence), selection wrap-around, Esc-clear, no-match fallback decision per config. 57 tests in `useTileSearch.spec.js`.
16+
- [x] Vitest: URL-template validation (https + `{query}` required). Covered on both sides: `useTileSearch.spec.js` (`isValidFallbackTemplate`/`resolveFallbackAction`) and PHP `AdminSettingsServiceTest.php` (`isValidQuicksearchFallbackTarget`, data-provider covering valid/invalid templates).
17+
- [ ] Playwright: type to filter tiles live; arrow + Enter opens the selected tile; Esc clears; `/` and `Ctrl+K` focus the bar. **Not run** — task instructions restricted this build to local unit tests only (no Playwright/e2e against the live instance).
18+
- [ ] Playwright/axe: search bar labelled, focus visible, active result exposed via `aria-activedescendant`. **Not run**, same reason. ARIA structure is covered by Vitest component tests instead (role/attribute assertions), which is a partial substitute but not a real axe audit.
19+
20+
## Docs
21+
- [ ] Add a "Quick search" section to the dashboard-viewing docs; document the `/` and `Ctrl+K` shortcuts and the fallback-target setting.
22+
23+
## Out of scope (follow-ups)
24+
- Cross-dashboard global search — `quicksearch-global`.
25+
- Search over live-tile values / widget contents — `quicksearch-content`.
26+
- Recent/suggested query history — no query is stored.
27+
- Server-side ranking / autocomplete endpoint.

0 commit comments

Comments
 (0)