Add browser profile filter to workflow list & add link to filtered list to profile detail pages (#2727)

This commit is contained in:
Emma Segal-Grossman 2025-07-14 12:39:22 -04:00 committed by GitHub
parent f91bfda42e
commit b3c8cc5994
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 537 additions and 34 deletions

View File

@ -236,7 +236,7 @@ export class ConfigDetails extends BtrixElement {
() =>
html`<a
class="text-blue-500 hover:text-blue-600"
href=${`/orgs/${crawlConfig!.oid}/browser-profiles/profile/${
href=${`${this.navigate.orgBasePath}/browser-profiles/profile/${
crawlConfig!.profileid
}`}
@click=${this.navigate.link}

View File

@ -9,3 +9,4 @@ import("./workflow-editor");
import("./workflow-list");
import("./workflow-schedule-filter");
import("./workflow-tag-filter");
import("./workflow-profile-filter");

View File

@ -0,0 +1,357 @@
import { localized, msg, str } from "@lit/localize";
import { Task } from "@lit/task";
import type {
SlChangeEvent,
SlCheckbox,
SlInput,
SlInputEvent,
} from "@shoelace-style/shoelace";
import clsx from "clsx";
import Fuse from "fuse.js";
import { html, nothing, type PropertyValues } from "lit";
import {
customElement,
property,
query,
queryAll,
state,
} from "lit/decorators.js";
import { repeat } from "lit/directives/repeat.js";
import queryString from "query-string";
import { isFocusable } from "tabbable";
import { BtrixElement } from "@/classes/BtrixElement";
import type { BtrixChangeEvent } from "@/events/btrix-change";
import { type APIPaginatedList } from "@/types/api";
import { type Profile } from "@/types/crawler";
import { pluralOf } from "@/utils/pluralize";
import { richText } from "@/utils/rich-text";
import { tw } from "@/utils/tailwind";
const MAX_PROFILES_IN_LABEL = 2;
const MAX_ORIGINS_IN_LIST = 5;
export type BtrixChangeWorkflowProfileFilterEvent = BtrixChangeEvent<
string[] | undefined
>;
/**
* @fires btrix-change
*/
@customElement("btrix-workflow-profile-filter")
@localized()
export class WorkflowProfileFilter extends BtrixElement {
@property({ type: Array })
profiles?: string[];
@state()
private searchString = "";
@query("sl-input")
private readonly input?: SlInput | null;
@queryAll("sl-checkbox")
private readonly checkboxes!: NodeListOf<SlCheckbox>;
private readonly fuse = new Fuse<Profile>([], {
keys: ["id", "name", "description", "origins"],
});
private selected = new Map<string, boolean>();
protected willUpdate(changedProperties: PropertyValues<this>): void {
if (changedProperties.has("profiles")) {
if (this.profiles) {
this.selected = new Map(this.profiles.map((tag) => [tag, true]));
} else if (changedProperties.get("profiles")) {
this.selected = new Map();
}
}
}
private readonly profilesTask = new Task(this, {
task: async () => {
const query = queryString.stringify(
{
pageSize: 1000,
page: 1,
},
{
arrayFormat: "comma",
},
);
const { items } = await this.api.fetch<APIPaginatedList<Profile>>(
`/orgs/${this.orgId}/profiles?${query}`,
);
this.fuse.setCollection(items);
// Match fuse shape
return items.map((item) => ({ item }));
},
args: () => [] as const,
});
render() {
return html`
<btrix-filter-chip
?checked=${!!this.profiles?.length}
selectFromDropdown
stayOpenOnChange
@sl-after-show=${() => {
if (this.input && !this.input.disabled) {
this.input.focus();
}
}}
@sl-after-hide=${() => {
this.searchString = "";
const selectedProfiles = [];
for (const [profile, value] of this.selected) {
if (value) {
selectedProfiles.push(profile);
}
}
this.dispatchEvent(
new CustomEvent<BtrixChangeEvent["detail"]>("btrix-change", {
detail: {
value: selectedProfiles.length ? selectedProfiles : undefined,
},
}),
);
}}
>
${this.profiles?.length
? html`<span class="opacity-75"
>${msg(
str`Using ${pluralOf("profiles", this.profiles.length)}`,
)}</span
>
${this.renderProfilesInLabel(this.profiles)}`
: msg("Browser Profile")}
<div
slot="dropdown-content"
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"
>
<header
class=${clsx(
this.profilesTask.value && tw`border-b`,
tw`flex-shrink-0 flex-grow-0 overflow-hidden rounded-t bg-white pb-3`,
)}
>
<sl-menu-label
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"
>
<div
id="profile-list-label"
class="leading-[var(--sl-input-height-small)]"
>
${msg("Filter by Browser Profile")}
</div>
${this.profiles?.length
? html`<sl-button
variant="text"
size="small"
class="part-[label]:px-0"
@click=${() => {
this.checkboxes.forEach((checkbox) => {
checkbox.checked = false;
});
this.dispatchEvent(
new CustomEvent<BtrixChangeEvent["detail"]>(
"btrix-change",
{
detail: {
value: undefined,
},
},
),
);
}}
>${msg("Clear")}</sl-button
>`
: nothing}
</sl-menu-label>
<div class="px-3">${this.renderSearch()}</div>
</header>
${this.profilesTask.render({
complete: (profiles) => {
let options = profiles;
if (profiles.length && this.searchString) {
options = this.fuse.search(this.searchString);
}
if (options.length) {
return this.renderList(options);
}
return html`<div class="p-3 text-neutral-500">
${this.searchString
? msg("No matching profiles found.")
: msg("No profiles found.")}
</div>`;
},
})}
</div>
</btrix-filter-chip>
`;
}
private renderProfilesInLabel(profiles: string[]) {
const formatter2 = this.localize.list(
profiles.length > MAX_PROFILES_IN_LABEL
? [
...profiles.slice(0, MAX_PROFILES_IN_LABEL),
msg(
str`${this.localize.number(profiles.length - MAX_PROFILES_IN_LABEL)} more`,
),
]
: profiles,
{ type: "disjunction" },
);
return formatter2.map((part, index, array) =>
part.type === "literal"
? html`<span class="opacity-75">${part.value}</span>`
: profiles.length > MAX_PROFILES_IN_LABEL && index === array.length - 1
? html`<span class="text-primary-500"> ${part.value} </span>`
: html`<span class="inline-block max-w-48 truncate"
>${this.profilesTask.value?.find(
({ item }) => item.id === part.value,
)?.item.name}</span
>`,
);
}
private renderSearch() {
return html`
<label for="profile-search" class="sr-only"
>${msg("Filter profiles")}</label
>
<sl-input
class="min-w-[30ch]"
id="profile-search"
role="combobox"
aria-autocomplete="list"
aria-expanded="true"
aria-controls="profile-listbox"
aria-activedescendant="profile-selected-option"
value=${this.searchString}
placeholder=${msg("Search for profile")}
size="small"
?disabled=${!this.profilesTask.value?.length}
@sl-input=${(e: SlInputEvent) =>
(this.searchString = (e.target as SlInput).value)}
@keydown=${(e: KeyboardEvent) => {
// Prevent moving to next tabbable element since dropdown should close
if (e.key === "Tab") e.preventDefault();
if (e.key === "ArrowDown" && isFocusable(this.checkboxes[0])) {
this.checkboxes[0].focus();
}
}}
>
${this.profilesTask.render({
pending: () => html`<sl-spinner slot="prefix"></sl-spinner>`,
complete: () => html`<sl-icon slot="prefix" name="search"></sl-icon>`,
})}
</sl-input>
`;
}
private renderList(opts: { item: Profile }[]) {
const profile = (profile: Profile) => {
const checked = this.selected.get(profile.id) === true;
return html`
<li role="option" aria-checked=${checked}>
<sl-checkbox
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"
value=${profile.id}
?checked=${checked}
?disabled=${!profile.inUse}
>
<span class="mb-1 inline-block min-w-0 max-w-96 truncate"
>${profile.name}</span
>
<btrix-format-date
class="col-start-2 ml-auto text-xs text-stone-600"
date=${profile.modified ?? profile.created}
></btrix-format-date>
${profile.inUse
? html`${profile.description &&
html`<div
class="col-span-2 min-w-0 truncate text-xs text-stone-600 contain-inline-size"
>
${profile.description}
</div>`}
<div
class="col-span-2 min-w-0 max-w-full text-xs text-stone-400 contain-inline-size"
>
${this.localize
.list(
profile.origins.length > MAX_ORIGINS_IN_LIST
? [
...profile.origins.slice(0, MAX_ORIGINS_IN_LIST),
msg(
str`${this.localize.number(profile.origins.length - MAX_ORIGINS_IN_LIST)} more`,
),
]
: profile.origins,
)
.map((part) =>
part.type === "literal"
? part.value
: richText(part.value, {
shortenOnly: true,
linkClass: tw`inline-block max-w-[min(theme(spacing.72),100%)] truncate font-medium text-stone-600`,
}),
)}
</div> `
: html`<div class="col-span-2 text-xs">
${msg("Not in use")}
</div>`}
</sl-checkbox>
</li>
`;
};
// TODO for if/when we correctly handle `inUse` in the profile list endpoint
// const sortedProfiles = opts.sort(({ item: a }, { item: b }) =>
// b.inUse === a.inUse ? 0 : b.inUse ? -1 : 1,
// );
// For now, we just hardcode `inUse` to be true
const sortedProfiles = opts.map(({ item }) => ({
item: { ...item, inUse: true },
}));
return html`
<ul
id="profile-listbox"
class="flex-1 overflow-auto p-1"
role="listbox"
aria-labelledby="profile-list-label"
aria-multiselectable="true"
@sl-change=${async (e: SlChangeEvent) => {
const { checked, value } = e.target as SlCheckbox;
this.selected.set(value, checked);
}}
>
${repeat(
sortedProfiles,
({ item }) => item,
({ item }) => profile(item),
)}
</ul>
`;
}
}

View File

@ -22,13 +22,17 @@ import { isFocusable } from "tabbable";
import { BtrixElement } from "@/classes/BtrixElement";
import type { BtrixChangeEvent } from "@/events/btrix-change";
import { type WorkflowTag, type WorkflowTags } from "@/types/workflow";
import { stopProp } from "@/utils/events";
import { tw } from "@/utils/tailwind";
const MAX_TAGS_IN_LABEL = 5;
export type BtrixChangeWorkflowTagFilterEvent = BtrixChangeEvent<
string[] | undefined
>;
type ChangeWorkflowTagEventDetails =
| { tags: string[]; type: "and" | "or" }
| undefined;
export type BtrixChangeWorkflowTagFilterEvent =
BtrixChangeEvent<ChangeWorkflowTagEventDetails>;
/**
* @fires btrix-change
@ -52,9 +56,19 @@ export class WorkflowTagFilter extends BtrixElement {
keys: ["tag"],
});
@state()
private get selectedTags() {
return Array.from(this.selected.entries())
.filter(([_tag, selected]) => selected)
.map(([tag]) => tag);
}
private selected = new Map<string, boolean>();
protected willUpdate(changedProperties: PropertyValues): void {
@state()
private type: "and" | "or" = "or";
protected willUpdate(changedProperties: PropertyValues<this>): void {
if (changedProperties.has("tags")) {
if (this.tags) {
this.selected = new Map(this.tags.map((tag) => [tag, true]));
@ -92,17 +106,17 @@ export class WorkflowTagFilter extends BtrixElement {
@sl-after-hide=${() => {
this.searchString = "";
const selectedTags = [];
for (const [tag, value] of this.selected) {
if (value) {
selectedTags.push(tag);
}
}
console.log("after hide");
this.dispatchEvent(
new CustomEvent<BtrixChangeEvent["detail"]>("btrix-change", {
detail: { value: selectedTags.length ? selectedTags : undefined },
new CustomEvent<
BtrixChangeEvent<ChangeWorkflowTagEventDetails>["detail"]
>("btrix-change", {
detail: {
value: this.selectedTags.length
? { tags: this.selectedTags, type: this.type }
: undefined,
},
}),
);
}}
@ -141,15 +155,16 @@ export class WorkflowTagFilter extends BtrixElement {
checkbox.checked = false;
});
this.type = "or";
this.dispatchEvent(
new CustomEvent<BtrixChangeEvent["detail"]>(
"btrix-change",
{
detail: {
value: undefined,
},
new CustomEvent<
BtrixChangeEvent<ChangeWorkflowTagEventDetails>["detail"]
>("btrix-change", {
detail: {
value: undefined,
},
),
}),
);
}}
>${msg("Clear")}</sl-button
@ -157,7 +172,32 @@ export class WorkflowTagFilter extends BtrixElement {
: nothing}
</sl-menu-label>
<div class="px-3">${this.renderSearch()}</div>
<div class="flex gap-2 px-3">
${this.renderSearch()}
<sl-radio-group
size="small"
value=${this.type}
@sl-change=${(event: SlChangeEvent) => {
this.type = (event.target as HTMLInputElement).value as
| "or"
| "and";
}}
@sl-after-hide=${stopProp}
>
<sl-tooltip hoist content=${msg("Any of the selected tags")}>
<sl-radio-button value="or" checked>
<sl-icon name="union" slot="prefix"></sl-icon>
${msg("Any")}
</sl-radio-button>
</sl-tooltip>
<sl-tooltip hoist content=${msg("All of the selected tags")}>
<sl-radio-button value="and">
<sl-icon name="intersect" slot="prefix"></sl-icon>
${msg("All")}
</sl-radio-button>
</sl-tooltip>
</sl-radio-group>
</div>
</header>
${this.orgTagsTask.render({
@ -194,6 +234,7 @@ export class WorkflowTagFilter extends BtrixElement {
),
]
: tags,
{ type: this.type === "and" ? "conjunction" : "disjunction" },
);
return formatter2.map((part, index, array) =>
@ -266,6 +307,7 @@ export class WorkflowTagFilter extends BtrixElement {
const { checked, value } = e.target as SlCheckbox;
this.selected.set(value, checked);
this.requestUpdate("selectedTags");
}}
>
${repeat(

View File

@ -160,6 +160,9 @@ export class BrowserProfilesDetail extends BtrixElement {
<div class="mb-7 flex flex-col gap-5 lg:flex-row">
<section class="flex-1">
<header class="flex items-center gap-2">
<h2 class="text-lg font-medium leading-none">
${msg("Browser Profile")}
</h2>
<sl-tooltip
content=${isBackedUp ? msg("Backed Up") : msg("Not Backed Up")}
?disabled=${!this.profile}
@ -167,7 +170,7 @@ export class BrowserProfilesDetail extends BtrixElement {
<sl-icon
class="${isBackedUp
? "text-success"
: "text-neutral-500"} text-base"
: "text-neutral-500"} ml-auto text-base"
name=${this.profile
? isBackedUp
? "clouds-fill"
@ -175,9 +178,36 @@ export class BrowserProfilesDetail extends BtrixElement {
: "clouds"}
></sl-icon>
</sl-tooltip>
<h2 class="text-lg font-medium leading-none">
${msg("Browser Profile")}
</h2>
${this.profile?.inUse
? html`
<sl-tooltip
content=${msg(
"View Crawl Workflows using this Browser Profile",
)}
>
<a
href=${`${this.navigate.orgBasePath}/workflows?profiles=${this.profile.id}`}
@click=${this.navigate.link}
class="ml-2 flex items-center gap-2 text-sm font-medium text-primary-500 transition-colors hover:text-primary-600"
>
${msg("In Use")}
<sl-icon
class="text-base"
name="arrow-right-circle"
></sl-icon>
</a>
</sl-tooltip>
`
: html`<sl-tooltip
content=${msg("Not In Use")}
?disabled=${!this.profile}
>
<sl-icon
class="text-base text-neutral-500"
name=${this.profile ? "slash-circle" : "clouds"}
></sl-icon>
</sl-tooltip>`}
</header>
${when(this.isCrawler, () =>
@ -249,12 +279,12 @@ export class BrowserProfilesDetail extends BtrixElement {
</header>
<!-- display: inline -->
<div
class="leading whitespace-pre-line rounded border p-5 leading-relaxed first-line:leading-[0]"
class="leading whitespace-pre-line rounded border p-5 leading-relaxed"
>${this.profile
? this.profile.description
? richText(this.profile.description)
: html`
<div class="text-center text-neutral-400">
<div class="text-center leading-[0] text-neutral-400">
&nbsp;${msg("No description added.")}
</div>
`

View File

@ -25,6 +25,7 @@ import { type SelectEvent } from "@/components/ui/search-combobox";
import { ClipboardController } from "@/controllers/clipboard";
import { SearchParamsController } from "@/controllers/searchParams";
import type { SelectJobTypeEvent } from "@/features/crawl-workflows/new-workflow-dialog";
import { type BtrixChangeWorkflowProfileFilterEvent } from "@/features/crawl-workflows/workflow-profile-filter";
import type { BtrixChangeWorkflowScheduleFilterEvent } from "@/features/crawl-workflows/workflow-schedule-filter";
import type { BtrixChangeWorkflowTagFilterEvent } from "@/features/crawl-workflows/workflow-tag-filter";
import { pageHeader } from "@/layouts/pageHeader";
@ -131,6 +132,12 @@ export class WorkflowsList extends BtrixElement {
@state()
private filterByTags?: string[];
@state()
private filterByTagsType: "and" | "or" = "or";
@state()
private filterByProfiles?: string[];
@query("#deleteDialog")
private readonly deleteDialog?: SlDialog | null;
@ -173,6 +180,12 @@ export class WorkflowsList extends BtrixElement {
this.filterByTags = undefined;
}
if (params.has("profiles")) {
this.filterByProfiles = params.getAll("profiles");
} else {
this.filterByProfiles = undefined;
}
// add filters present in search params
for (const [key, value] of params) {
// Filter by current user
@ -180,6 +193,10 @@ export class WorkflowsList extends BtrixElement {
this.filterByCurrentUser = value === "true";
}
if (key === "tagsType") {
this.filterByTagsType = value === "and" ? "and" : "or";
}
// Sorting field
if (key === "sortBy") {
if (value in sortableFields) {
@ -200,7 +217,18 @@ export class WorkflowsList extends BtrixElement {
}
// Ignored params
if (["page", "mine", "tags", "sortBy", "sortDir"].includes(key)) continue;
if (
[
"page",
"mine",
"tags",
"tagsType",
"profiles",
"sortBy",
"sortDir",
].includes(key)
)
continue;
// Convert string bools to filter values
if (value === "true") {
@ -239,6 +267,8 @@ export class WorkflowsList extends BtrixElement {
const resetToFirstPageProps = [
"filterByCurrentUser",
"filterByTags",
"filterByTagsType",
"filterByProfiles",
"filterByScheduled",
"filterBy",
"orderBy",
@ -278,15 +308,14 @@ export class WorkflowsList extends BtrixElement {
changedProperties.has("filterBy") ||
changedProperties.has("filterByCurrentUser") ||
changedProperties.has("filterByTags") ||
changedProperties.has("filterByTagsType") ||
changedProperties.has("filterByProfiles") ||
changedProperties.has("orderBy")
) {
this.searchParams.update((params) => {
// Reset page
params.delete("page");
// Existing tags
const tags = params.getAll("tags");
const newParams = [
// Known filters
...USED_FILTERS.map<[string, undefined]>((f) => [f, undefined]),
@ -299,6 +328,13 @@ export class WorkflowsList extends BtrixElement {
["tags", this.filterByTags],
[
"tagsType",
this.filterByTagsType !== "or" ? this.filterByTagsType : undefined,
],
["profiles", this.filterByProfiles],
// Sorting fields
[
"sortBy",
@ -319,7 +355,8 @@ export class WorkflowsList extends BtrixElement {
if (value !== undefined) {
if (Array.isArray(value)) {
value.forEach((v) => {
if (!tags.includes(v)) {
// Only add new array values to URL
if (!params.getAll(filter).includes(v)) {
params.append(filter, v);
}
});
@ -626,10 +663,18 @@ export class WorkflowsList extends BtrixElement {
<btrix-workflow-tag-filter
.tags=${this.filterByTags}
@btrix-change=${(e: BtrixChangeWorkflowTagFilterEvent) => {
this.filterByTags = e.detail.value;
this.filterByTags = e.detail.value?.tags;
this.filterByTagsType = e.detail.value?.type || "or";
}}
></btrix-workflow-tag-filter>
<btrix-workflow-profile-filter
.profiles=${this.filterByProfiles}
@btrix-change=${(e: BtrixChangeWorkflowProfileFilterEvent) => {
this.filterByProfiles = e.detail.value;
}}
></btrix-workflow-profile-filter>
<btrix-filter-chip
?checked=${this.filterBy.isCrawlRunning === true}
@btrix-change=${(e: BtrixFilterChipChangeEvent) => {
@ -976,6 +1021,8 @@ export class WorkflowsList extends BtrixElement {
INITIAL_PAGE_SIZE,
userid: this.filterByCurrentUser ? this.userInfo?.id : undefined,
tag: this.filterByTags || undefined,
tagMatch: this.filterByTagsType,
profileIds: this.filterByProfiles || undefined,
sortBy: this.orderBy.field,
sortDirection: this.orderBy.direction === "desc" ? -1 : 1,
},

View File

@ -221,6 +221,32 @@ const plurals = {
id: "browserWindows.plural.other",
}),
},
profiles: {
zero: msg("profiles", {
desc: 'plural form of "profiles" for zero profiles',
id: "profiles.plural.zero",
}),
one: msg("profile", {
desc: 'singular form for "profile"',
id: "profiles.plural.one",
}),
two: msg("profiles", {
desc: 'plural form of "profiles" for two profiles',
id: "profiles.plural.two",
}),
few: msg("profiles", {
desc: 'plural form of "profiles" for few profiles',
id: "profiles.plural.few",
}),
many: msg("profiles", {
desc: 'plural form of "profiles" for many profiles',
id: "profiles.plural.many",
}),
other: msg("profiles", {
desc: 'plural form of "profiles" for multiple/other profiles',
id: "profiles.plural.other",
}),
},
};
export const pluralOf = (word: keyof typeof plurals, count: number) => {