Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

* [PR-19](https://github.com/itk-dev/itk-project-database/pull/19)
Auto-upload files with a progress bar and image preview, view images in an
in-page lightbox, and refresh the media field styling.
* [PR-16](https://github.com/itk-dev/itk-project-database/pull/16)
Turn the strategies and tags fields into a searchable,
shared tag pool where new entries are capitalised and reused as suggestions.
Expand Down
15 changes: 14 additions & 1 deletion assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,14 @@ function initCollections() {
remove.className = "btn btn--danger btn--sm";
remove.dataset.collectionRemove = "";
remove.textContent = collection.dataset.removeLabel || "Remove";
remove.addEventListener("click", () => item.remove());
remove.addEventListener("click", () => {
item.remove();
// Autosave is the only save path now — tell it the form changed so
// the removed row is persisted (and the media frame re-renders).
collection.dispatchEvent(
new Event("change", { bubbles: true }),
);
});
item.appendChild(remove);
};

Expand Down Expand Up @@ -137,3 +144,9 @@ document.addEventListener("turbo:load", () => {
initContactSelect();
initTermSelect();
});

// A turbo-frame swap (the media section reloading after a file upload) replaces
// its collections, so re-bind the add/remove buttons on the fresh markup.
document.addEventListener("turbo:frame-load", () => {
initCollections();
});
152 changes: 105 additions & 47 deletions assets/controllers/autosave_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import { Controller } from "@hotwired/stimulus";
* On the edit form it posts changes to the edit endpoint without re-rendering,
* so focus and caret are never lost. On the new form (isNew) the first valid
* save creates the initiative and the controller swaps to editing that record
* in place (URL + action). File uploads are skipped on autosave and left for an
* explicit Save, which keeps the request equivalent to a manual save.
* in place (URL + action). Picking a file uploads it straight away via the same
* POST; afterwards the media turbo-frame is reloaded so the stored file shows as
* a link and its (now redundant) input is cleared — without that the file would
* linger in the input and re-upload on every later keystroke.
*/
export default class extends Controller {
static targets = ["status", "statusText", "save"];
static targets = ["status", "statusText"];

static values = {
debounce: { type: Number, default: 800 },
Expand All @@ -26,27 +28,25 @@ export default class extends Controller {
default: "Save failed — your changes are kept here",
},
requiredText: { type: String, default: "Add a title to save" },
filesHintText: { type: String, default: "Click Save to upload files" },
isNew: { type: Boolean, default: false },
};

initialize() {
this.timer = null;
this.inFlight = null;
this.creating = false;
this.xhr = null;
this.busy = false;
}

disconnect() {
window.clearTimeout(this.timer);
this.inFlight?.abort();
this.xhr?.abort();
}

schedule(event) {
// Picking a file can't autosave, but it reveals the Save button to upload it.
this.revealSaveForFiles();

// Files are uploaded only on an explicit Save.
// A picked file uploads on its own — save right away, no debounce.
if (event?.target?.type === "file") {
window.clearTimeout(this.timer);
this.save();
return;
}
// No "unsaved" text — the animated pen icon carries that state.
Expand All @@ -64,25 +64,16 @@ export default class extends Controller {
return;
}

// A picked-but-unsaved file is left for Save so we never half-upload it.
if (this.hasPendingFile()) {
this.setStatus(this.filesHintTextValue, "unsaved");
return;
}

// Hold off while a brand-new initiative is being created so we never
// POST the create twice; the next change saves it once it exists.
if (this.creating) {
// A create or a file upload must run to completion; don't start a second
// save on top of one (it would double-create the draft or cut the upload).
if (this.busy) {
return;
}

// Edits can supersede an in-flight request; a create must run to completion.
const creating = this.isNewValue;
if (!creating) {
this.inFlight?.abort();
}
this.inFlight = new AbortController();
this.creating = creating;
const withFiles = this.hasPendingFile();
// Supersede any in-flight plain edit; the new POST carries the whole form.
this.xhr?.abort();
this.busy = this.isNewValue || withFiles;

// Reveal the saving spinner only for slow saves (> 2s); a quick save jumps
// straight to "Gemt" with no flicker.
Expand All @@ -91,21 +82,26 @@ export default class extends Controller {
2000,
);

try {
const response = await fetch(this.element.action, {
method: "POST",
body: new FormData(this.element),
headers: {
"X-Autosave": "1",
"X-Requested-With": "XMLHttpRequest",
},
credentials: "same-origin",
signal: this.inFlight.signal,
});
// Picking a file drives a progress bar in the upload field via these events.
if (withFiles) {
this.dispatch("uploadstart", { target: document });
}
let ok = false;

if (201 === response.status) {
try {
const { status, location } = await this.request(
withFiles
? (percent) =>
this.dispatch("uploadprogress", {
target: document,
detail: { percent },
})
: null,
);

if (201 === status) {
ok = true;
// The draft now exists — edit it in place from here on.
const location = response.headers.get("X-Initiative-Location");
if (location) {
this.element.action = location;
this.isNewValue = false;
Expand All @@ -120,12 +116,19 @@ export default class extends Controller {
`${this.savedTextValue} · ${this.timestamp()}`,
"saved",
);
} else if (204 === response.status) {
if (withFiles) {
this.refreshMedia();
}
} else if (204 === status) {
ok = true;
this.setStatus(
`${this.savedTextValue} · ${this.timestamp()}`,
"saved",
);
} else if (422 === response.status) {
if (withFiles) {
this.refreshMedia();
}
} else if (422 === status) {
this.setStatus(this.errorTextValue, "error");
} else {
this.setStatus(this.offlineTextValue, "error");
Expand All @@ -136,10 +139,57 @@ export default class extends Controller {
}
} finally {
window.clearTimeout(savingTimer);
this.creating = false;
this.busy = false;
this.xhr = null;
if (withFiles) {
this.dispatch("uploadend", {
target: document,
detail: { ok },
});
}
}
}

// POST the whole form via XHR so file uploads can report real progress.
// Resolves with the status and the X-Initiative-Location header (if any);
// rejects with an AbortError when superseded.
request(onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
this.xhr = xhr;
xhr.open("POST", this.element.action, true);
xhr.setRequestHeader("X-Autosave", "1");
xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest");

if (onProgress && xhr.upload) {
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) {
onProgress(
Math.round((event.loaded / event.total) * 100),
);
}
});
}

xhr.addEventListener("load", () =>
resolve({
status: xhr.status,
location: xhr.getResponseHeader("X-Initiative-Location"),
}),
);
xhr.addEventListener("error", () =>
reject(new Error("Network error")),
);
xhr.addEventListener("abort", () => {
const error = new Error("Aborted");
error.name = "AbortError";
reject(error);
});

xhr.send(new FormData(this.element));
});
}

hasPendingFile() {
return Array.from(
this.element.querySelectorAll('input[type="file"]'),
Expand All @@ -152,10 +202,18 @@ export default class extends Controller {
).some((field) => "" === field.value.trim());
}

// Files only upload on an explicit Save, so surface that button while one is staged.
revealSaveForFiles() {
if (this.hasSaveTarget) {
this.saveTarget.hidden = !this.hasPendingFile();
// Reload just the files/images section so a freshly uploaded file shows as a
// link and its input is reset — the rest of the form keeps its state.
refreshMedia() {
const frame = document.getElementById("initiative-media");
if (!frame) {
return;
}
if (frame.src) {
frame.reload();
} else {
// First reload also gives the frame its source (the edit URL).
frame.src = this.element.action;
}
}

Expand Down
92 changes: 92 additions & 0 deletions assets/controllers/file_field_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { Controller } from "@hotwired/stimulus";

/*
* Drives a single upload field's transient UI: once a file is picked it shows
* the name and a progress bar that the autosave controller fills as the form
* (with the file) uploads. On success the media turbo-frame reloads and the
* server-rendered "uploaded" view replaces this; on failure the bar is hidden
* so the user can try again.
*/
export default class extends Controller {
static targets = ["filename", "progress", "bar"];

connect() {
this.onStart = this.onStart.bind(this);
this.onProgress = this.onProgress.bind(this);
this.onEnd = this.onEnd.bind(this);
document.addEventListener("autosave:uploadstart", this.onStart);
document.addEventListener("autosave:uploadprogress", this.onProgress);
document.addEventListener("autosave:uploadend", this.onEnd);
}

disconnect() {
document.removeEventListener("autosave:uploadstart", this.onStart);
document.removeEventListener(
"autosave:uploadprogress",
this.onProgress,
);
document.removeEventListener("autosave:uploadend", this.onEnd);
}

get input() {
return this.element.querySelector('input[type="file"]');
}

get pending() {
const input = this.input;
return Boolean(input && input.files && input.files.length > 0);
}

picked() {
if (!this.pending) {
return;
}
if (this.hasFilenameTarget) {
this.filenameTarget.textContent = this.input.files[0].name;
}
this.show(0);
}

onStart() {
if (this.pending) {
this.show(0);
}
}

onProgress(event) {
if (this.pending) {
this.show(event.detail.percent);
}
}

onEnd(event) {
if (!this.pending) {
return;
}
if (event.detail && event.detail.ok) {
// Hold full while the media frame reloads and replaces this field.
this.setBar(100);
} else {
this.hide();
}
}

show(percent) {
if (this.hasProgressTarget) {
this.progressTarget.hidden = false;
}
this.setBar(percent);
}

setBar(percent) {
if (this.hasBarTarget) {
this.barTarget.style.width = `${percent}%`;
}
}

hide() {
if (this.hasProgressTarget) {
this.progressTarget.hidden = true;
}
}
}
Loading
Loading