Skip to content
Open
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
11 changes: 11 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,14 @@ DATABASE_URL="mysql://db:db@mariadb:3306/db?serverVersion=10.11.2-MariaDB&charse
###> nelmio/cors-bundle ###
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
###< nelmio/cors-bundle ###

###> symfony/mercure-bundle ###
# See https://symfony.com/doc/current/mercure.html#configuration
# The URL of the Mercure hub, used by the app to publish updates (can be a local URL)
MERCURE_URL=http://mercure/.well-known/mercure
# The public URL of the Mercure hub, used by the browser to connect
MERCURE_PUBLIC_URL=https://mercure-${COMPOSE_DOMAIN}/.well-known/mercure
# The secret used to sign the JWTs
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
###< symfony/mercure-bundle ###
###< symfony/mercure-bundle ###
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

* [PR-3](https://github.com/itk-dev/itk-project-database/pull/3)
Add real-time activity feed with autosave
* [PR-2](https://github.com/itk-dev/itk-project-database/pull/2)
Add Symfony UX Turbo and stimulus
* [PR-1](https://github.com/itk-dev/itk-project-database/pull/1)
Expand Down
165 changes: 165 additions & 0 deletions assets/controllers/autosave_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { Controller } from "@hotwired/stimulus";

/*
* Debounced autosave for the initiative form.
*
* 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.
*/
export default class extends Controller {
static targets = ["status", "statusText", "save"];

static values = {
debounce: { type: Number, default: 800 },
savingText: { type: String, default: "Saving…" },
savedText: { type: String, default: "Saved" },
unsavedText: { type: String, default: "Unsaved changes" },
errorText: {
type: String,
default: "Couldn’t save — check the required fields",
},
offlineText: {
type: String,
default: "Save failed — your changes are kept here",
},
filesHintText: { type: String, default: "Click Save to upload files" },
isNew: { type: Boolean, default: false },
};

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

disconnect() {
window.clearTimeout(this.timer);
this.inFlight?.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.
if (event?.target?.type === "file") {
return;
}
// No "unsaved" text — the animated pen icon carries that state.
this.setStatus("", "unsaved");
window.clearTimeout(this.timer);
this.timer = window.setTimeout(() => this.save(), this.debounceValue);
}

async save() {
// 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) {
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;

// Reveal the saving spinner only for slow saves (> 2s); a quick save jumps
// straight to "Gemt" with no flicker.
const savingTimer = window.setTimeout(
() => this.setStatus(this.savingTextValue, "saving"),
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,
});

if (201 === response.status) {
// 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;
window.history.replaceState({}, "", location);
// Show URL = edit URL minus the trailing /edit; lets the
// breadcrumb turn the title into a link to the new record.
this.dispatch("created", {
detail: { showUrl: location.replace(/\/edit$/, "") },
});
}
this.setStatus(
`${this.savedTextValue} · ${this.timestamp()}`,
"saved",
);
} else if (204 === response.status) {
this.setStatus(
`${this.savedTextValue} · ${this.timestamp()}`,
"saved",
);
} else if (422 === response.status) {
this.setStatus(this.errorTextValue, "error");
} else {
this.setStatus(this.offlineTextValue, "error");
}
} catch (error) {
if ("AbortError" !== error.name) {
this.setStatus(this.offlineTextValue, "error");
}
} finally {
window.clearTimeout(savingTimer);
this.creating = false;
}
}

hasPendingFile() {
return Array.from(
this.element.querySelectorAll('input[type="file"]'),
).some((input) => input.files && input.files.length > 0);
}

// Files only upload on an explicit Save, so surface that button while one is staged.
revealSaveForFiles() {
if (this.hasSaveTarget) {
this.saveTarget.hidden = !this.hasPendingFile();
}
}

setStatus(text, state) {
if (!this.hasStatusTarget) {
return;
}
this.statusTarget.className = `autosave-status autosave-status--${state}`;
if (this.hasStatusTextTarget) {
this.statusTextTarget.textContent = text;
}
}

timestamp() {
const locale = document.documentElement.lang || "en";

return new Date().toLocaleTimeString(locale, {
hour: "2-digit",
minute: "2-digit",
});
}
}
89 changes: 89 additions & 0 deletions assets/controllers/dashboard_live_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { Controller } from "@hotwired/stimulus";

/*
* Animates live dashboard updates. The stats, recent list and status bars are
* refreshed by Turbo Streams that replace their markup wholesale, which would
* otherwise change with no transition. This snapshots a region's values just
* before its stream renders and, once rendered, flashes only the cells whose
* value changed (data-live-text) and slides status bars from their old to new
* width (data-live-bar) — so the eye is drawn to what actually changed rather
* than to the whole panel on every broadcast.
*/
export default class extends Controller {
connect() {
this.beforeStreamRender = this.beforeStreamRender.bind(this);
document.addEventListener(
"turbo:before-stream-render",
this.beforeStreamRender,
);
}

disconnect() {
document.removeEventListener(
"turbo:before-stream-render",
this.beforeStreamRender,
);
}

beforeStreamRender(event) {
const id = event.target.getAttribute("target");
const region = id && this.element.querySelector(`#${CSS.escape(id)}`);
if (!region) {
return;
}

const before = this.snapshot(region);
const render = event.detail.render;
event.detail.render = async (streamElement) => {
await render(streamElement);
const updated = this.element.querySelector(`#${CSS.escape(id)}`);
if (updated) {
this.animate(updated, before);
}
};
}

snapshot(region) {
const texts = new Map();
for (const el of region.querySelectorAll("[data-live-text]")) {
texts.set(el.dataset.liveText, el.textContent.trim());
}
const bars = new Map();
for (const el of region.querySelectorAll("[data-live-bar]")) {
bars.set(el.dataset.liveBar, el.style.width);
}
return { texts, bars };
}

animate(region, before) {
for (const el of region.querySelectorAll("[data-live-text]")) {
if (
before.texts.get(el.dataset.liveText) !== el.textContent.trim()
) {
el.classList.add("is-live-flash");
}
}

if (this.prefersReducedMotion) {
return;
}

for (const el of region.querySelectorAll("[data-live-bar]")) {
const from = before.bars.get(el.dataset.liveBar);
const to = el.style.width;
// Animate the visual width old -> new without touching the inline
// style: the bar is rendered at its final width, and a relational
// rescale can move bars whose own count never changed.
if (undefined !== from && from !== to && el.animate) {
el.animate([{ width: from }, { width: to }], {
duration: 450,
easing: "ease",
});
}
}
}

get prefersReducedMotion() {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
}
72 changes: 72 additions & 0 deletions assets/controllers/form_progress_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { Controller } from "@hotwired/stimulus";

/*
* Drives the initiative form's completion bar. Completion = filled / total over a
* fixed set of fields (the `fields` value, shared with the server so the bar and
* the list percentage always agree), grouped by field key so a multi-input field
* (links, funding) counts once. The bar's width and hue (red -> green) track the
* ratio. Used on both the new and edit forms.
*/
export default class extends Controller {
static targets = ["fill", "label"];

static values = {
fields: { type: Array, default: [] },
};

connect() {
this.recompute();
}

recompute() {
if (!this.hasFillTarget) {
return;
}

const wanted = new Set(this.fieldsValue);
const filled = new Set();

for (const el of this.element.querySelectorAll(
"input, select, textarea",
)) {
const key = this.fieldKey(el);
if (key && wanted.has(key) && this.isFilled(el)) {
filled.add(key);
}
}

const total = wanted.size;
const ratio = total ? filled.size / total : 0;
const percent = Math.round(ratio * 100);

this.fillTarget.style.width = `${percent}%`;
this.fillTarget.style.backgroundColor = `hsl(${Math.round(ratio * 120)}, 72%, 45%)`;
this.fillTarget.parentElement?.setAttribute(
"aria-valuenow",
String(percent),
);

if (this.hasLabelTarget) {
this.labelTarget.textContent = `${percent}%`;
}
}

// "initiative[links][0]" -> "links", "initiative[funding][]" -> "funding".
fieldKey(el) {
const match = el.name?.match(/\[([^\]]+)\]/);

return match ? match[1] : null;
}

isFilled(el) {
if ("checkbox" === el.type || "radio" === el.type) {
return el.checked;
}
if ("SELECT" === el.tagName) {
return el.multiple
? Array.from(el.selectedOptions).some((o) => "" !== o.value)
: "" !== el.value;
}
return "" !== el.value.trim();
}
}
Loading