diff --git a/frontend/src/components/ui/config-details.ts b/frontend/src/components/ui/config-details.ts index 3a54daf5e2..8812323535 100644 --- a/frontend/src/components/ui/config-details.ts +++ b/frontend/src/components/ui/config-details.ts @@ -236,7 +236,7 @@ export class ConfigDetails extends BtrixElement { () => html`; + +/** + * @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; + + private readonly fuse = new Fuse([], { + keys: ["id", "name", "description", "origins"], + }); + + private selected = new Map(); + + protected willUpdate(changedProperties: PropertyValues): 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>( + `/orgs/${this.orgId}/profiles?${query}`, + ); + + this.fuse.setCollection(items); + + // Match fuse shape + return items.map((item) => ({ item })); + }, + args: () => [] as const, + }); + + render() { + return html` + { + 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("btrix-change", { + detail: { + value: selectedProfiles.length ? selectedProfiles : undefined, + }, + }), + ); + }} + > + ${this.profiles?.length + ? html`${msg( + str`Using ${pluralOf("profiles", this.profiles.length)}`, + )} + ${this.renderProfilesInLabel(this.profiles)}` + : msg("Browser Profile")} + +
+
+ +
+ ${msg("Filter by Browser Profile")} +
+ ${this.profiles?.length + ? html` { + this.checkboxes.forEach((checkbox) => { + checkbox.checked = false; + }); + + this.dispatchEvent( + new CustomEvent( + "btrix-change", + { + detail: { + value: undefined, + }, + }, + ), + ); + }} + >${msg("Clear")}` + : nothing} +
+ +
${this.renderSearch()}
+
+ + ${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`
+ ${this.searchString + ? msg("No matching profiles found.") + : msg("No profiles found.")} +
`; + }, + })} +
+
+ `; + } + + 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`${part.value}` + : profiles.length > MAX_PROFILES_IN_LABEL && index === array.length - 1 + ? html` ${part.value} ` + : html`${this.profilesTask.value?.find( + ({ item }) => item.id === part.value, + )?.item.name}`, + ); + } + + private renderSearch() { + return html` + + + (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``, + complete: () => html``, + })} + + `; + } + + private renderList(opts: { item: Profile }[]) { + const profile = (profile: Profile) => { + const checked = this.selected.get(profile.id) === true; + + return html` +
  • + + ${profile.name} + + ${profile.inUse + ? html`${profile.description && + html`
    + ${profile.description} +
    `} +
    + ${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`, + }), + )} +
    ` + : html`
    + ${msg("Not in use")} +
    `} +
    +
  • + `; + }; + + // 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` +
      { + const { checked, value } = e.target as SlCheckbox; + + this.selected.set(value, checked); + }} + > + ${repeat( + sortedProfiles, + ({ item }) => item, + ({ item }) => profile(item), + )} +
    + `; + } +} diff --git a/frontend/src/features/crawl-workflows/workflow-tag-filter.ts b/frontend/src/features/crawl-workflows/workflow-tag-filter.ts index 3be316c019..e4fd48a96f 100644 --- a/frontend/src/features/crawl-workflows/workflow-tag-filter.ts +++ b/frontend/src/features/crawl-workflows/workflow-tag-filter.ts @@ -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; /** * @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(); - protected willUpdate(changedProperties: PropertyValues): void { + @state() + private type: "and" | "or" = "or"; + + protected willUpdate(changedProperties: PropertyValues): 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("btrix-change", { - detail: { value: selectedTags.length ? selectedTags : undefined }, + new CustomEvent< + BtrixChangeEvent["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( - "btrix-change", - { - detail: { - value: undefined, - }, + new CustomEvent< + BtrixChangeEvent["detail"] + >("btrix-change", { + detail: { + value: undefined, }, - ), + }), ); }} >${msg("Clear")} -
    ${this.renderSearch()}
    +
    + ${this.renderSearch()} + { + this.type = (event.target as HTMLInputElement).value as + | "or" + | "and"; + }} + @sl-after-hide=${stopProp} + > + + + + ${msg("Any")} + + + + + + ${msg("All")} + + + +
    ${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( diff --git a/frontend/src/pages/org/browser-profiles-detail.ts b/frontend/src/pages/org/browser-profiles-detail.ts index 99ce9dda06..45b4838f54 100644 --- a/frontend/src/pages/org/browser-profiles-detail.ts +++ b/frontend/src/pages/org/browser-profiles-detail.ts @@ -160,6 +160,9 @@ export class BrowserProfilesDetail extends BtrixElement {
    +

    + ${msg("Browser Profile")} +

    -

    - ${msg("Browser Profile")} -

    + + ${this.profile?.inUse + ? html` + +
    + ${msg("In Use")} + + + + ` + : html` + + `}
    ${when(this.isCrawler, () => @@ -249,12 +279,12 @@ export class BrowserProfilesDetail extends BtrixElement {
    ${this.profile ? this.profile.description ? richText(this.profile.description) : html` -
    +
     ${msg("No description added.")}
    ` diff --git a/frontend/src/pages/org/workflows-list.ts b/frontend/src/pages/org/workflows-list.ts index 6d0f0a7edb..24dee88af3 100644 --- a/frontend/src/pages/org/workflows-list.ts +++ b/frontend/src/pages/org/workflows-list.ts @@ -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 { { - this.filterByTags = e.detail.value; + this.filterByTags = e.detail.value?.tags; + this.filterByTagsType = e.detail.value?.type || "or"; }} > + { + this.filterByProfiles = e.detail.value; + }} + > + { @@ -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, }, diff --git a/frontend/src/utils/pluralize.ts b/frontend/src/utils/pluralize.ts index 0131538b81..6f5bfc8cd1 100644 --- a/frontend/src/utils/pluralize.ts +++ b/frontend/src/utils/pluralize.ts @@ -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) => {