Update crawls list control bar UI (#611)
This commit is contained in:
		
							parent
							
								
									ed94dde7e6
								
							
						
					
					
						commit
						974aeb5e93
					
				@ -1,7 +1,13 @@
 | 
			
		||||
import type { TemplateResult } from "lit";
 | 
			
		||||
import { state, property } from "lit/decorators.js";
 | 
			
		||||
import { ifDefined } from "lit/directives/if-defined.js";
 | 
			
		||||
import { msg, localized, str } from "@lit/localize";
 | 
			
		||||
import { when } from "lit/directives/when.js";
 | 
			
		||||
import type {
 | 
			
		||||
  SlCheckbox,
 | 
			
		||||
  SlMenuItem,
 | 
			
		||||
  SlSelect,
 | 
			
		||||
} from "@shoelace-style/shoelace";
 | 
			
		||||
import debounce from "lodash/fp/debounce";
 | 
			
		||||
import flow from "lodash/fp/flow";
 | 
			
		||||
import map from "lodash/fp/map";
 | 
			
		||||
@ -12,33 +18,89 @@ import { CopyButton } from "../../components/copy-button";
 | 
			
		||||
import { RelativeDuration } from "../../components/relative-duration";
 | 
			
		||||
import type { AuthState } from "../../utils/AuthService";
 | 
			
		||||
import LiteElement, { html } from "../../utils/LiteElement";
 | 
			
		||||
import type { Crawl, CrawlConfig, InitialCrawlConfig } from "./types";
 | 
			
		||||
import { SlCheckbox } from "@shoelace-style/shoelace";
 | 
			
		||||
import type {
 | 
			
		||||
  Crawl,
 | 
			
		||||
  CrawlState,
 | 
			
		||||
  CrawlConfig,
 | 
			
		||||
  InitialCrawlConfig,
 | 
			
		||||
} from "./types";
 | 
			
		||||
 | 
			
		||||
type CrawlSearchResult = {
 | 
			
		||||
  item: Crawl;
 | 
			
		||||
};
 | 
			
		||||
type SortField = "started" | "finished" | "configName" | "fileSize";
 | 
			
		||||
type SortDirection = "asc" | "desc";
 | 
			
		||||
 | 
			
		||||
const POLL_INTERVAL_SECONDS = 10;
 | 
			
		||||
const MIN_SEARCH_LENGTH = 2;
 | 
			
		||||
const sortableFieldLabels = {
 | 
			
		||||
  started_desc: msg("Newest"),
 | 
			
		||||
  started_asc: msg("Oldest"),
 | 
			
		||||
  finished_desc: msg("Recently Updated"),
 | 
			
		||||
  finished_asc: msg("Oldest Finished"),
 | 
			
		||||
  state: msg("Status"),
 | 
			
		||||
  configName: msg("Crawl Name"),
 | 
			
		||||
  cid: msg("Crawl Config ID"),
 | 
			
		||||
  fileSize_asc: msg("Smallest Files"),
 | 
			
		||||
  fileSize_desc: msg("Largest Files"),
 | 
			
		||||
const sortableFields: Record<
 | 
			
		||||
  SortField,
 | 
			
		||||
  { label: string; defaultDirection?: SortDirection }
 | 
			
		||||
> = {
 | 
			
		||||
  started: {
 | 
			
		||||
    label: msg("Date Created"),
 | 
			
		||||
    defaultDirection: "desc",
 | 
			
		||||
  },
 | 
			
		||||
  finished: {
 | 
			
		||||
    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) {
 | 
			
		||||
  return (
 | 
			
		||||
    crawl.state === "running" ||
 | 
			
		||||
    crawl.state === "starting" ||
 | 
			
		||||
    crawl.state === "stopping"
 | 
			
		||||
  );
 | 
			
		||||
  return activeCrawlStates.includes(crawl.state);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
@ -78,8 +140,8 @@ export class CrawlsList extends LiteElement {
 | 
			
		||||
 | 
			
		||||
  @state()
 | 
			
		||||
  private orderBy: {
 | 
			
		||||
    field: "started";
 | 
			
		||||
    direction: "asc" | "desc";
 | 
			
		||||
    field: SortField;
 | 
			
		||||
    direction: SortDirection;
 | 
			
		||||
  } = {
 | 
			
		||||
    field: "started",
 | 
			
		||||
    direction: "desc",
 | 
			
		||||
@ -89,12 +151,16 @@ export class CrawlsList extends LiteElement {
 | 
			
		||||
  private filterByCurrentUser = true;
 | 
			
		||||
 | 
			
		||||
  @state()
 | 
			
		||||
  private filterBy: string = "";
 | 
			
		||||
  private filterByState: CrawlState[] = [];
 | 
			
		||||
 | 
			
		||||
  @state()
 | 
			
		||||
  private searchBy: string = "";
 | 
			
		||||
 | 
			
		||||
  // For fuzzy search:
 | 
			
		||||
  private fuse = new Fuse([], {
 | 
			
		||||
    keys: ["cid", "configName"],
 | 
			
		||||
    shouldSort: false,
 | 
			
		||||
    threshold: 0.4, // stricter; default is 0.6
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  private timerId?: number;
 | 
			
		||||
@ -102,11 +168,19 @@ export class CrawlsList extends LiteElement {
 | 
			
		||||
  // TODO localize
 | 
			
		||||
  private numberFormatter = new Intl.NumberFormat();
 | 
			
		||||
 | 
			
		||||
  private sortCrawls(crawls: CrawlSearchResult[]): CrawlSearchResult[] {
 | 
			
		||||
    return orderBy(({ item }) => item[this.orderBy.field])(
 | 
			
		||||
      this.orderBy.direction
 | 
			
		||||
    )(crawls) as CrawlSearchResult[];
 | 
			
		||||
  }
 | 
			
		||||
  private filterCrawls = (crawls: Crawl[]) =>
 | 
			
		||||
    this.filterByState.length
 | 
			
		||||
      ? crawls.filter((crawl) =>
 | 
			
		||||
          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>) {
 | 
			
		||||
    if (
 | 
			
		||||
@ -143,7 +217,9 @@ export class CrawlsList extends LiteElement {
 | 
			
		||||
 | 
			
		||||
    return html`
 | 
			
		||||
      <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()}
 | 
			
		||||
        </header>
 | 
			
		||||
        <section>
 | 
			
		||||
@ -179,103 +255,162 @@ export class CrawlsList extends LiteElement {
 | 
			
		||||
 | 
			
		||||
  private renderControls() {
 | 
			
		||||
    return html`
 | 
			
		||||
      <div class="grid grid-cols-2 gap-3 items-center">
 | 
			
		||||
        <div class="col-span-2 md:col-span-1">
 | 
			
		||||
      <div
 | 
			
		||||
        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
 | 
			
		||||
            class="w-full"
 | 
			
		||||
            slot="trigger"
 | 
			
		||||
            placeholder=${msg("Search by Crawl Config name or ID")}
 | 
			
		||||
            clearable
 | 
			
		||||
            ?disabled=${!this.crawls?.length}
 | 
			
		||||
            value=${this.searchBy}
 | 
			
		||||
            @sl-clear=${() => {
 | 
			
		||||
              this.onSearchInput.cancel();
 | 
			
		||||
              this.searchBy = "";
 | 
			
		||||
            }}
 | 
			
		||||
            @sl-input=${this.onSearchInput}
 | 
			
		||||
          >
 | 
			
		||||
            <sl-icon name="search" slot="prefix"></sl-icon>
 | 
			
		||||
          </sl-input>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="col-span-12 md:col-span-1 flex items-center justify-end">
 | 
			
		||||
          ${this.userId
 | 
			
		||||
            ? html`<label class="mr-3">
 | 
			
		||||
                <span class="text-neutral-500 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 class="whitespace-nowrap text-neutral-500 mr-2">
 | 
			
		||||
            ${msg("Sort By")}
 | 
			
		||||
          </div>
 | 
			
		||||
          <sl-dropdown
 | 
			
		||||
        <div class="flex items-center">
 | 
			
		||||
          <div class="text-neutral-500 mx-2">${msg("View:")}</div>
 | 
			
		||||
          <sl-select
 | 
			
		||||
            class="flex-1 md:min-w-[14.5rem]"
 | 
			
		||||
            placement="bottom-end"
 | 
			
		||||
            distance="4"
 | 
			
		||||
            @sl-select=${(e: any) => {
 | 
			
		||||
              const [field, direction] = e.detail.item.value.split("_");
 | 
			
		||||
              this.orderBy = {
 | 
			
		||||
                field: field,
 | 
			
		||||
                direction: direction,
 | 
			
		||||
              };
 | 
			
		||||
            size="small"
 | 
			
		||||
            pill
 | 
			
		||||
            .value=${this.filterByState}
 | 
			
		||||
            multiple
 | 
			
		||||
            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
 | 
			
		||||
              slot="trigger"
 | 
			
		||||
            ${activeCrawlStates.map(
 | 
			
		||||
              (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"
 | 
			
		||||
              pill
 | 
			
		||||
              caret
 | 
			
		||||
              ?disabled=${!this.crawls?.length}
 | 
			
		||||
              >${(sortableFieldLabels as any)[this.orderBy.field] ||
 | 
			
		||||
              sortableFieldLabels[
 | 
			
		||||
                `${this.orderBy.field}_${this.orderBy.direction}`
 | 
			
		||||
              ]}</sl-button
 | 
			
		||||
              value=${this.orderBy.field}
 | 
			
		||||
              @sl-select=${(e: any) => {
 | 
			
		||||
                const field = e.detail.item.value as SortField;
 | 
			
		||||
                this.orderBy = {
 | 
			
		||||
                  field: field,
 | 
			
		||||
                  direction:
 | 
			
		||||
                    sortableFields[field].defaultDirection ||
 | 
			
		||||
                    this.orderBy.direction,
 | 
			
		||||
                };
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
            <sl-menu>
 | 
			
		||||
              ${Object.entries(sortableFieldLabels).map(
 | 
			
		||||
                ([value, label]) => html`
 | 
			
		||||
                  <sl-menu-item
 | 
			
		||||
                    value=${value}
 | 
			
		||||
                    ?checked=${value ===
 | 
			
		||||
                    `${this.orderBy.field}_${this.orderBy.direction}`}
 | 
			
		||||
                    >${label}</sl-menu-item
 | 
			
		||||
                  >
 | 
			
		||||
              ${Object.entries(sortableFields).map(
 | 
			
		||||
                ([value, { label }]) => html`
 | 
			
		||||
                  <sl-menu-item value=${value}>${label}</sl-menu-item>
 | 
			
		||||
                `
 | 
			
		||||
              )}
 | 
			
		||||
            </sl-menu>
 | 
			
		||||
          </sl-dropdown>
 | 
			
		||||
          <sl-icon-button
 | 
			
		||||
            name="arrow-down-up"
 | 
			
		||||
            label=${msg("Reverse sort")}
 | 
			
		||||
            @click=${() => {
 | 
			
		||||
              this.orderBy = {
 | 
			
		||||
                ...this.orderBy,
 | 
			
		||||
                direction: this.orderBy.direction === "asc" ? "desc" : "asc",
 | 
			
		||||
              };
 | 
			
		||||
            }}
 | 
			
		||||
          ></sl-icon-button>
 | 
			
		||||
            </sl-select>
 | 
			
		||||
            <sl-icon-button
 | 
			
		||||
              name="arrow-down-up"
 | 
			
		||||
              label=${msg("Reverse sort")}
 | 
			
		||||
              @click=${() => {
 | 
			
		||||
                this.orderBy = {
 | 
			
		||||
                  ...this.orderBy,
 | 
			
		||||
                  direction: this.orderBy.direction === "asc" ? "desc" : "asc",
 | 
			
		||||
                };
 | 
			
		||||
              }}
 | 
			
		||||
            ></sl-icon-button>
 | 
			
		||||
          </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() {
 | 
			
		||||
    // Return search results if valid filter string is available,
 | 
			
		||||
    // otherwise format crawls list like search results
 | 
			
		||||
    const filterResults =
 | 
			
		||||
      this.filterBy.length >= MIN_SEARCH_LENGTH
 | 
			
		||||
        ? () => this.fuse.search(this.filterBy)
 | 
			
		||||
    const searchResults =
 | 
			
		||||
      this.searchBy.length >= MIN_SEARCH_LENGTH
 | 
			
		||||
        ? () => this.fuse.search(this.searchBy)
 | 
			
		||||
        : 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`
 | 
			
		||||
      <ul class="border rounded">
 | 
			
		||||
        ${flow(
 | 
			
		||||
          filterResults,
 | 
			
		||||
          this.sortCrawls.bind(this),
 | 
			
		||||
          this.sortCrawls,
 | 
			
		||||
          map(this.renderCrawlItem)
 | 
			
		||||
        )(this.crawls as any)}
 | 
			
		||||
        )(filteredCrawls as CrawlSearchResult[])}
 | 
			
		||||
      </ul>
 | 
			
		||||
    `;
 | 
			
		||||
  }
 | 
			
		||||
@ -560,7 +695,7 @@ export class CrawlsList extends LiteElement {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private onSearchInput = debounce(200)((e: any) => {
 | 
			
		||||
    this.filterBy = e.target.value;
 | 
			
		||||
    this.searchBy = e.target.value;
 | 
			
		||||
  }) as any;
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user