Update crawls list control bar UI (#611)

This commit is contained in:
sua yoo 2023-02-22 11:14:44 -06:00 committed by GitHub
parent ed94dde7e6
commit 974aeb5e93
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

View File

@ -1,7 +1,13 @@
import type { TemplateResult } from "lit";
import { state, property } from "lit/decorators.js"; import { state, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js"; import { ifDefined } from "lit/directives/if-defined.js";
import { msg, localized, str } from "@lit/localize"; import { msg, localized, str } from "@lit/localize";
import { when } from "lit/directives/when.js"; import { when } from "lit/directives/when.js";
import type {
SlCheckbox,
SlMenuItem,
SlSelect,
} from "@shoelace-style/shoelace";
import debounce from "lodash/fp/debounce"; import debounce from "lodash/fp/debounce";
import flow from "lodash/fp/flow"; import flow from "lodash/fp/flow";
import map from "lodash/fp/map"; import map from "lodash/fp/map";
@ -12,33 +18,89 @@ import { CopyButton } from "../../components/copy-button";
import { RelativeDuration } from "../../components/relative-duration"; import { RelativeDuration } from "../../components/relative-duration";
import type { AuthState } from "../../utils/AuthService"; import type { AuthState } from "../../utils/AuthService";
import LiteElement, { html } from "../../utils/LiteElement"; import LiteElement, { html } from "../../utils/LiteElement";
import type { Crawl, CrawlConfig, InitialCrawlConfig } from "./types"; import type {
import { SlCheckbox } from "@shoelace-style/shoelace"; Crawl,
CrawlState,
CrawlConfig,
InitialCrawlConfig,
} from "./types";
type CrawlSearchResult = { type CrawlSearchResult = {
item: Crawl; item: Crawl;
}; };
type SortField = "started" | "finished" | "configName" | "fileSize";
type SortDirection = "asc" | "desc";
const POLL_INTERVAL_SECONDS = 10; const POLL_INTERVAL_SECONDS = 10;
const MIN_SEARCH_LENGTH = 2; const MIN_SEARCH_LENGTH = 2;
const sortableFieldLabels = { const sortableFields: Record<
started_desc: msg("Newest"), SortField,
started_asc: msg("Oldest"), { label: string; defaultDirection?: SortDirection }
finished_desc: msg("Recently Updated"), > = {
finished_asc: msg("Oldest Finished"), started: {
state: msg("Status"), label: msg("Date Created"),
configName: msg("Crawl Name"), defaultDirection: "desc",
cid: msg("Crawl Config ID"), },
fileSize_asc: msg("Smallest Files"), finished: {
fileSize_desc: msg("Largest Files"), label: msg("Date Completed"),
defaultDirection: "desc",
},
configName: {
label: msg("Crawl Name"),
defaultDirection: "desc",
},
fileSize: {
label: msg("File Size"),
defaultDirection: "desc",
},
}; };
const activeCrawlStates: CrawlState[] = ["starting", "running", "stopping"];
const inactiveCrawlStates: CrawlState[] = [
"complete",
"canceled",
"partial_complete",
"timed_out",
"failed",
];
const crawlState: Record<CrawlState, { label: string; icon?: TemplateResult }> =
{
starting: {
label: msg("Starting"),
icon: html``,
},
running: {
label: msg("Running"),
icon: html``,
},
complete: {
label: msg("Completed"),
icon: html``,
},
failed: {
label: msg("Failed"),
icon: html``,
},
partial_complete: {
label: msg("Partial Complete"),
icon: html``,
},
timed_out: {
label: msg("Timed Out"),
icon: html``,
},
stopping: {
label: msg("Stopping"),
icon: html``,
},
canceled: {
label: msg("Canceled"),
icon: html``,
},
};
function isActive(crawl: Crawl) { function isActive(crawl: Crawl) {
return ( return activeCrawlStates.includes(crawl.state);
crawl.state === "running" ||
crawl.state === "starting" ||
crawl.state === "stopping"
);
} }
/** /**
@ -78,8 +140,8 @@ export class CrawlsList extends LiteElement {
@state() @state()
private orderBy: { private orderBy: {
field: "started"; field: SortField;
direction: "asc" | "desc"; direction: SortDirection;
} = { } = {
field: "started", field: "started",
direction: "desc", direction: "desc",
@ -89,12 +151,16 @@ export class CrawlsList extends LiteElement {
private filterByCurrentUser = true; private filterByCurrentUser = true;
@state() @state()
private filterBy: string = ""; private filterByState: CrawlState[] = [];
@state()
private searchBy: string = "";
// For fuzzy search: // For fuzzy search:
private fuse = new Fuse([], { private fuse = new Fuse([], {
keys: ["cid", "configName"], keys: ["cid", "configName"],
shouldSort: false, shouldSort: false,
threshold: 0.4, // stricter; default is 0.6
}); });
private timerId?: number; private timerId?: number;
@ -102,11 +168,19 @@ export class CrawlsList extends LiteElement {
// TODO localize // TODO localize
private numberFormatter = new Intl.NumberFormat(); private numberFormatter = new Intl.NumberFormat();
private sortCrawls(crawls: CrawlSearchResult[]): CrawlSearchResult[] { private filterCrawls = (crawls: Crawl[]) =>
return orderBy(({ item }) => item[this.orderBy.field])( this.filterByState.length
this.orderBy.direction ? crawls.filter((crawl) =>
)(crawls) as CrawlSearchResult[]; this.filterByState.some((state) => crawl.state === state)
} )
: crawls;
private sortCrawls = (
crawlsResults: CrawlSearchResult[]
): CrawlSearchResult[] =>
orderBy(({ item }) => item[this.orderBy.field])(this.orderBy.direction)(
crawlsResults
) as CrawlSearchResult[];
protected willUpdate(changedProperties: Map<string, any>) { protected willUpdate(changedProperties: Map<string, any>) {
if ( if (
@ -143,7 +217,9 @@ export class CrawlsList extends LiteElement {
return html` return html`
<main> <main>
<header class="sticky z-10 mb-3 top-0 py-2 bg-neutral-0"> <header
class="sticky z-10 mb-3 top-2 p-2 bg-neutral-50 border rounded-lg"
>
${this.renderControls()} ${this.renderControls()}
</header> </header>
<section> <section>
@ -179,103 +255,162 @@ export class CrawlsList extends LiteElement {
private renderControls() { private renderControls() {
return html` return html`
<div class="grid grid-cols-2 gap-3 items-center"> <div
<div class="col-span-2 md:col-span-1"> class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-[minmax(0,100%)_fit-content(100%)_fit-content(100%)] gap-x-2 gap-y-2 items-center"
>
<div class="col-span-1 md:col-span-2 lg:col-span-1">
<sl-input <sl-input
class="w-full" class="w-full"
slot="trigger" slot="trigger"
placeholder=${msg("Search by Crawl Config name or ID")} placeholder=${msg("Search by Crawl Config name or ID")}
clearable clearable
?disabled=${!this.crawls?.length} ?disabled=${!this.crawls?.length}
value=${this.searchBy}
@sl-clear=${() => {
this.onSearchInput.cancel();
this.searchBy = "";
}}
@sl-input=${this.onSearchInput} @sl-input=${this.onSearchInput}
> >
<sl-icon name="search" slot="prefix"></sl-icon> <sl-icon name="search" slot="prefix"></sl-icon>
</sl-input> </sl-input>
</div> </div>
<div class="col-span-12 md:col-span-1 flex items-center justify-end"> <div class="flex items-center">
${this.userId <div class="text-neutral-500 mx-2">${msg("View:")}</div>
? html`<label class="mr-3"> <sl-select
<span class="text-neutral-500 mr-1" class="flex-1 md:min-w-[14.5rem]"
>${msg("Show Only Mine")}</span
>
<sl-switch
@sl-change=${(e: CustomEvent) =>
(this.filterByCurrentUser = (
e.target as SlCheckbox
).checked)}
?checked=${this.filterByCurrentUser}
></sl-switch>
</label>`
: ""}
<div class="whitespace-nowrap text-neutral-500 mr-2">
${msg("Sort By")}
</div>
<sl-dropdown
placement="bottom-end" placement="bottom-end"
distance="4" distance="4"
@sl-select=${(e: any) => { size="small"
const [field, direction] = e.detail.item.value.split("_"); pill
this.orderBy = { .value=${this.filterByState}
field: field, multiple
direction: direction, max-tags-visible="1"
}; placeholder=${msg("All Crawls")}
@sl-change=${(e: CustomEvent) => {
const value = (e.target as SlSelect).value as CrawlState[];
this.filterByState = value;
}} }}
> >
<sl-button ${activeCrawlStates.map(
slot="trigger" (state) => html`
<sl-menu-item value=${state}>
${crawlState[state].label}</sl-menu-item
>
`
)}
<sl-divider></sl-divider>
${inactiveCrawlStates.map(
(state) => html`
<sl-menu-item value=${state}>
${crawlState[state].label}</sl-menu-item
>
`
)}
</sl-select>
</div>
<div class="flex items-center">
<div class="whitespace-nowrap text-neutral-500 mx-2">
${msg("Sort by:")}
</div>
<div class="grow flex">
<sl-select
class="flex-1 md:min-w-[9.2rem]"
placement="bottom-end"
distance="4"
size="small" size="small"
pill pill
caret value=${this.orderBy.field}
?disabled=${!this.crawls?.length} @sl-select=${(e: any) => {
>${(sortableFieldLabels as any)[this.orderBy.field] || const field = e.detail.item.value as SortField;
sortableFieldLabels[ this.orderBy = {
`${this.orderBy.field}_${this.orderBy.direction}` field: field,
]}</sl-button direction:
sortableFields[field].defaultDirection ||
this.orderBy.direction,
};
}}
> >
<sl-menu> ${Object.entries(sortableFields).map(
${Object.entries(sortableFieldLabels).map( ([value, { label }]) => html`
([value, label]) => html` <sl-menu-item value=${value}>${label}</sl-menu-item>
<sl-menu-item
value=${value}
?checked=${value ===
`${this.orderBy.field}_${this.orderBy.direction}`}
>${label}</sl-menu-item
>
` `
)} )}
</sl-menu> </sl-select>
</sl-dropdown> <sl-icon-button
<sl-icon-button name="arrow-down-up"
name="arrow-down-up" label=${msg("Reverse sort")}
label=${msg("Reverse sort")} @click=${() => {
@click=${() => { this.orderBy = {
this.orderBy = { ...this.orderBy,
...this.orderBy, direction: this.orderBy.direction === "asc" ? "desc" : "asc",
direction: this.orderBy.direction === "asc" ? "desc" : "asc", };
}; }}
}} ></sl-icon-button>
></sl-icon-button> </div>
</div> </div>
</div> </div>
${this.userId
? html` <div class="h-6 mt-2 flex justify-end">
<label>
<span class="text-neutral-500 text-xs mr-1"
>${msg("Show Only Mine")}</span
>
<sl-switch
@sl-change=${(e: CustomEvent) =>
(this.filterByCurrentUser = (e.target as SlCheckbox).checked)}
?checked=${this.filterByCurrentUser}
></sl-switch>
</label>
</div>`
: ""}
`; `;
} }
private renderCrawlList() { private renderCrawlList() {
// Return search results if valid filter string is available, // Return search results if valid filter string is available,
// otherwise format crawls list like search results // otherwise format crawls list like search results
const filterResults = const searchResults =
this.filterBy.length >= MIN_SEARCH_LENGTH this.searchBy.length >= MIN_SEARCH_LENGTH
? () => this.fuse.search(this.filterBy) ? () => this.fuse.search(this.searchBy)
: map((crawl) => ({ item: crawl })); : map((crawl) => ({ item: crawl }));
const filteredCrawls = flow(
this.filterCrawls,
searchResults
)(this.crawls as Crawl[]);
if (!filteredCrawls.length) {
return html`
<div class="border rounded-lg bg-neutral-50 p-4">
<p class="text-center">
<span class="text-neutral-400"
>${msg("No matching crawls found.")}</span
>
<button
class="text-neutral-500 font-medium underline hover:no-underline"
@click=${() => {
this.filterByState = [];
this.onSearchInput.cancel();
this.searchBy = "";
}}
>
${msg("Clear all filters")}
</button>
</p>
<div></div>
</div>
`;
}
return html` return html`
<ul class="border rounded"> <ul class="border rounded">
${flow( ${flow(
filterResults, this.sortCrawls,
this.sortCrawls.bind(this),
map(this.renderCrawlItem) map(this.renderCrawlItem)
)(this.crawls as any)} )(filteredCrawls as CrawlSearchResult[])}
</ul> </ul>
`; `;
} }
@ -560,7 +695,7 @@ export class CrawlsList extends LiteElement {
} }
private onSearchInput = debounce(200)((e: any) => { private onSearchInput = debounce(200)((e: any) => {
this.filterBy = e.target.value; this.searchBy = e.target.value;
}) as any; }) as any;
/** /**