diff --git a/backend/btrixcloud/crawlconfigs.py b/backend/btrixcloud/crawlconfigs.py index dd74e4cc..c7cd93b4 100644 --- a/backend/btrixcloud/crawlconfigs.py +++ b/backend/btrixcloud/crawlconfigs.py @@ -602,6 +602,7 @@ class CrawlConfigOps: description: Optional[str] = None, tags: Optional[List[str]] = None, schedule: Optional[bool] = None, + isCrawlRunning: Optional[bool] = None, sort_by: str = "lastRun", sort_direction: int = -1, ) -> tuple[list[CrawlConfigOut], int]: @@ -634,6 +635,9 @@ class CrawlConfigOps: else: match_query["schedule"] = {"$in": ["", None]} + if isCrawlRunning is not None: + match_query["isCrawlRunning"] = isCrawlRunning + # pylint: disable=duplicate-code aggregate = [ {"$match": match_query}, @@ -1372,6 +1376,7 @@ def init_crawl_config_api( description: Optional[str] = None, tag: Union[List[str], None] = Query(default=None), schedule: Optional[bool] = None, + isCrawlRunning: Optional[bool] = None, sortBy: str = "", sortDirection: int = -1, ): @@ -1394,6 +1399,7 @@ def init_crawl_config_api( description=description, tags=tag, schedule=schedule, + isCrawlRunning=isCrawlRunning, page_size=pageSize, page=page, sort_by=sortBy, diff --git a/frontend/src/components/ui/combobox.ts b/frontend/src/components/ui/combobox.ts index a8de1bfa..46ebf297 100644 --- a/frontend/src/components/ui/combobox.ts +++ b/frontend/src/components/ui/combobox.ts @@ -26,7 +26,7 @@ export class Combobox extends LitElement { css` :host { position: relative; - z-index: 2; + z-index: 3; } `, ]; diff --git a/frontend/src/components/ui/pagination.ts b/frontend/src/components/ui/pagination.ts index e2a23ea6..508d507c 100644 --- a/frontend/src/components/ui/pagination.ts +++ b/frontend/src/components/ui/pagination.ts @@ -218,6 +218,15 @@ export class Pagination extends LitElement { this.onPageChange(constrainedPage, { dispatch: false }); } + // if page is out of bounds, clamp it & dispatch an event to re-fetch data + if ( + changedProperties.has("page") && + (this.page > this.pages || this.page < 1) + ) { + const constrainedPage = Math.max(1, Math.min(this.pages, this.page)); + this.onPageChange(constrainedPage, { dispatch: true }); + } + if (changedProperties.get("page") && this._page) { this.inputValue = `${this._page}`; } @@ -396,14 +405,11 @@ export class Pagination extends LitElement { } private setPage(page: number) { - this.searchParams.set((params) => { - if (page === 1) { - params.delete(this.name); - } else { - params.set(this.name, page.toString()); - } - return params; - }); + if (page === 1) { + this.searchParams.delete(this.name); + } else { + this.searchParams.set(this.name, page.toString()); + } } private calculatePages() { diff --git a/frontend/src/controllers/searchParams.ts b/frontend/src/controllers/searchParams.ts index a930ff71..6b3f9d49 100644 --- a/frontend/src/controllers/searchParams.ts +++ b/frontend/src/controllers/searchParams.ts @@ -12,7 +12,7 @@ export class SearchParamsController implements ReactiveController { return new URLSearchParams(location.search); } - public set( + public update( update: URLSearchParams | ((prev: URLSearchParams) => URLSearchParams), options: { replace?: boolean; data?: unknown } = { replace: false }, ) { @@ -23,6 +23,63 @@ export class SearchParamsController implements ReactiveController { ? update(this.searchParams).toString() : update.toString(); + if (url.toString() === location.toString()) return; + + if (options.replace) { + history.replaceState(options.data, "", url); + } else { + history.pushState(options.data, "", url); + } + } + + public set( + name: string, + value: string, + options: { replace?: boolean; data?: unknown } = { replace: false }, + ) { + this.prevParams = new URLSearchParams(this.searchParams); + const url = new URL(location.toString()); + const newParams = new URLSearchParams(this.searchParams); + newParams.set(name, value); + url.search = newParams.toString(); + + if (url.toString() === location.toString()) return; + + if (options.replace) { + history.replaceState(options.data, "", url); + } else { + history.pushState(options.data, "", url); + } + } + + public delete( + name: string, + value: string, + options?: { replace?: boolean; data?: unknown }, + ): void; + public delete( + name: string, + options?: { replace?: boolean; data?: unknown }, + ): void; + public delete( + name: string, + valueOrOptions?: string | { replace?: boolean; data?: unknown }, + options?: { replace?: boolean; data?: unknown }, + ) { + this.prevParams = new URLSearchParams(this.searchParams); + const url = new URL(location.toString()); + const newParams = new URLSearchParams(this.searchParams); + if (typeof valueOrOptions === "string") { + newParams.delete(name, valueOrOptions); + } else { + newParams.delete(name); + options = valueOrOptions; + } + options ??= { replace: false }; + url.search = newParams.toString(); + + if (url.toString() === location.toString()) return; + if (options.replace) { history.replaceState(options.data, "", url); } else { diff --git a/frontend/src/pages/org/dashboard.ts b/frontend/src/pages/org/dashboard.ts index 9761828f..b14cf4bc 100644 --- a/frontend/src/pages/org/dashboard.ts +++ b/frontend/src/pages/org/dashboard.ts @@ -260,6 +260,9 @@ export class Dashboard extends BtrixElement { name: "gear-wide-connected", class: this.colors.crawls, }, + button: { + url: "/items/crawl", + }, })} ${this.renderStat({ value: metrics.uploadCount, @@ -269,6 +272,9 @@ export class Dashboard extends BtrixElement { singleLabel: msg("Upload"), pluralLabel: msg("Uploads"), iconProps: { name: "upload", class: this.colors.uploads }, + button: { + url: "/items/upload", + }, })} ${this.renderStat({ value: metrics.profileCount, @@ -281,6 +287,9 @@ export class Dashboard extends BtrixElement { name: "window-fullscreen", class: this.colors.browserProfiles, }, + button: { + url: "/browser-profiles", + }, })} `, @@ -316,6 +328,9 @@ export class Dashboard extends BtrixElement { ? tw`animate-pulse text-green-600` : tw`text-neutral-600`, }, + button: { + url: "/workflows?isCrawlRunning=true", + }, })} ${this.renderStat({ value: metrics.workflowsQueuedCount, @@ -365,6 +380,9 @@ export class Dashboard extends BtrixElement { singleLabel: msg("Collection Total"), pluralLabel: msg("Collections Total"), iconProps: { name: "collection-fill" }, + button: { + url: "/collections", + }, })} ${this.renderStat({ value: metrics.publicCollectionsCount, @@ -919,14 +937,15 @@ export class Dashboard extends BtrixElement { private renderStat(stat: { value: number | string | TemplateResult; secondaryValue?: number | string | TemplateResult; + button?: { label?: string | TemplateResult; url: string }; singleLabel: string; pluralLabel: string; iconProps: { name: string; library?: string; class?: string }; }) { const { value, iconProps } = stat; return html` -
-
+
+
`, )} + ${when( + stat.button, + (button) => + html`${ + button.label ?? + html`` + }`, + )}
`; } diff --git a/frontend/src/pages/org/workflows-list.ts b/frontend/src/pages/org/workflows-list.ts index 34fa9b45..53959476 100644 --- a/frontend/src/pages/org/workflows-list.ts +++ b/frontend/src/pages/org/workflows-list.ts @@ -1,7 +1,9 @@ import { localized, msg, str } from "@lit/localize"; import type { + SlChangeEvent, SlCheckbox, SlDialog, + SlRadioGroup, SlSelectEvent, } from "@shoelace-style/shoelace"; import clsx from "clsx"; @@ -23,6 +25,7 @@ import { BtrixElement } from "@/classes/BtrixElement"; import { parsePage, type PageChangeEvent } from "@/components/ui/pagination"; 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 { pageHeader } from "@/layouts/pageHeader"; import { WorkflowTab } from "@/routes"; @@ -36,7 +39,12 @@ import { tw } from "@/utils/tailwind"; type SearchFields = "name" | "firstSeed"; type SortField = "lastRun" | "name" | "firstSeed" | "created" | "modified"; -type SortDirection = "asc" | "desc"; +const SORT_DIRECTIONS = ["asc", "desc"] as const; +type SortDirection = (typeof SORT_DIRECTIONS)[number]; +type Sort = { + field: SortField; + direction: SortDirection; +}; const FILTER_BY_CURRENT_USER_STORAGE_KEY = "btrix.filterByCurrentUser.crawlConfigs"; @@ -72,6 +80,16 @@ const sortableFields: Record< }, }; +const DEFAULT_SORT = { + field: "lastRun", + direction: sortableFields["lastRun"].defaultDirection!, +} as const; + +const USED_FILTERS = [ + "schedule", + "isCrawlRunning", +] as const satisfies (keyof ListWorkflow)[]; + /** * Usage: * ```ts @@ -102,13 +120,7 @@ export class WorkflowsList extends BtrixElement { private workflowToDelete?: ListWorkflow; @state() - private orderBy: { - field: SortField; - direction: SortDirection; - } = { - field: "lastRun", - direction: sortableFields["lastRun"].defaultDirection!, - }; + private orderBy: Sort = DEFAULT_SORT; @state() private filterBy: Partial<{ [k in keyof ListWorkflow]: boolean }> = {}; @@ -132,11 +144,83 @@ export class WorkflowsList extends BtrixElement { ); } + searchParams = new SearchParamsController(this, (params) => { + this.updateFiltersFromSearchParams(params); + }); + + private updateFiltersFromSearchParams( + params = this.searchParams.searchParams, + ) { + const filterBy = { ...this.filterBy }; + // remove filters no longer present in search params + for (const key of Object.keys(filterBy)) { + if (!params.has(key)) { + filterBy[key as keyof typeof filterBy] = undefined; + } + } + + // remove current user filter if not present in search params + if (!params.has("mine")) { + this.filterByCurrentUser = false; + } + + // add filters present in search params + for (const [key, value] of params) { + // Filter by current user + if (key === "mine") { + this.filterByCurrentUser = value === "true"; + } + + // Sorting field + if (key === "sortBy") { + if (value in sortableFields) { + this.orderBy = { + field: value as SortField, + direction: + // Use default direction for field if available, otherwise use current direction + sortableFields[value as SortField].defaultDirection || + this.orderBy.direction, + }; + } + } + if (key === "sortDir") { + if (SORT_DIRECTIONS.includes(value as SortDirection)) { + // Overrides sort direction if specified + this.orderBy = { ...this.orderBy, direction: value as SortDirection }; + } + } + + // Ignored params + if (["page", "mine", "sortBy", "sortDir"].includes(key)) continue; + + // Convert string bools to filter values + if (value === "true") { + filterBy[key as keyof typeof filterBy] = true; + } else if (value === "false") { + filterBy[key as keyof typeof filterBy] = false; + } else { + filterBy[key as keyof typeof filterBy] = undefined; + } + } + this.filterBy = { ...filterBy }; + } + constructor() { super(); + this.updateFiltersFromSearchParams(); + } + + connectedCallback() { + super.connectedCallback(); + // Apply filterByCurrentUser from session storage, and transparently update url without pushing to history stack + // This needs to happen here instead of in the constructor because this only occurs once after the element is connected to the DOM, + // and so it overrides the filter state set in `updateFiltersFromSearchParams` but only on first render, not on subsequent navigation. this.filterByCurrentUser = window.sessionStorage.getItem(FILTER_BY_CURRENT_USER_STORAGE_KEY) === "true"; + if (this.filterByCurrentUser) { + this.searchParams.set("mine", "true", { replace: true }); + } } protected async willUpdate( @@ -177,6 +261,56 @@ export class WorkflowsList extends BtrixElement { void this.fetchConfigSearchValues(); } + protected updated( + changedProperties: PropertyValues & Map, + ) { + if ( + changedProperties.has("filterBy") || + changedProperties.has("filterByCurrentUser") || + changedProperties.has("orderBy") + ) { + this.searchParams.update((params) => { + // Reset page + params.delete("page"); + + const newParams = [ + // Known filters + ...USED_FILTERS.map<[string, undefined]>((f) => [f, undefined]), + + // Existing filters + ...Object.entries(this.filterBy), + + // Filter by current user + ["mine", this.filterByCurrentUser || undefined], + + // Sorting fields + [ + "sortBy", + this.orderBy.field !== DEFAULT_SORT.field + ? this.orderBy.field + : undefined, + ], + [ + "sortDir", + this.orderBy.direction !== + sortableFields[this.orderBy.field].defaultDirection + ? this.orderBy.direction + : undefined, + ], + ] satisfies [string, boolean | string | undefined][]; + + for (const [filter, value] of newParams) { + if (value !== undefined) { + params.set(filter, value.toString()); + } else { + params.delete(filter); + } + } + return params; + }); + } + } + disconnectedCallback(): void { this.cancelInProgressGetWorkflows(); super.disconnectedCallback(); @@ -389,14 +523,77 @@ export class WorkflowsList extends BtrixElement { private renderControls() { return html` -
-
${this.renderSearch()}
+
+
${this.renderSearch()}
-
-
+ + +
+
+ - { - this.orderBy = { - ...this.orderBy, - direction: this.orderBy.direction === "asc" ? "desc" : "asc", - }; - }} - > + + { + this.orderBy = { + ...this.orderBy, + direction: this.orderBy.direction === "asc" ? "desc" : "asc", + }; + }} + > +
-
+
+ -
-
- - - -
-