diff --git a/frontend/src/components/ui/config-details.ts b/frontend/src/components/ui/config-details.ts index b8c84b5832..dc1e933de3 100644 --- a/frontend/src/components/ui/config-details.ts +++ b/frontend/src/components/ui/config-details.ts @@ -318,7 +318,7 @@ export class ConfigDetails extends BtrixElement { >`, crawlConfig?.autoAddCollections.length ? html`` : undefined, )} diff --git a/frontend/src/features/collections/collections-add.ts b/frontend/src/features/collections/collections-add.ts index dd5d265805..0412f64f74 100644 --- a/frontend/src/features/collections/collections-add.ts +++ b/frontend/src/features/collections/collections-add.ts @@ -1,7 +1,8 @@ -import { localized, msg, str } from "@lit/localize"; -import { Task } from "@lit/task"; +import { localized, msg } from "@lit/localize"; +import { Task, TaskStatus } from "@lit/task"; import type { SlInput, SlMenuItem } from "@shoelace-style/shoelace"; -import { html } from "lit"; +import Fuse from "fuse.js"; +import { html, nothing } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; import { when } from "lit/directives/when.js"; import debounce from "lodash/fp/debounce"; @@ -9,17 +10,22 @@ import queryString from "query-string"; import { BtrixElement } from "@/classes/BtrixElement"; import type { Combobox } from "@/components/ui/combobox"; -import type { BtrixRemoveLinkedCollectionEvent } from "@/features/collections/linked-collections/types"; +import type { + BtrixLoadedLinkedCollectionEvent, + BtrixRemoveLinkedCollectionEvent, + CollectionLikeItem, +} from "@/features/collections/linked-collections/types"; import type { APIPaginatedList, APIPaginationQuery, APISortQuery, } from "@/types/api"; -import type { Collection } from "@/types/collection"; +import type { Collection, CollectionSearchValues } from "@/types/collection"; import type { UnderlyingFunction } from "@/types/utils"; +import { TwoWayMap } from "@/utils/TwoWayMap"; const INITIAL_PAGE_SIZE = 10; -const MIN_SEARCH_LENGTH = 2; +const MIN_SEARCH_LENGTH = 1; export type CollectionsChangeEvent = CustomEvent<{ collections: string[]; @@ -48,12 +54,8 @@ export class CollectionsAdd extends BtrixElement { @property({ type: String }) label?: string; - /* Text to show on collection empty state */ - @property({ type: String }) - emptyText?: string; - @state() - private collectionIds: string[] = []; + private collections: CollectionLikeItem[] = []; @query("#search-input") private readonly input?: SlInput | null; @@ -61,6 +63,13 @@ export class CollectionsAdd extends BtrixElement { @query("btrix-combobox") private readonly combobox?: Combobox | null; + // Map collection names to ID for managing search options + private readonly nameSearchMap = new TwoWayMap(); + + private get collectionIds() { + return this.collections.map(({ id }) => id); + } + private get searchByValue() { return this.input ? this.input.value.trim() : ""; } @@ -69,6 +78,26 @@ export class CollectionsAdd extends BtrixElement { return this.searchByValue.length >= MIN_SEARCH_LENGTH; } + private readonly searchValuesTask = new Task(this, { + task: async (_args, { signal }) => { + const { names } = await this.getSearchValues(signal); + + return names; + }, + args: () => [] as const, + }); + + private readonly searchTask = new Task(this, { + task: async ([names], { signal }) => { + if (!names || signal.aborted) { + return; + } + + return new Fuse(names, { threshold: 0.4, minMatchCharLength: 2 }); + }, + args: () => [this.searchValuesTask.value] as const, + }); + private readonly searchResultsTask = new Task(this, { task: async ([searchByValue, hasSearchStr], { signal }) => { if (!hasSearchStr) return []; @@ -89,51 +118,57 @@ export class CollectionsAdd extends BtrixElement { connectedCallback() { if (this.initialCollections) { - this.collectionIds = this.initialCollections; + this.collections = this.initialCollections.map((id) => ({ id })); } super.connectedCallback(); } - disconnectedCallback() { - super.disconnectedCallback(); - } - render() { return html`
-
+
${this.renderSearch()}
- ${when(this.collectionIds, () => - this.collectionIds.length + ${when(this.collections, (collections) => + collections.length ? html` -
+
{ + const { item } = e.detail; + + if (item.name) { + this.nameSearchMap.set(item.name, item.id); + } + }} @btrix-remove=${(e: BtrixRemoveLinkedCollectionEvent) => { const { id } = e.detail.item; this.removeCollection(id); + + // Remove from search mapping + const name = this.nameSearchMap.getByValue(id); + + if (name) { + this.nameSearchMap.delete(name); + } }} >
` - : this.emptyText - ? html` -
-

${this.emptyText}

-
- ` - : "", + : nothing, )}
`; } private renderSearch() { + const disabled = !this.searchValuesTask.value?.length; + return html` { @@ -143,15 +178,19 @@ export class CollectionsAdd extends BtrixElement { @sl-select=${async (e: CustomEvent<{ item: SlMenuItem }>) => { this.combobox?.hide(); const item = e.detail.item; - const collId = item.dataset["key"]; - if (collId && this.collectionIds.indexOf(collId) === -1) { - const coll = this.searchResultsTask.value?.find( - (collection) => collection.id === collId, - ); - if (coll) { - const { id } = coll; - this.collectionIds = [...this.collectionIds, id]; - void this.dispatchChange(); + const name = item.dataset["key"]; + + const collections = await this.getCollections({ namePrefix: name }); + const coll = collections.items.find((c) => c.name === name); + + if (coll && this.findCollectionIndexById(coll.id) === -1) { + this.collections = [...this.collections, coll]; + void this.dispatchChange(); + + this.nameSearchMap.set(coll.name, coll.id); + + if (this.input) { + this.input.value = ""; } } }} @@ -159,8 +198,9 @@ export class CollectionsAdd extends BtrixElement { { this.combobox?.hide(); }} @@ -174,6 +214,19 @@ export class CollectionsAdd extends BtrixElement { >} > + ${when( + disabled && this.searchValuesTask.status === TaskStatus.COMPLETE, + () => html` +
+ ${msg("No collections found.")} + ${msg("Manage Collections")} +
+ `, + )}
${this.renderSearchResults()}
@@ -181,13 +234,13 @@ export class CollectionsAdd extends BtrixElement { } private renderSearchResults() { - return this.searchResultsTask.render({ + return this.searchTask.render({ pending: () => html` `, - complete: (searchResults) => { + complete: (fuse) => { if (!this.hasSearchStr) { return html` @@ -196,12 +249,14 @@ export class CollectionsAdd extends BtrixElement { `; } - // Filter out stale search results from last debounce invocation - const results = searchResults.filter((res) => - new RegExp(`^${this.searchByValue}`, "i").test(res.name), - ); + const results = fuse + ?.search(this.searchByValue) + // Filter out items that have been selected + .filter(({ item }) => !this.nameSearchMap.get(item)) + // Show first few results + .slice(0, 5); - if (!results.length) { + if (!results?.length) { return html` ${msg("No matching Collections found.")} @@ -210,16 +265,10 @@ export class CollectionsAdd extends BtrixElement { } return html` - ${results.map((item: Collection) => { + ${results.map(({ item }: { item: string }) => { return html` - - ${item.name} -
- ${msg(str`${item.crawlCount} items`)} -
+ + ${item} `; })} @@ -230,11 +279,12 @@ export class CollectionsAdd extends BtrixElement { private removeCollection(collectionId: string) { if (collectionId) { - const collIdIndex = this.collectionIds.indexOf(collectionId); + const collIdIndex = this.findCollectionIndexById(collectionId); + if (collIdIndex > -1) { - this.collectionIds = [ - ...this.collectionIds.slice(0, collIdIndex), - ...this.collectionIds.slice(collIdIndex + 1), + this.collections = [ + ...this.collections.slice(0, collIdIndex), + ...this.collections.slice(collIdIndex + 1), ]; void this.dispatchChange(); } @@ -245,10 +295,14 @@ export class CollectionsAdd extends BtrixElement { void this.searchResultsTask.run(); }); + private findCollectionIndexById(collectionId: string) { + return this.collections.findIndex(({ id }) => id === collectionId); + } + private filterOutSelectedCollections(results: Collection[]) { - return results.filter((result) => { - return !this.collectionIds.some((id) => id === result.id); - }); + return results.filter( + (result) => this.findCollectionIndexById(result.id) > -1, + ); } private async fetchCollectionsByPrefix( @@ -300,12 +354,19 @@ export class CollectionsAdd extends BtrixElement { return data; } + private async getSearchValues(signal: AbortSignal) { + return await this.api.fetch( + `/orgs/${this.orgId}/collections/search-values`, + { signal }, + ); + } + private async dispatchChange() { await this.updateComplete; this.dispatchEvent( - new CustomEvent("collections-change", { + new CustomEvent("collections-change", { detail: { collections: this.collectionIds }, - }) as CollectionsChangeEvent, + }), ); } } diff --git a/frontend/src/features/collections/linked-collections/index.ts b/frontend/src/features/collections/linked-collections/index.ts index 2450e9505c..1a9c5a9a70 100644 --- a/frontend/src/features/collections/linked-collections/index.ts +++ b/frontend/src/features/collections/linked-collections/index.ts @@ -1 +1,3 @@ import "./linked-collections"; +import "./linked-collections-list"; +import "./linked-collections-list-item"; diff --git a/frontend/src/features/collections/linked-collections/linked-collections-list-item.ts b/frontend/src/features/collections/linked-collections/linked-collections-list-item.ts new file mode 100644 index 0000000000..3d9714ae41 --- /dev/null +++ b/frontend/src/features/collections/linked-collections/linked-collections-list-item.ts @@ -0,0 +1,107 @@ +import { localized, msg } from "@lit/localize"; +import clsx from "clsx"; +import { html } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import type { + BtrixRemoveLinkedCollectionEvent, + CollectionLikeItem, +} from "./types"; +import { isActualCollection } from "./utils"; + +import { TailwindElement } from "@/classes/TailwindElement"; +import { pluralOf } from "@/utils/pluralize"; +import { tw } from "@/utils/tailwind"; + +@customElement("btrix-linked-collections-list-item") +@localized() +export class LinkedCollectionsListItem extends TailwindElement { + @property({ type: Object }) + item?: CollectionLikeItem; + + @property({ type: String }) + baseUrl?: string; + + @property({ type: Boolean }) + removable?: boolean; + + @property({ type: Boolean }) + loading = false; + + render() { + const item = this.item; + + if (!item) return; + + const actual = isActualCollection(item); + + const content = [ + html`
${item.name}
`, + ]; + + if (actual) { + content.push( + html`
+ ${item.crawlCount} + ${pluralOf("items", item.crawlCount)} +
`, + ); + } + + if (this.baseUrl) { + content.push( + html`
+ + + + +
`, + ); + } + + if (this.removable) { + content.push( + html`
+ + + this.dispatchEvent( + new CustomEvent( + "btrix-remove", + { + detail: { + item: item, + }, + bubbles: true, + composed: true, + }, + ), + )} + > + +
`, + ); + } + + return html`
  • +
    + ${content} +
    +
  • `; + } +} diff --git a/frontend/src/features/collections/linked-collections/linked-collections-list.ts b/frontend/src/features/collections/linked-collections/linked-collections-list.ts index 0beadea2cf..7b9508f2ed 100644 --- a/frontend/src/features/collections/linked-collections/linked-collections-list.ts +++ b/frontend/src/features/collections/linked-collections/linked-collections-list.ts @@ -1,17 +1,13 @@ -import { localized, msg } from "@lit/localize"; +import { localized } from "@lit/localize"; import clsx from "clsx"; import { html } from "lit"; import { customElement, property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; import { until } from "lit/directives/until.js"; -import type { - BtrixRemoveLinkedCollectionEvent, - CollectionLikeItem, -} from "./types"; -import { isActualCollection } from "./utils"; +import type { CollectionLikeItem } from "./types"; import { TailwindElement } from "@/classes/TailwindElement"; -import { pluralOf } from "@/utils/pluralize"; import { tw } from "@/utils/tailwind"; @customElement("btrix-linked-collections-list") @@ -33,87 +29,21 @@ export class LinkedCollectionsList extends TailwindElement { return; } - return html`
      - ${this.collections.map((item) => - item.request - ? until(item.request.then(this.renderItem)) - : this.renderItem(item, { loading: true }), - )} + return html`
        + ${this.collections.map((item, i) => { + const request = item.request || Promise.resolve(item); + + return html` 0 && tw`part-[base]:border-t`)} + .item=${until(request, item)} + baseUrl=${ifDefined(this.baseUrl)} + ?removable=${this.removable} + ?loading=${until( + request.then(() => false), + true, + )} + >`; + })}
      `; } - - private readonly renderItem = ( - item: CollectionLikeItem, - { loading } = { loading: false }, - ) => { - const actual = isActualCollection(item); - - const content = [ - html`
      ${item.name}
      `, - ]; - - if (actual) { - content.push( - html`
      - ${item.crawlCount} - ${pluralOf("items", item.crawlCount)} -
      `, - ); - } - - if (this.baseUrl) { - content.push( - html`
      - - - - -
      `, - ); - } - - if (this.removable) { - content.push( - html`
      - - - this.dispatchEvent( - new CustomEvent( - "btrix-remove", - { - detail: { - item: item, - }, - bubbles: true, - composed: true, - }, - ), - )} - > - -
      `, - ); - } - - return html`
    • - ${content} -
    • `; - }; } diff --git a/frontend/src/features/collections/linked-collections/linked-collections.ts b/frontend/src/features/collections/linked-collections/linked-collections.ts index fa9240a9e5..2d84239dac 100644 --- a/frontend/src/features/collections/linked-collections/linked-collections.ts +++ b/frontend/src/features/collections/linked-collections/linked-collections.ts @@ -4,15 +4,19 @@ import { html } from "lit"; import { customElement, property } from "lit/decorators.js"; import isEqual from "lodash/fp/isEqual"; -import type { CollectionLikeItem } from "./types"; +import type { + BtrixLoadedLinkedCollectionEvent, + CollectionLikeItem, +} from "./types"; +import { isActualCollection } from "./utils"; import { BtrixElement } from "@/classes/BtrixElement"; import type { Collection } from "@/types/collection"; -import "./linked-collections-list"; - /** * Display list of collections that are linked to a workflow or archived item by ID. + * + * @fires btrix-loaded */ @customElement("btrix-linked-collections") @localized() @@ -22,7 +26,7 @@ export class LinkedCollections extends BtrixElement { * unnecessarily fetched if IDs have not changed */ @property({ type: Array, hasChanged: (a, b) => !isEqual(a, b) }) - collectionIds: string[] = []; + collections: (string | CollectionLikeItem)[] = []; @property({ type: Boolean }) removable?: boolean; @@ -47,36 +51,62 @@ export class LinkedCollections extends BtrixElement { } private readonly collectionsTask = new Task(this, { - task: async ([ids]) => { + task: async ([collections]) => { // The API doesn't currently support getting collections by a list of IDs - const collectionsWithRequest: { - id: string; - request: Promise; - }[] = []; - - ids.forEach(async (id) => { - let request = this.collectionsMap.get(id); - - if (!request) { - request = this.fetchCollection( - id, - this.collectionsTaskController.signal, - ); - - this.collectionsMap.set(id, request); + const collectionsWithRequest: ( + | { + id: string; + request: Promise; + } + | CollectionLikeItem + )[] = []; + + collections.forEach(async (collOrId) => { + const idIsString = typeof collOrId === "string"; + + if (idIsString || !isActualCollection(collOrId)) { + const id = idIsString ? collOrId : collOrId.id; + + // Render async list that requests collection data + let request = this.collectionsMap.get(id); + + if (!request) { + request = this.fetchCollection( + id, + this.collectionsTaskController.signal, + ); + + this.collectionsMap.set(id, request); + } + + collectionsWithRequest.push({ id, request }); + + void request.then((item) => { + this.dispatchEvent( + new CustomEvent( + "btrix-loaded", + { + detail: { item }, + }, + ), + ); + }); + } else { + collectionsWithRequest.push(collOrId); } - - collectionsWithRequest.push({ id, request }); }); return collectionsWithRequest; }, - args: () => [this.collectionIds] as const, + args: () => [this.collections] as const, }); render() { const collections = - this.collectionsTask.value || this.collectionIds.map((id) => ({ id })); + this.collectionsTask.value || + this.collections.map((collOrId) => + typeof collOrId === "string" ? { id: collOrId } : collOrId, + ); return html`; + +export type BtrixLoadedLinkedCollectionEvent = CustomEvent<{ + item: CollectionLikeItem; +}>; diff --git a/frontend/src/features/crawl-workflows/workflow-editor.ts b/frontend/src/features/crawl-workflows/workflow-editor.ts index cf6a74b048..43c6490e21 100644 --- a/frontend/src/features/crawl-workflows/workflow-editor.ts +++ b/frontend/src/features/crawl-workflows/workflow-editor.ts @@ -2290,7 +2290,6 @@ https://archiveweb.page/images/${"logo.svg"}`} .label=${msg("Auto-Add to Collection")} .initialCollections=${this.formState.autoAddCollections} .configId=${this.configId} - emptyText=${msg("Search for a Collection to auto-add crawls")} @collections-change=${(e: CollectionsChangeEvent) => this.updateFormState( { @@ -2299,6 +2298,14 @@ https://archiveweb.page/images/${"logo.svg"}`} true, )} > + ${when( + !this.formState.autoAddCollections.length, + () => html` +
      +

      ${msg("No collections selected.")}

      +
      + `, + )} `)} ${this.renderHelpTextCol( msg(`Automatically add crawls from this workflow to one or more collections diff --git a/frontend/src/utils/LinkedList.ts b/frontend/src/utils/LinkedList.ts new file mode 100644 index 0000000000..e0559850c0 --- /dev/null +++ b/frontend/src/utils/LinkedList.ts @@ -0,0 +1,75 @@ +/** + * Based on https://github.com/solancer/two-way-map/tree/v1.0.3/src + */ + +export interface ILinkedListNode { + value: T; + next: ILinkedListNode | null; + prev: ILinkedListNode | null; +} + +export class LinkedList { + private head: ILinkedListNode | null = null; + private tail: ILinkedListNode | null = null; + + add(value: T): void { + const newNode: ILinkedListNode = { value, next: null, prev: this.tail }; + + if (!this.head) { + this.head = newNode; + } else if (this.tail) { + this.tail.next = newNode; + } + + this.tail = newNode; + } + + remove(value: T): void { + let current = this.head; + + while (current) { + if (current.value === value) { + if (current.prev) { + current.prev.next = current.next; + } else { + this.head = current.next; + } + + if (current.next) { + current.next.prev = current.prev; + } else { + this.tail = current.prev; + } + + return; + } + + current = current.next; + } + } + + toArray(): T[] { + const array: T[] = []; + let current = this.head; + + while (current) { + array.push(current.value); + current = current.next; + } + + return array; + } + + getFirst(): T | undefined { + return this.head?.value; + } + + getLast(): T | undefined { + return this.tail?.value; + } + + clear(): void { + this.head = null; + this.tail = null; + } +} diff --git a/frontend/src/utils/TwoWayMap.ts b/frontend/src/utils/TwoWayMap.ts new file mode 100644 index 0000000000..9890a00044 --- /dev/null +++ b/frontend/src/utils/TwoWayMap.ts @@ -0,0 +1,111 @@ +/** + * Based on https://github.com/solancer/two-way-map/tree/v1.0.3/src + */ + +import { LinkedList } from "./LinkedList"; + +export interface ITwoWayMap { + set: (key: K, value: V) => void; + get: (key: K) => V | undefined; + getByValue: (value: V) => K | undefined; + delete: (key: K) => boolean; + keys: () => K[]; + values: () => V[]; + entries: () => [K, V][]; + pop: (key: K, defaultValue?: V) => V | undefined; + popitem: (last?: boolean) => [K, V] | undefined; + copy: () => ITwoWayMap; + clear: () => void; +} + +export class TwoWayMap implements ITwoWayMap { + private readonly keyToValueMap = new Map(); + private readonly valueToKeyMap = new Map(); + private readonly orderList = new LinkedList(); + + set(key: K, value: V): void { + // Check if the key is already in the map and delete the reverse mapping + if (this.keyToValueMap.has(key)) { + this.valueToKeyMap.delete(this.keyToValueMap.get(key)!); + } + + // Check if the value is already in the map and delete the forward mapping + if (this.valueToKeyMap.has(value)) { + this.keyToValueMap.delete(this.valueToKeyMap.get(value)!); + } + + // Set new key-value and value-key mappings + this.keyToValueMap.set(key, value); + this.valueToKeyMap.set(value, key); + + // Add the key to the order list + this.orderList.add(key); + } + + get(key: K): V | undefined { + return this.keyToValueMap.get(key); + } + + getByValue(value: V): K | undefined { + return this.valueToKeyMap.get(value); + } + + delete(key: K): boolean { + if (!this.keyToValueMap.has(key)) { + return false; + } + + const value = this.keyToValueMap.get(key)!; + this.keyToValueMap.delete(key); + this.valueToKeyMap.delete(value); + this.orderList.remove(key); + return true; + } + + keys(): K[] { + return this.orderList.toArray(); + } + + values(): V[] { + return this.orderList.toArray().map((key) => this.keyToValueMap.get(key)!); + } + + entries(): [K, V][] { + return this.orderList + .toArray() + .map((key) => [key, this.keyToValueMap.get(key)!] as [K, V]); + } + + pop(key: K, defaultValue?: V): V | undefined { + if (this.keyToValueMap.has(key)) { + const value = this.keyToValueMap.get(key); + this.delete(key); + return value; + } else { + return defaultValue; + } + } + + popitem(last = true): [K, V] | undefined { + const key = last ? this.orderList.getLast() : this.orderList.getFirst(); + if (key !== undefined) { + const value = this.keyToValueMap.get(key); + this.delete(key); + return [key, value as V]; + } + } + + copy(): TwoWayMap { + const newMap = new TwoWayMap(); + this.keyToValueMap.forEach((value, key) => { + newMap.set(key, value); + }); + return newMap; + } + + clear(): void { + this.keyToValueMap.clear(); + this.valueToKeyMap.clear(); + this.orderList.clear(); + } +}