Using the Command Palette
The command palette is a keyboard-driven overlay that the yuuvis shell provides to every app. Users open it by pressing Ctrl twice in quick succession, anywhere in the client (on macOS, too, it is the Control key, not Cmd). The palette then works in one of two modes:
- Command mode (the default) — the palette lists the commands that apps have registered, such as Open ‘Drive’, Open settings or Log out. Typing filters the list by label; Enter or a click runs the selected command.
- Search mode — typing
#as the first character switches the palette to a fulltext search in the document management system (DMS). Each hit is displayed according to the settings of the app that owns the object’s type, and picking it opens the object in that app. Making Your App’s Objects Searchable explains how an app becomes the owner of a type.
As an app developer, you integrate with the palette in two ways: you register commands that let users trigger your app’s functions, and you make your app’s objects searchable so that users can find and open them without navigating to your app first.
Both are handled by two services:
CommandPaletteServicefrom@yuuvis/client-shell-core— registers commands, runs the search, and opens picked hits.ObjectConfigServicefrom@yuuvis/client-core— holds the object configs that define which properties of an object are shown as its title, description and so on. ItsregisterConfigTypes()method makes your app’s configs available to the palette’s search.
Prerequisites
Section titled “Prerequisites”- A working yuuvis shell client project (see Quick Start)
- An app or extension with a
ClientShellExtensionservice (see Apps) - For search integration: familiarity with object flavors and with your app’s object configs
Registering Commands
Section titled “Registering Commands”A command is a plain object with an id, a label and, optionally, a description and a callback. Register it with registerCommand(), or pass an array to registerCommands().
Where you register a command determines how long it is available. Commands that should be available as long as the client runs belong in a long-lived place, such as the init() method of your ClientShellExtension service. Commands that only make sense while a certain view is shown belong in that view’s component — register them when the component is created and unregister them when it is destroyed:
import { Component, DestroyRef, inject } from '@angular/core';import { CommandPaletteService } from '@yuuvis/client-shell-core';
@Component({ selector: 'my-app', template: '' })export class MyAppComponent { readonly #commandPalette = inject(CommandPaletteService);
constructor() { this.#commandPalette.registerCommand({ id: 'my-app.upload', label: 'Upload file', description: 'Add a new file to the current folder', callback: () => this.upload() }); // remove the command again when the view goes away inject(DestroyRef).onDestroy(() => this.#commandPalette.unregisterCommand('my-app.upload')); }
upload(): void { // ... }}The palette shows the commands sorted by label. Typing filters the list by label only; matching text is highlighted in both the label and the description.
Instead of passing a callback, you can subscribe to the observable that registerCommand() and registerCommands() return. Each time one of the registered commands is triggered, it emits that CommandPaletteCommand object, so with registerCommands() you can check its id to tell which command fired. In a constructor, this looks as follows:
this.#commandPalette .registerCommand({ id: 'my-app.upload', label: 'Upload file' }) .pipe(takeUntilDestroyed()) .subscribe(() => this.upload());The service does not check id for uniqueness: registering the same id twice adds a second entry to the list. This is why a component that is created repeatedly must unregister its commands on destroy. Prefix your IDs with your app’s name to avoid clashes with other apps.
Updating and Removing Commands
Section titled “Updating and Removing Commands”Use updateCommands() to change registered commands, typically their labels after the user switched the language. The method replaces every registered command whose id matches one of the passed commands with the passed object. Fields you omit are not kept, so always pass the complete command, including its callback.
The following example uses TranslateService, which the shell uses for localization and re-exports from @yuuvis/client-core, injected as #translate:
this.#translate.onLangChange.pipe(takeUntilDestroyed()).subscribe(() => this.#commandPalette.updateCommands([ { id: 'my-app.upload', label: this.#translate.instant('my-app.cmd.upload'), callback: () => this.upload() } ]));updateCommands() ignores IDs that are not registered, so it never adds a command. It also does not re-sort the list: a command whose new label belongs elsewhere alphabetically keeps its position until the next command is registered.
To remove commands, call unregisterCommand(id) or unregisterCommands(ids):
this.#commandPalette.unregisterCommand('my-app.upload');this.#commandPalette.unregisterCommands(['my-app.upload', 'my-app.share']);Running a Command Programmatically
Section titled “Running a Command Programmatically”triggerCommand(command) takes a CommandPaletteCommand object and runs it the same way the palette does when the user selects it: the command is emitted on command$ and its callback is invoked. If the palette is open, it is closed first, and the command runs once the palette has finished closing. You can call triggerCommand() without opening the palette at all.
command$ is a public observable of CommandPaletteService. It emits every triggered command, regardless of which app registered it. Use it for cross-cutting concerns such as logging or telemetry:
this.#commandPalette.command$ .pipe(takeUntilDestroyed()) .subscribe((cmd) => console.log(`Command '${cmd.id}' was triggered`));To react to your own commands only, use the observable returned by registerCommand() instead.
Temporarily Disabling the Palette
Section titled “Temporarily Disabling the Palette”Sometimes users must not run commands from the palette, for example while a blocking upload is in progress. Instead of unregistering all commands, register a disabled cause: an id plus a message that explains to the user why the palette is unavailable. Cause IDs are independent of command IDs.
this.#commandPalette.addDisabledCause({ id: 'my-app.upload-in-progress', message: 'Not available while an upload is running'});
// once the upload has finishedthis.#commandPalette.removeDisabledCause('my-app.upload-in-progress');As long as at least one cause is registered, the palette still opens, but it shows the cause messages instead of the input field and the command list. Users can therefore neither run commands nor search. Calling addDisabledCause() with an id that is already registered updates that cause’s message. removeAllDisabledCauses() removes every cause at once and makes the palette usable again.
A disabled cause only affects the palette’s user interface. Calling triggerCommand() from code still runs the command. Removing the last cause takes effect the next time the palette opens; a palette that is open at that moment stays disabled until it is closed.
Searching the DMS from the Palette
Section titled “Searching the DMS from the Palette”When the user types # as the first character, the palette switches to search mode. The # is removed from the input field, and the rest of what the user types is used as the fulltext search term. While the palette is not in search mode, a hint at the end of the input field (# Search) tells users about this option.
In search mode, the palette behaves as follows:
- Minimum length — a search starts once the term has at least two characters. For a shorter, non-empty term, the palette shows how many characters are required.
- Search as you type — the search runs 300 ms after the user stops typing. The term is matched as a prefix, so
invalso finds invoice. A new term cancels a search that is still running. - Result list — up to 20 hits are shown. Each hit shows a title and, where the object config of its type defines them, an icon, a description and two secondary values on the right (meta and aside). Object configs are explained in Step 2.
- Opening a hit — Enter or a click opens the selected hit in the app that owns it. If several apps can open the object, the hit shows one button (chip) per app, and the user picks one. Enter always opens the hit with the first of these apps; the order is described in How the Search Finds, Displays, and Opens Objects.
- Leaving search mode — the back-arrow button in front of the input, or Backspace on an empty input, clears the term and brings back the command list.
Opening a hit closes the palette.
How the Search Finds, Displays, and Opens Objects
Section titled “How the Search Finds, Displays, and Opens Objects”The palette itself does not know how your app’s objects are structured, how they should be displayed, or how to open them. It collects this information from registries that apps fill during initialization.
Two DMS terms are needed to follow the process. Every DMS object has exactly one primary object type, for example system:document for objects with file content. In addition, an object can carry any number of secondary object types (SOTs), such as appInvoice:invoice. An SOT adds a set of properties to the object — typically the properties a specific app works with — and thus marks the object as relevant for that app.
The search runs in four stages:
- Scope. The search covers objects whose SOTs belong to a registered object flavor — a registration that tells the shell about an SOT and which objects it can be applied to. The palette sends a query in CMIS, the SQL-like query language of the DMS backend, of the form
SELECT * FROM system:object WHERE system:secondaryObjectTypeIds IN (…) AND CONTAINS('<term>*'). It usesSearchServicefrom@yuuvis/client-coreinternally; you don’t need to set anything up for this. (If no flavor is registered at all, the SOT condition is dropped — see Limitations.) - Display. For each hit, the palette looks for the virtual object type that describes it. A virtual object type is an app-defined ID, such as
io.yuuvis.app.drive.file, that stands for a primary object type plus a set of SOTs. Object configs are stored under these IDs, so finding the virtual type leads to the config that defines how the hit is displayed. - Ownership.
ObjectConfigServicestores object configs in buckets: named groups of configs that keep the configs of different apps apart. An app claims a type by registering object configs for it in a bucket whose name is its app ID. Every app whose bucket contains a config for one of the hit’s types is a candidate. The candidates are ordered by type — first the owners of the virtual type that displays the hit, then the owners of its SOTs, then those of its primary object type — and, within one type, in the order the apps registered their configs. - Opening. Only candidates that registered an open handler with
ShellServiceare offered. When the user picks a hit, the palette closes and callsShellService.openObject(), which delegates to the app’s handler.
Making Your App’s Objects Searchable
Section titled “Making Your App’s Objects Searchable”For users to find, see, and open your app’s objects in the palette, your app registers four things. All four belong in the init() method of your ClientShellExtension service, which the shell calls on startup:
| Step | Registration | Effect on the palette |
|---|---|---|
| 1 | ShellService.exposeObjectFlavors() | Puts the flavor’s SOT into the search scope |
| 2 | ObjectConfigService.registerDefaults(configs, APP_ID) | Defines how objects are displayed and marks your app as their owner |
| 3 | ObjectConfigService.registerConfigTypes(types, APP_ID) | Lets the palette match a hit to your virtual type — and thus to your config and to your app |
| 4 | ShellService.registerObjectOpenHandler() | Lets the palette open a hit in your app |
The snippets in this section use the private fields #shell (ShellService), #objectConfig (ObjectConfigService) and #router (Angular’s Router), injected into the extension service. A Complete App Extension for Search shows the full class with all imports.
They also use the following constants, which an app typically defines in its schema file:
import { SystemType, VirtualObjectType } from '@yuuvis/client-core';import { AppFlavor } from '@yuuvis/client-shell-core';
export const APP_ID = 'io.example.app.invoices';export const APP_PREFIX = 'appInvoice:';
export const APP_FLAVORS: AppFlavor[] = [ { id: `${APP_ID}.flavor.invoice`, objectTypeID: SystemType.DOCUMENT, sot: `${APP_PREFIX}invoice`, applicableTo: { mimeTypes: ['application/pdf'] } }];
export const APP_TYPES: Record<string, VirtualObjectType> = { invoice: { id: `${APP_ID}.invoice`, objectType: SystemType.DOCUMENT, sots: [`${APP_PREFIX}invoice`] }};APP_PREFIX is the namespace of your app’s schema in the DMS backend. SOT and property names such as appInvoice:invoice or appInvoice:number must match the names defined there.
The SOT appInvoice:invoice appears twice, and both entries must name the same SOT: the flavor decides whether invoices are searched at all, the virtual type decides which config displays a hit. objectTypeID and applicableTo only control where the flavor can be applied by users; they do not affect the search. See Object Flavors for all flavor properties.
Step 1: Expose an Object Flavor
Section titled “Step 1: Expose an Object Flavor”The palette searches objects that carry the SOT of a registered object flavor. Expose your flavors with ShellService.exposeObjectFlavors():
this.#shell.exposeObjectFlavors(APP_FLAVORS);The sot property of each flavor is added to the search scope. Administrators can also define flavors in a server-side configuration instead of in code (see Configuring Flavors and Features); those count for the search scope as well.
Step 2: Register Default Object Configs Under Your App ID
Section titled “Step 2: Register Default Object Configs Under Your App ID”An object config defines which property of an object is shown in which place: as title, description, meta or aside. Register your configs with registerDefaults(), keyed by the IDs of your virtual types, and pass your app ID as the bucket:
import { BaseObjectTypeField, ObjectConfigRecord } from '@yuuvis/client-core';
const OC_DEFAULTS: ObjectConfigRecord = { [APP_TYPES['invoice'].id]: { objectTypeId: APP_TYPES['invoice'].id, title: { label: 'Invoice number', propertyName: `${APP_PREFIX}number` }, description: { label: 'Customer', propertyName: `${APP_PREFIX}customer` }, aside: { label: 'Modified', propertyName: BaseObjectTypeField.MODIFICATION_DATE } }};
this.#objectConfig.registerDefaults(OC_DEFAULTS, APP_ID);The bucket serves two purposes for the palette:
- Display. The palette reads the title, description, meta and aside values from this config. The configs you register are defaults: users can adjust them in the client (for example, which property a list shows as title), and the adjusted configs are stored per user. If a user has adjusted the config of this type in your bucket, the adjusted version is used. You don’t need to do anything to support this.
- Ownership. The palette determines which apps can open a hit by looking for buckets that contain a config for one of the hit’s types. Only configs registered with
registerDefaults()count here, and the bucket name must be your app ID — the same ID you use for the open handler in step 4. If they differ, the palette cannot connect the hit to your open handler.
Step 3: Register Your Virtual Object Types
Section titled “Step 3: Register Your Virtual Object Types”Object configs are keyed by virtual type IDs such as io.example.app.invoices.invoice. The palette, however, only knows each hit’s primary object type and SOTs. registerConfigTypes() publishes the mapping between the two:
this.#objectConfig.registerConfigTypes(Object.values(APP_TYPES), APP_ID);Pass the same bucket as in step 2: the palette looks up the config of a matched type in this bucket. With a different bucket, it doesn’t find your config and displays the hit with a fallback config instead. For each hit, the palette picks the registered virtual type that matches it:
- The hit must be of the type’s
objectType. A type withoutobjectTypematches any primary type. - The hit must carry all SOTs listed in the type’s
sots. A type withoutsotsmatches any object. - If several types match, the one that requires the most SOTs wins.
Registering a type with an ID that is already registered replaces the previous registration. Use unregisterConfigTypes(types) to remove types, for example when your extension is deactivated.
Without a matching registered type, the palette falls back to looking for a config keyed by one of the hit’s SOTs in any bucket. Because configs keyed by SOT are stored under either the raw (appInvoice:invoice) or a dotted spelling (appInvoice.invoice), both are tried. Finally, it tries the primary object type: first a config keyed by it in any bucket, then a global config (one registered or stored without a bucket), and, if there is none, the type’s default config, which simply shows the type’s first four fields.
Step 4: Register an Object Open Handler
Section titled “Step 4: Register an Object Open Handler”Finally, tell the shell how to open one of your objects. ShellService.registerObjectOpenHandler() takes your app ID and an open function that receives the object’s raw field data:
this.#shell.registerObjectOpenHandler({ appId: APP_ID, open: (data) => { const id = data[BaseObjectTypeField.OBJECT_ID] as string; this.#router.navigate([this.#shell.appBaseRoutes[APP_ID], 'invoice', id]); }});this.#shell.appBaseRoutes[APP_ID] holds the base route the shell assigned to your app. Use it instead of a hardcoded path, because the client project decides under which route an app is mounted.
Your app decides what “open” means — navigating to a route, as in the example, or showing a dialog. The palette calls the handler only after it has finished closing. This matters because the shell tracks open dialogs in the URL and, when a dialog closes, navigates once more to remove that entry; a navigation that started earlier would be overridden.
Without an open handler, your app is not offered for any hit, even if it owns the type. Unless another app can open the object, the hit is still listed, but picking it opens nothing.
Configuring the Palette
Section titled “Configuring the Palette”CommandPaletteService is a root-level singleton, so its settings apply to the palette as a whole, not to a single app. They are plain, writable properties; if several places set the same property, the last write wins. Set them once in your client project (the host application that bundles the apps), for example in a function registered with Angular’s provideAppInitializer(), rather than in individual apps:
import { ApplicationConfig, inject, provideAppInitializer } from '@angular/core';import { CommandPaletteService } from '@yuuvis/client-shell-core';
export const appConfig: ApplicationConfig = { providers: [ // ... other providers provideAppInitializer(() => { const commandPalette = inject(CommandPaletteService); commandPalette.searchModeIndicator = '>'; // use '>' instead of '#' commandPalette.searchMinChars = 3; commandPalette.searchResultSize = 50; commandPalette.searchPlaceholder = 'Find documents'; }) ]};| Property | Type | Default | Description |
|---|---|---|---|
searchModeIndicator | string | '#' | Character that switches the palette to search mode when typed first. Set it to '' to disable search mode entirely. |
searchMinChars | number | 2 | Minimum number of characters (without the indicator) before a search runs. |
searchResultSize | number | 20 | Maximum number of hits per search. |
searchPlaceholder | string | '' | Placeholder of the input field in search mode. If empty, the translated text of the i18n key yuv.shell.cmd.search.placeholder is used. |
placeholder | string | '' | Placeholder of the input field in command mode. If empty, no placeholder is shown. |
The placeholders and the indicator shown in the input hint are read each time the palette opens. searchMinChars, searchResultSize and the switch to search mode itself are evaluated for each entered term.
Reacting to Palette Events
Section titled “Reacting to Palette Events”To react when a user picks a search hit, subscribe to searchResult$. It emits a CommandPaletteSearchResultPick: the hit plus the app it was opened with. It also emits when no app could open the hit; app is then undefined:
this.#commandPalette.searchResult$ .pipe(takeUntilDestroyed()) .subscribe(({ result, app }) => console.log(`${result.title} opened with ${app?.label ?? 'no app'}`));overlayVisible$ emits true when the palette opens and false when it closes, for example to pause your own keyboard shortcuts while the palette is shown. Nothing is emitted before the palette opens for the first time. The underlying ReplaySubject has no buffer limit, so a late subscriber first receives all values emitted so far, in order. The last of these replayed values reflects the current state.
The CommandPaletteCommand Interface
Section titled “The CommandPaletteCommand Interface”export interface CommandPaletteCommand { id: string; label: string; description?: string; callback?: () => void;}| Property | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Identifies the command for updateCommands(), unregisterCommand() and the observable returned by registerCommand(). Not checked for uniqueness. |
label | string | Yes | Text shown in the palette. Typing in command mode filters on this text. |
description | string | No | Second line below the label. Matches are highlighted here as well. |
callback | () => void | No | Invoked when the command is triggered, right after it was emitted on command$. |
The CommandPaletteSearchResult Interface
Section titled “The CommandPaletteSearchResult Interface”Each hit of the search is resolved into a CommandPaletteSearchResult. You receive it through searchResult$, and you can pass it to triggerSearchResult().
export interface CommandPaletteSearchResult { id: string; objectTypeId: string; configTypeId: string; icon?: string; title: string; description?: string; meta?: string; aside?: string; apps: CommandPaletteSearchResultApp[]; data: Record<string, unknown>;}| Property | Type | Description |
|---|---|---|
id | string | Object ID of the hit. |
objectTypeId | string | Primary object type of the hit, for example system:document. |
configTypeId | string | ID of the type whose object config was used to display the hit: a virtual type, an SOT (in the prefix:name or prefix.name spelling under which a config was found), or the primary object type. |
icon | string | Icon from the object config. The palette renders it as a Material Symbols ligature name. |
title | string | Value of the config’s title property. Falls back to the object ID. |
description | string | Value of the config’s description property. |
meta | string | Value of the config’s meta property. |
aside | string | Value of the config’s aside property. |
apps | CommandPaletteSearchResultApp[] | Apps that can open the hit. Empty if no app can open it. |
data | Record<string, unknown> | Raw field data of the object. This is what the open handler receives. |
Only title is always set; the other display values are empty if the config does not define them. Values are shown as delivered by the backend: dates appear as ISO strings, and multi-value properties are joined with commas.
The apps in apps and the pick emitted on searchResult$ use these two interfaces:
export interface CommandPaletteSearchResultApp { id: string; // app ID, as registered with ShellService.registerObjectOpenHandler() label: string; // app title in the shell, falls back to the app ID}
export interface CommandPaletteSearchResultPick { result: CommandPaletteSearchResult; app?: CommandPaletteSearchResultApp; // undefined if no app could open the hit}triggerSearchResult(result, app?) is the search-mode counterpart of triggerCommand(): it emits the pick on searchResult$, closes the palette if it is open, and then opens the hit. Without app, the first entry of result.apps is used; if result.apps is empty, the pick is emitted but nothing is opened. Since CommandPaletteService offers no public way to run a search yourself, the results you pass typically come from searchResult$, for example to reopen a hit with a different app.
All interfaces are exported from @yuuvis/client-shell-core.
Limitations
Section titled “Limitations”- The palette cannot be opened programmatically. It only opens through the Ctrl Ctrl shortcut. The key is fixed: the
actionKeyproperty ofCommandPaletteService, which holds it, is read-only. The second key press must follow within 300 ms. Two presses less than 100 ms apart count as one. - Commands are read on open. Changes to the command list while the palette is open take effect the next time it opens.
- Types without a flavor are not searched — unless there are no flavors at all. As long as at least one flavor is registered, only objects with the SOT of a registered flavor are in scope. If no flavor is registered at all, the search covers every object type in the repository; hits of types no app configures are then shown with default values and cannot be opened.
- Raw values. Hit values are not formatted: a date is shown as an ISO string, a file size as a number of bytes.
- No open handler, no app. A hit is only openable if at least one app that registered a config for its type also registered an open handler. Otherwise the hit is listed and can be picked, which closes the palette and emits on
searchResult$, but nothing is opened. CommandPaletteModuleConfighas no effect. The interface is exported from@yuuvis/client-shell-core, so it may show up in your IDE’s auto-completion, but nothing reads it. Use the properties ofCommandPaletteServiceinstead;triggerKeyandaccentColorhave no equivalent.
Examples
Section titled “Examples”Registering App Navigation Commands
Section titled “Registering App Navigation Commands”This component registers one command per route of an app, re-labels the commands when the language changes, and removes them when it is destroyed. The shell’s own sidebar uses the same register-and-update pattern for its Open ’…’ commands. Place the component in a template that is shown as long as the commands should be available, for example the root component of your app.
import { Component, DestroyRef, inject } from '@angular/core';import { takeUntilDestroyed } from '@angular/core/rxjs-interop';import { Router } from '@angular/router';import { TranslateService } from '@yuuvis/client-core';import { CommandPaletteCommand, CommandPaletteService } from '@yuuvis/client-shell-core';
@Component({ selector: 'my-app-commands', template: '' })export class MyAppCommandsComponent { readonly #commandPalette = inject(CommandPaletteService); readonly #translate = inject(TranslateService); readonly #router = inject(Router);
readonly #routes = [ { id: 'my-app.cmd.inbox', path: '/my-app/inbox' }, { id: 'my-app.cmd.archive', path: '/my-app/archive' } ];
constructor() { this.#commandPalette.registerCommands(this.#createCommands()); this.#translate.onLangChange .pipe(takeUntilDestroyed()) .subscribe(() => this.#commandPalette.updateCommands(this.#createCommands())); inject(DestroyRef).onDestroy(() => this.#commandPalette.unregisterCommands(this.#routes.map((route) => route.id)) ); }
#createCommands(): CommandPaletteCommand[] { return this.#routes.map((route) => ({ id: route.id, label: this.#translate.instant(route.id), callback: () => this.#router.navigateByUrl(route.path) })); }}The translation keys double as command IDs, which helps keep them unique. my-app.cmd.inbox and my-app.cmd.archive must be defined in your app’s translation files; otherwise the palette shows labels such as !missing key: my-app.cmd.inbox (see Localization). registerCommands() also returns an observable, but because the commands use a callback, the example does not subscribe to it — so there is nothing to tear down.
A Complete App Extension for Search
Section titled “A Complete App Extension for Search”This extension service combines the four registrations from Making Your App’s Objects Searchable. It uses the constants from my-app.schema.ts shown there. How an extension service is registered with the client, so that the shell calls its init() on startup, is described in Apps.
import { Injectable, inject } from '@angular/core';import { Router } from '@angular/router';import { BaseObjectTypeField, ObjectConfigRecord, ObjectConfigService } from '@yuuvis/client-core';import { ClientShellExtension, ShellService } from '@yuuvis/client-shell-core';import { APP_FLAVORS, APP_ID, APP_PREFIX, APP_TYPES } from './my-app.schema';
const OC_DEFAULTS: ObjectConfigRecord = { [APP_TYPES['invoice'].id]: { objectTypeId: APP_TYPES['invoice'].id, title: { label: 'Invoice number', propertyName: `${APP_PREFIX}number` }, description: { label: 'Customer', propertyName: `${APP_PREFIX}customer` }, aside: { label: 'Modified', propertyName: BaseObjectTypeField.MODIFICATION_DATE } }};
@Injectable()export class MyAppExtension implements ClientShellExtension { readonly #shell = inject(ShellService); readonly #router = inject(Router); readonly #objectConfig = inject(ObjectConfigService);
init(): Promise<any> { // 1. search scope: invoices (objects with the SOT appInvoice:invoice) are searched this.#shell.exposeObjectFlavors(APP_FLAVORS); // 2. display + ownership: configs in the bucket APP_ID (2nd argument) this.#objectConfig.registerDefaults(OC_DEFAULTS, APP_ID); // 3. map hits to the virtual types, and thus to the configs in bucket APP_ID this.#objectConfig.registerConfigTypes(Object.values(APP_TYPES), APP_ID); // 4. open hits in this app, below the base route the shell assigned to it this.#shell.registerObjectOpenHandler({ appId: APP_ID, open: (data) => { const id = data[BaseObjectTypeField.OBJECT_ID] as string; this.#router.navigate([this.#shell.appBaseRoutes[APP_ID], 'invoice', id]); } }); return Promise.resolve(); }}When a user types #INV-2026 — the # switches the palette to search mode, INV-2026 is the search term — invoices whose indexed content or properties start with that term are listed with their invoice number as title and the customer as description. Picking one navigates to the invoice route of this app.
Logging Picked Search Results
Section titled “Logging Picked Search Results”This service logs each search hit a user picks, including hits that no app could open. Call start() once, for example from your extension’s init(); each additional call adds another subscription.
import { Injectable, inject } from '@angular/core';import { CommandPaletteService } from '@yuuvis/client-shell-core';
@Injectable({ providedIn: 'root' })export class PaletteTelemetryService { readonly #commandPalette = inject(CommandPaletteService);
start(): void { this.#commandPalette.searchResult$.subscribe(({ result, app }) => { if (!app) console.warn(`No app can open object ${result.id} (${result.objectTypeId})`); else console.info(`Opened ${result.id} with ${app.id}`); }); }}Because the service is a root-level singleton that lives as long as the client and start() runs once, the subscription does not need to be torn down.