Skip to content

Adds download/upload attachment archive UIs #1551

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 38 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
ab5cf00
Improves error handling for archives
Spoffy Apr 12, 2025
44ec8f1
Cleans zip archive handling and removes async requirement
Spoffy Apr 12, 2025
22c61a4
Updates archive tests
Spoffy Apr 12, 2025
b6787f4
Tidies imports
Spoffy Apr 12, 2025
d471af8
Adds missing res.end()
Spoffy Apr 12, 2025
310f0e4
Adds download button for archives
Spoffy Mar 29, 2025
db00fb6
Extracts uploadFormData from uploadFiles
Spoffy Mar 29, 2025
a11a2f8
Adds archive upload UI
Spoffy Mar 29, 2025
ece3119
Adds archive upload UI
Spoffy Apr 1, 2025
e2c7a02
Fixes compile-time error caused by import
Spoffy Apr 1, 2025
970002f
Improves attachment upload notification phrasing
Spoffy Apr 1, 2025
006a532
Updates attachments downloads to new design
Spoffy Apr 3, 2025
1cbead6
Updates copy menu designs + tests
Spoffy Apr 4, 2025
1857f7d
Iterates on attachment download modal
Spoffy Apr 8, 2025
714c645
WIP
Spoffy Apr 12, 2025
06799d9
Adds upload tests
Spoffy Apr 15, 2025
a61d9d2
Adds test for re-upload warning
Spoffy Apr 15, 2025
b2e41c8
Fixes linter failure
Spoffy Apr 15, 2025
09feac2
Updates to latest Figma design
Spoffy Apr 28, 2025
ee3db1e
Merge branch 'main' into spoffy/archive-ui
Spoffy Apr 28, 2025
ab72147
Fixes tests
Spoffy Apr 29, 2025
94c3239
Removes lingering warning message
Spoffy Apr 29, 2025
80bf482
Attempts fix for CI failure
Spoffy Apr 29, 2025
414c922
Makes reconnected toast dismissable, fixes test
Spoffy Apr 29, 2025
b306297
Prevents download link changing page
Spoffy Apr 29, 2025
e15f0ae
Changes file format select style
Spoffy Apr 29, 2025
7314244
Improves error case on chrome
Spoffy Apr 29, 2025
106f686
Improves test with extra assert
Spoffy May 5, 2025
808745f
Cleans import ordering
Spoffy May 5, 2025
45b004e
Removes unused tooltip
Spoffy May 5, 2025
11e5397
Fixes indentation
Spoffy May 5, 2025
46691bc
Clarifies comment on archive upload method
Spoffy May 5, 2025
d615998
Changes to a less-hacky method of eager wrapping
Spoffy May 5, 2025
ed65e9a
Merge branch 'main' into spoffy/archive-ui
Spoffy May 5, 2025
f48dd7c
Fix condition for .tar download prompt showing
Spoffy May 6, 2025
697dc3a
Removes export from uploadFormData
Spoffy May 7, 2025
0407dc4
Guard against setting a possibly disposed observable
Spoffy May 7, 2025
edc597f
Removes dead commented code
Spoffy May 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions app/client/lib/uploads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,23 @@ export async function uploadFiles(
}
}

return new Promise<UploadResult>((resolve, reject) => {
return uploadFormData(docUrl(options.docWorkerUrl, UPLOAD_URL_PATH), formData, onProgress);
}

/**
* POSTs the provided form data to the given endpoint.
* Provides progress tracking, error handling and promises.
* @param {string} url - Endpoint to send form data to.
* @param {FormData} formData - Data to send
* @param {ProgressCB} onProgress - Called periodically during the upload
* @returns {Promise<any>} - Parsed JSON from the endpoint. Uses `any` as no validation is performed.
*/
async function uploadFormData(
url: string, formData: FormData, onProgress: ProgressCB = noop
): Promise<any> {
return new Promise<any>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('post', docUrl(options.docWorkerUrl, UPLOAD_URL_PATH), true);
xhr.open('post', url, true);
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.withCredentials = true;
xhr.upload.addEventListener('progress', (e) => {
Expand Down
83 changes: 74 additions & 9 deletions app/client/ui/DocumentSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ import {makeT} from 'app/client/lib/localization';
import {cssMarkdownSpan} from 'app/client/lib/markdown';
import {reportError} from 'app/client/models/AppModel';
import type {DocPageModel} from 'app/client/models/DocPageModel';
import {reportWarning} from 'app/client/models/errors';
import {urlState} from 'app/client/models/gristUrlState';
import {KoSaveableObservable} from 'app/client/models/modelUtil';
import {AdminSection, AdminSectionItem} from 'app/client/ui/AdminPanelCss';
import {openFilePicker} from 'app/client/ui/FileDialog';
import {hoverTooltip, showTransientTooltip, withInfoTooltip} from 'app/client/ui/tooltips';
import {bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons';
import {cssRadioCheckboxOptions, radioCheckboxOption} from 'app/client/ui2018/checkbox';
Expand Down Expand Up @@ -226,11 +228,11 @@ export class DocSettingsPage extends Disposable {
}),
]),

isDocOwner ? this._buildTransferDom() : null,
isDocOwner ? this._buildAttachmentStorageSection() : null,
);
}

private _buildTransferDom() {
private _buildAttachmentStorageSection() {
const INTERNAL = 'internal', EXTERNAL = 'external';

const storageType = Computed.create(this, use => {
Expand Down Expand Up @@ -350,13 +352,79 @@ export class DocSettingsPage extends Disposable {
]),
]),
),
this._buildAttachmentUploadSection(),
]);
}

private _buildAttachmentUploadSection() {
const isUploadingObs = Observable.create(this, false);
const buttonText = Computed.create(this, (use) => use(isUploadingObs) ? t('Uploading...') : t('Upload'));

const uploadButton = cssSmallButton(
dom.text(buttonText),
dom.on('click',
async () => {
// This may never return due to openFilePicker. Anything past this point isn't guaranteed
// to execute.
const file = await this._pickAttachmentsFile();
if (!file) {
return;
}
if (isUploadingObs.isDisposed()) { return; }
isUploadingObs.set(true);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the presence of the comment above, I think it is better to test if the owner is not disposed after promise returns? (Here and below).

Copy link
Contributor Author

@Spoffy Spoffy May 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what you mean, can you describe the change in more detail?

Are you suggesting we check if the owner is disposed before setting the observable? If so, for my benefit learning GrainJS, what's the reason to do that?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are doing an async operation. By the time the promise is resolved, someone could move to another page, destroying all observables you are setting.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've pushed some guards for this. Is this something that Observables should be handling internally?

It feels like once disposed, they should either error or do nothing if set is called on them.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me know if the additional checks are sufficient!

try {
await this._uploadAttachmentsArchive(file);
} finally {
if (!isUploadingObs.isDisposed()) {
isUploadingObs.set(false);
}
}
}),
dom.prop('disabled', isUploadingObs),
testId('upload-attachment-archive')
);

return dom.create(AdminSectionItem, {
id: 'uploadAttachments',
name: withInfoTooltip(
dom('span', t('Upload missing attachments'), testId('transfer-header')),
'uploadAttachments',
),
value: uploadButton,
});
}

// May never finish - see `openFilePicker` for more info.
private async _pickAttachmentsFile(): Promise<File | undefined> {
const files = await openFilePicker({
multiple: false,
accept: ".tar",
});
return files[0];
}

private async _uploadAttachmentsArchive(file: File) {
try {
const uploadResult = await this._gristDoc.docApi.uploadAttachmentArchive(file);
this._gristDoc.app.topAppModel.notifier.createNotification({
title: "Attachments upload complete",
message: `${uploadResult.added} attachment files reconnected`,
level: 'info',
canUserClose: true,
expireSec: 5,
});
} catch (err) {
reportWarning(err.toString(), {
key: "attachmentArchiveUploadError",
title: "Attachments upload failed",
level: 'error'
});
}
}

private async _reloadEngine(ask = true) {
const docPageModel = this._gristDoc.docPageModel;
const handler = async () => {
await docPageModel.appModel.api.getDocAPI(docPageModel.currentDocId.get()!).forceReload();
await this._gristDoc.docApi.forceReload();
document.location.reload();
};
if (!ask) {
Expand All @@ -377,7 +445,6 @@ export class DocSettingsPage extends Disposable {
}

private async _startTiming() {
const docPageModel = this._gristDoc.docPageModel;
modal((ctl, owner) => {
this.onDispose(() => ctl.close());
const selected = Observable.create<TimingModalOption>(owner, TimingModalOption.Adhoc);
Expand All @@ -387,7 +454,7 @@ export class DocSettingsPage extends Disposable {
if (selected.get() === TimingModalOption.Reload) {
page.set(TimingModalPage.Spinner);
await this._gristDoc.docApi.startTiming();
await docPageModel.appModel.api.getDocAPI(docPageModel.currentDocId.get()!).forceReload();
await this._gristDoc.docApi.forceReload();
ctl.close();
urlState().pushUrl({docPage: 'timing'}).catch(reportError);
} else {
Expand Down Expand Up @@ -551,10 +618,9 @@ export class DocSettingsPage extends Disposable {
}

private async _doSetEngine(val: EngineCode|undefined) {
const docPageModel = this._gristDoc.docPageModel;
if (this._engine.get() !== val) {
await this._docInfo.documentSettingsJson.prop('engine').saveOnly(val);
await docPageModel.appModel.api.getDocAPI(docPageModel.currentDocId.get()!).forceReload();
await this._gristDoc.docApi.forceReload();
}
}
}
Expand Down Expand Up @@ -698,7 +764,6 @@ function stillInternalCopy(inProgress: Observable<boolean>, ...args: IDomArgs<HT
});
}


const cssContainer = styled('div', `
overflow-y: auto;
position: relative;
Expand Down
10 changes: 10 additions & 0 deletions app/client/ui/GristTooltips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export type Tooltip =
| 'viewAsBanner'
| 'reassignTwoWayReference'
| 'attachmentStorage'
| 'uploadAttachments'
| 'adminControls'
;

Expand Down Expand Up @@ -234,6 +235,15 @@ see or edit which parts of your document.')
dom('div', cssLink({href: commonUrls.helpAdminControls, target: "_blank"}, t('Learn more.'))),
...args,
),
uploadAttachments: (...args: DomElementArg[]) => cssTooltipContent(
cssMarkdownSpan(
t(
"This allows you to add attachments that are missing from external storage, e.g. in an imported document. " +
"Only .tar attachment archives downloaded from Grist can be uploaded here."
),
),
...args,
),
};

type ErrorTooltip = 'summaryFormulas';
Expand Down
Loading