From 4fe575474583bbd2bba21349e310d560cdd771ec Mon Sep 17 00:00:00 2001 From: sua yoo Date: Tue, 28 Apr 2026 10:11:39 -0700 Subject: [PATCH 01/16] fix notifications with same message id --- frontend/src/theme.stylesheet.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/theme.stylesheet.css b/frontend/src/theme.stylesheet.css index 47bdb9547b..257ae9570f 100644 --- a/frontend/src/theme.stylesheet.css +++ b/frontend/src/theme.stylesheet.css @@ -96,6 +96,11 @@ --sl-focus-ring-color: var(--sl-color-primary-200); --sl-focus-ring-width: 2px; + /* Z index */ + + /* Place dialog above toast */ + --sl-z-index-dialog: 1000; + /* * Forms */ From 4fd33889dca0ad9a5a4ce0ea32b519d5ae022cc4 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Mon, 27 Apr 2026 15:56:19 -0700 Subject: [PATCH 02/16] add context --- .../OrgUploadsContextController.ts | 38 +++++++++++++++++++ frontend/src/context/org-uploads/index.ts | 5 +++ .../src/context/org-uploads/org-uploads.ts | 10 +++++ frontend/src/context/org-uploads/types.ts | 15 ++++++++ frontend/src/features/org/index.ts | 1 + .../src/features/org/org-uploads-overlay.ts | 21 ++++++++++ frontend/src/pages/org/index.ts | 6 +++ 7 files changed, 96 insertions(+) create mode 100644 frontend/src/context/org-uploads/OrgUploadsContextController.ts create mode 100644 frontend/src/context/org-uploads/index.ts create mode 100644 frontend/src/context/org-uploads/org-uploads.ts create mode 100644 frontend/src/context/org-uploads/types.ts create mode 100644 frontend/src/features/org/org-uploads-overlay.ts diff --git a/frontend/src/context/org-uploads/OrgUploadsContextController.ts b/frontend/src/context/org-uploads/OrgUploadsContextController.ts new file mode 100644 index 0000000000..7994fc9d1b --- /dev/null +++ b/frontend/src/context/org-uploads/OrgUploadsContextController.ts @@ -0,0 +1,38 @@ +import { ContextProvider } from "@lit/context"; +import { type ReactiveController } from "lit"; + +import { + orgUploadsContext, + orgUploadsInitialValue, + type OrgUploadsContext, +} from "./org-uploads"; + +import type { BtrixElement } from "@/classes/BtrixElement"; + +/** + * Provides data on org uploads to subscribed descendents of a component. + * + * @example Usage: + * ```ts + * class Component extends BtrixElement { + * readonly [orgUploadsContextKey] = new OrgUploadsContextController(this); + * } + * ``` + */ +export class OrgUploadsContextController implements ReactiveController { + readonly #host: BtrixElement; + readonly #context: ContextProvider<{ __context__: OrgUploadsContext }>; + + constructor(host: BtrixElement) { + this.#host = host; + this.#context = new ContextProvider(this.#host, { + context: orgUploadsContext, + initialValue: orgUploadsInitialValue, + }); + + host.addController(this); + } + + hostConnected(): void {} + hostDisconnected(): void {} +} diff --git a/frontend/src/context/org-uploads/index.ts b/frontend/src/context/org-uploads/index.ts new file mode 100644 index 0000000000..0046026b03 --- /dev/null +++ b/frontend/src/context/org-uploads/index.ts @@ -0,0 +1,5 @@ +import { orgUploadsContext, type OrgUploadsContext } from "./org-uploads"; + +export type { OrgUploadsContext }; + +export default orgUploadsContext; diff --git a/frontend/src/context/org-uploads/org-uploads.ts b/frontend/src/context/org-uploads/org-uploads.ts new file mode 100644 index 0000000000..d6f1f04e54 --- /dev/null +++ b/frontend/src/context/org-uploads/org-uploads.ts @@ -0,0 +1,10 @@ +import { createContext } from "@lit/context"; + +import { orgUploadsContextKey, type OrgUpload } from "./types"; + +export type OrgUploadsContext = OrgUpload[]; + +export const orgUploadsInitialValue = [] satisfies OrgUploadsContext; + +export const orgUploadsContext = + createContext(orgUploadsContextKey); diff --git a/frontend/src/context/org-uploads/types.ts b/frontend/src/context/org-uploads/types.ts new file mode 100644 index 0000000000..03a4bd704b --- /dev/null +++ b/frontend/src/context/org-uploads/types.ts @@ -0,0 +1,15 @@ +export const orgUploadsContextKey = Symbol("org-uploads"); + +export type OrgUpload = { + // TODO +}; + +export type OrgUploadsEventDetail = { + // TODO +}; + +declare global { + interface GlobalEventHandlersEventMap { + "btrix-org-upload-start": CustomEvent; + } +} diff --git a/frontend/src/features/org/index.ts b/frontend/src/features/org/index.ts index e697ac84fe..ef46299d1a 100644 --- a/frontend/src/features/org/index.ts +++ b/frontend/src/features/org/index.ts @@ -1,2 +1,3 @@ import("./org-status-banner"); +import("./org-uploads-overlay"); import("./usage-history-table"); diff --git a/frontend/src/features/org/org-uploads-overlay.ts b/frontend/src/features/org/org-uploads-overlay.ts new file mode 100644 index 0000000000..77dacd3417 --- /dev/null +++ b/frontend/src/features/org/org-uploads-overlay.ts @@ -0,0 +1,21 @@ +import { consume } from "@lit/context"; +import { localized } from "@lit/localize"; +import { html } from "lit"; +import { customElement } from "lit/decorators.js"; + +import { BtrixElement } from "@/classes/BtrixElement"; +import orgUploadsContext, { + type OrgUploadsContext, +} from "@/context/org-uploads"; + +@customElement("btrix-org-uploads-overlay") +@localized() +export class OrgUploadsOverlay extends BtrixElement { + @consume({ context: orgUploadsContext, subscribe: true }) + private readonly orgUploads?: OrgUploadsContext; + + render() { + console.log("this.orgUploads", this.orgUploads); + return html``; + } +} diff --git a/frontend/src/pages/org/index.ts b/frontend/src/pages/org/index.ts index 71f1f3b881..2db2bf12ee 100644 --- a/frontend/src/pages/org/index.ts +++ b/frontend/src/pages/org/index.ts @@ -25,6 +25,9 @@ import { orgProxiesContext, type OrgProxiesContext, } from "@/context/org-proxies"; +import orgUploadsContext, { + type OrgUploadsContext, +} from "@/context/org-uploads"; import { SearchOrgContextController } from "@/context/search-org/SearchOrgContextController"; import { searchOrgContextKey } from "@/context/search-org/types"; import type { QuotaUpdateDetail } from "@/controllers/api"; @@ -114,6 +117,9 @@ export class Org extends BtrixElement { @provide({ context: orgCrawlerChannelsContext }) crawlerChannels: OrgCrawlerChannelsContext = null; + @provide({ context: orgUploadsContext }) + orgUploads: OrgUploadsContext = []; + @property({ type: Object }) viewStateData?: ViewState["data"]; From 87b90ecad60da26d023b6796d639c0d90d64381f Mon Sep 17 00:00:00 2001 From: sua yoo Date: Mon, 27 Apr 2026 16:22:21 -0700 Subject: [PATCH 03/16] use upload api controller --- frontend/src/controllers/api.ts | 11 ++- .../features/archived-items/file-uploader.ts | 78 ++----------------- 2 files changed, 13 insertions(+), 76 deletions(-) diff --git a/frontend/src/controllers/api.ts b/frontend/src/controllers/api.ts index ab50bbc6a8..7cab1f6ef5 100644 --- a/frontend/src/controllers/api.ts +++ b/frontend/src/controllers/api.ts @@ -37,10 +37,14 @@ export enum AbortReason { export class APIController implements ReactiveController { host: ReactiveControllerHost & EventTarget; - uploadProgress = 0; + #uploadProgress = 0; private uploadRequest: XMLHttpRequest | null = null; + public get uploadProgress() { + return this.#uploadProgress; + } + constructor(host: APIController["host"]) { this.host = host; host.addController(this); @@ -248,17 +252,18 @@ export class APIController implements ReactiveController { } readonly onUploadProgress = throttle(100)((e: ProgressEvent) => { - this.uploadProgress = (e.loaded / e.total) * 100; + this.#uploadProgress = (e.loaded / e.total) * 100; this.host.requestUpdate(); }); - private cancelUpload() { + public cancelUpload() { if (this.uploadRequest) { this.uploadRequest.abort(); this.uploadRequest = null; } this.onUploadProgress.cancel(); + this.#uploadProgress = 0; } } diff --git a/frontend/src/features/archived-items/file-uploader.ts b/frontend/src/features/archived-items/file-uploader.ts index 1ea724839d..6f5e00a548 100644 --- a/frontend/src/features/archived-items/file-uploader.ts +++ b/frontend/src/features/archived-items/file-uploader.ts @@ -4,7 +4,6 @@ import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js"; import { html, type PropertyValues } from "lit"; import { customElement, property, queryAsync, state } from "lit/decorators.js"; import { when } from "lit/directives/when.js"; -import throttle from "lodash/fp/throttle"; import queryString from "query-string"; import { BtrixElement } from "@/classes/BtrixElement"; @@ -14,7 +13,6 @@ import type { Tags } from "@/components/ui/tag-input"; import type { BtrixTagsChangeEvent } from "@/features/archived-items/item-tags-input"; import { type CollectionsChangeEvent } from "@/features/collections/collections-add"; import { DESCRIPTION_MAX_LENGTH } from "@/types/archivedItems"; -import { APIError } from "@/utils/api"; import { maxLengthValidator } from "@/utils/form"; export type FileUploaderRequestCloseEvent = CustomEvent>; @@ -42,8 +40,6 @@ enum AbortReason { * > * ``` * - * @TODO Refactor to use this.api.upload - * * @event request-close * @event upload-start * @event uploaded @@ -72,9 +68,6 @@ export class FileUploader extends BtrixElement { @state() private fileList: File[] = []; - @state() - private progress = 0; - @queryAsync("#fileUploadForm") private readonly form!: Promise; @@ -82,9 +75,6 @@ export class FileUploader extends BtrixElement { DESCRIPTION_MAX_LENGTH, ); - // Use to cancel requests - private uploadRequest: XMLHttpRequest | null = null; - willUpdate(changedProperties: PropertyValues & Map) { if (changedProperties.has("open") && this.open) { if (changedProperties.get("open") === undefined) { @@ -245,7 +235,7 @@ export class FileUploader extends BtrixElement { (file) => html``, )} @@ -262,7 +252,7 @@ export class FileUploader extends BtrixElement { variant="primary" size="small" @click=${() => { - this.cancelUpload(); + this.api.cancelUpload(); this.requestClose(); }} > @@ -286,7 +276,7 @@ export class FileUploader extends BtrixElement { (file) => html``, )} @@ -302,7 +292,7 @@ export class FileUploader extends BtrixElement { } private readonly handleRemoveFile = (e: FileRemoveEvent) => { - this.cancelUpload(); + this.api.cancelUpload(); const idx = this.fileList.indexOf(e.detail.item as File); if (idx === -1) return; this.fileList = [ @@ -311,17 +301,11 @@ export class FileUploader extends BtrixElement { ]; }; - private cancelUpload() { - this.uploadRequest?.abort(); - this.onUploadProgress.cancel(); - } - private resetState() { this.fileList = []; this.tagsToSave = []; this.isUploading = false; this.isConfirmingCancel = false; - this.progress = 0; } private tryRequestClose(e?: CustomEvent) { @@ -368,13 +352,11 @@ export class FileUploader extends BtrixElement { tags: this.tagsToSave, }); - const data = await this.upload( + const data = await this.api.upload( `orgs/${this.orgId}/uploads/stream?${query}`, file, ); - this.uploadRequest = null; - // Dispatch event here because we're not using apiFetch() for uploads if (data.storageQuotaReached) { this.dispatchEvent( @@ -440,56 +422,6 @@ export class FileUploader extends BtrixElement { this.isUploading = false; } - // Use XHR to get upload progress - private async upload( - url: string, - file: File, - ): Promise<{ id: string; added: boolean; storageQuotaReached: boolean }> { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - - xhr.open("PUT", `/api/${url}`); - xhr.setRequestHeader("Content-Type", "application/octet-stream"); - Object.entries(this.authState!.headers).forEach(([k, v]) => { - xhr.setRequestHeader(k, v); - }); - xhr.addEventListener("load", () => { - if (xhr.status === 200) { - resolve( - JSON.parse(xhr.response as string) as { - id: string; - added: boolean; - storageQuotaReached: boolean; - }, - ); - } - if (xhr.status === 403) { - reject(AbortReason.QuotaReached); - } - }); - xhr.addEventListener("error", () => { - reject( - new APIError({ - message: xhr.statusText, - status: xhr.status, - }), - ); - }); - xhr.addEventListener("abort", () => { - reject(AbortReason.UserCancel); - }); - xhr.upload.addEventListener("progress", this.onUploadProgress); - - xhr.send(file); - - this.uploadRequest = xhr; - }); - } - - private readonly onUploadProgress = throttle(100)((e: ProgressEvent) => { - this.progress = (e.loaded / e.total) * 100; - }); - private async checkFormValidity(formEl: HTMLFormElement) { await this.updateComplete; return !formEl.querySelector("[data-invalid]"); From 955382d7616fdd024ea54723d5dbd92adb456292 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Mon, 27 Apr 2026 16:41:56 -0700 Subject: [PATCH 04/16] enable multiple uploads --- frontend/src/controllers/api.ts | 69 ++++++++++++++----- .../edit-dialog/helpers/submit-task.ts | 1 + 2 files changed, 51 insertions(+), 19 deletions(-) diff --git a/frontend/src/controllers/api.ts b/frontend/src/controllers/api.ts index 7cab1f6ef5..383ee2f214 100644 --- a/frontend/src/controllers/api.ts +++ b/frontend/src/controllers/api.ts @@ -36,24 +36,34 @@ export enum AbortReason { */ export class APIController implements ReactiveController { host: ReactiveControllerHost & EventTarget; + readonly #hostId: string; - #uploadProgress = 0; + readonly #uploadProgress = new Map(); - private uploadRequest: XMLHttpRequest | null = null; + readonly #uploadRequest = new Map(); public get uploadProgress() { - return this.#uploadProgress; + return this.#uploadProgress.get(this.#hostId); } constructor(host: APIController["host"]) { this.host = host; + this.#hostId = window.crypto.randomUUID(); host.addController(this); } hostConnected() {} hostDisconnected() { - this.cancelUpload(); + for (const request of this.#uploadRequest.values()) { + try { + request.abort(); + } catch (e) { + console.debug(e); + } + } + + this.#uploadRequest.clear(); } async fetch(path: string, options?: RequestInit): Promise { @@ -184,16 +194,18 @@ export class APIController implements ReactiveController { async upload( path: string, file: File, + uploadId?: string, abortSignal?: AbortSignal, ): Promise<{ id: string; added: boolean; storageQuotaReached: boolean }> { const auth = appState.auth; if (!auth) throw new Error("auth not in state"); - // TODO handle multiple uploads - if (this.uploadRequest) { + const id = uploadId || this.#hostId; + + if (this.#uploadRequest.get(id)) { console.debug("upload request exists"); - this.cancelUpload(); + this.cancelUpload(id); } return new Promise((resolve, reject) => { @@ -228,6 +240,8 @@ export class APIController implements ReactiveController { }), ); } + + this.#uploadRequest.delete(id); }); xhr.addEventListener("error", () => { reject(AbortReason.NetworkError); @@ -238,32 +252,49 @@ export class APIController implements ReactiveController { xhr.addEventListener("abort", () => { reject(AbortReason.UserCancel); }); - xhr.upload.addEventListener("progress", this.onUploadProgress); + + const onUploadProgress = throttle(100)((e: ProgressEvent) => { + this.#uploadProgress.set(id, (e.loaded / e.total) * 100); + + this.host.requestUpdate(); + }); + + xhr.upload.addEventListener("progress", onUploadProgress); xhr.send(file); abortSignal?.addEventListener("abort", () => { xhr.abort(); + onUploadProgress.cancel(); reject(AbortReason.UserCancel); }); - this.uploadRequest = xhr; + this.#uploadRequest.set(id, xhr); }); } - readonly onUploadProgress = throttle(100)((e: ProgressEvent) => { - this.#uploadProgress = (e.loaded / e.total) * 100; + cancelUpload(uploadId?: string) { + const cancel = (id: string) => { + const request = this.#uploadRequest.get(id); + + if (request) { + request.abort(); + } - this.host.requestUpdate(); - }); + this.#uploadProgress.delete(id); + this.#uploadRequest.delete(id); + }; - public cancelUpload() { - if (this.uploadRequest) { - this.uploadRequest.abort(); - this.uploadRequest = null; + if (uploadId) { + cancel(uploadId); + } else { + for (const id in this.#uploadRequest.keys()) { + cancel(id); + } } + } - this.onUploadProgress.cancel(); - this.#uploadProgress = 0; + uploadProgressFor(uploadId: string) { + return this.#uploadProgress.get(uploadId); } } diff --git a/frontend/src/features/collections/edit-dialog/helpers/submit-task.ts b/frontend/src/features/collections/edit-dialog/helpers/submit-task.ts index 5504aed382..9ea380e79e 100644 --- a/frontend/src/features/collections/edit-dialog/helpers/submit-task.ts +++ b/frontend/src/features/collections/edit-dialog/helpers/submit-task.ts @@ -54,6 +54,7 @@ export default function submitTask( this.api.upload( `/orgs/${this.orgId}/collections/${this.collection.id}/thumbnail?${searchParams.toString()}`, file, + undefined, signal, ), ); From 4ee85a6d2c01dbacd9ba4db8fba39b287ddfe5b0 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Thu, 30 Apr 2026 12:13:24 -0700 Subject: [PATCH 05/16] add component --- .../OrgUploadsContextController.ts | 133 +++++++- .../src/context/org-uploads/org-uploads.ts | 4 +- frontend/src/context/org-uploads/types.ts | 22 +- frontend/src/controllers/notify.ts | 4 +- .../src/features/org/org-uploads-overlay.ts | 315 +++++++++++++++++- .../features/org/OrgUploadsOverlay.stories.ts | 227 +++++++++++++ frontend/src/theme.stylesheet.css | 25 +- frontend/src/utils/LiteElement.ts | 5 +- frontend/src/utils/bytes.ts | 2 + frontend/src/utils/pluralize.ts | 52 +++ frontend/src/utils/workflow.ts | 3 +- 11 files changed, 766 insertions(+), 26 deletions(-) create mode 100644 frontend/src/stories/features/org/OrgUploadsOverlay.stories.ts create mode 100644 frontend/src/utils/bytes.ts diff --git a/frontend/src/context/org-uploads/OrgUploadsContextController.ts b/frontend/src/context/org-uploads/OrgUploadsContextController.ts index 7994fc9d1b..66ac293e09 100644 --- a/frontend/src/context/org-uploads/OrgUploadsContextController.ts +++ b/frontend/src/context/org-uploads/OrgUploadsContextController.ts @@ -1,4 +1,5 @@ import { ContextProvider } from "@lit/context"; +import { msg } from "@lit/localize"; import { type ReactiveController } from "lit"; import { @@ -6,8 +7,13 @@ import { orgUploadsInitialValue, type OrgUploadsContext, } from "./org-uploads"; +import type { + OrgUploadCancelRemoveEventDetail, + OrgUploadEventDetail, +} from "./types"; import type { BtrixElement } from "@/classes/BtrixElement"; +import { AbortReason } from "@/controllers/api"; /** * Provides data on org uploads to subscribed descendents of a component. @@ -22,6 +28,7 @@ import type { BtrixElement } from "@/classes/BtrixElement"; export class OrgUploadsContextController implements ReactiveController { readonly #host: BtrixElement; readonly #context: ContextProvider<{ __context__: OrgUploadsContext }>; + readonly #uploadRequests = new Map(); constructor(host: BtrixElement) { this.#host = host; @@ -33,6 +40,128 @@ export class OrgUploadsContextController implements ReactiveController { host.addController(this); } - hostConnected(): void {} - hostDisconnected(): void {} + hostConnected(): void { + this.#host.addEventListener("btrix-org-upload", this.onUpload); + this.#host.addEventListener("btrix-org-upload-cancel", this.onCancel); + this.#host.addEventListener("btrix-org-upload-remove", this.onRemove); + } + hostDisconnected(): void { + this.#host.removeEventListener("btrix-org-upload", this.onUpload); + this.#host.removeEventListener("btrix-org-upload-cancel", this.onCancel); + this.#host.removeEventListener("btrix-org-upload-remove", this.onRemove); + } + + private readonly onUpload = async (e: CustomEvent) => { + e.stopPropagation(); + + const { apiPath, file, itemName, uploadId: eventUploadId } = e.detail; + + if (eventUploadId && this.#uploadRequests.has(eventUploadId)) { + this.abort(eventUploadId); + } + + const onUploadProgress = (e: ProgressEvent) => { + this.#context.setValue({ + ...this.#context.value, + [uploadId]: { + itemName, + filename: file.name, + loaded: e.loaded, + total: e.total, + }, + }); + }; + + const uploadId = eventUploadId ?? window.crypto.randomUUID(); + const uploadComplete = this.#host.api.upload( + apiPath, + file, + undefined, + onUploadProgress, + ); + const request = uploadComplete.request; + + if (request) { + this.#uploadRequests.set(uploadId, request); + } + + try { + const { id } = await uploadComplete; + + this.#context.setValue({ + ...this.#context.value, + [uploadId]: { + ...this.#context.value[uploadId], + itemId: id, + }, + }); + } catch (err) { + console.debug(err); + + if (err === AbortReason.UserCancel) { + console.debug("Upload aborted to user cancel"); + + this.#context.setValue({ + ...this.#context.value, + [uploadId]: { + ...this.#context.value[uploadId], + canceled: true, + }, + }); + } else { + let message = msg("Sorry, couldn't upload file at this time."); + console.debug(err); + if (err === AbortReason.QuotaReached) { + message = msg( + "Your org does not have enough storage to upload this file.", + ); + this.#host.dispatchEvent( + new CustomEvent("btrix-storage-quota-update", { + detail: { reached: true }, + bubbles: true, + }), + ); + } + this.#host.notify.toast({ + message: message, + variant: "danger", + icon: "exclamation-octagon", + id: "file-upload-status", + }); + } + } + + this.#uploadRequests.delete(uploadId); + }; + + private readonly onCancel = ( + e: CustomEvent, + ) => { + e.stopPropagation(); + + e.detail.uploadIds.forEach(this.abort); + }; + + private readonly onRemove = ( + e: CustomEvent, + ) => { + e.stopPropagation(); + + e.detail.uploadIds.forEach(this.remove); + }; + + private readonly abort = (uploadId: string) => { + const request = this.#uploadRequests.get(uploadId); + + if (request) { + request.abort(); + } else { + console.debug("no request for uploadId", uploadId); + } + }; + + private readonly remove = (uploadId: string) => { + const { [uploadId]: _canceled, ...context } = this.#context.value; + this.#context.setValue(context); + }; } diff --git a/frontend/src/context/org-uploads/org-uploads.ts b/frontend/src/context/org-uploads/org-uploads.ts index d6f1f04e54..df6d46a9bc 100644 --- a/frontend/src/context/org-uploads/org-uploads.ts +++ b/frontend/src/context/org-uploads/org-uploads.ts @@ -2,9 +2,9 @@ import { createContext } from "@lit/context"; import { orgUploadsContextKey, type OrgUpload } from "./types"; -export type OrgUploadsContext = OrgUpload[]; +export type OrgUploadsContext = Record; -export const orgUploadsInitialValue = [] satisfies OrgUploadsContext; +export const orgUploadsInitialValue = {} satisfies OrgUploadsContext; export const orgUploadsContext = createContext(orgUploadsContextKey); diff --git a/frontend/src/context/org-uploads/types.ts b/frontend/src/context/org-uploads/types.ts index 03a4bd704b..accb6d437f 100644 --- a/frontend/src/context/org-uploads/types.ts +++ b/frontend/src/context/org-uploads/types.ts @@ -1,15 +1,29 @@ export const orgUploadsContextKey = Symbol("org-uploads"); export type OrgUpload = { - // TODO + itemId?: string; + canceled?: boolean; + itemName: string; + filename: string; + loaded: number; + total: number; }; -export type OrgUploadsEventDetail = { - // TODO +export type OrgUploadEventDetail = { + uploadId?: string; + itemName: string; + apiPath: string; + file: File; +}; + +export type OrgUploadCancelRemoveEventDetail = { + uploadIds: string[]; }; declare global { interface GlobalEventHandlersEventMap { - "btrix-org-upload-start": CustomEvent; + "btrix-org-upload": CustomEvent; + "btrix-org-upload-cancel": CustomEvent; + "btrix-org-upload-remove": CustomEvent; } } diff --git a/frontend/src/controllers/notify.ts b/frontend/src/controllers/notify.ts index 3e370eab52..5e583bb413 100644 --- a/frontend/src/controllers/notify.ts +++ b/frontend/src/controllers/notify.ts @@ -40,7 +40,7 @@ export interface NotifyEventMap { const NOTIFY_EVENT_NAME: keyof NotifyEventMap = "btrix-notify"; -const iconMap = { +export const notifyIconFor = { info: "info-circle", primary: "info-circle", success: "check2-circle", @@ -73,7 +73,7 @@ export class NotifyController implements ReactiveController { ...detail, variant, type: "toast", - icon: detail.icon ?? iconMap[variant], + icon: detail.icon ?? notifyIconFor[variant], duration: detail.duration ?? (variant === "danger" ? 10000 : 5000), }, }), diff --git a/frontend/src/features/org/org-uploads-overlay.ts b/frontend/src/features/org/org-uploads-overlay.ts index 77dacd3417..5b785ba50c 100644 --- a/frontend/src/features/org/org-uploads-overlay.ts +++ b/frontend/src/features/org/org-uploads-overlay.ts @@ -1,21 +1,322 @@ -import { consume } from "@lit/context"; -import { localized } from "@lit/localize"; -import { html } from "lit"; -import { customElement } from "lit/decorators.js"; +import { consume, ContextConsumer } from "@lit/context"; +import { localized, msg, str } from "@lit/localize"; +import type { SlAlert } from "@shoelace-style/shoelace"; +import clsx from "clsx"; +import { html, nothing, type PropertyValues } from "lit"; +import { customElement, query, state } from "lit/decorators.js"; +import { repeat } from "lit/directives/repeat.js"; +import sum from "lodash/fp/sum"; import { BtrixElement } from "@/classes/BtrixElement"; +import notificationsContext from "@/context/notifications"; import orgUploadsContext, { type OrgUploadsContext, } from "@/context/org-uploads"; +import type { + OrgUpload, + OrgUploadCancelRemoveEventDetail, +} from "@/context/org-uploads/types"; +import { notifyIconFor } from "@/controllers/notify"; +import { OrgTab } from "@/routes"; +import { pluralOf } from "@/utils/pluralize"; +import { tw } from "@/utils/tailwind"; @customElement("btrix-org-uploads-overlay") @localized() export class OrgUploadsOverlay extends BtrixElement { + readonly #notifications = new ContextConsumer(this, { + context: notificationsContext, + subscribe: true, + callback: () => this.updateToastStackOffset(), + }); + @consume({ context: orgUploadsContext, subscribe: true }) - private readonly orgUploads?: OrgUploadsContext; + @state() + private readonly orgUploads: OrgUploadsContext = {}; + + @state() + private minimized = false; + + @state() + private canceling?: string; + + @query("sl-alert") + private readonly alert?: SlAlert; + + protected willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has("orgUploads")) { + if ( + this.canceling && + this.canceling in this.orgUploads && + this.orgUploads[this.canceling].canceled + ) { + this.canceling = undefined; + } + } + } + + protected updated(changedProperties: PropertyValues): void { + if ( + changedProperties.has("orgUploads") || + changedProperties.has("minimized") + ) { + this.updateToastStackOffset(); + } + + if (changedProperties.has("orgUploads")) { + void this.updateAlertVisibility(); + } + } + + private async updateAlertVisibility() { + const uploadIds = Object.keys(this.orgUploads); + + if (uploadIds.length) { + await this.alert?.updateComplete; + await this.alert?.show(); + } else { + await this.alert?.hide(); + } + } + + /** + * Offset app notification stack so that org uploads are always pinned to the bottom. + */ + private readonly updateToastStackOffset = () => { + const uploadIds = Object.keys(this.orgUploads); + + if (uploadIds.length && this.#notifications.value?.length) { + document.body.style.setProperty( + "--btrix-toast-stack-offset", + this.minimized + ? "5.375rem" + : `calc(${(1 + uploadIds.length) * 2.625}rem + 2.75rem)`, + ); + } else { + document.body.style.removeProperty("--btrix-toast-stack-offset"); + } + }; render() { - console.log("this.orgUploads", this.orgUploads); - return html``; + const canceledUploads = []; + const uploadsInProgress = []; + + Object.entries(this.orgUploads).forEach(([id, upload]) => { + if (upload.canceled) { + canceledUploads.push(id); + } else if (!upload.itemId) { + uploadsInProgress.push(id); + } + }); + + const uploads = Object.entries(this.orgUploads); + const totalCount = uploads.length; + const inProgressCount = uploadsInProgress.length; + const canceledCount = canceledUploads.length; + const allDone = inProgressCount === 0; + const allCanceled = canceledCount === totalCount; + + const sumLoaded = sum(uploads.map(([_id, { loaded }]) => loaded)); + const sumTotal = sum(uploads.map(([_id, { total }]) => total)); + + const number_of_files_in_progress = this.localize.number(inProgressCount); + const plural_of_files_in_progress = pluralOf("files", inProgressCount); + const number_of_files = this.localize.number(totalCount); + const plural_of_files = pluralOf("files", totalCount); + const plural_of_uploads = pluralOf("uploads", canceledCount); + + return html` +
+ { + const uploadIds = Object.keys(this.orgUploads); + + this.dispatchEvent( + new CustomEvent( + "btrix-org-upload-remove", + { + detail: { uploadIds }, + bubbles: true, + composed: true, + }, + ), + ); + }} + > + + +
+
+ ${allCanceled + ? msg(str`Canceled file ${plural_of_uploads}`) + : allDone + ? msg(str`Uploaded ${number_of_files} ${plural_of_files}`) + : msg( + str`Uploading ${number_of_files_in_progress} ${plural_of_files_in_progress}`, + )} +
+ + ${this.minimized && !allCanceled + ? html`
+ ${sumLoaded < sumTotal + ? `${this.localize.bytes(sumLoaded)} / ` + : nothing} + ${this.localize.bytes(sumTotal)} +
` + : nothing} + (this.minimized = !this.minimized)} + > + ${allDone + ? html` void this.alert?.hide()} + >` + : nothing} +
+ +
+ ${repeat( + uploads, + ([id]) => id, + ([id, upload]) => this.renderUpload(id, upload), + )} +
+
+
+ + ${this.renderDialog()} + `; + } + + private readonly renderUpload = (uploadId: string, upload: OrgUpload) => { + const progress = (upload.loaded / upload.total) * 100; + const removeOrHide = () => { + if (Object.keys(this.orgUploads).length > 1) { + this.dispatchEvent( + new CustomEvent( + "btrix-org-upload-remove", + { + detail: { uploadIds: [uploadId] }, + bubbles: true, + composed: true, + }, + ), + ); + } else { + void this.alert?.hide(); + } + }; + + return html` +
+
+
+
+ ${upload.itemName} +
+
+ ${upload.canceled + ? msg("Canceled") + : html`${upload.loaded < upload.total + ? `${this.localize.bytes(upload.loaded)} / ` + : nothing} + ${this.localize.bytes(upload.total)}`} +
+
+ +
+ ${upload.itemId + ? html` { + removeOrHide(); + this.navigate.link(e); + }} + >` + : html` { + if (upload.canceled) { + removeOrHide(); + } else { + this.canceling = uploadId; + } + }} + >`} +
+ `; + }; + + private renderDialog() { + const upload = this.canceling ? this.orgUploads[this.canceling] : undefined; + const upload_name = upload?.itemName; + + return html` +

+ ${msg(str`Are you sure you want to cancel uploading “${upload_name}”?`)} +

+
+ (this.canceling = undefined)} + >${msg("Continue Upload")} + { + if (!this.canceling) return; + + this.dispatchEvent( + new CustomEvent( + "btrix-org-upload-cancel", + { + detail: { uploadIds: [this.canceling] }, + bubbles: true, + composed: true, + }, + ), + ); + }} + > + ${msg("Cancel Upload")} + +
+
`; } } diff --git a/frontend/src/stories/features/org/OrgUploadsOverlay.stories.ts b/frontend/src/stories/features/org/OrgUploadsOverlay.stories.ts new file mode 100644 index 0000000000..b198deefef --- /dev/null +++ b/frontend/src/stories/features/org/OrgUploadsOverlay.stories.ts @@ -0,0 +1,227 @@ +import type { + Meta, + StoryContext, + StoryFn, + StoryObj, +} from "@storybook/web-components"; +import { html } from "lit"; +import type { DecoratorFunction } from "storybook/internal/types"; + +import { argTypes } from "../excludeContainerProperties"; + +import type { OrgUploadsOverlay } from "@/features/org/org-uploads-overlay"; +import { + orgDecorator, + type StorybookOrgProps, +} from "@/stories/decorators/orgDecorator"; +import { BYTES_PER_GB, BYTES_PER_MB } from "@/utils/bytes"; + +import "@/features/org/org-uploads-overlay"; + +type RenderProps = OrgUploadsOverlay & StorybookOrgProps; + +function containerDecorator(story: StoryFn, context: StoryContext) { + const { args } = context; + return html`
${story(args, context)}
`; +} + +const meta = { + title: "Features/Org/Org Uploads Overlay", + component: "btrix-org-uploads-overlay", + tags: ["autodocs"], + decorators: [ + orgDecorator as DecoratorFunction, + containerDecorator as DecoratorFunction, + ], + render: () => html` `, + argTypes: { + ...argTypes, + }, + args: {}, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const WithUploadInProgress: Story = { + render: () => html` + + `, +}; + +export const Minimized: Story = { + render: () => html` + + `, +}; + +export const MultipleUploadsInProgress: Story = { + render: () => html` + + `, +}; + +export const SomeInProgress: Story = { + render: () => html` + + `, +}; + +export const AllDone: Story = { + render: () => html` + + `, +}; + +export const Cancel: Story = { + render: () => html` + + `, +}; + +export const WithCanceled: Story = { + render: () => html` + + `, +}; + +export const WithToastStack: Story = { + render: () => html` +
+ Example toast +
+ + `, +}; diff --git a/frontend/src/theme.stylesheet.css b/frontend/src/theme.stylesheet.css index 257ae9570f..381dfa0354 100644 --- a/frontend/src/theme.stylesheet.css +++ b/frontend/src/theme.stylesheet.css @@ -26,6 +26,9 @@ /* Custom screen widths */ --btrix-screen-desktop: 82.5rem; /* Should match tailwind.config.screens.desktop */ + /* Offset toast notification stack */ + --btrix-toast-stack-offset: 0; + /* * Shoelace Theme Tokens */ @@ -96,11 +99,6 @@ --sl-focus-ring-color: var(--sl-color-primary-200); --sl-focus-ring-width: 2px; - /* Z index */ - - /* Place dialog above toast */ - --sl-z-index-dialog: 1000; - /* * Forms */ @@ -595,22 +593,37 @@ .btrix-toast-stack { position: fixed; top: auto; - bottom: 0; + bottom: var(--btrix-toast-stack-offset, 0); inset-inline-end: 0; z-index: var(--sl-z-index-toast); width: 28rem; max-width: 100%; max-height: 100%; overflow: auto; + pointer-events: none; } .btrix-toast-stack sl-alert { margin: var(--sl-spacing-medium); + pointer-events: auto; } .btrix-toast-stack sl-alert::part(base) { box-shadow: var(--sl-shadow-large); } + + sl-alert::part(message) { + /* Decrease padding */ + padding: var(--sl-spacing-medium); + } + + sl-alert::part(icon) { + padding-inline-start: var(--sl-spacing-medium); + } + + sl-alert::part(close-button) { + padding-inline-end: var(--sl-spacing-medium); + } } /* Following styles won't work with layers */ diff --git a/frontend/src/utils/LiteElement.ts b/frontend/src/utils/LiteElement.ts index d1c65131bd..3f508eb1d0 100644 --- a/frontend/src/utils/LiteElement.ts +++ b/frontend/src/utils/LiteElement.ts @@ -81,6 +81,7 @@ export default class LiteElement extends LitElement { /** * @deprecated New components should use APIController directly */ - apiFetch = async (...args: Parameters) => - this.apiController.fetch(...args); + apiFetch = async ( + ...args: Parameters + ) => this.apiController.fetch(...args); } diff --git a/frontend/src/utils/bytes.ts b/frontend/src/utils/bytes.ts new file mode 100644 index 0000000000..4c99b8875b --- /dev/null +++ b/frontend/src/utils/bytes.ts @@ -0,0 +1,2 @@ +export const BYTES_PER_MB = 1e6; +export const BYTES_PER_GB = 1e9; diff --git a/frontend/src/utils/pluralize.ts b/frontend/src/utils/pluralize.ts index e5eddd9b57..052e922ed4 100644 --- a/frontend/src/utils/pluralize.ts +++ b/frontend/src/utils/pluralize.ts @@ -39,6 +39,32 @@ const plurals = { id: "crawls.plural.other", }), }, + uploads: { + zero: msg("uploads", { + desc: 'plural form of "upload" for zero uploads', + id: "uploads.plural.zero", + }), + one: msg("upload", { + desc: 'singular form for "upload"', + id: "uploads.plural.one", + }), + two: msg("uploads", { + desc: 'plural form of "upload" for two uploads', + id: "uploads.plural.two", + }), + few: msg("uploads", { + desc: 'plural form of "upload" for few uploads', + id: "uploads.plural.few", + }), + many: msg("uploads", { + desc: 'plural form of "upload" for many uploads', + id: "uploads.plural.many", + }), + other: msg("uploads", { + desc: 'plural form of "upload" for multiple/other uploads', + id: "uploads.plural.other", + }), + }, items: { zero: msg("items", { desc: 'plural form of "item" for zero items', @@ -455,6 +481,32 @@ const plurals = { id: "organizations.plural.other", }), }, + files: { + zero: msg("files", { + desc: 'plural form of "file" for zero files', + id: "files.plural.zero", + }), + one: msg("file", { + desc: 'singular form for "file"', + id: "files.plural.one", + }), + two: msg("files", { + desc: 'plural form of "file" for two files', + id: "files.plural.two", + }), + few: msg("files", { + desc: 'plural form of "file" for few files', + id: "files.plural.few", + }), + many: msg("files", { + desc: 'plural form of "file" for many files', + id: "files.plural.many", + }), + other: msg("files", { + desc: 'plural form of "file" for multiple/other files', + id: "files.plural.other", + }), + }, }; /** diff --git a/frontend/src/utils/workflow.ts b/frontend/src/utils/workflow.ts index 1fb275a9e7..343688c3ae 100644 --- a/frontend/src/utils/workflow.ts +++ b/frontend/src/utils/workflow.ts @@ -19,12 +19,13 @@ import { } from "@/types/crawler"; import type { OrgData } from "@/types/org"; import { NewWorkflowOnlyScopeType, WorkflowScopeType } from "@/types/workflow"; +import { BYTES_PER_GB } from "@/utils/bytes"; import { unescapeCustomPrefix } from "@/utils/crawl-workflows/unescapeCustomPrefix"; import { DEFAULT_MAX_SCALE, isPageScopeType } from "@/utils/crawler"; import { getNextDate, getScheduleInterval } from "@/utils/cron"; import localize, { getDefaultLang } from "@/utils/localize"; -export const BYTES_PER_GB = 1e9; +export { BYTES_PER_GB }; export const DEFAULT_SELECT_LINKS = ["a[href]->href" as const]; export const DEFAULT_AUTOCLICK_SELECTOR = "a"; export const SEED_LIST_FILE_EXT = "txt"; From 56877b623229fff4d3f10651a0b164574aac7df6 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Fri, 1 May 2026 11:23:14 -0700 Subject: [PATCH 06/16] update component --- frontend/src/components/notification-stack.ts | 3 +- .../OrgUploadsContextController.ts | 22 ++ frontend/src/controllers/api.ts | 288 +++++++------- frontend/src/controllers/notify.ts | 1 + .../features/archived-items/file-uploader.ts | 236 +++-------- .../edit-dialog/helpers/submit-task.ts | 1 - frontend/src/features/org/index.ts | 2 +- ...loads-overlay.ts => org-uploads-dialog.ts} | 181 +++++---- .../archived-item-detail.ts | 27 +- frontend/src/pages/org/archived-items.ts | 8 - .../src/pages/org/collection-detail/dedupe.ts | 2 +- frontend/src/pages/org/index.ts | 14 +- frontend/src/pages/public/org.ts | 5 +- .../features/org/OrgUploadsDialog.stories.ts | 369 ++++++++++++++++++ .../features/org/OrgUploadsOverlay.stories.ts | 227 ----------- 15 files changed, 729 insertions(+), 657 deletions(-) rename frontend/src/features/org/{org-uploads-overlay.ts => org-uploads-dialog.ts} (66%) create mode 100644 frontend/src/stories/features/org/OrgUploadsDialog.stories.ts delete mode 100644 frontend/src/stories/features/org/OrgUploadsOverlay.stories.ts diff --git a/frontend/src/components/notification-stack.ts b/frontend/src/components/notification-stack.ts index 731b0e6c99..832f3ef34d 100644 --- a/frontend/src/components/notification-stack.ts +++ b/frontend/src/components/notification-stack.ts @@ -57,7 +57,7 @@ export class NotificationStack extends BtrixElement {
${repeat(this.notifications, ({ id }) => id, this.renderNotification)} @@ -72,7 +72,6 @@ export class NotificationStack extends BtrixElement { return html`; readonly #uploadRequests = new Map(); + static uploadsByStatus(context: OrgUploadsContext) { + const uploads = Object.entries(context); + const all: (OrgUpload & { uploadId: string })[] = []; + const canceled: (OrgUpload & { uploadId: string })[] = []; + const inProgress: (OrgUpload & { uploadId: string })[] = []; + + uploads.forEach(([uploadId, upload]) => { + const item = { uploadId, ...upload }; + + all.push(item); + + if (upload.canceled) { + canceled.push(item); + } else if (!upload.itemId) { + inProgress.push(item); + } + }); + + return { all, canceled, inProgress }; + } + constructor(host: BtrixElement) { this.#host = host; this.#context = new ContextProvider(this.#host, { diff --git a/frontend/src/controllers/api.ts b/frontend/src/controllers/api.ts index 383ee2f214..3626e003d4 100644 --- a/frontend/src/controllers/api.ts +++ b/frontend/src/controllers/api.ts @@ -4,6 +4,7 @@ import throttle from "lodash/fp/throttle"; import { APIError } from "@/utils/api"; import AuthService from "@/utils/AuthService"; +import { BYTES_PER_GB, BYTES_PER_MB } from "@/utils/bytes"; import appState from "@/utils/state"; export type QuotaUpdateDetail = { reached: boolean }; @@ -20,6 +21,12 @@ export enum AbortReason { RequestTimeout = "request-timeout", } +type UploadResponseBody = { + id: string; + added: boolean; + storageQuotaReached: boolean; +}; + /** * Utilities for interacting with the Browsertrix backend API * @@ -36,37 +43,24 @@ export enum AbortReason { */ export class APIController implements ReactiveController { host: ReactiveControllerHost & EventTarget; - readonly #hostId: string; - - readonly #uploadProgress = new Map(); - readonly #uploadRequest = new Map(); - - public get uploadProgress() { - return this.#uploadProgress.get(this.#hostId); - } + private uploadRequest: XMLHttpRequest | null = null; constructor(host: APIController["host"]) { this.host = host; - this.#hostId = window.crypto.randomUUID(); host.addController(this); } hostConnected() {} hostDisconnected() { - for (const request of this.#uploadRequest.values()) { - try { - request.abort(); - } catch (e) { - console.debug(e); - } - } - - this.#uploadRequest.clear(); + this.cancelUpload(); } - async fetch(path: string, options?: RequestInit): Promise { + async fetch( + path: string, + options?: RequestInit, + ): Promise { const auth = appState.auth; if (!auth) throw new Error("auth not in state"); @@ -82,9 +76,122 @@ export class APIController implements ReactiveController { }); if (resp.ok) { - const body = await resp.json(); + const body = (await resp.json()) as NonNullable; + return this.handleOk>(body); + } + + let errorDetail; + try { + errorDetail = (await resp.json()).detail; + } catch { + /* empty */ + } + + const error = this.handleError(resp.status, errorDetail); + throw new APIError(error); + } + + upload( + path: string, + file: File, + abortSignal?: AbortSignal, + /** + * Custom XMLHttpRequest['upload'] loadstart and progress event callback, + * which need to be attached before `send`. + */ + uploadCallback?: (e: ProgressEvent) => void, + ): Promise & { request?: XMLHttpRequest } { + const auth = appState.auth; + + if (!auth) throw new Error("auth not in state"); + + let request: XMLHttpRequest | undefined; + + const promise: Promise & { request?: XMLHttpRequest } = + new Promise((resolve, reject) => { + if (abortSignal?.aborted) { + reject(AbortReason.UserCancel); + } + const xhr = new XMLHttpRequest(); + + xhr.open("PUT", `/api${path}`); + xhr.setRequestHeader("Content-Type", "application/octet-stream"); + Object.entries(auth.headers).forEach(([k, v]) => { + xhr.setRequestHeader(k, v); + }); + xhr.addEventListener("load", () => { + if (xhr.status === 200) { + resolve( + JSON.parse(xhr.response as string) as { + id: string; + added: boolean; + storageQuotaReached: boolean; + }, + ); + } + if (xhr.status >= 401) { + reject( + new APIError( + this.handleError( + xhr.status, + xhr.responseType === "json" && xhr.response, + ), + ), + ); + } + }); + xhr.addEventListener("error", () => { + reject(AbortReason.NetworkError); + }); + xhr.addEventListener("timeout", () => { + reject(AbortReason.RequestTimeout); + }); + xhr.addEventListener("abort", () => { + reject(AbortReason.UserCancel); + }); + + if (uploadCallback) { + const onUploadProgress = throttle( + file.size > BYTES_PER_GB + ? 800 + : file.size > BYTES_PER_MB + ? 400 + : 200, + )(uploadCallback); + + xhr.upload.addEventListener("loadstart", onUploadProgress); + xhr.upload.addEventListener("progress", onUploadProgress); + xhr.upload.addEventListener("load", (e: ProgressEvent) => { + onUploadProgress.cancel(); + uploadCallback(e); + }); + xhr.upload.addEventListener("abort", () => onUploadProgress.cancel()); + xhr.upload.addEventListener("error", () => onUploadProgress.cancel()); + xhr.upload.addEventListener("timeout", () => + onUploadProgress.cancel(), + ); + } + + xhr.send(file); + + abortSignal?.addEventListener("abort", () => { + xhr.abort(); + reject(AbortReason.UserCancel); + }); + + request = xhr; + }); + + promise.request = request; + this.uploadRequest = request ?? null; + + return promise; + } + + private readonly handleOk = (body: T) => { + if ("storageQuotaReached" in body) { const storageQuotaReached = body.storageQuotaReached; - const executionMinutesQuotaReached = body.execMinutesQuotaReached; + if (typeof storageQuotaReached === "boolean") { if (storageQuotaReached !== appState.org?.storageQuotaReached) { this.host.dispatchEvent( @@ -96,6 +203,11 @@ export class APIController implements ReactiveController { ); } } + } + + if ("execMinutesQuotaReached" in body) { + const executionMinutesQuotaReached = body.execMinutesQuotaReached; + if (typeof executionMinutesQuotaReached === "boolean") { if ( executionMinutesQuotaReached != appState.org?.execMinutesQuotaReached @@ -112,21 +224,19 @@ export class APIController implements ReactiveController { ); } } - - return body as T; } - let errorDetail; - let errorDetails = null; - try { - errorDetail = (await resp.json()).detail; - } catch { - /* empty */ - } + return body; + }; + private readonly handleError = ( + status: Response["status"], + errorDetail?: unknown, + ) => { + let errorDetails = null; let errorMessage: string = msg("Unknown API error"); - switch (resp.status) { + switch (status) { case 401: { this.host.dispatchEvent(AuthService.createNeedLoginEvent()); errorMessage = msg("Need login"); @@ -183,118 +293,18 @@ export class APIController implements ReactiveController { } } - throw new APIError({ + return { message: errorMessage, - status: resp.status, + status: status, details: errorDetails, - errorCode: errorDetail, - }); - } - - async upload( - path: string, - file: File, - uploadId?: string, - abortSignal?: AbortSignal, - ): Promise<{ id: string; added: boolean; storageQuotaReached: boolean }> { - const auth = appState.auth; - - if (!auth) throw new Error("auth not in state"); - - const id = uploadId || this.#hostId; - - if (this.#uploadRequest.get(id)) { - console.debug("upload request exists"); - this.cancelUpload(id); - } - - return new Promise((resolve, reject) => { - if (abortSignal?.aborted) { - reject(AbortReason.UserCancel); - } - const xhr = new XMLHttpRequest(); - - xhr.open("PUT", `/api${path}`); - xhr.setRequestHeader("Content-Type", "application/octet-stream"); - Object.entries(auth.headers).forEach(([k, v]) => { - xhr.setRequestHeader(k, v); - }); - xhr.addEventListener("load", () => { - if (xhr.status === 200) { - resolve( - JSON.parse(xhr.response as string) as { - id: string; - added: boolean; - storageQuotaReached: boolean; - }, - ); - } - if (xhr.status === 403) { - reject(AbortReason.QuotaReached); - } - if (xhr.status >= 404) { - reject( - new APIError({ - message: xhr.statusText, - status: xhr.status, - }), - ); - } - - this.#uploadRequest.delete(id); - }); - xhr.addEventListener("error", () => { - reject(AbortReason.NetworkError); - }); - xhr.addEventListener("timeout", () => { - reject(AbortReason.RequestTimeout); - }); - xhr.addEventListener("abort", () => { - reject(AbortReason.UserCancel); - }); - - const onUploadProgress = throttle(100)((e: ProgressEvent) => { - this.#uploadProgress.set(id, (e.loaded / e.total) * 100); - - this.host.requestUpdate(); - }); - - xhr.upload.addEventListener("progress", onUploadProgress); - - xhr.send(file); - - abortSignal?.addEventListener("abort", () => { - xhr.abort(); - onUploadProgress.cancel(); - reject(AbortReason.UserCancel); - }); - - this.#uploadRequest.set(id, xhr); - }); - } - - cancelUpload(uploadId?: string) { - const cancel = (id: string) => { - const request = this.#uploadRequest.get(id); - - if (request) { - request.abort(); - } - - this.#uploadProgress.delete(id); - this.#uploadRequest.delete(id); + errorCode: errorDetail as APIError["errorCode"], }; + }; - if (uploadId) { - cancel(uploadId); - } else { - for (const id in this.#uploadRequest.keys()) { - cancel(id); - } + private cancelUpload() { + if (this.uploadRequest) { + this.uploadRequest.abort(); + this.uploadRequest = null; } } - - uploadProgressFor(uploadId: string) { - return this.#uploadProgress.get(uploadId); - } } diff --git a/frontend/src/controllers/notify.ts b/frontend/src/controllers/notify.ts index 5e583bb413..f5788eb2bd 100644 --- a/frontend/src/controllers/notify.ts +++ b/frontend/src/controllers/notify.ts @@ -41,6 +41,7 @@ export interface NotifyEventMap { const NOTIFY_EVENT_NAME: keyof NotifyEventMap = "btrix-notify"; export const notifyIconFor = { + neutral: "info-circle", info: "info-circle", primary: "info-circle", success: "check2-circle", diff --git a/frontend/src/features/archived-items/file-uploader.ts b/frontend/src/features/archived-items/file-uploader.ts index 6f5e00a548..8d0ae96a5a 100644 --- a/frontend/src/features/archived-items/file-uploader.ts +++ b/frontend/src/features/archived-items/file-uploader.ts @@ -1,3 +1,4 @@ +import { ContextConsumer } from "@lit/context"; import { localized, msg } from "@lit/localize"; import type { SlButton } from "@shoelace-style/shoelace"; import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js"; @@ -10,6 +11,8 @@ import { BtrixElement } from "@/classes/BtrixElement"; import type { FileRemoveEvent } from "@/components/ui/file-list"; import type { BtrixFileChangeEvent } from "@/components/ui/file-list/events"; import type { Tags } from "@/components/ui/tag-input"; +import orgUploadsContext from "@/context/org-uploads"; +import type { OrgUploadEventDetail } from "@/context/org-uploads/types"; import type { BtrixTagsChangeEvent } from "@/features/archived-items/item-tags-input"; import { type CollectionsChangeEvent } from "@/features/collections/collections-add"; import { DESCRIPTION_MAX_LENGTH } from "@/types/archivedItems"; @@ -25,40 +28,41 @@ export type FileUploaderUploadedEvent = CustomEvent<{ fileSize: number; }>; -enum AbortReason { - UserCancel = "user-canceled", - QuotaReached = "storage_quota_reached", -} - /** * Usage: * ```ts * * ``` * - * @event request-close - * @event upload-start - * @event uploaded + * @event request-CollectionSavedEvent */ @customElement("btrix-file-uploader") @localized() export class FileUploader extends BtrixElement { + readonly #orgUploads = new ContextConsumer(this, { + context: orgUploadsContext, + subscribe: true, + callback: (value) => { + if (this.uploadingId && this.uploadingId in value) { + // Finish with dialog once upload has begun + this.uploadingId = undefined; + this.requestClose(); + } + }, + }); + @property({ type: Boolean }) open = false; @state() - private isUploading = false; + private uploadingId?: string; @state() private isDialogVisible = false; - @state() - private isConfirmingCancel = false; - @state() private collectionIds: string[] = []; @@ -87,29 +91,27 @@ export class FileUploader extends BtrixElement { } render() { - const uploadInProgress = this.isUploading || this.isConfirmingCancel; return html` (this.isDialogVisible = true)} @sl-after-hide=${() => (this.isDialogVisible = false)} - @sl-request-close=${this.tryRequestClose} - style="--width: ${uploadInProgress ? 40 : 60}rem;" > - ${when(this.isDialogVisible, () => - uploadInProgress ? this.renderUploading() : this.renderForm(), - )} + ${when(this.isDialogVisible, () => this.renderForm())} `; } private renderForm() { + const loading = Boolean(this.uploadingId); + return html`
@@ -142,8 +144,8 @@ export class FileUploader extends BtrixElement { { // Using submit method instead of type="submit" fixes // incorrect getRootNode in Chrome @@ -222,77 +224,7 @@ export class FileUploader extends BtrixElement { `; } - private renderUploading() { - if (this.isConfirmingCancel) { - return html` -
-

- ${msg("Cancel this upload?")} -

-
- - ${Array.from(this.fileList).map( - (file) => - html``, - )} - -
-
- (this.isConfirmingCancel = false)} - > - ${msg("No")} - - { - this.api.cancelUpload(); - this.requestClose(); - }} - > - ${msg("Yes")} - -
-
- `; - } - return html` -
-

- ${msg("Uploading...")} -

-

- ${msg("Keep this window open until your upload finishes.")} -

-
- - ${Array.from(this.fileList).map( - (file) => - html``, - )} - -
-
-
- this.tryRequestClose()}> - ${msg("Cancel")} - -
- `; - } - private readonly handleRemoveFile = (e: FileRemoveEvent) => { - this.api.cancelUpload(); const idx = this.fileList.indexOf(e.detail.item as File); if (idx === -1) return; this.fileList = [ @@ -304,24 +236,14 @@ export class FileUploader extends BtrixElement { private resetState() { this.fileList = []; this.tagsToSave = []; - this.isUploading = false; - this.isConfirmingCancel = false; + this.uploadingId = undefined; } - private tryRequestClose(e?: CustomEvent) { - if (this.isUploading) { - e?.preventDefault(); - this.isConfirmingCancel = true; - } else { - this.requestClose(); - } - } - - private requestClose() { + private readonly requestClose = () => { this.dispatchEvent( new CustomEvent("request-close") as FileUploaderRequestCloseEvent, ); - } + }; private async onSubmit(e: SubmitEvent) { e.preventDefault(); @@ -332,94 +254,30 @@ export class FileUploader extends BtrixElement { const file = this.fileList[0] as File | undefined; if (!file) return; - this.isUploading = true; + this.uploadingId = window.crypto.randomUUID(); + + const { name, description } = serialize(formEl); + const query = queryString.stringify({ + filename: file.name, + name, + description: description, + collections: this.collectionIds, + tags: this.tagsToSave, + }); + + // Dispatch information for upload to be handled on the org level this.dispatchEvent( - new CustomEvent("upload-start", { + new CustomEvent("btrix-org-upload", { detail: { - fileName: file.name, - fileSize: file.size, + uploadId: this.uploadingId, + itemName: name as string, + apiPath: `/orgs/${this.orgId}/uploads/stream?${query}`, + file, }, - }) as FileUploaderUploadedEvent, + bubbles: true, + composed: true, + }), ); - - const { name, description } = serialize(formEl); - try { - const query = queryString.stringify({ - filename: file.name, - name, - description: description, - collections: this.collectionIds, - tags: this.tagsToSave, - }); - - const data = await this.api.upload( - `orgs/${this.orgId}/uploads/stream?${query}`, - file, - ); - - // Dispatch event here because we're not using apiFetch() for uploads - if (data.storageQuotaReached) { - this.dispatchEvent( - new CustomEvent("btrix-storage-quota-update", { - detail: { reached: true }, - bubbles: true, - }), - ); - } - - if (data.id && data.added) { - this.dispatchEvent( - new CustomEvent("uploaded", { - detail: { - fileName: file.name, - fileSize: file.size, - }, - }) as FileUploaderUploadedEvent, - ); - this.requestClose(); - this.notify.toast({ - message: msg( - html`Successfully uploaded ${name}.
- View Item `, - ), - variant: "success", - icon: "check2-circle", - id: "file-upload-status", - }); - } else { - throw data; - } - } catch (err) { - if (err === AbortReason.UserCancel) { - console.debug("Upload aborted to user cancel"); - } else { - let message = msg("Sorry, couldn't upload file at this time."); - console.debug(err); - if (err === AbortReason.QuotaReached) { - message = msg( - "Your org does not have enough storage to upload this file.", - ); - this.dispatchEvent( - new CustomEvent("btrix-storage-quota-update", { - detail: { reached: true }, - bubbles: true, - }), - ); - } - this.notify.toast({ - message: message, - variant: "danger", - icon: "exclamation-octagon", - id: "file-upload-status", - }); - } - } - this.isUploading = false; } private async checkFormValidity(formEl: HTMLFormElement) { diff --git a/frontend/src/features/collections/edit-dialog/helpers/submit-task.ts b/frontend/src/features/collections/edit-dialog/helpers/submit-task.ts index 9ea380e79e..5504aed382 100644 --- a/frontend/src/features/collections/edit-dialog/helpers/submit-task.ts +++ b/frontend/src/features/collections/edit-dialog/helpers/submit-task.ts @@ -54,7 +54,6 @@ export default function submitTask( this.api.upload( `/orgs/${this.orgId}/collections/${this.collection.id}/thumbnail?${searchParams.toString()}`, file, - undefined, signal, ), ); diff --git a/frontend/src/features/org/index.ts b/frontend/src/features/org/index.ts index ef46299d1a..2201f7d215 100644 --- a/frontend/src/features/org/index.ts +++ b/frontend/src/features/org/index.ts @@ -1,3 +1,3 @@ import("./org-status-banner"); -import("./org-uploads-overlay"); +import("./org-uploads-dialog"); import("./usage-history-table"); diff --git a/frontend/src/features/org/org-uploads-overlay.ts b/frontend/src/features/org/org-uploads-dialog.ts similarity index 66% rename from frontend/src/features/org/org-uploads-overlay.ts rename to frontend/src/features/org/org-uploads-dialog.ts index 5b785ba50c..bdf89c75c5 100644 --- a/frontend/src/features/org/org-uploads-overlay.ts +++ b/frontend/src/features/org/org-uploads-dialog.ts @@ -1,4 +1,4 @@ -import { consume, ContextConsumer } from "@lit/context"; +import { ContextConsumer } from "@lit/context"; import { localized, msg, str } from "@lit/localize"; import type { SlAlert } from "@shoelace-style/shoelace"; import clsx from "clsx"; @@ -9,9 +9,9 @@ import sum from "lodash/fp/sum"; import { BtrixElement } from "@/classes/BtrixElement"; import notificationsContext from "@/context/notifications"; -import orgUploadsContext, { - type OrgUploadsContext, -} from "@/context/org-uploads"; +import orgUploadsContext from "@/context/org-uploads"; +import { orgUploadsInitialValue } from "@/context/org-uploads/org-uploads"; +import { OrgUploadsContextController } from "@/context/org-uploads/OrgUploadsContextController"; import type { OrgUpload, OrgUploadCancelRemoveEventDetail, @@ -21,61 +21,76 @@ import { OrgTab } from "@/routes"; import { pluralOf } from "@/utils/pluralize"; import { tw } from "@/utils/tailwind"; -@customElement("btrix-org-uploads-overlay") +/** + * Displays status of org-wide uploads in a non-modal dialog. + */ +@customElement("btrix-org-uploads-dialog") @localized() -export class OrgUploadsOverlay extends BtrixElement { +export class OrgUploadsDialog extends BtrixElement { readonly #notifications = new ContextConsumer(this, { context: notificationsContext, subscribe: true, callback: () => this.updateToastStackOffset(), }); - @consume({ context: orgUploadsContext, subscribe: true }) + readonly #orgUploads = new ContextConsumer(this, { + context: orgUploadsContext, + subscribe: true, + callback: (value) => { + this.uploadsByStatus = OrgUploadsContextController.uploadsByStatus(value); + + if (this.cancelIds.size) { + // Remove IDs that have been removed + this.cancelIds = new Set(Object.keys(value)).intersection( + this.cancelIds, + ); + } + }, + }); + + @state() + private uploadsByStatus = OrgUploadsContextController.uploadsByStatus( + orgUploadsInitialValue, + ); + @state() - private readonly orgUploads: OrgUploadsContext = {}; + private open = false; @state() private minimized = false; @state() - private canceling?: string; + private cancelIds = new Set(); @query("sl-alert") private readonly alert?: SlAlert; - protected willUpdate(changedProperties: PropertyValues): void { - if (changedProperties.has("orgUploads")) { - if ( - this.canceling && - this.canceling in this.orgUploads && - this.orgUploads[this.canceling].canceled - ) { - this.canceling = undefined; - } - } + get uploadIds() { + return this.uploadsByStatus.all.map(({ uploadId }) => uploadId); } protected updated(changedProperties: PropertyValues): void { if ( - changedProperties.has("orgUploads") || + changedProperties.has("uploadsByStatus") || changedProperties.has("minimized") ) { this.updateToastStackOffset(); } - if (changedProperties.has("orgUploads")) { + if (changedProperties.has("uploadsByStatus")) { void this.updateAlertVisibility(); } } private async updateAlertVisibility() { - const uploadIds = Object.keys(this.orgUploads); + await this.alert?.updateComplete; + + const uploadIds = this.uploadIds; if (uploadIds.length) { - await this.alert?.updateComplete; - await this.alert?.show(); + this.open = true; } else { - await this.alert?.hide(); + this.open = false; } } @@ -83,7 +98,7 @@ export class OrgUploadsOverlay extends BtrixElement { * Offset app notification stack so that org uploads are always pinned to the bottom. */ private readonly updateToastStackOffset = () => { - const uploadIds = Object.keys(this.orgUploads); + const uploadIds = this.uploadIds; if (uploadIds.length && this.#notifications.value?.length) { document.body.style.setProperty( @@ -98,26 +113,15 @@ export class OrgUploadsOverlay extends BtrixElement { }; render() { - const canceledUploads = []; - const uploadsInProgress = []; - - Object.entries(this.orgUploads).forEach(([id, upload]) => { - if (upload.canceled) { - canceledUploads.push(id); - } else if (!upload.itemId) { - uploadsInProgress.push(id); - } - }); - - const uploads = Object.entries(this.orgUploads); - const totalCount = uploads.length; - const inProgressCount = uploadsInProgress.length; - const canceledCount = canceledUploads.length; + const { all, canceled, inProgress } = this.uploadsByStatus; + const totalCount = all.length; + const inProgressCount = inProgress.length; + const canceledCount = canceled.length; const allDone = inProgressCount === 0; const allCanceled = canceledCount === totalCount; - const sumLoaded = sum(uploads.map(([_id, { loaded }]) => loaded)); - const sumTotal = sum(uploads.map(([_id, { total }]) => total)); + const sumLoaded = sum(all.map(({ loaded }) => loaded)); + const sumTotal = sum(all.map(({ total }) => total)); const number_of_files_in_progress = this.localize.number(inProgressCount); const plural_of_files_in_progress = pluralOf("files", inProgressCount); @@ -133,14 +137,13 @@ export class OrgUploadsOverlay extends BtrixElement { variant=${allDone && !allCanceled ? "success" : "primary"} class="pointer-events-auto m-4 part-[base]:shadow-lg" duration=${allCanceled ? 5000 : Infinity} + ?open=${this.open} @sl-after-hide=${() => { - const uploadIds = Object.keys(this.orgUploads); - this.dispatchEvent( new CustomEvent( "btrix-org-upload-remove", { - detail: { uploadIds }, + detail: { uploadIds: this.uploadIds }, bubbles: true, composed: true, }, @@ -177,20 +180,27 @@ export class OrgUploadsOverlay extends BtrixElement {
` : nothing} (this.minimized = !this.minimized)} > - ${allDone - ? html` void this.alert?.hide()} - >` - : nothing} + { + if (inProgressCount) { + this.cancelIds = new Set( + inProgress.map(({ uploadId }) => uploadId), + ); + } else { + this.open = false; + } + }} + >
- ${repeat( - uploads, - ([id]) => id, - ([id, upload]) => this.renderUpload(id, upload), - )} + ${repeat(all, ({ uploadId }) => uploadId, this.renderUpload)}
@@ -212,22 +218,24 @@ export class OrgUploadsOverlay extends BtrixElement { `; } - private readonly renderUpload = (uploadId: string, upload: OrgUpload) => { + private readonly renderUpload = ( + upload: OrgUpload & { uploadId: string }, + ) => { const progress = (upload.loaded / upload.total) * 100; const removeOrHide = () => { - if (Object.keys(this.orgUploads).length > 1) { + if (this.uploadIds.length > 1) { this.dispatchEvent( new CustomEvent( "btrix-org-upload-remove", { - detail: { uploadIds: [uploadId] }, + detail: { uploadIds: [upload.uploadId] }, bubbles: true, composed: true, }, ), ); } else { - void this.alert?.hide(); + this.open = false; } }; @@ -267,13 +275,14 @@ export class OrgUploadsOverlay extends BtrixElement { }} >` : html` { if (upload.canceled) { removeOrHide(); } else { - this.canceling = uploadId; + this.cancelIds = new Set([upload.uploadId]); } }} >`} @@ -282,31 +291,53 @@ export class OrgUploadsOverlay extends BtrixElement { }; private renderDialog() { - const upload = this.canceling ? this.orgUploads[this.canceling] : undefined; - const upload_name = upload?.itemName; + const cancelCount = this.cancelIds.size; + const someCanceled = new Set( + this.uploadsByStatus.canceled.map(({ uploadId }) => uploadId), + ).intersection(this.cancelIds); + const isSomeCanceling = Boolean(someCanceled.size); + + const message = () => { + if (cancelCount === 1) { + const uploadId = this.cancelIds.values().next().value; + const orgUploads = this.#orgUploads.value; + + if (uploadId && orgUploads?.[uploadId]) { + const upload_name = orgUploads[uploadId].itemName; + return msg( + str`Are you sure you want to cancel uploading “${upload_name}”?`, + ); + } + } + + const number_of_files = this.localize.number(cancelCount); + const plural_of_files = pluralOf("files", cancelCount); + + return msg( + str`Are you sure you want to cancel uploading ${number_of_files} ${plural_of_files}?`, + ); + }; return html` -

- ${msg(str`Are you sure you want to cancel uploading “${upload_name}”?`)} -

+

${message()}

- (this.canceling = undefined)} + (this.cancelIds = new Set())} >${msg("Continue Upload")} { - if (!this.canceling) return; - this.dispatchEvent( new CustomEvent( "btrix-org-upload-cancel", { - detail: { uploadIds: [this.canceling] }, + detail: { uploadIds: Array.from(this.cancelIds.values()) }, bubbles: true, composed: true, }, diff --git a/frontend/src/pages/org/archived-item-detail/archived-item-detail.ts b/frontend/src/pages/org/archived-item-detail/archived-item-detail.ts index b97af43ebd..b28de838a3 100644 --- a/frontend/src/pages/org/archived-item-detail/archived-item-detail.ts +++ b/frontend/src/pages/org/archived-item-detail/archived-item-detail.ts @@ -1514,13 +1514,26 @@ export class ArchivedItemDetail extends BtrixElement { private async fetchCrawl(): Promise { try { this.item = await this.getCrawl(); - } catch { - this.notify.toast({ - message: msg("Sorry, couldn't retrieve crawl at this time."), - variant: "danger", - icon: "exclamation-octagon", - id: "archived-item-retrieve-error", - }); + } catch (err) { + console.debug(err); + + this.navigate.to(`${this.navigate.orgBasePath}/${OrgTab.Items}`); + + if (isApiError(err) && err.statusCode === 404) { + this.notify.toast({ + message: msg("Archived item does not exist."), + variant: "danger", + icon: "exclamation-octagon", + id: "archived-item-retrieve-error", + }); + } else { + this.notify.toast({ + message: msg("Sorry, couldn't retrieve item at this time."), + variant: "danger", + icon: "exclamation-octagon", + id: "archived-item-retrieve-error", + }); + } } } diff --git a/frontend/src/pages/org/archived-items.ts b/frontend/src/pages/org/archived-items.ts index ba2c6f42b2..fe15e5825e 100644 --- a/frontend/src/pages/org/archived-items.ts +++ b/frontend/src/pages/org/archived-items.ts @@ -524,14 +524,6 @@ export class CrawlsList extends BtrixElement { (this.isUploadingArchive = false)} - @uploaded=${() => { - if (this.itemType !== "crawl") { - this.pagination = { - ...this.pagination, - page: 1, - }; - } - }} > `, )} diff --git a/frontend/src/pages/org/collection-detail/dedupe.ts b/frontend/src/pages/org/collection-detail/dedupe.ts index 241ab269c5..cf5bb01ed9 100644 --- a/frontend/src/pages/org/collection-detail/dedupe.ts +++ b/frontend/src/pages/org/collection-detail/dedupe.ts @@ -25,11 +25,11 @@ import type { Collection } from "@/types/collection"; import type { ArchivedItem, Workflow } from "@/types/crawler"; import type { DedupeIndexStats } from "@/types/dedupe"; import { SortDirection } from "@/types/utils"; +import { BYTES_PER_MB } from "@/utils/bytes"; import { finishedCrawlStates } from "@/utils/crawler"; import { indexAvailable, indexUpdating } from "@/utils/dedupe"; import { tw } from "@/utils/tailwind"; -const BYTES_PER_MB = 1e6; const INITIAL_PAGE_SIZE = 10; enum ItemsView { diff --git a/frontend/src/pages/org/index.ts b/frontend/src/pages/org/index.ts index 2db2bf12ee..f1ac21f6f3 100644 --- a/frontend/src/pages/org/index.ts +++ b/frontend/src/pages/org/index.ts @@ -28,6 +28,8 @@ import { import orgUploadsContext, { type OrgUploadsContext, } from "@/context/org-uploads"; +import { OrgUploadsContextController } from "@/context/org-uploads/OrgUploadsContextController"; +import { orgUploadsContextKey } from "@/context/org-uploads/types"; import { SearchOrgContextController } from "@/context/search-org/SearchOrgContextController"; import { searchOrgContextKey } from "@/context/search-org/types"; import type { QuotaUpdateDetail } from "@/controllers/api"; @@ -118,7 +120,7 @@ export class Org extends BtrixElement { crawlerChannels: OrgCrawlerChannelsContext = null; @provide({ context: orgUploadsContext }) - orgUploads: OrgUploadsContext = []; + orgUploads: OrgUploadsContext = {}; @property({ type: Object }) viewStateData?: ViewState["data"]; @@ -143,6 +145,9 @@ export class Org extends BtrixElement { private isCreateDialogVisible = false; private readonly [searchOrgContextKey] = new SearchOrgContextController(this); + private readonly [orgUploadsContextKey] = new OrgUploadsContextController( + this, + ); private readonly proxiesTask = new Task(this, { task: async ([id], { signal }) => { @@ -408,6 +413,8 @@ export class Org extends BtrixElement { ${this.renderNewResourceDialogs()}
+ + `; } @@ -503,11 +510,6 @@ export class Org extends BtrixElement { (this.openDialogName = undefined)} - @uploaded=${() => { - if (this.orgTab === OrgTab.Dashboard) { - this.navigate.to(`${this.navigate.orgBasePath}/items/upload`); - } - }} > ${crawlingDefaultsReady diff --git a/frontend/src/pages/public/org.ts b/frontend/src/pages/public/org.ts index a86912418b..59cca3d66a 100644 --- a/frontend/src/pages/public/org.ts +++ b/frontend/src/pages/public/org.ts @@ -14,6 +14,7 @@ import { type PublicCollection, } from "@/types/collection"; import type { OrgData, PublicOrgCollections } from "@/types/org"; +import type { UserInfo } from "@/types/user"; import { SortDirection } from "@/types/utils"; import { richText } from "@/utils/rich-text"; import { toShortUrl } from "@/utils/url-helpers"; @@ -298,7 +299,9 @@ export class PublicOrg extends BtrixElement { private async getUserOrg(): Promise { try { - const userInfo = this.userInfo || (await this.api.fetch("/users/me")); + const userInfo = + this.userInfo || + (await this.api.fetch("/users/me")); const userOrg = userInfo?.orgs.find((org) => org.slug === this.orgSlug); if (!userOrg) { diff --git a/frontend/src/stories/features/org/OrgUploadsDialog.stories.ts b/frontend/src/stories/features/org/OrgUploadsDialog.stories.ts new file mode 100644 index 0000000000..3173503577 --- /dev/null +++ b/frontend/src/stories/features/org/OrgUploadsDialog.stories.ts @@ -0,0 +1,369 @@ +import type { + Meta, + StoryContext, + StoryFn, + StoryObj, +} from "@storybook/web-components"; +import { html } from "lit"; +import type { DecoratorFunction } from "storybook/internal/types"; + +import { argTypes } from "../excludeContainerProperties"; + +import type { OrgUploadsDialog } from "@/features/org/org-uploads-dialog"; +import { + orgDecorator, + type StorybookOrgProps, +} from "@/stories/decorators/orgDecorator"; +import { BYTES_PER_GB, BYTES_PER_MB } from "@/utils/bytes"; + +import "@/features/org/org-uploads-dialog"; + +type RenderProps = OrgUploadsDialog & StorybookOrgProps; + +function containerDecorator(story: StoryFn, context: StoryContext) { + const { args } = context; + return html`
${story(args, context)}
`; +} + +const meta = { + title: "Features/Org/Org Uploads Dialog", + component: "btrix-org-uploads-dialog", + tags: ["autodocs"], + decorators: [ + orgDecorator as DecoratorFunction, + containerDecorator as DecoratorFunction, + ], + render: () => html` `, + argTypes: { + ...argTypes, + }, + args: {}, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const WithUploadInProgress: Story = { + render: () => html` + + `, +}; + +export const Minimized: Story = { + render: () => html` + + `, +}; + +export const MultipleUploadsInProgress: Story = { + render: () => html` + + `, +}; + +export const SomeInProgress: Story = { + render: () => html` + + `, +}; + +export const AllDone: Story = { + render: () => html` + + `, +}; + +export const Cancel: Story = { + render: () => html` + + `, +}; + +export const WithCanceled: Story = { + render: () => html` + + `, +}; + +export const WithToastStack: Story = { + render: () => html` +
+ Example toast +
+ + `, +}; diff --git a/frontend/src/stories/features/org/OrgUploadsOverlay.stories.ts b/frontend/src/stories/features/org/OrgUploadsOverlay.stories.ts deleted file mode 100644 index b198deefef..0000000000 --- a/frontend/src/stories/features/org/OrgUploadsOverlay.stories.ts +++ /dev/null @@ -1,227 +0,0 @@ -import type { - Meta, - StoryContext, - StoryFn, - StoryObj, -} from "@storybook/web-components"; -import { html } from "lit"; -import type { DecoratorFunction } from "storybook/internal/types"; - -import { argTypes } from "../excludeContainerProperties"; - -import type { OrgUploadsOverlay } from "@/features/org/org-uploads-overlay"; -import { - orgDecorator, - type StorybookOrgProps, -} from "@/stories/decorators/orgDecorator"; -import { BYTES_PER_GB, BYTES_PER_MB } from "@/utils/bytes"; - -import "@/features/org/org-uploads-overlay"; - -type RenderProps = OrgUploadsOverlay & StorybookOrgProps; - -function containerDecorator(story: StoryFn, context: StoryContext) { - const { args } = context; - return html`
${story(args, context)}
`; -} - -const meta = { - title: "Features/Org/Org Uploads Overlay", - component: "btrix-org-uploads-overlay", - tags: ["autodocs"], - decorators: [ - orgDecorator as DecoratorFunction, - containerDecorator as DecoratorFunction, - ], - render: () => html` `, - argTypes: { - ...argTypes, - }, - args: {}, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const WithUploadInProgress: Story = { - render: () => html` - - `, -}; - -export const Minimized: Story = { - render: () => html` - - `, -}; - -export const MultipleUploadsInProgress: Story = { - render: () => html` - - `, -}; - -export const SomeInProgress: Story = { - render: () => html` - - `, -}; - -export const AllDone: Story = { - render: () => html` - - `, -}; - -export const Cancel: Story = { - render: () => html` - - `, -}; - -export const WithCanceled: Story = { - render: () => html` - - `, -}; - -export const WithToastStack: Story = { - render: () => html` -
- Example toast -
- - `, -}; From 5b3c525763cf984c4c75aa848142f806761184e2 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Fri, 1 May 2026 11:24:24 -0700 Subject: [PATCH 07/16] rename toast stack --- .../src/components/{notification-stack.ts => toast-stack.ts} | 4 ++-- frontend/src/index.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename frontend/src/components/{notification-stack.ts => toast-stack.ts} (97%) diff --git a/frontend/src/components/notification-stack.ts b/frontend/src/components/toast-stack.ts similarity index 97% rename from frontend/src/components/notification-stack.ts rename to frontend/src/components/toast-stack.ts index 832f3ef34d..54e2f3da2c 100644 --- a/frontend/src/components/notification-stack.ts +++ b/frontend/src/components/toast-stack.ts @@ -19,7 +19,7 @@ import { import { tw } from "@/utils/tailwind"; /** - * Global notifications to stack in bottom end of the viewport. + * Global toast notifications to stack in bottom end of the viewport. * * This component reuses `.sl-toast-stack` styles instead of using Shoelace's * `SlAlert.toast()` to reactively render the toast state of a notification @@ -27,7 +27,7 @@ import { tw } from "@/utils/tailwind"; * * @fires btrix-remove-notification */ -@customElement("btrix-notification-stack") +@customElement("btrix-toast-stack") export class NotificationStack extends BtrixElement { @consume({ context: notificationsContext, subscribe: true }) @state() diff --git a/frontend/src/index.ts b/frontend/src/index.ts index 0d50bbd0a7..158d271e43 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -359,7 +359,7 @@ export class App extends BtrixElement { ${this.renderUserGuide()} - + `; } From 3ea675628ed258c99d876ba8853672d92b195868 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Mon, 4 May 2026 09:43:38 -0700 Subject: [PATCH 08/16] fix import --- frontend/src/components/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 361752d5f2..aa5da8cf2d 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -7,5 +7,5 @@ import("./not-found"); import("./screencast"); import("./beta-badges"); import("./detail-page-title"); -import("./notification-stack"); +import("./toast-stack"); import("./verified-badge"); From bcb27ce3b27dcf6abe42b38ee69677674a19e148 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Mon, 4 May 2026 10:44:07 -0700 Subject: [PATCH 09/16] handle delay between creation --- .../src/features/org/org-uploads-dialog.ts | 72 +++++++++++++------ frontend/src/theme.stylesheet.css | 3 +- 2 files changed, 53 insertions(+), 22 deletions(-) diff --git a/frontend/src/features/org/org-uploads-dialog.ts b/frontend/src/features/org/org-uploads-dialog.ts index bdf89c75c5..0cb56c6232 100644 --- a/frontend/src/features/org/org-uploads-dialog.ts +++ b/frontend/src/features/org/org-uploads-dialog.ts @@ -1,6 +1,6 @@ import { ContextConsumer } from "@lit/context"; import { localized, msg, str } from "@lit/localize"; -import type { SlAlert } from "@shoelace-style/shoelace"; +import type { SlAlert, SlIconButton } from "@shoelace-style/shoelace"; import clsx from "clsx"; import { html, nothing, type PropertyValues } from "lit"; import { customElement, query, state } from "lit/decorators.js"; @@ -18,6 +18,7 @@ import type { } from "@/context/org-uploads/types"; import { notifyIconFor } from "@/controllers/notify"; import { OrgTab } from "@/routes"; +import { stopProp } from "@/utils/events"; import { pluralOf } from "@/utils/pluralize"; import { tw } from "@/utils/tailwind"; @@ -172,12 +173,15 @@ export class OrgUploadsDialog extends BtrixElement { ${this.minimized && !allCanceled - ? html`
+ ? html`
` + ` : nothing} { const progress = (upload.loaded / upload.total) * 100; + const uploaded = upload.loaded === upload.total; + const isItem = Boolean(upload.itemId); + const removeOrHide = () => { if (this.uploadIds.length > 1) { this.dispatchEvent( @@ -250,30 +257,53 @@ export class OrgUploadsDialog extends BtrixElement { ${upload.itemName}
- ${upload.canceled - ? msg("Canceled") - : html`${upload.loaded < upload.total - ? `${this.localize.bytes(upload.loaded)} / ` - : nothing} - ${this.localize.bytes(upload.total)}`} + ${isItem + ? msg("Uploaded") + : upload.canceled + ? msg("Canceled") + : uploaded + ? msg("Finishing") + : html`${this.localize.bytes(upload.loaded)} / + ${this.localize.bytes(upload.total)}`}
- ${upload.itemId - ? html` { - removeOrHide(); - this.navigate.link(e); - }} - >` + ${uploaded + ? html` + { + if ((e.target as SlIconButton).disabled) { + e.preventDefault(); + return; + } + removeOrHide(); + this.navigate.link(e); + }} + > + ` : html` Date: Wed, 6 May 2026 14:38:48 -0700 Subject: [PATCH 10/16] add tooltips --- .../src/features/org/org-uploads-dialog.ts | 76 ++++++++++++------- 1 file changed, 48 insertions(+), 28 deletions(-) diff --git a/frontend/src/features/org/org-uploads-dialog.ts b/frontend/src/features/org/org-uploads-dialog.ts index 0cb56c6232..dca2ce125f 100644 --- a/frontend/src/features/org/org-uploads-dialog.ts +++ b/frontend/src/features/org/org-uploads-dialog.ts @@ -228,6 +228,21 @@ export class OrgUploadsDialog extends BtrixElement { const progress = (upload.loaded / upload.total) * 100; const uploaded = upload.loaded === upload.total; const isItem = Boolean(upload.itemId); + const linkBtn = html` { + if ((e.target as SlIconButton).disabled) { + e.preventDefault(); + return; + } + removeOrHide(); + this.navigate.link(e); + }} + >`; const removeOrHide = () => { if (this.uploadIds.length > 1) { @@ -279,43 +294,48 @@ export class OrgUploadsDialog extends BtrixElement { > ${uploaded - ? html` + ${linkBtn} + ` + : html` + ${linkBtn} + ` + : html` { - if ((e.target as SlIconButton).disabled) { - e.preventDefault(); - return; + name="x" + class="text-base" + label=${msg("Cancel Upload")} + @click=${() => { + if (upload.canceled) { + removeOrHide(); + } else { + this.cancelIds = new Set([upload.uploadId]); } - removeOrHide(); - this.navigate.link(e); }} > - ` - : html` { - if (upload.canceled) { - removeOrHide(); - } else { - this.cancelIds = new Set([upload.uploadId]); - } - }} - >`} + `} `; }; From ac55650f42344d61af3bab9472900e9ec01a3456 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Wed, 6 May 2026 14:44:45 -0700 Subject: [PATCH 11/16] update stories --- .../src/features/org/org-uploads-dialog.ts | 2 +- .../features/org/OrgUploadsDialog.stories.ts | 135 +++++++++++------- 2 files changed, 83 insertions(+), 54 deletions(-) diff --git a/frontend/src/features/org/org-uploads-dialog.ts b/frontend/src/features/org/org-uploads-dialog.ts index dca2ce125f..c80a3da7c1 100644 --- a/frontend/src/features/org/org-uploads-dialog.ts +++ b/frontend/src/features/org/org-uploads-dialog.ts @@ -306,7 +306,7 @@ export class OrgUploadsDialog extends BtrixElement { ${linkBtn} ` : html` html` + + `, +}; + +export const Done: Story = { + render: () => html` + + `, +}; + +export const Canceled: Story = { + render: () => html` + + `, +}; + +export const MultipleInProgress: Story = { render: () => html` html` html` html` - - `, -}; - export const WithToastStack: Story = { render: () => html`
Date: Wed, 6 May 2026 15:06:44 -0700 Subject: [PATCH 12/16] fix cancel --- .../src/features/org/org-uploads-dialog.ts | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/frontend/src/features/org/org-uploads-dialog.ts b/frontend/src/features/org/org-uploads-dialog.ts index c80a3da7c1..3db54bf06d 100644 --- a/frontend/src/features/org/org-uploads-dialog.ts +++ b/frontend/src/features/org/org-uploads-dialog.ts @@ -40,11 +40,17 @@ export class OrgUploadsDialog extends BtrixElement { callback: (value) => { this.uploadsByStatus = OrgUploadsContextController.uploadsByStatus(value); - if (this.cancelIds.size) { + if (this.cancelIds.length) { // Remove IDs that have been removed - this.cancelIds = new Set(Object.keys(value)).intersection( - this.cancelIds, - ); + const cancelIds: string[] = []; + + this.cancelIds.forEach((id) => { + if (id in value && !value[id].canceled) { + cancelIds.push(id); + } + }); + + this.cancelIds = cancelIds; } }, }); @@ -61,7 +67,7 @@ export class OrgUploadsDialog extends BtrixElement { private minimized = false; @state() - private cancelIds = new Set(); + private cancelIds: string[] = []; @query("sl-alert") private readonly alert?: SlAlert; @@ -197,9 +203,7 @@ export class OrgUploadsDialog extends BtrixElement { label=${msg("Close")} @click=${() => { if (inProgressCount) { - this.cancelIds = new Set( - inProgress.map(({ uploadId }) => uploadId), - ); + this.cancelIds = inProgress.map(({ uploadId }) => uploadId); } else { this.open = false; } @@ -297,6 +301,7 @@ export class OrgUploadsDialog extends BtrixElement { ? isItem ? html`` : html`` : html` @@ -341,11 +348,7 @@ export class OrgUploadsDialog extends BtrixElement { }; private renderDialog() { - const cancelCount = this.cancelIds.size; - const someCanceled = new Set( - this.uploadsByStatus.canceled.map(({ uploadId }) => uploadId), - ).intersection(this.cancelIds); - const isSomeCanceling = Boolean(someCanceled.size); + const cancelCount = this.cancelIds.length; const message = () => { if (cancelCount === 1) { @@ -374,20 +377,18 @@ export class OrgUploadsDialog extends BtrixElement { >

${message()}

- (this.cancelIds = new Set())} + (this.cancelIds = [])} >${msg("Continue Upload")} { this.dispatchEvent( new CustomEvent( "btrix-org-upload-cancel", { - detail: { uploadIds: Array.from(this.cancelIds.values()) }, + detail: { uploadIds: [...this.cancelIds] }, bubbles: true, composed: true, }, From d1d329653b7eb27cecb10bcb946f2ffd2b3f42a4 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Wed, 6 May 2026 15:13:40 -0700 Subject: [PATCH 13/16] add beforeunload --- frontend/src/features/org/org-uploads-dialog.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/frontend/src/features/org/org-uploads-dialog.ts b/frontend/src/features/org/org-uploads-dialog.ts index 3db54bf06d..ed177a6e3d 100644 --- a/frontend/src/features/org/org-uploads-dialog.ts +++ b/frontend/src/features/org/org-uploads-dialog.ts @@ -76,6 +76,22 @@ export class OrgUploadsDialog extends BtrixElement { return this.uploadsByStatus.all.map(({ uploadId }) => uploadId); } + connectedCallback(): void { + super.connectedCallback(); + window.addEventListener("beforeunload", this.onBeforeUnload); + } + + disconnectedCallback(): void { + window.removeEventListener("beforeunload", this.onBeforeUnload); + super.disconnectedCallback(); + } + + private readonly onBeforeUnload = (e: BeforeUnloadEvent) => { + if (this.open && this.uploadsByStatus.inProgress.length) { + e.preventDefault(); + } + }; + protected updated(changedProperties: PropertyValues): void { if ( changedProperties.has("uploadsByStatus") || From d4a3a0390e6aeb451318fffda616271c56249dd8 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Wed, 6 May 2026 15:22:23 -0700 Subject: [PATCH 14/16] fix opening upload form multiple times --- .../features/archived-items/file-uploader.ts | 27 ++++++++++--------- frontend/src/pages/org/archived-items.ts | 5 +++- frontend/src/pages/org/index.ts | 5 +++- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/frontend/src/features/archived-items/file-uploader.ts b/frontend/src/features/archived-items/file-uploader.ts index 8d0ae96a5a..8b14d03ef8 100644 --- a/frontend/src/features/archived-items/file-uploader.ts +++ b/frontend/src/features/archived-items/file-uploader.ts @@ -3,11 +3,18 @@ import { localized, msg } from "@lit/localize"; import type { SlButton } from "@shoelace-style/shoelace"; import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js"; import { html, type PropertyValues } from "lit"; -import { customElement, property, queryAsync, state } from "lit/decorators.js"; +import { + customElement, + property, + query, + queryAsync, + state, +} from "lit/decorators.js"; import { when } from "lit/directives/when.js"; import queryString from "query-string"; import { BtrixElement } from "@/classes/BtrixElement"; +import type { Dialog } from "@/components/ui/dialog"; import type { FileRemoveEvent } from "@/components/ui/file-list"; import type { BtrixFileChangeEvent } from "@/components/ui/file-list/events"; import type { Tags } from "@/components/ui/tag-input"; @@ -33,7 +40,6 @@ export type FileUploaderUploadedEvent = CustomEvent<{ * ```ts * * ``` * @@ -49,7 +55,7 @@ export class FileUploader extends BtrixElement { if (this.uploadingId && this.uploadingId in value) { // Finish with dialog once upload has begun this.uploadingId = undefined; - this.requestClose(); + this.close(); } }, }); @@ -72,6 +78,9 @@ export class FileUploader extends BtrixElement { @state() private fileList: File[] = []; + @query("btrix-dialog") + private readonly dialog?: Dialog | null; + @queryAsync("#fileUploadForm") private readonly form!: Promise; @@ -108,11 +117,7 @@ export class FileUploader extends BtrixElement { const loading = Boolean(this.uploadingId); return html` - +

@@ -239,10 +244,8 @@ export class FileUploader extends BtrixElement { this.uploadingId = undefined; } - private readonly requestClose = () => { - this.dispatchEvent( - new CustomEvent("request-close") as FileUploaderRequestCloseEvent, - ); + private readonly close = () => { + void this.dialog?.hide(); }; private async onSubmit(e: SubmitEvent) { diff --git a/frontend/src/pages/org/archived-items.ts b/frontend/src/pages/org/archived-items.ts index fe15e5825e..2d4e132ca3 100644 --- a/frontend/src/pages/org/archived-items.ts +++ b/frontend/src/pages/org/archived-items.ts @@ -523,7 +523,10 @@ export class CrawlsList extends BtrixElement { () => html` (this.isUploadingArchive = false)} + @sl-after-hide=${(e: CustomEvent) => { + e.stopPropagation(); + this.isUploadingArchive = false; + }} > `, )} diff --git a/frontend/src/pages/org/index.ts b/frontend/src/pages/org/index.ts index f1ac21f6f3..142c089494 100644 --- a/frontend/src/pages/org/index.ts +++ b/frontend/src/pages/org/index.ts @@ -509,7 +509,10 @@ export class Org extends BtrixElement { > (this.openDialogName = undefined)} + @sl-after-hide=${(e: CustomEvent) => { + e.stopPropagation(); + this.openDialogName = undefined; + }} > ${crawlingDefaultsReady From 521730cf4de07995125bf67641baa9084d0b55f2 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Thu, 7 May 2026 17:10:30 -0700 Subject: [PATCH 15/16] switch to decorators --- .../NotificationsContextController.ts | 8 +- .../OrgUploadsContextController.ts | 44 +- .../decorators/notificationsDecorator.ts | 47 ++ .../stories/decorators/orgUploadsDecorator.ts | 44 ++ .../features/org/OrgUploadsDialog.stories.ts | 558 +++++++----------- 5 files changed, 348 insertions(+), 353 deletions(-) create mode 100644 frontend/src/stories/decorators/notificationsDecorator.ts create mode 100644 frontend/src/stories/decorators/orgUploadsDecorator.ts diff --git a/frontend/src/context/notifications/NotificationsContextController.ts b/frontend/src/context/notifications/NotificationsContextController.ts index 0f27392d68..f380d5c7ff 100644 --- a/frontend/src/context/notifications/NotificationsContextController.ts +++ b/frontend/src/context/notifications/NotificationsContextController.ts @@ -52,6 +52,10 @@ export class NotificationsContextController implements ReactiveController { ); } + addNotification(notification: AppNotification) { + this.#context.setValue([notification, ...this.#context.value]); + } + private readonly onNotify = (e: CustomEvent) => { e.stopPropagation(); @@ -88,8 +92,4 @@ export class NotificationsContextController implements ReactiveController { console.debug("no notification with id"), e.detail.id; } }; - - private addNotification(notification: AppNotification) { - this.#context.setValue([notification, ...this.#context.value]); - } } diff --git a/frontend/src/context/org-uploads/OrgUploadsContextController.ts b/frontend/src/context/org-uploads/OrgUploadsContextController.ts index 60c3cbf6f6..ea3996e527 100644 --- a/frontend/src/context/org-uploads/OrgUploadsContextController.ts +++ b/frontend/src/context/org-uploads/OrgUploadsContextController.ts @@ -73,6 +73,16 @@ export class OrgUploadsContextController implements ReactiveController { this.#host.removeEventListener("btrix-org-upload-remove", this.onRemove); } + setUpload(uploadId: string, upload: Partial) { + this.#context.setValue({ + ...this.#context.value, + [uploadId]: { + ...this.#context.value[uploadId], + ...upload, + }, + }); + } + private readonly onUpload = async (e: CustomEvent) => { e.stopPropagation(); @@ -83,14 +93,11 @@ export class OrgUploadsContextController implements ReactiveController { } const onUploadProgress = (e: ProgressEvent) => { - this.#context.setValue({ - ...this.#context.value, - [uploadId]: { - itemName, - filename: file.name, - loaded: e.loaded, - total: e.total, - }, + this.setUpload(uploadId, { + itemName, + filename: file.name, + loaded: e.loaded, + total: e.total, }); }; @@ -110,12 +117,13 @@ export class OrgUploadsContextController implements ReactiveController { try { const { id } = await uploadComplete; - this.#context.setValue({ - ...this.#context.value, - [uploadId]: { - ...this.#context.value[uploadId], - itemId: id, - }, + this.setUpload(uploadId, { + ...this.#context.value[uploadId], + itemId: id, + }); + + this.setUpload(uploadId, { + itemId: id, }); } catch (err) { console.debug(err); @@ -123,12 +131,8 @@ export class OrgUploadsContextController implements ReactiveController { if (err === AbortReason.UserCancel) { console.debug("Upload aborted to user cancel"); - this.#context.setValue({ - ...this.#context.value, - [uploadId]: { - ...this.#context.value[uploadId], - canceled: true, - }, + this.setUpload(uploadId, { + canceled: true, }); } else { let message = msg("Sorry, couldn't upload file at this time."); diff --git a/frontend/src/stories/decorators/notificationsDecorator.ts b/frontend/src/stories/decorators/notificationsDecorator.ts new file mode 100644 index 0000000000..88c7aa401d --- /dev/null +++ b/frontend/src/stories/decorators/notificationsDecorator.ts @@ -0,0 +1,47 @@ +import type { StoryContext, StoryFn } from "@storybook/web-components"; +import { html, type PropertyValues } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import { BtrixElement } from "@/classes/BtrixElement"; +import type { NotificationsContext } from "@/context/notifications"; +import { notificationsInitialValue } from "@/context/notifications/notifications"; +import { NotificationsContextController } from "@/context/notifications/NotificationsContextController"; +import { notificationsContextKey } from "@/context/notifications/types"; + +import "@/components/toast-stack"; + +export type StorybookNotificationsProps = { + notifications?: NotificationsContext; +}; + +@customElement("btrix-storybook-notifications") +export class StorybookOrg extends BtrixElement { + @property({ type: Array, attribute: false }) + notifications: NotificationsContext = notificationsInitialValue; + + private readonly [notificationsContextKey] = + new NotificationsContextController(this); + + protected willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has("notifications")) { + this.notifications.forEach((notification) => { + this[notificationsContextKey].addNotification(notification); + }); + } + } + + render() { + return html` `; + } +} + +export function notificationsDecorator(story: StoryFn, context: StoryContext) { + const { args } = context; + const { notifications } = args as StorybookNotificationsProps; + + return html` + ${story(args, context)} + `; +} diff --git a/frontend/src/stories/decorators/orgUploadsDecorator.ts b/frontend/src/stories/decorators/orgUploadsDecorator.ts new file mode 100644 index 0000000000..6f37f6d239 --- /dev/null +++ b/frontend/src/stories/decorators/orgUploadsDecorator.ts @@ -0,0 +1,44 @@ +import type { StoryContext, StoryFn } from "@storybook/web-components"; +import { html, type PropertyValues } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import { BtrixElement } from "@/classes/BtrixElement"; +import type { OrgUploadsContext } from "@/context/org-uploads"; +import { orgUploadsInitialValue } from "@/context/org-uploads/org-uploads"; +import { OrgUploadsContextController } from "@/context/org-uploads/OrgUploadsContextController"; +import { orgUploadsContextKey } from "@/context/org-uploads/types"; + +export type StorybookOrgUploadsProps = { + orgUploads?: OrgUploadsContext; +}; + +@customElement("btrix-storybook-org-uploads") +export class StorybookOrg extends BtrixElement { + @property({ type: Object, attribute: false }) + orgUploads: OrgUploadsContext = orgUploadsInitialValue; + + private readonly [orgUploadsContextKey] = new OrgUploadsContextController( + this, + ); + + protected willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has("orgUploads")) { + Object.entries(this.orgUploads).forEach(([uploadId, upload]) => { + this[orgUploadsContextKey].setUpload(uploadId, upload); + }); + } + } + + render() { + return html``; + } +} + +export function orgUploadsDecorator(story: StoryFn, context: StoryContext) { + const { args } = context; + const { orgUploads } = args as StorybookOrgUploadsProps; + + return html` + ${story(args, context)} + `; +} diff --git a/frontend/src/stories/features/org/OrgUploadsDialog.stories.ts b/frontend/src/stories/features/org/OrgUploadsDialog.stories.ts index e1dc761128..81f9fe6de3 100644 --- a/frontend/src/stories/features/org/OrgUploadsDialog.stories.ts +++ b/frontend/src/stories/features/org/OrgUploadsDialog.stories.ts @@ -10,15 +10,26 @@ import type { DecoratorFunction } from "storybook/internal/types"; import { argTypes } from "../excludeContainerProperties"; import type { OrgUploadsDialog } from "@/features/org/org-uploads-dialog"; +import { + notificationsDecorator, + type StorybookNotificationsProps, +} from "@/stories/decorators/notificationsDecorator"; import { orgDecorator, type StorybookOrgProps, } from "@/stories/decorators/orgDecorator"; +import { + orgUploadsDecorator, + type StorybookOrgUploadsProps, +} from "@/stories/decorators/orgUploadsDecorator"; import { BYTES_PER_GB, BYTES_PER_MB } from "@/utils/bytes"; import "@/features/org/org-uploads-dialog"; -type RenderProps = OrgUploadsDialog & StorybookOrgProps; +type RenderProps = OrgUploadsDialog & + StorybookOrgProps & + StorybookOrgUploadsProps & + StorybookNotificationsProps; function containerDecorator(story: StoryFn, context: StoryContext) { const { args } = context; @@ -30,10 +41,12 @@ const meta = { component: "btrix-org-uploads-dialog", tags: ["autodocs"], decorators: [ + notificationsDecorator as DecoratorFunction, orgDecorator as DecoratorFunction, + orgUploadsDecorator as DecoratorFunction, containerDecorator as DecoratorFunction, ], - render: () => html` `, + render: () => html``, argTypes: { ...argTypes, }, @@ -44,355 +57,242 @@ export default meta; type Story = StoryObj; export const WithUploadInProgress: Story = { - render: () => html` - - `, + args: { + orgUploads: { + "upload-1": { + itemName: "Test WACZ File", + filename: "test_file.wacz", + loaded: 9.005 * BYTES_PER_MB, + total: 50.15 * BYTES_PER_MB, + }, + }, + }, }; export const Minimized: Story = { - render: () => html` - + html` - `, + >`, }; export const Finishing: Story = { - render: () => html` - - `, + args: { + orgUploads: { + "upload-1": { + itemName: "Test WACZ File", + filename: "test_file.wacz", + loaded: 50.15 * BYTES_PER_MB, + total: 50.15 * BYTES_PER_MB, + }, + }, + }, }; -export const Done: Story = { - render: () => html` - - `, +export const Complete: Story = { + args: { + orgUploads: { + "upload-1": { + itemName: "Test WACZ File", + filename: "test_file.wacz", + loaded: 50.15 * BYTES_PER_MB, + total: 50.15 * BYTES_PER_MB, + itemId: "upload-item-id-1-item-id", + }, + }, + }, }; export const Canceled: Story = { - render: () => html` - - `, + args: { + orgUploads: { + "upload-1": { + itemName: "Test WACZ File", + filename: "test_file.wacz", + loaded: 9.005 * BYTES_PER_MB, + total: 50.15 * BYTES_PER_MB, + canceled: true, + }, + }, + }, }; export const MultipleInProgress: Story = { - render: () => html` - - `, + args: { + orgUploads: { + "upload-1": { + itemName: "Test WACZ File", + filename: "test_file.wacz", + loaded: 9.005 * BYTES_PER_MB, + total: 50.15 * BYTES_PER_MB, + }, + "upload-2": { + itemName: + "Test WACZ file with longer file name for testing long file name", + filename: + "test_file_with_longer_file_name_for_testing_long_file_name.wacz", + loaded: 310 * BYTES_PER_MB, + total: 4.85 * BYTES_PER_GB, + }, + }, + }, +}; + +export const SomeFinishing: Story = { + args: { + orgUploads: { + "upload-1": { + itemName: "Test WACZ File", + filename: "test_file.wacz", + loaded: 50.15 * BYTES_PER_MB, + total: 50.15 * BYTES_PER_MB, + }, + "upload-2": { + itemName: + "Test WACZ file with longer file name for testing long file name", + filename: + "test_file_with_longer_file_name_for_testing_long_file_name.wacz", + loaded: 310 * BYTES_PER_MB, + total: 4.85 * BYTES_PER_GB, + }, + }, + }, }; -export const SomeInProgress: Story = { - render: () => html` - - `, +export const SomeComplete: Story = { + args: { + orgUploads: { + "upload-1": { + itemName: "Test WACZ File", + filename: "test_file.wacz", + loaded: 50.15 * BYTES_PER_MB, + total: 50.15 * BYTES_PER_MB, + itemId: "upload-item-id-1", + }, + "upload-2": { + itemName: + "Test WACZ file with longer file name for testing long file name", + filename: + "test_file_with_longer_file_name_for_testing_long_file_name.wacz", + loaded: 310 * BYTES_PER_MB, + total: 4.85 * BYTES_PER_GB, + }, + }, + }, }; -export const MultipleDone: Story = { - render: () => html` - - `, +export const SomeCanceled: Story = { + args: { + orgUploads: { + "upload-1": { + itemName: "Test WACZ File", + filename: "test_file.wacz", + loaded: 50.15 * BYTES_PER_MB, + total: 50.15 * BYTES_PER_MB, + canceled: true, + }, + "upload-2": { + itemName: + "Test WACZ file with longer file name for testing long file name", + filename: + "test_file_with_longer_file_name_for_testing_long_file_name.wacz", + loaded: 310 * BYTES_PER_MB, + total: 4.85 * BYTES_PER_GB, + }, + }, + }, }; -export const CancelDialog: Story = { - render: () => html` - - `, +export const MultipleComplete: Story = { + args: { + orgUploads: { + "upload-1": { + itemName: "Test WACZ File", + filename: "test_file.wacz", + loaded: 50.15 * BYTES_PER_MB, + total: 50.15 * BYTES_PER_MB, + itemId: "upload-item-id-1", + }, + "upload-2": { + itemName: + "Test WACZ file with longer file name for testing long file name", + filename: + "test_file_with_longer_file_name_for_testing_long_file_name.wacz", + loaded: 310 * BYTES_PER_MB, + total: 4.85 * BYTES_PER_GB, + itemId: "upload-item-id-2", + }, + }, + }, +}; + +export const MixedStates: Story = { + args: { + orgUploads: { + "upload-1": { + itemName: "Test WACZ File", + filename: "test_file.wacz", + loaded: 50.15 * BYTES_PER_MB, + total: 50.15 * BYTES_PER_MB, + canceled: true, + }, + "upload-2": { + itemName: + "Test WACZ file with longer file name for testing long file name", + filename: + "test_file_with_longer_file_name_for_testing_long_file_name.wacz", + loaded: 4.85 * BYTES_PER_MB, + total: 4.85 * BYTES_PER_GB, + itemId: "upload-item-id-2", + }, + "upload-3": { + itemName: "Test WACZ file 2", + filename: "test_file_2.wacz", + loaded: 4.3 * BYTES_PER_MB, + total: 4.3 * BYTES_PER_MB, + }, + }, + }, }; export const WithToastStack: Story = { - render: () => html` -
- Example toast -
- - `, + args: { + notifications: [ + { + id: "notification-1", + type: "toast", + message: "Success!", + variant: "success", + closable: true, + duration: Infinity, + }, + ], + orgUploads: { + "upload-1": { + itemName: "Test WACZ File", + filename: "test_file.wacz", + loaded: 9.005 * BYTES_PER_MB, + total: 50.15 * BYTES_PER_MB, + }, + "upload-2": { + itemName: + "Test WACZ file with longer file name for testing long file name", + filename: + "test_file_with_longer_file_name_for_testing_long_file_name.wacz", + loaded: 4.85 * BYTES_PER_MB, + total: 4.85 * BYTES_PER_GB, + itemId: "upload-item-id-2", + }, + }, + }, }; From c278f7a1edff1c18e41f4ed79280266c80322107 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Thu, 7 May 2026 17:24:33 -0700 Subject: [PATCH 16/16] fix uploaded count --- .../OrgUploadsContextController.ts | 7 +++-- .../src/features/org/org-uploads-dialog.ts | 26 ++++++++++++------- .../features/org/OrgUploadsDialog.stories.ts | 20 +++++--------- 3 files changed, 28 insertions(+), 25 deletions(-) diff --git a/frontend/src/context/org-uploads/OrgUploadsContextController.ts b/frontend/src/context/org-uploads/OrgUploadsContextController.ts index ea3996e527..501d4ea992 100644 --- a/frontend/src/context/org-uploads/OrgUploadsContextController.ts +++ b/frontend/src/context/org-uploads/OrgUploadsContextController.ts @@ -36,6 +36,7 @@ export class OrgUploadsContextController implements ReactiveController { const all: (OrgUpload & { uploadId: string })[] = []; const canceled: (OrgUpload & { uploadId: string })[] = []; const inProgress: (OrgUpload & { uploadId: string })[] = []; + const uploaded: (OrgUpload & { uploadId: string })[] = []; uploads.forEach(([uploadId, upload]) => { const item = { uploadId, ...upload }; @@ -44,12 +45,14 @@ export class OrgUploadsContextController implements ReactiveController { if (upload.canceled) { canceled.push(item); - } else if (!upload.itemId) { + } else if (upload.itemId) { + uploaded.push(item); + } else { inProgress.push(item); } }); - return { all, canceled, inProgress }; + return { all, canceled, inProgress, uploaded }; } constructor(host: BtrixElement) { diff --git a/frontend/src/features/org/org-uploads-dialog.ts b/frontend/src/features/org/org-uploads-dialog.ts index ed177a6e3d..fc17797eb4 100644 --- a/frontend/src/features/org/org-uploads-dialog.ts +++ b/frontend/src/features/org/org-uploads-dialog.ts @@ -136,10 +136,11 @@ export class OrgUploadsDialog extends BtrixElement { }; render() { - const { all, canceled, inProgress } = this.uploadsByStatus; + const { all, canceled, inProgress, uploaded } = this.uploadsByStatus; const totalCount = all.length; const inProgressCount = inProgress.length; const canceledCount = canceled.length; + const uploadedCount = uploaded.length; const allDone = inProgressCount === 0; const allCanceled = canceledCount === totalCount; @@ -148,16 +149,18 @@ export class OrgUploadsDialog extends BtrixElement { const number_of_files_in_progress = this.localize.number(inProgressCount); const plural_of_files_in_progress = pluralOf("files", inProgressCount); - const number_of_files = this.localize.number(totalCount); - const plural_of_files = pluralOf("files", totalCount); + const number_of_uploaded_files = this.localize.number(uploadedCount); + const plural_of_uploaded_files = pluralOf("files", uploadedCount); const plural_of_uploads = pluralOf("uploads", canceledCount); + const variant = allDone && uploadedCount ? "success" : "primary"; + return html`
@@ -188,7 +191,9 @@ export class OrgUploadsDialog extends BtrixElement { ${allCanceled ? msg(str`Canceled file ${plural_of_uploads}`) : allDone - ? msg(str`Uploaded ${number_of_files} ${plural_of_files}`) + ? msg( + str`Uploaded ${number_of_uploaded_files} ${plural_of_uploaded_files}`, + ) : msg( str`Uploading ${number_of_files_in_progress} ${plural_of_files_in_progress}`, )} @@ -246,7 +251,7 @@ export class OrgUploadsDialog extends BtrixElement { upload: OrgUpload & { uploadId: string }, ) => { const progress = (upload.loaded / upload.total) * 100; - const uploaded = upload.loaded === upload.total; + const uploaded = !upload.canceled && upload.loaded === upload.total; const isItem = Boolean(upload.itemId); const linkBtn = html`` : html` { if (upload.canceled) { removeOrHide(); diff --git a/frontend/src/stories/features/org/OrgUploadsDialog.stories.ts b/frontend/src/stories/features/org/OrgUploadsDialog.stories.ts index 81f9fe6de3..99bc3a6c27 100644 --- a/frontend/src/stories/features/org/OrgUploadsDialog.stories.ts +++ b/frontend/src/stories/features/org/OrgUploadsDialog.stories.ts @@ -213,7 +213,7 @@ export const SomeCanceled: Story = { }, }; -export const MultipleComplete: Story = { +export const MultipleDone: Story = { args: { orgUploads: { "upload-1": { @@ -221,14 +221,14 @@ export const MultipleComplete: Story = { filename: "test_file.wacz", loaded: 50.15 * BYTES_PER_MB, total: 50.15 * BYTES_PER_MB, - itemId: "upload-item-id-1", + canceled: true, }, "upload-2": { itemName: "Test WACZ file with longer file name for testing long file name", filename: "test_file_with_longer_file_name_for_testing_long_file_name.wacz", - loaded: 310 * BYTES_PER_MB, + loaded: 4.85 * BYTES_PER_GB, total: 4.85 * BYTES_PER_GB, itemId: "upload-item-id-2", }, @@ -236,7 +236,7 @@ export const MultipleComplete: Story = { }, }; -export const MixedStates: Story = { +export const MultipleComplete: Story = { args: { orgUploads: { "upload-1": { @@ -244,23 +244,17 @@ export const MixedStates: Story = { filename: "test_file.wacz", loaded: 50.15 * BYTES_PER_MB, total: 50.15 * BYTES_PER_MB, - canceled: true, + itemId: "upload-item-id-1", }, "upload-2": { itemName: "Test WACZ file with longer file name for testing long file name", filename: "test_file_with_longer_file_name_for_testing_long_file_name.wacz", - loaded: 4.85 * BYTES_PER_MB, + loaded: 4.85 * BYTES_PER_GB, total: 4.85 * BYTES_PER_GB, itemId: "upload-item-id-2", }, - "upload-3": { - itemName: "Test WACZ file 2", - filename: "test_file_2.wacz", - loaded: 4.3 * BYTES_PER_MB, - total: 4.3 * BYTES_PER_MB, - }, }, }, }; @@ -289,7 +283,7 @@ export const WithToastStack: Story = { "Test WACZ file with longer file name for testing long file name", filename: "test_file_with_longer_file_name_for_testing_long_file_name.wacz", - loaded: 4.85 * BYTES_PER_MB, + loaded: 4.85 * BYTES_PER_GB, total: 4.85 * BYTES_PER_GB, itemId: "upload-item-id-2", },