Fix add to collection dropdown (#1766)
Fixes https://github.com/webrecorder/browsertrix/issues/1638 ### Changes - Fixes collection selection during upload and in workflow settings - Refactors `btrix-collections-add` to `TailwindComponent` with lit `Task` usage ### Manual testing 1. Log in as crawler 2. Go to "Archived Items" 3. Click "Upload WACZ" 4. Select wacz file to upload 5. Search for collection in "Add to Collection". Verify you're able to select a search result 6. Save. Verify collection saves as expected. 7. Go to "Crawling" 8. Create a new workflow 9. Go to "Metadata" 10. Repeat 5-6.
This commit is contained in:
parent
fe65ccf579
commit
270e056c34
@ -53,7 +53,8 @@ export class Combobox extends LitElement {
|
|||||||
|
|
||||||
@queryAssignedElements({
|
@queryAssignedElements({
|
||||||
slot: "menu-item",
|
slot: "menu-item",
|
||||||
selector: "sl-menu-item:not([disabled])",
|
selector: "sl-menu-item",
|
||||||
|
flatten: true,
|
||||||
})
|
})
|
||||||
private readonly menuItems?: SlMenuItem[];
|
private readonly menuItems?: SlMenuItem[];
|
||||||
|
|
||||||
@ -122,7 +123,12 @@ export class Combobox extends LitElement {
|
|||||||
|
|
||||||
private onKeydown(e: KeyboardEvent) {
|
private onKeydown(e: KeyboardEvent) {
|
||||||
if (this.open && e.key === "ArrowDown") {
|
if (this.open && e.key === "ArrowDown") {
|
||||||
if (this.menu && this.menuItems?.length && !this.menu.getCurrentItem()) {
|
if (
|
||||||
|
this.menu &&
|
||||||
|
this.menuItems?.length &&
|
||||||
|
!this.menu.getCurrentItem() &&
|
||||||
|
!this.menuItems[0].disabled
|
||||||
|
) {
|
||||||
// Focus on first menu item
|
// Focus on first menu item
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const menuItem = this.menuItems[0];
|
const menuItem = this.menuItems[0];
|
||||||
@ -159,4 +165,12 @@ export class Combobox extends LitElement {
|
|||||||
private closeDropdown() {
|
private closeDropdown() {
|
||||||
this.dropdown?.classList.add("animateHide");
|
this.dropdown?.classList.add("animateHide");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public show() {
|
||||||
|
this.open = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public hide() {
|
||||||
|
this.open = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,16 @@
|
|||||||
import { localized, msg, str } from "@lit/localize";
|
import { localized, msg, str } from "@lit/localize";
|
||||||
|
import { Task } from "@lit/task";
|
||||||
import type { SlInput, SlMenuItem } from "@shoelace-style/shoelace";
|
import type { SlInput, SlMenuItem } from "@shoelace-style/shoelace";
|
||||||
import { customElement, property, state } from "lit/decorators.js";
|
import { html } from "lit";
|
||||||
|
import { customElement, property, query, state } from "lit/decorators.js";
|
||||||
import { when } from "lit/directives/when.js";
|
import { when } from "lit/directives/when.js";
|
||||||
import debounce from "lodash/fp/debounce";
|
import debounce from "lodash/fp/debounce";
|
||||||
import queryString from "query-string";
|
import queryString from "query-string";
|
||||||
|
|
||||||
|
import { TailwindElement } from "@/classes/TailwindElement";
|
||||||
|
import type { Combobox } from "@/components/ui/combobox";
|
||||||
|
import { APIController } from "@/controllers/api";
|
||||||
|
import { NotifyController } from "@/controllers/notify";
|
||||||
import type {
|
import type {
|
||||||
APIPaginatedList,
|
APIPaginatedList,
|
||||||
APIPaginationQuery,
|
APIPaginationQuery,
|
||||||
@ -13,7 +19,6 @@ import type {
|
|||||||
import type { Collection } from "@/types/collection";
|
import type { Collection } from "@/types/collection";
|
||||||
import type { UnderlyingFunction } from "@/types/utils";
|
import type { UnderlyingFunction } from "@/types/utils";
|
||||||
import type { AuthState } from "@/utils/AuthService";
|
import type { AuthState } from "@/utils/AuthService";
|
||||||
import LiteElement, { html } from "@/utils/LiteElement";
|
|
||||||
|
|
||||||
const INITIAL_PAGE_SIZE = 10;
|
const INITIAL_PAGE_SIZE = 10;
|
||||||
const MIN_SEARCH_LENGTH = 2;
|
const MIN_SEARCH_LENGTH = 2;
|
||||||
@ -37,7 +42,7 @@ export type CollectionsChangeEvent = CustomEvent<{
|
|||||||
*/
|
*/
|
||||||
@localized()
|
@localized()
|
||||||
@customElement("btrix-collections-add")
|
@customElement("btrix-collections-add")
|
||||||
export class CollectionsAdd extends LiteElement {
|
export class CollectionsAdd extends TailwindElement {
|
||||||
@property({ type: Object })
|
@property({ type: Object })
|
||||||
authState!: AuthState;
|
authState!: AuthState;
|
||||||
|
|
||||||
@ -63,18 +68,35 @@ export class CollectionsAdd extends LiteElement {
|
|||||||
@state()
|
@state()
|
||||||
private collectionIds: string[] = [];
|
private collectionIds: string[] = [];
|
||||||
|
|
||||||
@state()
|
@query("sl-input")
|
||||||
private searchByValue = "";
|
private readonly input?: SlInput | null;
|
||||||
|
|
||||||
@state()
|
@query("btrix-combobox")
|
||||||
private searchResults: Collection[] = [];
|
private readonly combobox?: Combobox | null;
|
||||||
|
|
||||||
|
private readonly api = new APIController(this);
|
||||||
|
private readonly notify = new NotifyController(this);
|
||||||
|
|
||||||
|
private get searchByValue() {
|
||||||
|
return this.input ? this.input.value.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
private get hasSearchStr() {
|
private get hasSearchStr() {
|
||||||
return this.searchByValue.length >= MIN_SEARCH_LENGTH;
|
return this.searchByValue.length >= MIN_SEARCH_LENGTH;
|
||||||
}
|
}
|
||||||
|
|
||||||
@state()
|
private readonly searchResultsTask = new Task(this, {
|
||||||
private searchResultsOpen = false;
|
task: async ([searchByValue, hasSearchStr], { signal }) => {
|
||||||
|
if (!hasSearchStr) return [];
|
||||||
|
const data = await this.fetchCollectionsByPrefix(searchByValue, signal);
|
||||||
|
let searchResults: Collection[] = [];
|
||||||
|
if (data?.items.length) {
|
||||||
|
searchResults = this.filterOutSelectedCollections(data.items);
|
||||||
|
}
|
||||||
|
return searchResults;
|
||||||
|
},
|
||||||
|
args: () => [this.searchByValue, this.hasSearchStr] as const,
|
||||||
|
});
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
if (this.initialCollections) {
|
if (this.initialCollections) {
|
||||||
@ -85,7 +107,6 @@ export class CollectionsAdd extends LiteElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
this.onSearchInput.cancel();
|
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -121,17 +142,16 @@ export class CollectionsAdd extends LiteElement {
|
|||||||
private renderSearch() {
|
private renderSearch() {
|
||||||
return html`
|
return html`
|
||||||
<btrix-combobox
|
<btrix-combobox
|
||||||
?open=${this.searchResultsOpen}
|
|
||||||
@request-close=${() => {
|
@request-close=${() => {
|
||||||
this.searchResultsOpen = false;
|
this.combobox?.hide();
|
||||||
this.searchByValue = "";
|
if (this.input) this.input.value = "";
|
||||||
}}
|
}}
|
||||||
@sl-select=${async (e: CustomEvent<{ item: SlMenuItem }>) => {
|
@sl-select=${async (e: CustomEvent<{ item: SlMenuItem }>) => {
|
||||||
this.searchResultsOpen = false;
|
this.combobox?.hide();
|
||||||
const item = e.detail.item;
|
const item = e.detail.item;
|
||||||
const collId = item.dataset["key"];
|
const collId = item.dataset["key"];
|
||||||
if (collId && this.collectionIds.indexOf(collId) === -1) {
|
if (collId && this.collectionIds.indexOf(collId) === -1) {
|
||||||
const coll = this.searchResults.find(
|
const coll = this.searchResultsTask.value?.find(
|
||||||
(collection) => collection.id === collId,
|
(collection) => collection.id === collId,
|
||||||
);
|
);
|
||||||
if (coll) {
|
if (coll) {
|
||||||
@ -152,10 +172,13 @@ export class CollectionsAdd extends LiteElement {
|
|||||||
size="small"
|
size="small"
|
||||||
placeholder=${msg("Search by Collection name")}
|
placeholder=${msg("Search by Collection name")}
|
||||||
clearable
|
clearable
|
||||||
value=${this.searchByValue}
|
|
||||||
@sl-clear=${() => {
|
@sl-clear=${() => {
|
||||||
this.searchResultsOpen = false;
|
this.combobox?.hide();
|
||||||
this.onSearchInput.cancel();
|
}}
|
||||||
|
@keyup=${() => {
|
||||||
|
if (this.combobox && !this.combobox.open && this.hasSearchStr) {
|
||||||
|
this.combobox.show();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
@sl-input=${this.onSearchInput as UnderlyingFunction<
|
@sl-input=${this.onSearchInput as UnderlyingFunction<
|
||||||
typeof this.onSearchInput
|
typeof this.onSearchInput
|
||||||
@ -169,43 +192,51 @@ export class CollectionsAdd extends LiteElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private renderSearchResults() {
|
private renderSearchResults() {
|
||||||
if (!this.hasSearchStr) {
|
return this.searchResultsTask.render({
|
||||||
return html`
|
pending: () => html`
|
||||||
<sl-menu-item slot="menu-item" disabled
|
<sl-menu-item slot="menu-item" disabled>
|
||||||
>${msg("Start typing to search Collections.")}</sl-menu-item
|
<sl-spinner></sl-spinner>
|
||||||
>
|
</sl-menu-item>
|
||||||
`;
|
`,
|
||||||
}
|
complete: (searchResults) => {
|
||||||
|
if (!this.hasSearchStr) {
|
||||||
|
return html`
|
||||||
|
<sl-menu-item slot="menu-item" disabled>
|
||||||
|
${msg("Start typing to search Collections.")}
|
||||||
|
</sl-menu-item>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
// Filter out stale search results from last debounce invocation
|
// Filter out stale search results from last debounce invocation
|
||||||
const searchResults = this.searchResults.filter((res) =>
|
const results = searchResults.filter((res) =>
|
||||||
new RegExp(`^${this.searchByValue}`, "i").test(res.name),
|
new RegExp(`^${this.searchByValue}`, "i").test(res.name),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!searchResults.length) {
|
if (!results.length) {
|
||||||
return html`
|
return html`
|
||||||
<sl-menu-item slot="menu-item" disabled
|
<sl-menu-item slot="menu-item" disabled>
|
||||||
>${msg("No matching Collections found.")}</sl-menu-item
|
${msg("No matching Collections found.")}
|
||||||
>
|
</sl-menu-item>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
|
||||||
${searchResults.map((item: Collection) => {
|
|
||||||
return html`
|
return html`
|
||||||
<sl-menu-item class="w-full" slot="menu-item" data-key=${item.id}>
|
${results.map((item: Collection) => {
|
||||||
<div class="flex w-full items-center gap-2">
|
return html`
|
||||||
<div class="grow justify-self-stretch truncate">${item.name}</div>
|
<sl-menu-item slot="menu-item" data-key=${item.id}>
|
||||||
<div
|
${item.name}
|
||||||
class="font-monostyle flex-auto text-right text-xs text-neutral-500"
|
<div
|
||||||
>
|
slot="suffix"
|
||||||
${msg(str`${item.crawlCount} items`)}
|
class="font-monostyle flex-auto text-right text-xs text-neutral-500"
|
||||||
</div>
|
>
|
||||||
</div>
|
${msg(str`${item.crawlCount} items`)}
|
||||||
</sl-menu-item>
|
</div>
|
||||||
|
</sl-menu-item>
|
||||||
|
`;
|
||||||
|
})}
|
||||||
`;
|
`;
|
||||||
})}
|
},
|
||||||
`;
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderCollectionItem(id: string) {
|
private renderCollectionItem(id: string) {
|
||||||
@ -249,19 +280,8 @@ export class CollectionsAdd extends LiteElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly onSearchInput = debounce(200)(async (e: Event) => {
|
private readonly onSearchInput = debounce(400)(() => {
|
||||||
this.searchByValue = (e.target as SlInput).value.trim();
|
void this.searchResultsTask.run();
|
||||||
|
|
||||||
if (!this.searchResultsOpen && this.hasSearchStr) {
|
|
||||||
this.searchResultsOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await this.fetchCollectionsByPrefix(this.searchByValue);
|
|
||||||
let searchResults: Collection[] = [];
|
|
||||||
if (data?.items.length) {
|
|
||||||
searchResults = this.filterOutSelectedCollections(data.items);
|
|
||||||
}
|
|
||||||
this.searchResults = searchResults;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
private filterOutSelectedCollections(results: Collection[]) {
|
private filterOutSelectedCollections(results: Collection[]) {
|
||||||
@ -270,21 +290,31 @@ export class CollectionsAdd extends LiteElement {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetchCollectionsByPrefix(namePrefix: string) {
|
private async fetchCollectionsByPrefix(
|
||||||
|
namePrefix: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const results = await this.getCollections({
|
const results = await this.getCollections(
|
||||||
oid: this.orgId,
|
{
|
||||||
namePrefix: namePrefix,
|
oid: this.orgId,
|
||||||
sortBy: "name",
|
namePrefix: namePrefix,
|
||||||
pageSize: INITIAL_PAGE_SIZE,
|
sortBy: "name",
|
||||||
});
|
pageSize: INITIAL_PAGE_SIZE,
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
);
|
||||||
return results;
|
return results;
|
||||||
} catch {
|
} catch (e) {
|
||||||
this.notify({
|
if ((e as Error).name === "AbortError") {
|
||||||
message: msg("Sorry, couldn't retrieve Collections at this time."),
|
console.debug("Fetch aborted to throttle");
|
||||||
variant: "danger",
|
} else {
|
||||||
icon: "exclamation-octagon",
|
this.notify.toast({
|
||||||
});
|
message: msg("Sorry, couldn't retrieve Collections at this time."),
|
||||||
|
variant: "danger",
|
||||||
|
icon: "exclamation-octagon",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -295,13 +325,15 @@ export class CollectionsAdd extends LiteElement {
|
|||||||
}> &
|
}> &
|
||||||
APIPaginationQuery &
|
APIPaginationQuery &
|
||||||
APISortQuery,
|
APISortQuery,
|
||||||
|
signal?: AbortSignal,
|
||||||
) {
|
) {
|
||||||
const query = queryString.stringify(params || {}, {
|
const query = queryString.stringify(params || {}, {
|
||||||
arrayFormat: "comma",
|
arrayFormat: "comma",
|
||||||
});
|
});
|
||||||
const data = await this.apiFetch<APIPaginatedList<Collection>>(
|
const data = await this.api.fetch<APIPaginatedList<Collection>>(
|
||||||
`/orgs/${this.orgId}/collections?${query}`,
|
`/orgs/${this.orgId}/collections?${query}`,
|
||||||
this.authState!,
|
this.authState!,
|
||||||
|
{ signal },
|
||||||
);
|
);
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@ -322,7 +354,7 @@ export class CollectionsAdd extends LiteElement {
|
|||||||
private readonly getCollection = async (
|
private readonly getCollection = async (
|
||||||
collId: string,
|
collId: string,
|
||||||
): Promise<Collection | undefined> => {
|
): Promise<Collection | undefined> => {
|
||||||
return this.apiFetch(
|
return this.api.fetch(
|
||||||
`/orgs/${this.orgId}/collections/${collId}`,
|
`/orgs/${this.orgId}/collections/${collId}`,
|
||||||
this.authState!,
|
this.authState!,
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user