Skip to content

Commit 4ce9270

Browse files
feat(tools): Add tool scope picker component
Create tool-scope-picker component similar to tool-picker that displays selected scopes as removable chips and opens a modal for adding new scopes. Provides cleaner picker UI compared to toggle-based component. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 0b89931 commit 4ce9270

2 files changed

Lines changed: 298 additions & 0 deletions

File tree

Umbraco.AI/src/Umbraco.AI.Web.StaticAssets/Client/src/tool/components/exports.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { UaiToolPickerElement } from "./tool-picker/tool-picker.element.js";
2+
export { UaiToolScopePickerElement } from "./tool-scope-picker/tool-scope-picker.element.js";
23
export { UaiToolScopePermissionsElement } from "./tool-scope-permissions/tool-scope-permissions.element.js";
34
export { UaiToolScopePermissionsOverrideElement } from "./tool-scope-permissions-override/tool-scope-permissions-override.element.js";
45
export type { UaiToolScopePermissionState, UaiToolScopePermission } from "./tool-scope-permissions-override/tool-scope-permissions-override.element.js";
Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
import { css, customElement, html, nothing, property, repeat, state } from "@umbraco-cms/backoffice/external/lit";
2+
import { UmbLitElement } from "@umbraco-cms/backoffice/lit-element";
3+
import { UmbChangeEvent } from "@umbraco-cms/backoffice/event";
4+
import { UmbFormControlMixin } from "@umbraco-cms/backoffice/validation";
5+
import { UMB_MODAL_MANAGER_CONTEXT } from "@umbraco-cms/backoffice/modal";
6+
import { UaiToolRepository, type ToolScopeItemResponseModel } from "../../repository/tool.repository.js";
7+
import { UAI_ITEM_PICKER_MODAL } from "../../../core/modals/item-picker/item-picker-modal.token.js";
8+
import type { UaiPickableItemModel } from "../../../core/modals/item-picker/types.js";
9+
import { toCamelCase } from "../../utils.js";
10+
11+
const elementName = "uai-tool-scope-picker";
12+
13+
interface UaiToolScopeItemModel {
14+
id: string;
15+
name: string;
16+
description: string;
17+
domain: string;
18+
icon: string;
19+
}
20+
21+
/**
22+
* Tool scope picker component that allows selecting one or more tool scopes.
23+
*
24+
* @fires change - Fires when the selection changes (UmbChangeEvent).
25+
*
26+
* @example
27+
* Multiple selection:
28+
* ```html
29+
* <uai-tool-scope-picker
30+
* .value=${["content-read", "media-write"]}
31+
* @change=${(e) => console.log(e.target.value)}
32+
* ></uai-tool-scope-picker>
33+
* ```
34+
* @public
35+
*/
36+
@customElement(elementName)
37+
export class UaiToolScopePickerElement extends UmbFormControlMixin<string[] | undefined, typeof UmbLitElement, undefined>(
38+
UmbLitElement,
39+
undefined,
40+
) {
41+
/**
42+
* Readonly mode - cannot add or remove.
43+
*/
44+
@property({ type: Boolean, reflect: true })
45+
public readonly = false;
46+
47+
/**
48+
* The selected tool scope ID(s).
49+
*/
50+
override set value(val: string[] | undefined) {
51+
this.#setValue(val);
52+
}
53+
override get value(): string[] | undefined {
54+
if (this._selection.length === 0) return undefined;
55+
return this._selection;
56+
}
57+
58+
@state()
59+
private _selection: string[] = [];
60+
61+
@state()
62+
private _items: UaiToolScopeItemModel[] = [];
63+
64+
@state()
65+
private _loading = false;
66+
67+
#toolRepository = new UaiToolRepository(this);
68+
69+
#setValue(val: string[] | undefined) {
70+
const newSelection = val ?? [];
71+
72+
// Check if selection actually changed
73+
const hasChanged =
74+
newSelection.length !== this._selection.length ||
75+
newSelection.some((id, index) => id !== this._selection[index]);
76+
77+
if (!hasChanged) {
78+
return;
79+
}
80+
81+
this._selection = newSelection;
82+
83+
if (newSelection.length === 0) {
84+
this._items = [];
85+
return;
86+
}
87+
88+
this.#loadItems();
89+
}
90+
91+
async #loadItems() {
92+
if (this._selection.length === 0) {
93+
this._items = [];
94+
return;
95+
}
96+
97+
this._loading = true;
98+
99+
const { data, error } = await this.#toolRepository.getToolScopes();
100+
101+
if (!error && data) {
102+
// Preserve selection order
103+
this._items = this._selection
104+
.map((id) => {
105+
const scope = data.find((s: ToolScopeItemResponseModel) => s.id.toLowerCase() === id.toLowerCase());
106+
if (!scope) return undefined;
107+
108+
const camelCaseId = toCamelCase(scope.id);
109+
const localizedName = this.localize.term(`uaiToolScope_${camelCaseId}Label`) || scope.id;
110+
const localizedDescription =
111+
this.localize.term(`uaiToolScope_${camelCaseId}Description`) || scope.description || "";
112+
113+
return {
114+
id: scope.id,
115+
name: localizedName,
116+
description: localizedDescription,
117+
domain: scope.domain || "General",
118+
icon: scope.icon || "icon-wand",
119+
};
120+
})
121+
.filter((item): item is UaiToolScopeItemModel => item !== undefined);
122+
}
123+
124+
this._loading = false;
125+
}
126+
127+
async #openPicker() {
128+
const modalManager = await this.getContext(UMB_MODAL_MANAGER_CONTEXT);
129+
if (!modalManager) return;
130+
131+
const modal = modalManager.open(this, UAI_ITEM_PICKER_MODAL, {
132+
data: {
133+
fetchItems: () => this.#fetchAvailableScopes(),
134+
selectionMode: "multiple",
135+
title: this.localize.term("uaiToolScope_selectScope") || "Select Tool Scopes",
136+
noResultsMessage: this.localize.term("uaiAgent_noToolScopesAvailable") || "No tool scopes available",
137+
},
138+
});
139+
140+
try {
141+
const result = await modal.onSubmit();
142+
if (result?.selection?.length) {
143+
this.#addSelections(result.selection);
144+
}
145+
} catch {
146+
// Modal was cancelled
147+
}
148+
}
149+
150+
async #fetchAvailableScopes(): Promise<UaiPickableItemModel[]> {
151+
const { data } = await this.#toolRepository.getToolScopes();
152+
153+
if (!data) return [];
154+
155+
// Filter out already selected items and map to picker format
156+
return data
157+
.filter((scope: ToolScopeItemResponseModel) =>
158+
!this._selection.some((id) => id.toLowerCase() === scope.id.toLowerCase())
159+
)
160+
.map((scope: ToolScopeItemResponseModel) => {
161+
const camelCaseId = toCamelCase(scope.id);
162+
const localizedName = this.localize.term(`uaiToolScope_${camelCaseId}Label`) || scope.id;
163+
const localizedDescription =
164+
this.localize.term(`uaiToolScope_${camelCaseId}Description`) || scope.description || "";
165+
166+
return {
167+
value: scope.id,
168+
label: localizedName,
169+
description: localizedDescription,
170+
icon: scope.icon || "icon-wand",
171+
};
172+
});
173+
}
174+
175+
#addSelections(items: UaiPickableItemModel[]) {
176+
// Filter out already selected items
177+
const newValues = items
178+
.map((item) => item.value)
179+
.filter((value) => !this._selection.some((id) => id.toLowerCase() === value.toLowerCase()));
180+
181+
if (newValues.length === 0) return;
182+
183+
this._selection = [...this._selection, ...newValues];
184+
185+
this.#loadItems();
186+
this.dispatchEvent(new UmbChangeEvent());
187+
}
188+
189+
#onRemove(id: string) {
190+
this._selection = this._selection.filter((x) => x.toLowerCase() !== id.toLowerCase());
191+
this._items = this._items.filter((x) => x.id.toLowerCase() !== id.toLowerCase());
192+
this.dispatchEvent(new UmbChangeEvent());
193+
}
194+
195+
override render() {
196+
return html` <div class="container">${this.#renderItems()} ${this.#renderAddButton()}</div> `;
197+
}
198+
199+
#renderItems() {
200+
if (this._loading) {
201+
return html`<uui-loader-bar></uui-loader-bar>`;
202+
}
203+
204+
if (!this._items.length) return nothing;
205+
206+
return html`
207+
<uui-ref-list>
208+
${repeat(
209+
this._items,
210+
(item) => item.id,
211+
(item) => this.#renderItem(item),
212+
)}
213+
</uui-ref-list>
214+
`;
215+
}
216+
217+
#renderItem(item: UaiToolScopeItemModel) {
218+
return html`
219+
<uui-ref-node name=${item.name} detail=${item.description} readonly>
220+
<umb-icon slot="icon" name=${item.icon}></umb-icon>
221+
<uui-tag slot="tag" look="secondary">${item.domain}</uui-tag>
222+
${!this.readonly
223+
? html`
224+
<uui-action-bar slot="actions">
225+
<uui-button
226+
label="Remove"
227+
@click=${(e: Event) => {
228+
e.stopPropagation();
229+
this.#onRemove(item.id);
230+
}}
231+
>
232+
<uui-icon name="icon-trash"></uui-icon>
233+
</uui-button>
234+
</uui-action-bar>
235+
`
236+
: nothing}
237+
</uui-ref-node>
238+
`;
239+
}
240+
241+
#renderAddButton() {
242+
if (this.readonly) return nothing;
243+
244+
return html`
245+
<uui-button
246+
id="btn-add"
247+
look="placeholder"
248+
@click=${this.#openPicker}
249+
label=${this.localize.term("uaiAgent_addScope") || "Add Scope"}
250+
>
251+
<uui-icon name="icon-add"></uui-icon>
252+
${this.localize.term("general_add")}
253+
</uui-button>
254+
`;
255+
}
256+
257+
static override styles = [
258+
css`
259+
:host {
260+
display: block;
261+
}
262+
263+
.container {
264+
display: flex;
265+
flex-direction: column;
266+
gap: var(--uui-size-space-3);
267+
}
268+
269+
#btn-add {
270+
width: 100%;
271+
}
272+
273+
uui-ref-list {
274+
display: flex;
275+
flex-direction: column;
276+
gap: var(--uui-size-space-1);
277+
}
278+
279+
uui-ref-node {
280+
padding: var(--uui-size-space-3);
281+
}
282+
283+
uui-ref-node::before {
284+
border-radius: var(--uui-border-radius);
285+
border: 1px solid var(--uui-color-divider-standalone);
286+
}
287+
`,
288+
];
289+
}
290+
291+
export default UaiToolScopePickerElement;
292+
293+
declare global {
294+
interface HTMLElementTagNameMap {
295+
[elementName]: UaiToolScopePickerElement;
296+
}
297+
}

0 commit comments

Comments
 (0)