Skip to content

Commit b3c8cc5

Browse files
authored
Add browser profile filter to workflow list & add link to filtered list to profile detail pages (#2727)
1 parent f91bfda commit b3c8cc5

File tree

7 files changed

+537
-34
lines changed

7 files changed

+537
-34
lines changed

frontend/src/components/ui/config-details.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ export class ConfigDetails extends BtrixElement {
236236
() =>
237237
html`<a
238238
class="text-blue-500 hover:text-blue-600"
239-
href=${`/orgs/${crawlConfig!.oid}/browser-profiles/profile/${
239+
href=${`${this.navigate.orgBasePath}/browser-profiles/profile/${
240240
crawlConfig!.profileid
241241
}`}
242242
@click=${this.navigate.link}

frontend/src/features/crawl-workflows/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ import("./workflow-editor");
99
import("./workflow-list");
1010
import("./workflow-schedule-filter");
1111
import("./workflow-tag-filter");
12+
import("./workflow-profile-filter");
Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
1+
import { localized, msg, str } from "@lit/localize";
2+
import { Task } from "@lit/task";
3+
import type {
4+
SlChangeEvent,
5+
SlCheckbox,
6+
SlInput,
7+
SlInputEvent,
8+
} from "@shoelace-style/shoelace";
9+
import clsx from "clsx";
10+
import Fuse from "fuse.js";
11+
import { html, nothing, type PropertyValues } from "lit";
12+
import {
13+
customElement,
14+
property,
15+
query,
16+
queryAll,
17+
state,
18+
} from "lit/decorators.js";
19+
import { repeat } from "lit/directives/repeat.js";
20+
import queryString from "query-string";
21+
import { isFocusable } from "tabbable";
22+
23+
import { BtrixElement } from "@/classes/BtrixElement";
24+
import type { BtrixChangeEvent } from "@/events/btrix-change";
25+
import { type APIPaginatedList } from "@/types/api";
26+
import { type Profile } from "@/types/crawler";
27+
import { pluralOf } from "@/utils/pluralize";
28+
import { richText } from "@/utils/rich-text";
29+
import { tw } from "@/utils/tailwind";
30+
31+
const MAX_PROFILES_IN_LABEL = 2;
32+
const MAX_ORIGINS_IN_LIST = 5;
33+
34+
export type BtrixChangeWorkflowProfileFilterEvent = BtrixChangeEvent<
35+
string[] | undefined
36+
>;
37+
38+
/**
39+
* @fires btrix-change
40+
*/
41+
@customElement("btrix-workflow-profile-filter")
42+
@localized()
43+
export class WorkflowProfileFilter extends BtrixElement {
44+
@property({ type: Array })
45+
profiles?: string[];
46+
47+
@state()
48+
private searchString = "";
49+
50+
@query("sl-input")
51+
private readonly input?: SlInput | null;
52+
53+
@queryAll("sl-checkbox")
54+
private readonly checkboxes!: NodeListOf<SlCheckbox>;
55+
56+
private readonly fuse = new Fuse<Profile>([], {
57+
keys: ["id", "name", "description", "origins"],
58+
});
59+
60+
private selected = new Map<string, boolean>();
61+
62+
protected willUpdate(changedProperties: PropertyValues<this>): void {
63+
if (changedProperties.has("profiles")) {
64+
if (this.profiles) {
65+
this.selected = new Map(this.profiles.map((tag) => [tag, true]));
66+
} else if (changedProperties.get("profiles")) {
67+
this.selected = new Map();
68+
}
69+
}
70+
}
71+
72+
private readonly profilesTask = new Task(this, {
73+
task: async () => {
74+
const query = queryString.stringify(
75+
{
76+
pageSize: 1000,
77+
page: 1,
78+
},
79+
{
80+
arrayFormat: "comma",
81+
},
82+
);
83+
const { items } = await this.api.fetch<APIPaginatedList<Profile>>(
84+
`/orgs/${this.orgId}/profiles?${query}`,
85+
);
86+
87+
this.fuse.setCollection(items);
88+
89+
// Match fuse shape
90+
return items.map((item) => ({ item }));
91+
},
92+
args: () => [] as const,
93+
});
94+
95+
render() {
96+
return html`
97+
<btrix-filter-chip
98+
?checked=${!!this.profiles?.length}
99+
selectFromDropdown
100+
stayOpenOnChange
101+
@sl-after-show=${() => {
102+
if (this.input && !this.input.disabled) {
103+
this.input.focus();
104+
}
105+
}}
106+
@sl-after-hide=${() => {
107+
this.searchString = "";
108+
109+
const selectedProfiles = [];
110+
111+
for (const [profile, value] of this.selected) {
112+
if (value) {
113+
selectedProfiles.push(profile);
114+
}
115+
}
116+
117+
this.dispatchEvent(
118+
new CustomEvent<BtrixChangeEvent["detail"]>("btrix-change", {
119+
detail: {
120+
value: selectedProfiles.length ? selectedProfiles : undefined,
121+
},
122+
}),
123+
);
124+
}}
125+
>
126+
${this.profiles?.length
127+
? html`<span class="opacity-75"
128+
>${msg(
129+
str`Using ${pluralOf("profiles", this.profiles.length)}`,
130+
)}</span
131+
>
132+
${this.renderProfilesInLabel(this.profiles)}`
133+
: msg("Browser Profile")}
134+
135+
<div
136+
slot="dropdown-content"
137+
class="flex max-h-[var(--auto-size-available-height)] max-w-[var(--auto-size-available-width)] flex-col overflow-hidden rounded border bg-white text-left"
138+
>
139+
<header
140+
class=${clsx(
141+
this.profilesTask.value && tw`border-b`,
142+
tw`flex-shrink-0 flex-grow-0 overflow-hidden rounded-t bg-white pb-3`,
143+
)}
144+
>
145+
<sl-menu-label
146+
class="min-h-[var(--sl-input-height-small)] part-[base]:flex part-[base]:items-center part-[base]:justify-between part-[base]:gap-4 part-[base]:px-3"
147+
>
148+
<div
149+
id="profile-list-label"
150+
class="leading-[var(--sl-input-height-small)]"
151+
>
152+
${msg("Filter by Browser Profile")}
153+
</div>
154+
${this.profiles?.length
155+
? html`<sl-button
156+
variant="text"
157+
size="small"
158+
class="part-[label]:px-0"
159+
@click=${() => {
160+
this.checkboxes.forEach((checkbox) => {
161+
checkbox.checked = false;
162+
});
163+
164+
this.dispatchEvent(
165+
new CustomEvent<BtrixChangeEvent["detail"]>(
166+
"btrix-change",
167+
{
168+
detail: {
169+
value: undefined,
170+
},
171+
},
172+
),
173+
);
174+
}}
175+
>${msg("Clear")}</sl-button
176+
>`
177+
: nothing}
178+
</sl-menu-label>
179+
180+
<div class="px-3">${this.renderSearch()}</div>
181+
</header>
182+
183+
${this.profilesTask.render({
184+
complete: (profiles) => {
185+
let options = profiles;
186+
187+
if (profiles.length && this.searchString) {
188+
options = this.fuse.search(this.searchString);
189+
}
190+
191+
if (options.length) {
192+
return this.renderList(options);
193+
}
194+
195+
return html`<div class="p-3 text-neutral-500">
196+
${this.searchString
197+
? msg("No matching profiles found.")
198+
: msg("No profiles found.")}
199+
</div>`;
200+
},
201+
})}
202+
</div>
203+
</btrix-filter-chip>
204+
`;
205+
}
206+
207+
private renderProfilesInLabel(profiles: string[]) {
208+
const formatter2 = this.localize.list(
209+
profiles.length > MAX_PROFILES_IN_LABEL
210+
? [
211+
...profiles.slice(0, MAX_PROFILES_IN_LABEL),
212+
msg(
213+
str`${this.localize.number(profiles.length - MAX_PROFILES_IN_LABEL)} more`,
214+
),
215+
]
216+
: profiles,
217+
{ type: "disjunction" },
218+
);
219+
220+
return formatter2.map((part, index, array) =>
221+
part.type === "literal"
222+
? html`<span class="opacity-75">${part.value}</span>`
223+
: profiles.length > MAX_PROFILES_IN_LABEL && index === array.length - 1
224+
? html`<span class="text-primary-500"> ${part.value} </span>`
225+
: html`<span class="inline-block max-w-48 truncate"
226+
>${this.profilesTask.value?.find(
227+
({ item }) => item.id === part.value,
228+
)?.item.name}</span
229+
>`,
230+
);
231+
}
232+
233+
private renderSearch() {
234+
return html`
235+
<label for="profile-search" class="sr-only"
236+
>${msg("Filter profiles")}</label
237+
>
238+
<sl-input
239+
class="min-w-[30ch]"
240+
id="profile-search"
241+
role="combobox"
242+
aria-autocomplete="list"
243+
aria-expanded="true"
244+
aria-controls="profile-listbox"
245+
aria-activedescendant="profile-selected-option"
246+
value=${this.searchString}
247+
placeholder=${msg("Search for profile")}
248+
size="small"
249+
?disabled=${!this.profilesTask.value?.length}
250+
@sl-input=${(e: SlInputEvent) =>
251+
(this.searchString = (e.target as SlInput).value)}
252+
@keydown=${(e: KeyboardEvent) => {
253+
// Prevent moving to next tabbable element since dropdown should close
254+
if (e.key === "Tab") e.preventDefault();
255+
if (e.key === "ArrowDown" && isFocusable(this.checkboxes[0])) {
256+
this.checkboxes[0].focus();
257+
}
258+
}}
259+
>
260+
${this.profilesTask.render({
261+
pending: () => html`<sl-spinner slot="prefix"></sl-spinner>`,
262+
complete: () => html`<sl-icon slot="prefix" name="search"></sl-icon>`,
263+
})}
264+
</sl-input>
265+
`;
266+
}
267+
268+
private renderList(opts: { item: Profile }[]) {
269+
const profile = (profile: Profile) => {
270+
const checked = this.selected.get(profile.id) === true;
271+
272+
return html`
273+
<li role="option" aria-checked=${checked}>
274+
<sl-checkbox
275+
class="w-full part-[label]:grid part-[base]:w-full part-[label]:w-full part-[label]:items-center part-[label]:justify-between part-[label]:gap-x-2 part-[label]:gap-y-1 part-[base]:rounded part-[base]:p-2 part-[base]:hover:bg-primary-50"
276+
value=${profile.id}
277+
?checked=${checked}
278+
?disabled=${!profile.inUse}
279+
>
280+
<span class="mb-1 inline-block min-w-0 max-w-96 truncate"
281+
>${profile.name}</span
282+
>
283+
<btrix-format-date
284+
class="col-start-2 ml-auto text-xs text-stone-600"
285+
date=${profile.modified ?? profile.created}
286+
></btrix-format-date>
287+
${profile.inUse
288+
? html`${profile.description &&
289+
html`<div
290+
class="col-span-2 min-w-0 truncate text-xs text-stone-600 contain-inline-size"
291+
>
292+
${profile.description}
293+
</div>`}
294+
<div
295+
class="col-span-2 min-w-0 max-w-full text-xs text-stone-400 contain-inline-size"
296+
>
297+
${this.localize
298+
.list(
299+
profile.origins.length > MAX_ORIGINS_IN_LIST
300+
? [
301+
...profile.origins.slice(0, MAX_ORIGINS_IN_LIST),
302+
msg(
303+
str`${this.localize.number(profile.origins.length - MAX_ORIGINS_IN_LIST)} more`,
304+
),
305+
]
306+
: profile.origins,
307+
)
308+
.map((part) =>
309+
part.type === "literal"
310+
? part.value
311+
: richText(part.value, {
312+
shortenOnly: true,
313+
linkClass: tw`inline-block max-w-[min(theme(spacing.72),100%)] truncate font-medium text-stone-600`,
314+
}),
315+
)}
316+
</div> `
317+
: html`<div class="col-span-2 text-xs">
318+
${msg("Not in use")}
319+
</div>`}
320+
</sl-checkbox>
321+
</li>
322+
`;
323+
};
324+
325+
// TODO for if/when we correctly handle `inUse` in the profile list endpoint
326+
327+
// const sortedProfiles = opts.sort(({ item: a }, { item: b }) =>
328+
// b.inUse === a.inUse ? 0 : b.inUse ? -1 : 1,
329+
// );
330+
331+
// For now, we just hardcode `inUse` to be true
332+
const sortedProfiles = opts.map(({ item }) => ({
333+
item: { ...item, inUse: true },
334+
}));
335+
336+
return html`
337+
<ul
338+
id="profile-listbox"
339+
class="flex-1 overflow-auto p-1"
340+
role="listbox"
341+
aria-labelledby="profile-list-label"
342+
aria-multiselectable="true"
343+
@sl-change=${async (e: SlChangeEvent) => {
344+
const { checked, value } = e.target as SlCheckbox;
345+
346+
this.selected.set(value, checked);
347+
}}
348+
>
349+
${repeat(
350+
sortedProfiles,
351+
({ item }) => item,
352+
({ item }) => profile(item),
353+
)}
354+
</ul>
355+
`;
356+
}
357+
}

0 commit comments

Comments
 (0)