diff --git a/backend/btrixcloud/crawlconfigs.py b/backend/btrixcloud/crawlconfigs.py index 9a1f5119b7..e1c9d60b0f 100644 --- a/backend/btrixcloud/crawlconfigs.py +++ b/backend/btrixcloud/crawlconfigs.py @@ -21,6 +21,7 @@ from .pagination import DEFAULT_PAGE_SIZE, paginated_format from .models import ( + TYPE_ALL_CRAWL_STATES, CrawlConfigIn, ConfigRevision, CrawlConfig, @@ -732,6 +733,7 @@ async def get_crawl_configs( description: Optional[str] = None, tags: Optional[List[str]] = None, tag_match: Optional[ListFilterType] = ListFilterType.AND, + last_crawl_state: list[TYPE_ALL_CRAWL_STATES] | None = None, schedule: Optional[bool] = None, is_crawl_running: Optional[bool] = None, sort_by: str = "lastRun", @@ -773,6 +775,9 @@ async def get_crawl_configs( if is_crawl_running is not None: match_query["isCrawlRunning"] = is_crawl_running + if last_crawl_state: + match_query["lastCrawlState"] = {"$in": last_crawl_state} + # pylint: disable=duplicate-code aggregate: List[Dict[str, Union[object, str, int]]] = [ {"$match": match_query}, @@ -1567,6 +1572,10 @@ async def get_crawl_configs( description='Defaults to `"and"` if omitted', ), ] = ListFilterType.AND, + last_crawl_state: Annotated[ + list[TYPE_ALL_CRAWL_STATES] | None, + Query(alias="lastCrawlState", title="Last Crawl State"), + ] = None, schedule: Optional[bool] = None, is_crawl_running: Annotated[ Optional[bool], Query(alias="isCrawlRunning", title="Is Crawl Running") @@ -1596,6 +1605,7 @@ async def get_crawl_configs( description=description, tags=tag, tag_match=tag_match, + last_crawl_state=last_crawl_state, schedule=schedule, is_crawl_running=is_crawl_running, page_size=page_size, diff --git a/frontend/src/features/archived-items/archived-item-state-filter.ts b/frontend/src/features/archived-items/archived-item-state-filter.ts index e9bbb9e8d1..541db1e4b7 100644 --- a/frontend/src/features/archived-items/archived-item-state-filter.ts +++ b/frontend/src/features/archived-items/archived-item-state-filter.ts @@ -182,7 +182,10 @@ export class ArchivedItemStateFilter extends BtrixElement { } private renderLabel(state: CrawlState) { - const { icon, label } = CrawlStatus.getContent({ state }); + const { icon, label } = CrawlStatus.getContent({ + state, + originalState: state, + }); return html`${icon}${label} { const checked = this.selected.get(state) === true; - const { icon, label } = CrawlStatus.getContent({ state }); + const { icon, label } = CrawlStatus.getContent({ + state, + originalState: state, + }); return html`
  • diff --git a/frontend/src/features/crawl-workflows/index.ts b/frontend/src/features/crawl-workflows/index.ts index 57a46ba5a5..2dd12c1a8a 100644 --- a/frontend/src/features/crawl-workflows/index.ts +++ b/frontend/src/features/crawl-workflows/index.ts @@ -11,3 +11,4 @@ import("./workflow-list"); import("./workflow-schedule-filter"); import("./workflow-tag-filter"); import("./workflow-profile-filter"); +import("./workflow-last-crawl-state-filter"); diff --git a/frontend/src/features/crawl-workflows/workflow-last-crawl-state-filter.ts b/frontend/src/features/crawl-workflows/workflow-last-crawl-state-filter.ts new file mode 100644 index 0000000000..f3d8aec6d0 --- /dev/null +++ b/frontend/src/features/crawl-workflows/workflow-last-crawl-state-filter.ts @@ -0,0 +1,274 @@ +import { localized, msg, str } from "@lit/localize"; +import type { + SlChangeEvent, + SlCheckbox, + SlInput, + SlInputEvent, +} from "@shoelace-style/shoelace"; +import Fuse from "fuse.js"; +import { html, type PropertyValues } from "lit"; +import { + customElement, + property, + query, + queryAll, + state, +} from "lit/decorators.js"; +import { repeat } from "lit/directives/repeat.js"; +import { isFocusable } from "tabbable"; + +import { BtrixElement } from "@/classes/BtrixElement"; +import type { BtrixChangeEvent } from "@/events/btrix-change"; +import { CrawlStatus } from "@/features/archived-items/crawl-status"; +import { CRAWL_STATES, type CrawlState } from "@/types/crawlState"; +import { isNotEqual } from "@/utils/is-not-equal"; +import { tw } from "@/utils/tailwind"; + +const MAX_STATES_IN_LABEL = 2; + +type ChangeWorkflowLastCrawlStateEventDetails = CrawlState[]; + +export type BtrixChangeWorkflowLastCrawlStateFilterEvent = + BtrixChangeEvent; + +/** + * @fires btrix-change + */ +@customElement("btrix-workflow-last-crawl-state-filter") +@localized() +export class WorkflowLastCrawlStateFilter extends BtrixElement { + @property({ type: Array }) + states?: CrawlState[] | undefined; + + @state() + private searchString = ""; + + @query("sl-input") + private readonly input?: SlInput | null; + + @queryAll("sl-checkbox") + private readonly checkboxes!: NodeListOf; + + private readonly fuse = new Fuse(CRAWL_STATES); + + @state({ hasChanged: isNotEqual }) + selected = new Map(); + + protected willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has("states")) { + if (this.states) { + this.selected = new Map(this.states.map((state) => [state, true])); + } else if (changedProperties.get("states")) { + this.selected = new Map(); + } + } + } + + protected updated(changedProperties: PropertyValues): void { + if (changedProperties.has("selected")) { + this.dispatchEvent( + new CustomEvent< + BtrixChangeEvent["detail"] + >("btrix-change", { + detail: { + value: Array.from(this.selected.entries()) + .filter(([_tag, selected]) => selected) + .map(([tag]) => tag), + }, + }), + ); + } + } + + render() { + const options = this.searchString + ? this.fuse.search(this.searchString) + : CRAWL_STATES.map((state) => ({ item: state })); + return html` + { + if (this.input && !this.input.disabled) { + this.input.focus(); + } + }} + @sl-after-hide=${() => { + this.searchString = ""; + }} + > + ${this.states?.length + ? html`${msg("Latest Crawl Status")} + ${this.renderStatesInLabel(this.states)}` + : msg("Latest Crawl Status")} + +
    +
    + +
    + ${msg("Filter by Latest Crawl Status")} +
    + ${this.states?.length + ? html` { + this.checkboxes.forEach((checkbox) => { + checkbox.checked = false; + }); + + this.dispatchEvent( + new CustomEvent< + BtrixChangeEvent["detail"] + >("btrix-change", { + detail: { + value: [], + }, + }), + ); + }} + >${msg("Clear")}` + : html`${msg("Any")}`} +
    + +
    ${this.renderSearch()}
    +
    + + ${options.length > 0 + ? this.renderList(options) + : html`
    + ${msg("No matching states found.")} +
    `} +
    +
    + `; + } + + private renderStatesInLabel(states: string[]) { + const formatter = this.localize.list( + states.length > MAX_STATES_IN_LABEL + ? [ + ...states.slice(0, MAX_STATES_IN_LABEL), + msg( + str`${this.localize.number(states.length - MAX_STATES_IN_LABEL)} more`, + ), + ] + : states, + { type: "disjunction" }, + ); + + return formatter.map((part, index, array) => + part.type === "literal" + ? html`${part.value}` + : states.length > MAX_STATES_IN_LABEL && index === array.length - 1 + ? html` ${part.value} ` + : html`${this.renderLabel(part.value as CrawlState)}`, + ); + } + + private renderLabel(state: CrawlState) { + const { icon, label } = CrawlStatus.getContent({ + state, + originalState: state, + }); + return html`${icon}${label}`; + } + + 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(); + } + }} + > + + + `; + } + + private renderList(opts: { item: CrawlState }[]) { + const state = (state: CrawlState) => { + const checked = this.selected.get(state) === true; + + const { icon, label } = CrawlStatus.getContent({ + state, + originalState: state, + }); + + return html` +
  • + + ${label}${icon} + +
  • + `; + }; + + return html` + + `; + } +} diff --git a/frontend/src/pages/org/workflows-list.ts b/frontend/src/pages/org/workflows-list.ts index 6ab7d4a061..19898f49cc 100644 --- a/frontend/src/pages/org/workflows-list.ts +++ b/frontend/src/pages/org/workflows-list.ts @@ -32,6 +32,7 @@ import { Action, type BtrixSelectActionEvent, } from "@/features/crawl-workflows/workflow-action-menu/types"; +import { type BtrixChangeWorkflowLastCrawlStateFilterEvent } from "@/features/crawl-workflows/workflow-last-crawl-state-filter"; 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"; @@ -40,6 +41,7 @@ import { WorkflowTab } from "@/routes"; import scopeTypeLabels from "@/strings/crawl-workflows/scopeType"; import { deleteConfirmation } from "@/strings/ui"; import type { APIPaginatedList, APIPaginationQuery } from "@/types/api"; +import { type CrawlState } from "@/types/crawlState"; import { NewWorkflowOnlyScopeType, type StorageSeedFile, @@ -102,6 +104,7 @@ type FilterBy = { firstSeed?: string; schedule?: boolean; isCrawlRunning?: true; + lastCrawlState?: CrawlState[]; }; /** @@ -176,6 +179,7 @@ export class WorkflowsList extends BtrixElement { "firstSeed", "schedule", "isCrawlRunning", + "lastCrawlState", ] as (keyof FilterBy)[]; keys.forEach((key) => { if (value[key] == null) { @@ -196,12 +200,19 @@ export class WorkflowsList extends BtrixElement { params.delete(key); } break; + case "lastCrawlState": + params.delete("lastCrawlStatus"); + value[key].forEach((state) => { + params.append("lastCrawlStatus", state); + }); + break; } } }); return params; }, (params) => { + const status = params.getAll("lastCrawlStatus") as CrawlState[]; return { name: params.get("name") ?? undefined, firstSeed: params.get("firstSeed") ?? undefined, @@ -209,6 +220,7 @@ export class WorkflowsList extends BtrixElement { ? params.get("schedule") === "true" : undefined, isCrawlRunning: params.get("isCrawlRunning") === "true" || undefined, + lastCrawlState: status.length ? status : undefined, }; }, ); @@ -297,13 +309,7 @@ export class WorkflowsList extends BtrixElement { } private clearFilters() { - this.filterBy.setValue({ - ...this.filterBy.value, - firstSeed: undefined, - name: undefined, - isCrawlRunning: undefined, - schedule: undefined, - }); + this.filterBy.setValue({}); this.filterByCurrentUser.setValue(false); this.filterByTags.setValue(undefined); this.filterByProfiles.setValue([]); @@ -679,6 +685,16 @@ export class WorkflowsList extends BtrixElement { }} > + { + this.filterBy.setValue({ + ...this.filterBy.value, + lastCrawlState: e.detail.value, + }); + }} + > + { diff --git a/frontend/src/types/crawlState.ts b/frontend/src/types/crawlState.ts index 9c56c48d19..6abc45b67b 100644 --- a/frontend/src/types/crawlState.ts +++ b/frontend/src/types/crawlState.ts @@ -43,7 +43,7 @@ export const SUCCESSFUL_AND_FAILED_STATES = [ ...FAILED_STATES, ] as const; -const CRAWL_STATES = [ +export const CRAWL_STATES = [ ...RUNNING_AND_WAITING_STATES, ...SUCCESSFUL_AND_FAILED_STATES, ] as const;