From 1646f72c7ba485143308d9c23dbc457a34a1d700 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Tue, 1 Sep 2026 20:24:18 -0700 Subject: [PATCH 01/20] update checkbox alignment --- .../archived-item-list/archived-item-list-item.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/features/archived-items/archived-item-list/archived-item-list-item.ts b/frontend/src/features/archived-items/archived-item-list/archived-item-list-item.ts index e21f77ca56..51d468721f 100644 --- a/frontend/src/features/archived-items/archived-item-list/archived-item-list-item.ts +++ b/frontend/src/features/archived-items/archived-item-list/archived-item-list-item.ts @@ -124,7 +124,7 @@ export class ArchivedItemListItem extends BtrixElement { ${this.checkbox ? html` (e.target as HTMLElement) .querySelector("sl-checkbox") From 391659cc492b572f183f06eb847f9580be86052a Mon Sep 17 00:00:00 2001 From: sua yoo Date: Wed, 2 Sep 2026 09:39:56 -0700 Subject: [PATCH 02/20] add control --- frontend/src/layouts/listControls.ts | 31 ++++++-- frontend/src/pages/org/archived-items.ts | 78 ++++++++++++++++++- frontend/src/plurals/items-selected.ts | 34 ++++++++ .../archived-items/ArchivedItems.stories.ts | 47 +++++++++++ .../pages/archived-items/ArchivedItems.ts | 2 + 5 files changed, 185 insertions(+), 7 deletions(-) create mode 100644 frontend/src/plurals/items-selected.ts diff --git a/frontend/src/layouts/listControls.ts b/frontend/src/layouts/listControls.ts index 22ae42b69f..f548174417 100644 --- a/frontend/src/layouts/listControls.ts +++ b/frontend/src/layouts/listControls.ts @@ -9,11 +9,13 @@ export function listControls({ renderSortControl, renderViewControl, renderFilterControl, + renderBulkActionsControl, }: { renderSearchControl: () => TemplateResult; renderSortControl: () => TemplateResult; renderViewControl?: () => TemplateResult; renderFilterControl?: () => TemplateResult; + renderBulkActionsControl?: () => TemplateResult; }) { return html`
` : nothing}
- ${renderFilterControl - ? html`
- - ${msg("Filter by:")} - - ${renderFilterControl()} + ${renderFilterControl || renderBulkActionsControl + ? html`
+ ${renderBulkActionsControl + ? html`
+ ${renderBulkActionsControl()} +
` + : nothing} + ${renderFilterControl + ? html`
+ + ${msg("Filter by:")} + + ${renderFilterControl()} +
` + : nothing}
` : nothing}
diff --git a/frontend/src/pages/org/archived-items.ts b/frontend/src/pages/org/archived-items.ts index 864381c531..6c095e0997 100644 --- a/frontend/src/pages/org/archived-items.ts +++ b/frontend/src/pages/org/archived-items.ts @@ -1,7 +1,12 @@ import { ContextConsumer } from "@lit/context"; import { localized, msg, str } from "@lit/localize"; import { deepArrayEquals } from "@lit/task/deep-equals.js"; -import type { SlSelect } from "@shoelace-style/shoelace"; +import type { + SlChangeEvent, + SlCheckbox, + SlSelect, +} from "@shoelace-style/shoelace"; +import clsx from "clsx"; import { html, nothing, type PropertyValues, type TemplateResult } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; @@ -29,12 +34,14 @@ import { ClipboardController } from "@/controllers/clipboard"; import PollTask from "@/controllers/poll"; import { SearchParamsValue } from "@/controllers/searchParamsValue"; import { type BtrixUserGuideShowEvent } from "@/events/btrix-user-guide-show"; +import { type ArchivedItemCheckedEvent } from "@/features/archived-items/archived-item-list/types"; import { type BtrixChangeArchivedItemStateFilterEvent } from "@/features/archived-items/archived-item-state-filter"; import { CrawlStatus } from "@/features/archived-items/crawl-status"; import { type BtrixChangeQARatingFilterEvent } from "@/features/archived-items/qa-rating-filter"; import { emptyMessage } from "@/layouts/emptyMessage"; import { listControls } from "@/layouts/listControls"; import { pageHeader } from "@/layouts/pageHeader"; +import { pluralOfItemsSelected } from "@/plurals/items-selected"; import type { APIPaginatedList, APIPaginationQuery } from "@/types/api"; import { UPLOAD_STATES, type CrawlState } from "@/types/crawlState"; import { isApiError } from "@/utils/api"; @@ -150,6 +157,9 @@ export class CrawlsList extends BtrixElement { @property({ type: String }) itemType: ArchivedItem["type"] | null = null; + @property({ type: Boolean, noAccessor: true }) + bulkActions = false; + @state() private pagination: Required = { page: parsePage(new URLSearchParams(location.search).get("page")), @@ -323,6 +333,11 @@ export class CrawlsList extends BtrixElement { @query("btrix-tag-filter") private readonly tagFilter?: TagFilter | null; + private visibleItems = new Set(); + + @state() + selectedItems = new Set(); + private get hasFiltersSet() { return [ this.filterBy.value.id, @@ -374,6 +389,8 @@ export class CrawlsList extends BtrixElement { signal, ); + this.visibleItems = new Set(data.items.map(({ id }) => id)); + return data; } catch (e) { if ((e as Error).name === "AbortError") { @@ -560,6 +577,10 @@ export class CrawlsList extends BtrixElement { renderSearchControl: this.renderSearch, renderSortControl: this.renderSortControl, renderFilterControl: this.renderFilterControl, + renderBulkActionsControl: + this.isCrawler && this.bulkActions + ? this.renderBulkActionsControl + : undefined, })} @@ -614,6 +635,16 @@ export class CrawlsList extends BtrixElement { ${items.length ? html` + ${when( + this.isCrawler && this.bulkActions, + () => html` + + ${msg("Selected for bulk actions")} + + `, + )} ${msg("Row actions")} @@ -800,6 +831,40 @@ export class CrawlsList extends BtrixElement { )}`; }; + private readonly renderBulkActionsControl = () => { + const visibleCount = this.visibleItems.size; + const selected = this.visibleItems.intersection(this.selectedItems); + const selectedCount = selected.size; + const allSelected = selectedCount > 0 && selectedCount === visibleCount; + const someSelected = selectedCount > 0 && selectedCount !== visibleCount; + + return html`
+ { + const checked = (e.target as SlCheckbox).checked; + + if (checked) { + this.selectedItems = new Set(this.visibleItems); + } else { + this.selectedItems = new Set(); + } + }} + > + ${msg("Select all")} + + + ${pluralOfItemsSelected(selectedCount)} +
+ + `; + }; + private readonly renderSearch = () => { return html` { + if (e.detail.value.checked) { + this.selectedItems.add(item.id); + } else { + this.selectedItems.delete(item.id); + } + + this.selectedItems = new Set(this.selectedItems); + }} > diff --git a/frontend/src/plurals/items-selected.ts b/frontend/src/plurals/items-selected.ts new file mode 100644 index 0000000000..06d1b63094 --- /dev/null +++ b/frontend/src/plurals/items-selected.ts @@ -0,0 +1,34 @@ +import { msg, str } from "@lit/localize"; + +import localize from "@/utils/localize"; +import { pluralize } from "@/utils/pluralize"; + +export const pluralOfItemsSelected = (number: number) => { + const count = localize.number(number); + return pluralize(number, { + zero: msg("0 items selected", { + desc: "plural form of 'X items selected' for zero items selected", + id: "items_selected.plural.zero", + }), + one: msg("1 item selected", { + desc: "plural form of 'X items selected' for one rule", + id: "items_selected.plural.one", + }), + two: msg("2 items selected", { + desc: "plural form of 'X items selected' for two items selected", + id: "items_selected.plural.two", + }), + few: msg(str`${count} items selected`, { + desc: "plural form of 'X items selected' for few items selected", + id: "items_selected.plural.few", + }), + many: msg(str`${count} items selected`, { + desc: "plural form of 'X items selected' for many items selected", + id: "items_selected.plural.many", + }), + other: msg(str`${count} items selected`, { + desc: "plural form of 'X items selected' for other items selected", + id: "items_selected.plural.other", + }), + }); +}; diff --git a/frontend/src/stories/pages/archived-items/ArchivedItems.stories.ts b/frontend/src/stories/pages/archived-items/ArchivedItems.stories.ts index c0aea0233e..f707fe3adc 100644 --- a/frontend/src/stories/pages/archived-items/ArchivedItems.stories.ts +++ b/frontend/src/stories/pages/archived-items/ArchivedItems.stories.ts @@ -119,3 +119,50 @@ export const Crawler: Story = { }, }, }; + +export const BulkActions: Story = { + args: { + isCrawler: true, + bulkActions: true, + }, + parameters: { + msw: { + handlers: [ + searchValuesRequest(), + tagCountsRequest(), + http.get(/\/all-crawls/, async () => { + await delay(500); + return HttpResponse.json>( + archivedItemsMock as APIPaginatedList, + ); + }), + ], + }, + }, +}; + +export const SelectedItems: Story = { + args: { + isCrawler: true, + bulkActions: true, + selectedItems: new Set( + (archivedItemsMock.items as ListArchivedItem[]) + .slice(0, 2) + .map(({ id }) => id), + ), + }, + parameters: { + msw: { + handlers: [ + searchValuesRequest(), + tagCountsRequest(), + http.get(/\/all-crawls/, async () => { + await delay(500); + return HttpResponse.json>( + archivedItemsMock as APIPaginatedList, + ); + }), + ], + }, + }, +}; diff --git a/frontend/src/stories/pages/archived-items/ArchivedItems.ts b/frontend/src/stories/pages/archived-items/ArchivedItems.ts index ce7174d5ff..5885549c37 100644 --- a/frontend/src/stories/pages/archived-items/ArchivedItems.ts +++ b/frontend/src/stories/pages/archived-items/ArchivedItems.ts @@ -11,6 +11,8 @@ export type RenderProps = CrawlsList; export const renderComponent = (props: Partial) => { return html``; }; From 30bb8ca569f8ca00581ba0b3a3177aeb203e1f5e Mon Sep 17 00:00:00 2001 From: sua yoo Date: Wed, 2 Sep 2026 12:34:06 -0700 Subject: [PATCH 03/20] fix linting issue --- frontend/src/pages/org/archived-items.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/pages/org/archived-items.ts b/frontend/src/pages/org/archived-items.ts index 6c095e0997..6af826da14 100644 --- a/frontend/src/pages/org/archived-items.ts +++ b/frontend/src/pages/org/archived-items.ts @@ -6,7 +6,6 @@ import type { SlCheckbox, SlSelect, } from "@shoelace-style/shoelace"; -import clsx from "clsx"; import { html, nothing, type PropertyValues, type TemplateResult } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; From ae5ff968dca29402a2e533f31dca1df13e7702cb Mon Sep 17 00:00:00 2001 From: sua yoo Date: Wed, 2 Sep 2026 13:39:41 -0700 Subject: [PATCH 04/20] adjust for smaller screens --- frontend/src/layouts/listControls.ts | 34 +++++++++++++++++++----- frontend/src/pages/org/archived-items.ts | 17 +++++++----- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/frontend/src/layouts/listControls.ts b/frontend/src/layouts/listControls.ts index f548174417..ca2269051b 100644 --- a/frontend/src/layouts/listControls.ts +++ b/frontend/src/layouts/listControls.ts @@ -19,11 +19,11 @@ export function listControls({ }) { return html`
${renderFilterControl || renderBulkActionsControl - ? html`
+ ? html`
${renderBulkActionsControl ? html`` @@ -71,8 +78,12 @@ export function listControls({ ? html`
@@ -83,6 +94,17 @@ export function listControls({ : nothing}
` : nothing} + ${renderBulkActionsControl + ? html`` + : nothing}
`; } diff --git a/frontend/src/pages/org/archived-items.ts b/frontend/src/pages/org/archived-items.ts index 6af826da14..975a4119ad 100644 --- a/frontend/src/pages/org/archived-items.ts +++ b/frontend/src/pages/org/archived-items.ts @@ -834,10 +834,11 @@ export class CrawlsList extends BtrixElement { const visibleCount = this.visibleItems.size; const selected = this.visibleItems.intersection(this.selectedItems); const selectedCount = selected.size; - const allSelected = selectedCount > 0 && selectedCount === visibleCount; - const someSelected = selectedCount > 0 && selectedCount !== visibleCount; + const anySelected = selectedCount > 0; + const allSelected = anySelected && selectedCount === visibleCount; + const someSelected = anySelected && selectedCount !== visibleCount; - return html`
+ return html`
- `; + ${anySelected + ? html`` + : nothing} `; }; private readonly renderSearch = () => { From 254fccc977bf51e9a831f488dd5e6ed553cd5793 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Thu, 3 Sep 2026 11:08:28 -0700 Subject: [PATCH 05/20] use dialog --- .../bulk-delete-items-dialog.ts | 3 +- .../archived-items/delete-item-dialog.ts | 4 +- frontend/src/pages/org/archived-items.ts | 93 ++++++++++++------- .../archived-items/ArchivedItems.stories.ts | 2 +- .../pages/archived-items/ArchivedItems.ts | 2 +- 5 files changed, 64 insertions(+), 40 deletions(-) diff --git a/frontend/src/features/archived-items/bulk-delete-items-dialog.ts b/frontend/src/features/archived-items/bulk-delete-items-dialog.ts index 7bbfd18ab5..5e19659a1c 100644 --- a/frontend/src/features/archived-items/bulk-delete-items-dialog.ts +++ b/frontend/src/features/archived-items/bulk-delete-items-dialog.ts @@ -13,6 +13,7 @@ import { deleteConfirmation } from "@/strings/ui"; import type { ListArchivedItem } from "@/types/crawler"; import { pathForArchivedItem } from "@/utils/archived-items/pathForArchivedItem"; import { renderName } from "@/utils/crawler"; +import { isNotEqual } from "@/utils/is-not-equal"; import { tw } from "@/utils/tailwind"; /** @@ -22,7 +23,7 @@ import { tw } from "@/utils/tailwind"; @customElement("btrix-bulk-delete-items-dialog") @localized() export class BulkDeleteItemsDialog extends BtrixElement { - @property({ type: Array }) + @property({ type: Array, hasChanged: isNotEqual }) items?: ListArchivedItem[]; @property({ type: Boolean }) diff --git a/frontend/src/features/archived-items/delete-item-dialog.ts b/frontend/src/features/archived-items/delete-item-dialog.ts index 6d894d1671..2168a12e70 100644 --- a/frontend/src/features/archived-items/delete-item-dialog.ts +++ b/frontend/src/features/archived-items/delete-item-dialog.ts @@ -9,7 +9,7 @@ import { BtrixElement } from "@/classes/BtrixElement"; import type { Dialog } from "@/components/ui/dialog"; import type { ArchivedItemSectionName } from "@/pages/org/archived-item-detail/archived-item-detail"; import { CommonTab, OrgTab, WorkflowTab } from "@/routes"; -import type { ArchivedItem } from "@/types/crawler"; +import type { ArchivedItem, ListArchivedItem } from "@/types/crawler"; import { isCrawl, isFailed, renderName } from "@/utils/crawler"; import { pluralOf } from "@/utils/pluralize"; @@ -23,7 +23,7 @@ import { pluralOf } from "@/utils/pluralize"; @localized() export class DeleteItemDialog extends BtrixElement { @property({ type: Object }) - item?: ArchivedItem; + item?: ListArchivedItem | ArchivedItem; @property({ type: Boolean }) open = false; diff --git a/frontend/src/pages/org/archived-items.ts b/frontend/src/pages/org/archived-items.ts index 975a4119ad..0886abf216 100644 --- a/frontend/src/pages/org/archived-items.ts +++ b/frontend/src/pages/org/archived-items.ts @@ -8,12 +8,13 @@ import type { } from "@shoelace-style/shoelace"; import { html, nothing, type PropertyValues, type TemplateResult } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; +import { guard } from "lit/directives/guard.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { repeat } from "lit/directives/repeat.js"; import { when } from "lit/directives/when.js"; import queryString from "query-string"; -import type { ArchivedItem, Crawl, Workflow } from "./types"; +import type { ArchivedItem, Crawl, ListArchivedItem, Workflow } from "./types"; import { BtrixElement } from "@/classes/BtrixElement"; import { @@ -35,7 +36,6 @@ import { SearchParamsValue } from "@/controllers/searchParamsValue"; import { type BtrixUserGuideShowEvent } from "@/events/btrix-user-guide-show"; import { type ArchivedItemCheckedEvent } from "@/features/archived-items/archived-item-list/types"; import { type BtrixChangeArchivedItemStateFilterEvent } from "@/features/archived-items/archived-item-state-filter"; -import { CrawlStatus } from "@/features/archived-items/crawl-status"; import { type BtrixChangeQARatingFilterEvent } from "@/features/archived-items/qa-rating-filter"; import { emptyMessage } from "@/layouts/emptyMessage"; import { listControls } from "@/layouts/listControls"; @@ -314,17 +314,11 @@ export class CrawlsList extends BtrixElement { @state() private itemToEdit: ArchivedItem | null = null; - @state() - private isEditingItem = false; - @state() private itemToDelete: ArchivedItem | null = null; @state() - private isDeletingItem = false; - - @state() - private isUploadingArchive = false; + private openDialog?: "edit" | "delete" | "bulkDelete" | "upload"; @query("#stateSelect") stateSelect?: SlSelect; @@ -332,10 +326,13 @@ export class CrawlsList extends BtrixElement { @query("btrix-tag-filter") private readonly tagFilter?: TagFilter | null; - private visibleItems = new Set(); + /** + * Track visible items (i.e. items on current page) to compare with selected items for bulk actions + */ + private visibleItems = new Map(); @state() - selectedItems = new Set(); + selectedItemIds = new Set(); private get hasFiltersSet() { return [ @@ -388,7 +385,10 @@ export class CrawlsList extends BtrixElement { signal, ); - this.visibleItems = new Set(data.items.map(({ id }) => id)); + this.visibleItems = new Map(data.items.map((item) => [item.id, item])); + this.selectedItemIds = this.selectedItemIds.intersection( + this.visibleItems, + ); return data; } catch (e) { @@ -544,7 +544,7 @@ export class CrawlsList extends BtrixElement { (this.isUploadingArchive = true)} + @click=${() => (this.openDialog = "upload")} ?disabled=${isArchivingDisabled(this.org)} > @@ -603,10 +603,10 @@ export class CrawlsList extends BtrixElement { this.isCrawler && this.orgId, () => html` { e.stopPropagation(); - this.isUploadingArchive = false; + this.openDialog = undefined; }} > `, @@ -679,8 +679,8 @@ export class CrawlsList extends BtrixElement { ? html` (this.isEditingItem = false)} + ?open=${this.openDialog === "edit"} + @request-close=${() => (this.openDialog = undefined)} @updated=${() => { /* TODO fetch current page or single crawl */ void this.archivedItemsTask.run(); @@ -692,10 +692,10 @@ export class CrawlsList extends BtrixElement { (this.isDeletingItem = false)} + ?open=${this.openDialog === "delete"} + @sl-after-hide=${() => (this.openDialog = undefined)} @btrix-confirm=${async () => { - this.isDeletingItem = false; + this.openDialog = undefined; if (this.itemToDelete) { await this.deleteItem(this.itemToDelete); } @@ -708,6 +708,8 @@ export class CrawlsList extends BtrixElement { >` : nothing} + + ${this.renderBulkActionsDialog()} `; private readonly renderSortControl = () => { @@ -832,7 +834,7 @@ export class CrawlsList extends BtrixElement { private readonly renderBulkActionsControl = () => { const visibleCount = this.visibleItems.size; - const selected = this.visibleItems.intersection(this.selectedItems); + const selected = this.selectedItemIds.intersection(this.visibleItems); const selectedCount = selected.size; const anySelected = selectedCount > 0; const allSelected = anySelected && selectedCount === visibleCount; @@ -847,13 +849,13 @@ export class CrawlsList extends BtrixElement { const checked = (e.target as SlCheckbox).checked; if (checked) { - this.selectedItems = new Set(this.visibleItems); + this.selectedItemIds = new Set(this.visibleItems.keys()); } else { - this.selectedItems = new Set(); + this.selectedItemIds = new Set(); } }} > - ${msg("Select all")} + ${msg("Select Visible")} ${pluralOfItemsSelected(selectedCount)} @@ -861,8 +863,9 @@ export class CrawlsList extends BtrixElement { ${anySelected ? html` (this.openDialog = "bulkDelete")} >` : nothing} `; }; @@ -913,15 +916,15 @@ export class CrawlsList extends BtrixElement { href=${`${this.navigate.orgBasePath}/${pathForArchivedItem(item)}`} .item=${item} ?checkbox=${this.isCrawler && this.bulkActions} - ?checked=${this.selectedItems.has(item.id)} + ?checked=${this.selectedItemIds.has(item.id)} @btrix-change=${(e: ArchivedItemCheckedEvent) => { if (e.detail.value.checked) { - this.selectedItems.add(item.id); + this.selectedItemIds.add(item.id); } else { - this.selectedItems.delete(item.id); + this.selectedItemIds.delete(item.id); } - this.selectedItems = new Set(this.selectedItems); + this.selectedItemIds = new Set(this.selectedItemIds); }} > @@ -945,7 +948,7 @@ export class CrawlsList extends BtrixElement { @click=${async () => { this.itemToEdit = item; await this.updateComplete; - this.isEditingItem = true; + this.openDialog = "edit"; }} > @@ -1024,11 +1027,31 @@ export class CrawlsList extends BtrixElement { `; }; - private readonly renderStatusMenuItem = (state: CrawlState) => { - const { icon, label } = CrawlStatus.getContent({ state }); + private renderBulkActionsDialog() { + const dialog = () => { + const selected = true; + const notSelected = false; + const group = Map.groupBy([...this.visibleItems.values()], (item) => + this.selectedItemIds.has(item.id) ? selected : notSelected, + ); + const items = group.get(selected); + + return html` (this.openDialog = undefined)} + @btrix-confirm=${async () => { + this.openDialog = undefined; + console.log("TODO"); + }} + >`; + }; - return html`${icon}${label}`; - }; + return guard( + [this.selectedItemIds, this.openDialog === "bulkDelete"], + dialog, + ); + } private renderEmptyState() { if (this.hasFiltersSet) { @@ -1199,7 +1222,7 @@ export class CrawlsList extends BtrixElement { private readonly confirmDeleteItem = (item: ArchivedItem) => { this.itemToDelete = item; - this.isDeletingItem = true; + this.openDialog = "delete"; }; private async deleteItem(item: ArchivedItem) { diff --git a/frontend/src/stories/pages/archived-items/ArchivedItems.stories.ts b/frontend/src/stories/pages/archived-items/ArchivedItems.stories.ts index f707fe3adc..f3e241e158 100644 --- a/frontend/src/stories/pages/archived-items/ArchivedItems.stories.ts +++ b/frontend/src/stories/pages/archived-items/ArchivedItems.stories.ts @@ -145,7 +145,7 @@ export const SelectedItems: Story = { args: { isCrawler: true, bulkActions: true, - selectedItems: new Set( + selectedItemIds: new Set( (archivedItemsMock.items as ListArchivedItem[]) .slice(0, 2) .map(({ id }) => id), diff --git a/frontend/src/stories/pages/archived-items/ArchivedItems.ts b/frontend/src/stories/pages/archived-items/ArchivedItems.ts index 5885549c37..799a1ed338 100644 --- a/frontend/src/stories/pages/archived-items/ArchivedItems.ts +++ b/frontend/src/stories/pages/archived-items/ArchivedItems.ts @@ -13,6 +13,6 @@ export const renderComponent = (props: Partial) => { ?isCrawler=${props.isCrawler} ?bulkActions=${props.bulkActions} itemType=${ifDefined(props.itemType || undefined)} - .selectedItems=${props.selectedItems || new Set()} + .selectedItemIds=${props.selectedItemIds || new Set()} >`; }; From a13a39c8eb97d9b450ae57ac2a95a6014620f44f Mon Sep 17 00:00:00 2001 From: sua yoo Date: Thu, 3 Sep 2026 12:05:19 -0700 Subject: [PATCH 06/20] allow adding feature flags to stories --- frontend/src/stories/decorators/orgDecorator.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/frontend/src/stories/decorators/orgDecorator.ts b/frontend/src/stories/decorators/orgDecorator.ts index de53d7231c..d73e9e92f7 100644 --- a/frontend/src/stories/decorators/orgDecorator.ts +++ b/frontend/src/stories/decorators/orgDecorator.ts @@ -47,6 +47,7 @@ export type StorybookOrgProps = { orgQuotas?: boolean | OrgData["quotas"]; orgSubscription?: boolean | OrgData["subscription"]; orgOnboarding?: Onboarding; + orgFeatureFlags?: OrgData["featureFlags"]; }; @customElement("btrix-storybook-org") @@ -65,6 +66,9 @@ export class StorybookOrg extends TailwindElement { @property({ type: Object }) users?: OrgData["users"]; + @property({ type: Object }) + featureFlags?: OrgData["featureFlags"]; + @property({ type: Object }) usage?: OrgData["usage"]; @@ -112,6 +116,7 @@ export class StorybookOrg extends TailwindElement { users: this.users || {}, usage: this.usage || {}, subscription: this.subscription || null, + featureFlags: this.featureFlags || {}, quotas: this.quotas || (mapValues(() => 0, quotas) as typeof quotas), note: "", }); @@ -132,8 +137,14 @@ export class StorybookOrg extends TailwindElement { export function orgDecorator(story: StoryFn, context: StoryContext) { const { args } = context; - const { orgUsers, orgUsage, orgQuotas, orgSubscription, orgOnboarding } = - args as StorybookOrgProps; + const { + orgUsers, + orgUsage, + orgQuotas, + orgSubscription, + orgOnboarding, + orgFeatureFlags, + } = args as StorybookOrgProps; return html` ${story(args, context)} `; From 312285c46a76a463cfa522b12865f6c79ac58214 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Thu, 3 Sep 2026 12:48:12 -0700 Subject: [PATCH 07/20] add dedupe story --- .../archived-items/ArchivedItems.stories.ts | 38 +++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/frontend/src/stories/pages/archived-items/ArchivedItems.stories.ts b/frontend/src/stories/pages/archived-items/ArchivedItems.stories.ts index f3e241e158..1a6835f351 100644 --- a/frontend/src/stories/pages/archived-items/ArchivedItems.stories.ts +++ b/frontend/src/stories/pages/archived-items/ArchivedItems.stories.ts @@ -6,7 +6,10 @@ import { renderComponent, type RenderProps } from "./ArchivedItems"; import archivedItemsMock from "@/__mocks__/api/orgs/[id]/all-crawls"; import { type TagCounts } from "@/components/ui/tag-filter/types"; -import { orgDecorator } from "@/stories/decorators/orgDecorator"; +import { + orgDecorator, + type StorybookOrgProps, +} from "@/stories/decorators/orgDecorator"; import { userDecorator, type StorybookUserProps, @@ -29,10 +32,10 @@ const meta = { user: true, auth: true, }, -} satisfies Meta; +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; const searchValuesRequest = () => http.get(/\/all-crawls\/search-values/, async () => { @@ -166,3 +169,32 @@ export const SelectedItems: Story = { }, }, }; + +export const SelectedItemsWithDependents: Story = { + args: { + orgFeatureFlags: { + dedupeEnabled: true, + }, + isCrawler: true, + bulkActions: true, + selectedItemIds: new Set( + (archivedItemsMock.items as ListArchivedItem[]) + .slice(2) + .map(({ id }) => id), + ), + }, + parameters: { + msw: { + handlers: [ + searchValuesRequest(), + tagCountsRequest(), + http.get(/\/all-crawls/, async () => { + await delay(500); + return HttpResponse.json>( + archivedItemsMock as APIPaginatedList, + ); + }), + ], + }, + }, +}; From 80d5ddf681465805e6e5a08acd0fe68627751e0a Mon Sep 17 00:00:00 2001 From: sua yoo Date: Tue, 15 Sep 2026 14:52:22 -0700 Subject: [PATCH 08/20] fix types --- frontend/src/pages/org/archived-items.ts | 2 +- frontend/src/utils/crawler.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/org/archived-items.ts b/frontend/src/pages/org/archived-items.ts index 0886abf216..8d53d2039d 100644 --- a/frontend/src/pages/org/archived-items.ts +++ b/frontend/src/pages/org/archived-items.ts @@ -1136,7 +1136,7 @@ export class CrawlsList extends BtrixElement { "Archived items are the result of a web archiving process, like crawl workflows.", ), actions: html` (this.isUploadingArchive = true)} + @click=${() => (this.openDialog = "upload")} ?disabled=${isArchivingDisabled(this.org)} > diff --git a/frontend/src/utils/crawler.ts b/frontend/src/utils/crawler.ts index 30aea4a1cd..b798595184 100644 --- a/frontend/src/utils/crawler.ts +++ b/frontend/src/utils/crawler.ts @@ -7,6 +7,7 @@ import { type ArchivedItem, type Crawl, type CrawlReplay, + type ListArchivedItem, type ProxiesAPIResponse, type Upload, type Workflow, @@ -48,7 +49,7 @@ const DEPTH_SUPPORTED_SCOPES = [ ScopeType.Any, ]; -export function isCrawl(item: Crawl | Upload): item is Crawl { +export function isCrawl(item: ArchivedItem | ListArchivedItem): item is Crawl { return item.type === "crawl"; } From 79149ccac05530061294d6147681a83478e5cbae Mon Sep 17 00:00:00 2001 From: sua yoo Date: Tue, 15 Sep 2026 15:18:10 -0700 Subject: [PATCH 09/20] update task --- .../bulk-delete-items-dialog.ts | 11 ++- frontend/src/pages/org/archived-items.ts | 70 ++++++++++++++++++- frontend/src/pages/org/index.ts | 1 + 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/frontend/src/features/archived-items/bulk-delete-items-dialog.ts b/frontend/src/features/archived-items/bulk-delete-items-dialog.ts index 5e19659a1c..9875866d49 100644 --- a/frontend/src/features/archived-items/bulk-delete-items-dialog.ts +++ b/frontend/src/features/archived-items/bulk-delete-items-dialog.ts @@ -29,6 +29,9 @@ export class BulkDeleteItemsDialog extends BtrixElement { @property({ type: Boolean }) open = false; + @property({ type: Boolean }) + inProgress = false; + @query("btrix-dialog") readonly dialog?: Dialog | null; @@ -96,6 +99,11 @@ export class BulkDeleteItemsDialog extends BtrixElement { class="[--width:36rem]" .label=${msg("Delete Archived Items?")} .open=${this.open} + @sl-request-close=${(e: CustomEvent) => { + if (this.inProgress) { + e.preventDefault(); + } + }} > ${when(this.items, this.renderContent)} `; @@ -157,7 +165,8 @@ export class BulkDeleteItemsDialog extends BtrixElement { { this.dispatchEvent(new CustomEvent("btrix-confirm")); }} diff --git a/frontend/src/pages/org/archived-items.ts b/frontend/src/pages/org/archived-items.ts index 8d53d2039d..ede8d31d3c 100644 --- a/frontend/src/pages/org/archived-items.ts +++ b/frontend/src/pages/org/archived-items.ts @@ -1,5 +1,6 @@ import { ContextConsumer } from "@lit/context"; import { localized, msg, str } from "@lit/localize"; +import { Task, TaskStatus } from "@lit/task"; import { deepArrayEquals } from "@lit/task/deep-equals.js"; import type { SlChangeEvent, @@ -442,6 +443,59 @@ export class CrawlsList extends BtrixElement { timeoutSeconds: POLL_INTERVAL_SECONDS, }); + private readonly deleteItemsTask = new Task(this, { + autoRun: false, + task: async ([ids], { signal }) => { + if (!ids) return; + + try { + const _data = await this.api.fetch( + `/orgs/${this.orgId}/all-crawls/delete`, + { + method: "POST", + body: JSON.stringify({ + crawl_ids: ids, + }), + signal, + }, + ); + + ids.forEach((id) => this.visibleItems.delete(id)); + this.selectedItemIds = this.selectedItemIds.difference(new Set(ids)); + + void this.archivedItemsTask.run(); + + this.notify.toast({ + message: msg(str`Successfully deleted archived item.`), + variant: "success", + icon: "check2-circle", + id: "archived-item-deleted", + }); + + this.tagFilter?.refreshOrgTags(); + } catch (err) { + let message = msg( + str`Sorry, couldn't delete archived item at this time.`, + ); + if (isApiError(err)) { + if (err.details == "not_allowed") { + message = msg( + str`Only org owners can delete other users' archived items.`, + ); + } else if (err.message) { + message = err.message; + } + } + this.notify.toast({ + message: message, + variant: "danger", + icon: "exclamation-octagon", + }); + } + }, + args: () => [undefined] as readonly [undefined | string[]], + }); + // For fuzzy search: private readonly searchKeys = ["id", "name", "firstSeed"]; @@ -834,7 +888,7 @@ export class CrawlsList extends BtrixElement { private readonly renderBulkActionsControl = () => { const visibleCount = this.visibleItems.size; - const selected = this.selectedItemIds.intersection(this.visibleItems); + const selected = this.selectedItemIds; const selectedCount = selected.size; const anySelected = selectedCount > 0; const allSelected = anySelected && selectedCount === visibleCount; @@ -1039,16 +1093,23 @@ export class CrawlsList extends BtrixElement { return html` (this.openDialog = undefined)} @btrix-confirm=${async () => { + await this.deleteItemsTask.run([ + Array.from(this.selectedItemIds.values()), + ]); this.openDialog = undefined; - console.log("TODO"); }} >`; }; return guard( - [this.selectedItemIds, this.openDialog === "bulkDelete"], + [ + this.selectedItemIds, + this.openDialog === "bulkDelete", + this.deleteItemsTask.status === TaskStatus.PENDING, + ], dialog, ); } @@ -1250,6 +1311,9 @@ export class CrawlsList extends BtrixElement { }), }, ); + + this.visibleItems.delete(item.id); + // TODO eager list update before server response void this.archivedItemsTask.run(); // const { items, ...crawlsData } = this.archivedItems!; diff --git a/frontend/src/pages/org/index.ts b/frontend/src/pages/org/index.ts index 31246588ed..4690aea5e3 100644 --- a/frontend/src/pages/org/index.ts +++ b/frontend/src/pages/org/index.ts @@ -603,6 +603,7 @@ export class Org extends BtrixElement { return html``; From 352d971f9b9bda70a82a4787d36187665aac8ef5 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Tue, 15 Sep 2026 15:23:06 -0700 Subject: [PATCH 10/20] pause poll --- frontend/src/pages/org/archived-items.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/frontend/src/pages/org/archived-items.ts b/frontend/src/pages/org/archived-items.ts index ede8d31d3c..b360cf0479 100644 --- a/frontend/src/pages/org/archived-items.ts +++ b/frontend/src/pages/org/archived-items.ts @@ -559,6 +559,16 @@ export class CrawlsList extends BtrixElement { } } + protected updated(changedProperties: PropertyValues): void { + if (changedProperties.has("selectedItemIds")) { + if (this.selectedItemIds.size) { + this.archivedItemsTask.pause(); + } else { + this.archivedItemsTask.resume(); + } + } + } + render() { const listTypes: { itemType: ArchivedItem["type"] | null; From cee451d915eb880594681ea57bbd5366f532e0c1 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Tue, 15 Sep 2026 15:33:16 -0700 Subject: [PATCH 11/20] make more visible in small screens --- frontend/src/layouts/listControls.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/layouts/listControls.ts b/frontend/src/layouts/listControls.ts index ca2269051b..ece64e356b 100644 --- a/frontend/src/layouts/listControls.ts +++ b/frontend/src/layouts/listControls.ts @@ -97,7 +97,7 @@ export function listControls({ ${renderBulkActionsControl ? html`