diff --git a/.env b/.env index bde6dd0..c763b11 100644 --- a/.env +++ b/.env @@ -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 ### diff --git a/CHANGELOG.md b/CHANGELOG.md index a28b62a..f71abab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/assets/controllers/autosave_controller.js b/assets/controllers/autosave_controller.js new file mode 100644 index 0000000..847fc22 --- /dev/null +++ b/assets/controllers/autosave_controller.js @@ -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", + }); + } +} diff --git a/assets/controllers/dashboard_live_controller.js b/assets/controllers/dashboard_live_controller.js new file mode 100644 index 0000000..92321cd --- /dev/null +++ b/assets/controllers/dashboard_live_controller.js @@ -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; + } +} diff --git a/assets/controllers/form_progress_controller.js b/assets/controllers/form_progress_controller.js new file mode 100644 index 0000000..0b4a94c --- /dev/null +++ b/assets/controllers/form_progress_controller.js @@ -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(); + } +} diff --git a/assets/controllers/title_mirror_controller.js b/assets/controllers/title_mirror_controller.js new file mode 100644 index 0000000..641184c --- /dev/null +++ b/assets/controllers/title_mirror_controller.js @@ -0,0 +1,48 @@ +import { Controller } from "@hotwired/stimulus"; + +/* + * Mirrors the initiative title into the page heading and breadcrumb as it is + * typed, so a new (or freshly auto-created) initiative is identifiable before + * the page is ever reloaded. Falls back to the original text when emptied. + */ +export default class extends Controller { + static targets = ["heading", "crumb"]; + + connect() { + this.defaults = new Map(); + for (const el of this.mirrors) { + this.defaults.set(el, el.textContent); + } + } + + update(event) { + const value = event.target.value.trim(); + for (const el of this.mirrors) { + el.textContent = value || this.defaults.get(el); + } + } + + // Once the draft is created it has a page of its own, so turn the breadcrumb + // crumb from plain text into a link to it (keeping it a live-updating target). + linkCrumb(event) { + const url = event.detail?.showUrl; + if (!url) { + return; + } + this.crumbTargets.forEach((el) => { + if ("A" === el.tagName) { + return; + } + const link = document.createElement("a"); + link.href = url; + link.textContent = el.textContent; + link.setAttribute("data-title-mirror-target", "crumb"); + this.defaults.set(link, this.defaults.get(el) ?? el.textContent); + el.replaceWith(link); + }); + } + + get mirrors() { + return [...this.headingTargets, ...this.crumbTargets]; + } +} diff --git a/assets/styles/app.css b/assets/styles/app.css index f61b5b9..0a272bc 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -482,7 +482,17 @@ h3 { display: grid; grid-template-columns: 1.4fr 1fr; gap: var(--itk-space-5); - align-items: start; +} + +.dashboard-col { + display: flex; + flex-direction: column; + gap: var(--itk-space-5); +} + +.card--activity { + display: flex; + flex-direction: column; } @media (max-width: 900px) { @@ -1110,3 +1120,266 @@ textarea { gap: var(--itk-space-2); margin-top: var(--itk-space-3); } + +.form-statusbar { + position: sticky; + top: calc(64px + var(--itk-space-3)); + z-index: 50; + display: flex; + flex-direction: column; + align-items: stretch; + gap: var(--itk-space-2); + padding: var(--itk-space-3) var(--itk-space-4); + background-color: var(--itk-surface); + border: 1px solid var(--itk-slate-200); + border-radius: var(--itk-radius-3); + box-shadow: var(--itk-shadow-1); +} + +.form-progress { + display: flex; + align-items: center; + gap: var(--itk-space-3); + flex: 1; +} + +.form-progress__title { + font-size: var(--itk-text-sm); + font-weight: 600; + color: var(--itk-slate-700); + white-space: nowrap; +} + +.form-progress__track { + flex: 1; + height: 8px; + border-radius: 999px; + background-color: var(--itk-slate-200); + overflow: hidden; +} + +.form-progress__fill { + height: 100%; + width: 0; + border-radius: inherit; + background-color: var(--itk-danger); + transition: + width 240ms ease, + background-color 240ms ease; +} + +.form-progress__label { + font-size: var(--itk-text-sm); + font-weight: 600; + color: var(--itk-slate-700); + min-width: 3ch; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.completion { + display: flex; + align-items: center; + gap: var(--itk-space-2); +} + +.completion__bar { + display: block; + width: 64px; + height: 6px; + border-radius: 999px; + background-color: var(--itk-slate-200); + overflow: hidden; +} + +.completion__fill { + display: block; + height: 100%; + border-radius: inherit; +} + +.completion__value { + font-size: var(--itk-text-xs); + font-weight: 600; + color: var(--itk-slate-700); + min-width: 4ch; + font-variant-numeric: tabular-nums; +} + +.form-statusbar__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--itk-space-3); +} + +.autosave-status { + display: inline-flex; + align-items: center; + gap: var(--itk-space-2); + font-size: var(--itk-text-sm); + color: var(--itk-slate-500); +} + +.autosave-status__text:empty { + display: none; +} + +.autosave-status__icon { + display: none; + width: 14px; + height: 14px; + flex: none; + background: center / contain no-repeat; +} + +.autosave-status--unsaved .autosave-status__icon { + display: inline-block; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23b45309' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 20h9'/%3E%3Cpath d='M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z'/%3E%3C/svg%3E"); + transform-origin: 75% 75%; + animation: autosave-write 1s ease-in-out infinite; +} + +.autosave-status--saving .autosave-status__icon { + display: inline-block; + border: 2px solid var(--itk-slate-200); + border-top-color: var(--itk-primary); + border-radius: 50%; + animation: autosave-spin 0.6s linear infinite; +} + +.autosave-status--saved .autosave-status__icon { + display: inline-block; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23008d3d' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E"); +} + +.autosave-status--saving { + color: var(--itk-slate-500); +} + +.autosave-status--saved { + color: var(--itk-green); +} + +.autosave-status--unsaved { + color: #b45309; +} + +.autosave-status--error { + color: var(--itk-accent); +} + +@keyframes autosave-spin { + to { + transform: rotate(360deg); + } +} + +@keyframes autosave-write { + 0%, + 100% { + transform: rotate(-10deg); + } + 50% { + transform: rotate(6deg) translateY(-1px); + } +} + +.activity-feed-wrap { + position: relative; + flex: 1; + min-height: 16rem; +} + +.activity-feed { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + overflow-y: auto; +} + +.activity-item { + display: flex; + align-items: flex-start; + gap: var(--itk-space-3); + padding: var(--itk-space-3) var(--itk-space-5); + border-bottom: 1px solid var(--itk-slate-100); +} + +.activity-item:last-child { + border-bottom: none; +} + +.activity-item__dot { + margin-top: 6px; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--itk-slate-300); + flex: none; +} + +.activity-item--created .activity-item__dot { + background: var(--itk-green); +} + +.activity-item--updated .activity-item__dot { + background: var(--itk-blue); +} + +.activity-item--deleted .activity-item__dot { + background: var(--itk-accent); +} + +.activity-item__body { + display: flex; + flex-direction: column; + gap: 2px; +} + +.activity-item__time { + font-size: var(--itk-text-xs); + color: var(--itk-slate-500); +} + +@keyframes activity-flash { + from { + background: var(--itk-slate-50); + opacity: 0.35; + transform: translateY(-4px); + } + to { + background: transparent; + opacity: 1; + transform: translateY(0); + } +} + +.activity-feed > .activity-item:first-child { + animation: activity-flash 1.2s ease-out; +} + +@keyframes live-flash { + 0% { + background-color: color-mix(in srgb, var(--itk-blue) 22%, transparent); + } + 100% { + background-color: transparent; + } +} + +.is-live-flash { + animation: live-flash 1.1s ease-out; + border-radius: var(--itk-radius-2); +} + +@media (prefers-reduced-motion: reduce) { + .is-live-flash { + animation: none; + } + + .activity-feed > .activity-item:first-child { + animation: none; + } +} diff --git a/compose.override.yaml b/compose.override.yaml index 2906099..1a5d676 100644 --- a/compose.override.yaml +++ b/compose.override.yaml @@ -3,4 +3,10 @@ services: database: ports: - "5432" -###< doctrine/doctrine-bundle ### + ###< doctrine/doctrine-bundle ### + + ###> symfony/mercure-bundle ### + mercure: + ports: + - "80" +###< symfony/mercure-bundle ### diff --git a/composer.json b/composer.json index 69d3c45..2479e11 100644 --- a/composer.json +++ b/composer.json @@ -20,6 +20,7 @@ "symfony/flex": "^2", "symfony/form": "~8.1.0", "symfony/framework-bundle": "~8.1.0", + "symfony/mercure-bundle": "^0.4.2", "symfony/rate-limiter": "~8.1.0", "symfony/runtime": "~8.1.0", "symfony/security-bundle": "~8.1.0", diff --git a/composer.lock b/composer.lock index 47e20b1..80024cc 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7fe64938328a8a2a46bf0789516c6001", + "content-hash": "e0811affeb7f6e47553cf6e50913ebaa", "packages": [ { "name": "api-platform/core", @@ -1480,6 +1480,79 @@ }, "time": "2025-11-30T20:12:26+00:00" }, + { + "name": "lcobucci/jwt", + "version": "5.6.0", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/jwt.git", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "ext-sodium": "*", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/clock": "^1.0" + }, + "require-dev": { + "infection/infection": "^0.29", + "lcobucci/clock": "^3.2", + "lcobucci/coding-standard": "^11.0", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.2", + "phpstan/phpstan": "^1.10.7", + "phpstan/phpstan-deprecation-rules": "^1.1.3", + "phpstan/phpstan-phpunit": "^1.3.10", + "phpstan/phpstan-strict-rules": "^1.5.0", + "phpunit/phpunit": "^11.1" + }, + "suggest": { + "lcobucci/clock": ">= 3.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com", + "role": "Developer" + } + ], + "description": "A simple library to work with JSON Web Token and JSON Web Signature", + "keywords": [ + "JWS", + "jwt" + ], + "support": { + "issues": "https://github.com/lcobucci/jwt/issues", + "source": "https://github.com/lcobucci/jwt/tree/5.6.0" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2025-10-17T11:30:53+00:00" + }, { "name": "league/csv", "version": "9.28.0", @@ -3925,6 +3998,172 @@ ], "time": "2026-05-29T08:46:08+00:00" }, + { + "name": "symfony/mercure", + "version": "v0.7.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/mercure.git", + "reference": "3ba1d19c9792d6bf66cf6cb4412ea289e9a42565" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mercure/zipball/3ba1d19c9792d6bf66cf6cb4412ea289e9a42565", + "reference": "3ba1d19c9792d6bf66cf6cb4412ea289e9a42565", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.0|^3.0", + "symfony/http-client": "^6.4|^7.3|^8.0", + "symfony/http-foundation": "^6.4|^7.3|^8.0", + "symfony/web-link": "^6.4|^7.3|^8.0" + }, + "require-dev": { + "lcobucci/jwt": "^3.4|^4.0|^5.0", + "symfony/event-dispatcher": "^6.4|^7.3|^8.0", + "symfony/http-kernel": "^6.4|^7.3|^8.0", + "symfony/phpunit-bridge": "^7.3.4|^8.0", + "symfony/stopwatch": "^6.4|^7.3|^8.0", + "twig/twig": "^2.0|^3.0|^4.0" + }, + "suggest": { + "symfony/stopwatch": "Integration with the profiler performances" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/dunglas/mercure", + "name": "dunglas/mercure" + }, + "branch-alias": { + "dev-main": "0.6.x-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Mercure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Mercure Component", + "homepage": "https://symfony.com", + "keywords": [ + "mercure", + "push", + "sse", + "updates" + ], + "support": { + "issues": "https://github.com/symfony/mercure/issues", + "source": "https://github.com/symfony/mercure/tree/v0.7.2" + }, + "funding": [ + { + "url": "https://github.com/dunglas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/mercure", + "type": "tidelift" + } + ], + "time": "2025-12-15T15:22:09+00:00" + }, + { + "name": "symfony/mercure-bundle", + "version": "v0.4.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/mercure-bundle.git", + "reference": "eae8bf5a75b4e1203bd9aa4181c7950a4df4b3e3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mercure-bundle/zipball/eae8bf5a75b4e1203bd9aa4181c7950a4df4b3e3", + "reference": "eae8bf5a75b4e1203bd9aa4181c7950a4df4b3e3", + "shasum": "" + }, + "require": { + "lcobucci/jwt": "^3.4|^4.0|^5.0", + "php": ">=8.1", + "symfony/config": "^6.4|^7.3|^8.0", + "symfony/dependency-injection": "^6.4|^7.3|^8.0", + "symfony/http-kernel": "^6.4|^7.3|^8.0", + "symfony/mercure": "*", + "symfony/web-link": "^6.4|^7.3|^8.0" + }, + "require-dev": { + "symfony/phpunit-bridge": "^7.3.4|^8.0", + "symfony/stopwatch": "^6.4|^7.3|^8.0", + "symfony/ux-turbo": "*", + "symfony/var-dumper": "^6.4|^7.3|^8.0" + }, + "suggest": { + "symfony/messenger": "To use the Messenger integration" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Bundle\\MercureBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony MercureBundle", + "homepage": "https://symfony.com", + "keywords": [ + "mercure", + "push", + "sse", + "updates" + ], + "support": { + "issues": "https://github.com/symfony/mercure-bundle/issues", + "source": "https://github.com/symfony/mercure-bundle/tree/v0.4.2" + }, + "funding": [ + { + "url": "https://github.com/dunglas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/mercure-bundle", + "type": "tidelift" + } + ], + "time": "2025-11-25T12:51:49+00:00" + }, { "name": "symfony/mime", "version": "v8.1.0", diff --git a/config/bundles.php b/config/bundles.php index 9157181..5a5b42a 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -16,4 +16,5 @@ Zenstruck\Foundry\ZenstruckFoundryBundle::class => ['dev' => true, 'test' => true], Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true], Symfony\UX\Turbo\TurboBundle::class => ['all' => true], + Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true], ]; diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index 290611c..ff1e3e1 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -13,6 +13,8 @@ doctrine: identity_generation_preferences: Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity auto_mapping: true + resolve_target_entities: + Symfony\Component\Security\Core\User\UserInterface: App\Entity\User mappings: App: type: attribute diff --git a/config/packages/mercure.yaml b/config/packages/mercure.yaml new file mode 100644 index 0000000..970ea9c --- /dev/null +++ b/config/packages/mercure.yaml @@ -0,0 +1,8 @@ +mercure: + hubs: + default: + url: '%env(default::MERCURE_URL)%' + public_url: '%env(default::MERCURE_PUBLIC_URL)%' + jwt: + secret: '%env(MERCURE_JWT_SECRET)%' + publish: '*' diff --git a/config/reference.php b/config/reference.php index 9f4a7f0..830f746 100644 --- a/config/reference.php +++ b/config/reference.php @@ -825,7 +825,7 @@ * }, * }, * mercure?: bool|array{ - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * hub_url?: scalar|Param|null, // The URL sent in the Link HTTP header. If not set, will default to the URL for MercureBundle's default hub. // Default: null * include_type?: bool|Param, // Always include @type in updates (including delete ones). // Default: false * }, @@ -1733,6 +1733,27 @@ * }, * default_transport?: scalar|Param|null, // Default: "default" * } + * @psalm-type MercureConfig = array{ + * hubs?: array, + * subscribe?: list, + * secret?: scalar|Param|null, // The JWT Secret to use. + * passphrase?: scalar|Param|null, // The JWT secret passphrase. // Default: "" + * algorithm?: scalar|Param|null, // The algorithm to use to sign the JWT // Default: "hmac.sha256" + * }, + * jwt_provider?: scalar|Param|null, // Deprecated: The child node "jwt_provider" at path "mercure.hubs..jwt_provider" is deprecated, use "jwt.provider" instead. // The ID of a service to call to generate the JSON Web Token. + * bus?: scalar|Param|null, // Name of the Messenger bus where the handler for this hub must be registered. Default to the default bus if Messenger is enabled. + * }>, + * default_hub?: scalar|Param|null, + * default_cookie_lifetime?: int|Param, // Default lifetime of the cookie containing the JWT, in seconds. Defaults to the value of "framework.session.cookie_lifetime". // Default: null + * enable_profiler?: bool|Param, // Deprecated: The child node "enable_profiler" at path "mercure.enable_profiler" is deprecated. // Enable Symfony Web Profiler integration. + * } * @psalm-type ConfigType = array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -1748,6 +1769,7 @@ * vich_uploader?: VichUploaderConfig, * stimulus?: StimulusConfig, * turbo?: TurboConfig, + * mercure?: MercureConfig, * "when@dev"?: array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -1766,6 +1788,7 @@ * zenstruck_foundry?: ZenstruckFoundryConfig, * stimulus?: StimulusConfig, * turbo?: TurboConfig, + * mercure?: MercureConfig, * }, * "when@prod"?: array{ * imports?: ImportsConfig, @@ -1782,6 +1805,7 @@ * vich_uploader?: VichUploaderConfig, * stimulus?: StimulusConfig, * turbo?: TurboConfig, + * mercure?: MercureConfig, * }, * "when@test"?: array{ * imports?: ImportsConfig, @@ -1800,6 +1824,7 @@ * zenstruck_foundry?: ZenstruckFoundryConfig, * stimulus?: StimulusConfig, * turbo?: TurboConfig, + * mercure?: MercureConfig, * }, * ...addSql('ALTER TABLE initiative ADD created_by_id INT DEFAULT NULL, ADD modified_by_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE initiative ADD CONSTRAINT FK_E115DEFEB03A8386 FOREIGN KEY (created_by_id) REFERENCES `user` (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE initiative ADD CONSTRAINT FK_E115DEFE99049ECE FOREIGN KEY (modified_by_id) REFERENCES `user` (id) ON DELETE SET NULL'); + $this->addSql('CREATE INDEX IDX_E115DEFEB03A8386 ON initiative (created_by_id)'); + $this->addSql('CREATE INDEX IDX_E115DEFE99049ECE ON initiative (modified_by_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE initiative DROP FOREIGN KEY FK_E115DEFEB03A8386'); + $this->addSql('ALTER TABLE initiative DROP FOREIGN KEY FK_E115DEFE99049ECE'); + $this->addSql('DROP INDEX IDX_E115DEFEB03A8386 ON initiative'); + $this->addSql('DROP INDEX IDX_E115DEFE99049ECE ON initiative'); + $this->addSql('ALTER TABLE initiative DROP created_by_id, DROP modified_by_id'); + } +} diff --git a/src/Controller/InitiativeController.php b/src/Controller/InitiativeController.php index f5116d3..2b80067 100644 --- a/src/Controller/InitiativeController.php +++ b/src/Controller/InitiativeController.php @@ -5,10 +5,12 @@ namespace App\Controller; use App\Entity\Initiative; +use App\Entity\User; use App\Form\InitiativeFilterType; use App\Form\InitiativeType; use App\Model\InitiativeFilter; use App\Repository\InitiativeRepository; +use App\Service\ActivityPublisher; use App\Service\Paginator; use Doctrine\ORM\EntityManagerInterface; use League\Csv\Writer; @@ -107,10 +109,18 @@ public function export(Request $request, InitiativeRepository $initiatives, Tran } #[Route('/initiatives/new', name: 'app_initiative_new', methods: ['GET', 'POST'])] - public function new(Request $request, EntityManagerInterface $entityManager): Response + public function new(Request $request, EntityManagerInterface $entityManager, ActivityPublisher $activityPublisher): Response { + // Autosave creates the initiative as soon as the form is valid; the page + // then switches to editing it in place, so a new form saves like an edit. + // Same guard as edit(): the mandatory X-Autosave header stands in for CSRF. + $isAutosave = $request->headers->has('X-Autosave'); + $initiative = new Initiative(); - $form = $this->createForm(InitiativeType::class, $initiative); + $form = $this->createForm(InitiativeType::class, $initiative, [ + 'csrf_protection' => !$isAutosave, + 'allow_extra_fields' => $isAutosave, + ]); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { @@ -118,11 +128,26 @@ public function new(Request $request, EntityManagerInterface $entityManager): Re $entityManager->persist($initiative); $entityManager->flush(); + $activityPublisher->publish('created', $initiative, $this->currentUser()); + + if ($isAutosave) { + // Hand back the edit URL so the form keeps autosaving in place. + $response = new Response(null, Response::HTTP_CREATED); + $response->headers->set('X-Initiative-Location', $this->generateUrl('app_initiative_edit', ['id' => $initiative->getId()])); + + return $response; + } + $this->addFlash('success', 'flash.initiative.created'); return $this->redirectToRoute('app_initiative_show', ['id' => $initiative->getId()]); } + if ($isAutosave) { + // Not valid yet (e.g. no title): report it without creating anything. + return new Response(null, Response::HTTP_UNPROCESSABLE_ENTITY); + } + return $this->render('initiative/new.html.twig', [ 'form' => $form, 'initiative' => $initiative, @@ -138,20 +163,45 @@ public function show(Initiative $initiative): Response } #[Route('/initiatives/{id}/edit', name: 'app_initiative_edit', requirements: ['id' => '\d+'], methods: ['GET', 'POST'])] - public function edit(Request $request, Initiative $initiative, EntityManagerInterface $entityManager): Response + public function edit(Request $request, Initiative $initiative, EntityManagerInterface $entityManager, ActivityPublisher $activityPublisher): Response { - $form = $this->createForm(InitiativeType::class, $initiative); + // Autosave posts the same form via fetch; it expects to stay on the page. + $isAutosave = $request->headers->has('X-Autosave'); + + // A background fetch can't run the stateless-CSRF JS (no real submit), so the + // sentinel token never resolves. Autosave is guarded instead by the mandatory + // X-Autosave header — a cross-origin caller can't set it without a refused CORS + // pre-flight — so we drop CSRF and ignore the now-stray _token field for it. + $form = $this->createForm(InitiativeType::class, $initiative, [ + 'csrf_protection' => !$isAutosave, + 'allow_extra_fields' => $isAutosave, + ]); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $this->removeEmptyMedia($initiative); $entityManager->flush(); + // Autosave is now the only save path on this form (the Save button is + // gone), so it must drive the live feed and dashboard too. Repeated + // autosaves of the same initiative don't flood the feed: each row is + // keyed by id and bumped in place rather than stacked. + $activityPublisher->publish('updated', $initiative, $this->currentUser()); + + if ($isAutosave) { + return new Response(null, Response::HTTP_NO_CONTENT); + } + $this->addFlash('success', 'flash.initiative.updated'); return $this->redirectToRoute('app_initiative_show', ['id' => $initiative->getId()]); } + if ($isAutosave) { + // Report the failed validation without redrawing the form the user is editing. + return new Response(null, Response::HTTP_UNPROCESSABLE_ENTITY); + } + return $this->render('initiative/edit.html.twig', [ 'form' => $form, 'initiative' => $initiative, @@ -159,17 +209,31 @@ public function edit(Request $request, Initiative $initiative, EntityManagerInte } #[Route('/initiatives/{id}/delete', name: 'app_initiative_delete', requirements: ['id' => '\d+'], methods: ['POST'])] - public function delete(Request $request, Initiative $initiative, EntityManagerInterface $entityManager): Response + public function delete(Request $request, Initiative $initiative, EntityManagerInterface $entityManager, ActivityPublisher $activityPublisher): Response { if ($this->isCsrfTokenValid('delete-initiative-'.$initiative->getId(), (string) $request->request->get('_token'))) { + $actor = $this->currentUser(); + $entityManager->remove($initiative); $entityManager->flush(); + + // Publish after removal so the live dashboard counts are already up to + // date; the detached entity still holds its title for the feed line. + $activityPublisher->publish('deleted', $initiative, $actor); + $this->addFlash('success', 'flash.initiative.deleted'); } return $this->redirectToRoute('app_initiative_index'); } + private function currentUser(): ?User + { + $user = $this->getUser(); + + return $user instanceof User ? $user : null; + } + /** * Drop media rows the user added but left empty (no uploaded file). */ diff --git a/src/Entity/BlameableInterface.php b/src/Entity/BlameableInterface.php new file mode 100644 index 0000000..5dd132f --- /dev/null +++ b/src/Entity/BlameableInterface.php @@ -0,0 +1,21 @@ + ['initiative:read']], + normalizationContext: ['groups' => ['initiative:read', 'timestampable:read']], paginationItemsPerPage: 50, paginationClientItemsPerPage: true, order: ['createdAt' => 'DESC'], @@ -53,8 +54,24 @@ #[ApiFilter(RangeFilter::class, properties: ['budget'])] #[ApiFilter(DateFilter::class, properties: ['timePeriodStart', 'timePeriodEnd', 'createdAt'])] #[ApiFilter(OrderFilter::class, properties: ['title', 'budget', 'createdAt', 'timePeriodStart'])] -class Initiative +class Initiative implements BlameableInterface, TimestampableInterface { + use BlameableTrait; + use TimestampableTrait; + + /** + * Fields that count toward {@see getCompletionPercentage()} and the client-side + * progress bar. Limited to the initiative's own columns so list rendering stays + * query-free; the booleans and the relational lists are intentionally excluded. + * + * @var list + */ + public const array COMPLETION_FIELDS = [ + 'title', 'category', 'description', 'initiativeType', 'status', + 'organizationalAnchoring', 'endorsementAuthor', + 'budget', 'funding', 'timePeriodStart', 'timePeriodEnd', 'author', + ]; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] @@ -164,14 +181,6 @@ class Initiative #[Groups(['initiative:read'])] private bool $published = true; - #[ORM\Column] - #[Groups(['initiative:read'])] - private \DateTimeImmutable $createdAt; - - #[ORM\Column] - #[Groups(['initiative:read'])] - private \DateTimeImmutable $updatedAt; - public function __construct() { $this->strategies = new ArrayCollection(); @@ -180,14 +189,6 @@ public function __construct() $this->tags = new ArrayCollection(); $this->images = new ArrayCollection(); $this->attachments = new ArrayCollection(); - $this->createdAt = new \DateTimeImmutable(); - $this->updatedAt = new \DateTimeImmutable(); - } - - #[ORM\PreUpdate] - public function touch(): void - { - $this->updatedAt = new \DateTimeImmutable(); } public function getId(): ?int @@ -564,14 +565,28 @@ public function setPublished(bool $published): static return $this; } - public function getCreatedAt(): \DateTimeImmutable - { - return $this->createdAt; - } - - public function getUpdatedAt(): \DateTimeImmutable - { - return $this->updatedAt; + /** + * Share of {@see COMPLETION_FIELDS} that are filled in, as a 0–100 percentage. + * Reads only own columns, so it is safe to call per row in a listing. + */ + public function getCompletionPercentage(): int + { + $checks = [ + null !== $this->title && '' !== $this->title, + null !== $this->category, + null !== $this->description && '' !== $this->description, + null !== $this->initiativeType, + null !== $this->status, + null !== $this->organizationalAnchoring, + null !== $this->endorsementAuthor, + null !== $this->budget, + [] !== $this->funding, + null !== $this->timePeriodStart, + null !== $this->timePeriodEnd, + null !== $this->author && '' !== $this->author, + ]; + + return (int) round(\count(array_filter($checks)) / \count($checks) * 100); } public function __toString(): string diff --git a/src/Entity/TimestampableInterface.php b/src/Entity/TimestampableInterface.php new file mode 100644 index 0000000..77e88a6 --- /dev/null +++ b/src/Entity/TimestampableInterface.php @@ -0,0 +1,19 @@ +createdBy; + } + + public function getModifiedBy(): ?UserInterface + { + return $this->modifiedBy; + } + + public function setCreatedBy(?UserInterface $user): void + { + $this->createdBy = $user; + } + + public function setModifiedBy(?UserInterface $user): void + { + $this->modifiedBy = $user; + } +} diff --git a/src/Entity/Trait/TimestampableTrait.php b/src/Entity/Trait/TimestampableTrait.php new file mode 100644 index 0000000..9df7930 --- /dev/null +++ b/src/Entity/Trait/TimestampableTrait.php @@ -0,0 +1,47 @@ +flush()`. `?? null` is safe on uninitialized typed + // properties (PHP 8+) and lets callers null-check before flush. + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt ?? null; + } + + public function getUpdatedAt(): ?\DateTimeImmutable + { + return $this->updatedAt ?? null; + } + + public function setCreatedAt(\DateTimeImmutable $createdAt): void + { + $this->createdAt = $createdAt; + } + + public function setUpdatedAt(\DateTimeImmutable $updatedAt): void + { + $this->updatedAt = $updatedAt; + } +} diff --git a/src/EventListener/BlameableListener.php b/src/EventListener/BlameableListener.php new file mode 100644 index 0000000..cb806e4 --- /dev/null +++ b/src/EventListener/BlameableListener.php @@ -0,0 +1,55 @@ +security->getUser(); + if (!$user instanceof UserInterface) { + return; + } + + $em = $args->getObjectManager(); + $uow = $em->getUnitOfWork(); + + foreach ($uow->getScheduledEntityInsertions() as $entity) { + if (!$entity instanceof BlameableInterface) { + continue; + } + if (null === $entity->getCreatedBy()) { + $entity->setCreatedBy($user); + } + $entity->setModifiedBy($user); + $uow->recomputeSingleEntityChangeSet($em->getClassMetadata($entity::class), $entity); + } + + foreach ($uow->getScheduledEntityUpdates() as $entity) { + if (!$entity instanceof BlameableInterface) { + continue; + } + $entity->setModifiedBy($user); + $uow->recomputeSingleEntityChangeSet($em->getClassMetadata($entity::class), $entity); + } + } +} diff --git a/src/EventListener/TimestampableListener.php b/src/EventListener/TimestampableListener.php new file mode 100644 index 0000000..fc910b9 --- /dev/null +++ b/src/EventListener/TimestampableListener.php @@ -0,0 +1,45 @@ +getObjectManager(); + $uow = $em->getUnitOfWork(); + $now = new \DateTimeImmutable(); + + foreach ($uow->getScheduledEntityInsertions() as $entity) { + if (!$entity instanceof TimestampableInterface) { + continue; + } + if (null === $entity->getCreatedAt()) { + $entity->setCreatedAt($now); + } + $entity->setUpdatedAt($now); + $uow->recomputeSingleEntityChangeSet($em->getClassMetadata($entity::class), $entity); + } + + foreach ($uow->getScheduledEntityUpdates() as $entity) { + if (!$entity instanceof TimestampableInterface) { + continue; + } + $entity->setUpdatedAt($now); + $uow->recomputeSingleEntityChangeSet($em->getClassMetadata($entity::class), $entity); + } + } +} diff --git a/src/Service/ActivityPublisher.php b/src/Service/ActivityPublisher.php new file mode 100644 index 0000000..94fe273 --- /dev/null +++ b/src/Service/ActivityPublisher.php @@ -0,0 +1,75 @@ +urlGenerator->generate('app_initiative_show', ['id' => $initiative->getId()]); + + $stream = $this->twig->render('activity/_broadcast.html.twig', [ + 'action' => $action, + 'initiativeId' => $initiative->getId(), + 'title' => $initiative->getTitle(), + 'url' => $url, + 'actor' => $actor?->getName(), + 'at' => new \DateTimeImmutable(), + 'total' => $this->initiatives->countAll(), + 'published' => $this->initiatives->countPublished(true), + 'drafts' => $this->initiatives->countPublished(false), + 'byStatus' => $this->initiatives->countByStatus(), + 'statuses' => Status::cases(), + 'recent' => $this->initiatives->findRecent(8), + 'contactCount' => $this->contacts->count([]), + ]); + + try { + $this->hub->publish(new Update(self::TOPIC, $stream)); + } catch (\Throwable $e) { + // A live-broadcast failure (e.g. the Mercure hub being unreachable) must + // never break the underlying save — autosave is the primary save path. + $this->logger->warning('Failed to publish activity update: {message}', [ + 'message' => $e->getMessage(), + 'exception' => $e, + ]); + } + } +} diff --git a/symfony.lock b/symfony.lock index 4be5b67..99ae35d 100644 --- a/symfony.lock +++ b/symfony.lock @@ -196,6 +196,18 @@ ".editorconfig" ] }, + "symfony/mercure-bundle": { + "version": "0.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "0.4", + "ref": "b141b8c8f13bc8c31d718a5488039b712c0d3592" + }, + "files": [ + "config/packages/mercure.yaml" + ] + }, "symfony/property-info": { "version": "8.1", "recipe": { diff --git a/templates/activity/_broadcast.html.twig b/templates/activity/_broadcast.html.twig new file mode 100644 index 0000000..b3430da --- /dev/null +++ b/templates/activity/_broadcast.html.twig @@ -0,0 +1,16 @@ +{# One Mercure payload that refreshes every live dashboard region. Streams whose + target id is absent on the current page (everything but the dashboard) are ignored + by Turbo, so this is safe to broadcast to all subscribers of the topic. #} + + + + + + + + + + + + + diff --git a/templates/activity/_item.html.twig b/templates/activity/_item.html.twig new file mode 100644 index 0000000..4e63a41 --- /dev/null +++ b/templates/activity/_item.html.twig @@ -0,0 +1,11 @@ +
+ +
+ + {%- if actor %}{{ actor }} {% endif -%} + {{ ('activity.action.' ~ action)|trans }} + {% if url %}{{ title }}{% else %}{{ title }}{% endif %} + + +
+
diff --git a/templates/dashboard/_recent.html.twig b/templates/dashboard/_recent.html.twig new file mode 100644 index 0000000..5cfd43b --- /dev/null +++ b/templates/dashboard/_recent.html.twig @@ -0,0 +1,24 @@ +{% if recent is empty %} +
+
{{ 'dashboard.empty'|trans }}
+ {{ 'action.new'|trans }} +
+{% else %} +
+ {% for initiative in recent %} +
+
+ {{ initiative.title }} +
+ {%- if initiative.organizationalAnchoring %}{{ initiative.organizationalAnchoring.labelKey|trans }} · {% endif -%} + {{ initiative.createdAt|date('d.m.Y') }} +
+
+
+ {% if initiative.status %}{{ initiative.status.labelKey|trans }}{% endif %} + {% if not initiative.published %}{{ 'filter.draft'|trans }}{% endif %} +
+
+ {% endfor %} +
+{% endif %} diff --git a/templates/dashboard/_stats.html.twig b/templates/dashboard/_stats.html.twig new file mode 100644 index 0000000..150bb00 --- /dev/null +++ b/templates/dashboard/_stats.html.twig @@ -0,0 +1,16 @@ +
+
{{ 'dashboard.total'|trans }}
+
{{ total }}
+
+
+
{{ 'dashboard.published'|trans }}
+
{{ published }}
+
+
+
{{ 'dashboard.drafts'|trans }}
+
{{ drafts }}
+
+
+
{{ 'nav.contacts'|trans }}
+
{{ contactCount }}
+
diff --git a/templates/dashboard/_status_bars.html.twig b/templates/dashboard/_status_bars.html.twig new file mode 100644 index 0000000..4459e6f --- /dev/null +++ b/templates/dashboard/_status_bars.html.twig @@ -0,0 +1,16 @@ +{% set maxCount = 1 %} +{% for status in statuses %} + {% set maxCount = max(maxCount, byStatus[status.value]|default(0)) %} +{% endfor %} +{% for status in statuses %} + {% set count = byStatus[status.value]|default(0) %} +
+
+ {{ status.labelKey|trans }} + {{ count }} +
+
+
+
+
+{% endfor %} diff --git a/templates/dashboard/index.html.twig b/templates/dashboard/index.html.twig index 6dfbc94..d5e5daa 100644 --- a/templates/dashboard/index.html.twig +++ b/templates/dashboard/index.html.twig @@ -3,7 +3,7 @@ {% block title %}{{ 'dashboard.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} {% block body %} -
+
-
-
-
{{ 'dashboard.total'|trans }}
-
{{ total }}
-
-
-
{{ 'dashboard.published'|trans }}
-
{{ published }}
-
-
-
{{ 'dashboard.drafts'|trans }}
-
{{ drafts }}
-
-
-
{{ 'nav.contacts'|trans }}
-
{{ contactCount }}
-
+ {{ turbo_stream_from('activities') }} + +
+ {{ include('dashboard/_stats.html.twig') }}
-
-
- {{ 'dashboard.recent'|trans }} - {{ 'action.view'|trans }} +
+
+
+ {{ 'dashboard.recent'|trans }} +
+
+ {{ include('dashboard/_recent.html.twig') }} +
- {% if recent is empty %} -
-
{{ 'dashboard.empty'|trans }}
- {{ 'action.new'|trans }} + +
+
+ {{ 'dashboard.by_status'|trans }}
- {% else %} -
- {% for initiative in recent %} -
-
- {{ initiative.title }} -
- {%- if initiative.organizationalAnchoring %}{{ initiative.organizationalAnchoring.labelKey|trans }} · {% endif -%} - {{ initiative.createdAt|date('d.m.Y') }} -
-
-
- {% if initiative.status %}{{ initiative.status.labelKey|trans }}{% endif %} - {% if not initiative.published %}{{ 'filter.draft'|trans }}{% endif %} -
-
- {% endfor %} +
+ {{ include('dashboard/_status_bars.html.twig') }}
- {% endif %} +
-
+
- {{ 'dashboard.by_status'|trans }} + {{ 'activity.title'|trans }}
-
- {% set maxCount = 1 %} - {% for status in statuses %} - {% set maxCount = max(maxCount, byStatus[status.value]|default(0)) %} - {% endfor %} - {% for status in statuses %} - {% set count = byStatus[status.value]|default(0) %} -
-
- {{ status.labelKey|trans }} - {{ count }} -
-
-
-
-
- {% endfor %} +
+
+ {% for initiative in recent %} + {{ include('activity/_item.html.twig', { + id: initiative.id, + action: 'updated', + title: initiative.title, + url: path('app_initiative_show', {id: initiative.id}), + actor: initiative.modifiedBy ? initiative.modifiedBy.name : null, + at: initiative.updatedAt, + }) }} + {% endfor %} +
diff --git a/templates/initiative/_form.html.twig b/templates/initiative/_form.html.twig index 749e5b9..3d2986c 100644 --- a/templates/initiative/_form.html.twig +++ b/templates/initiative/_form.html.twig @@ -1,9 +1,49 @@ -{{ form_start(form, {attr: {class: 'form'}}) }} +{% set form_attr = { + class: 'form', + 'data-controller': 'form-progress', + 'data-action': 'input->form-progress#recompute change->form-progress#recompute', + 'data-form-progress-fields-value': constant('App\\Entity\\Initiative::COMPLETION_FIELDS')|json_encode, +} %} +{% if autosave|default(false) %} + {% set form_attr = form_attr|merge({ + 'data-controller': 'form-progress autosave', + 'data-action': 'input->form-progress#recompute change->form-progress#recompute input->autosave#schedule change->autosave#schedule', + 'data-autosave-debounce-value': 800, + 'data-autosave-saving-text-value': 'autosave.saving'|trans, + 'data-autosave-saved-text-value': 'autosave.saved'|trans, + 'data-autosave-unsaved-text-value': 'autosave.unsaved'|trans, + 'data-autosave-error-text-value': 'autosave.error'|trans, + 'data-autosave-offline-text-value': 'autosave.offline'|trans, + 'data-autosave-files-hint-text-value': 'autosave.files_hint'|trans, + 'data-autosave-is-new-value': initiative.id ? 'false' : 'true', + }) %} +{% endif %} +{{ form_start(form, {attr: form_attr}) }} +
+
+ {{ 'autosave.completion'|trans }} + {% if autosave|default(false) %} + + + + + {% endif %} +
+
+
+
+
+ 0% +
+
+ {{ form_errors(form) }}
{{ 'initiative.section.basics'|trans }}
- {{ form_row(form.title) }} + {% set title_attr = {'data-action': 'input->title-mirror#update'} %} + {% if not initiative.id %}{% set title_attr = title_attr|merge({autofocus: true}) %}{% endif %} + {{ form_row(form.title, {attr: title_attr}) }}
{{ form_row(form.category) }} {{ form_row(form.initiativeType) }} @@ -126,7 +166,15 @@
- - {{ 'action.cancel'|trans }} + {% if autosave|default(false) %} + + {% else %} + + {{ 'action.cancel'|trans }} + {% endif %}
+ {% do form.links.setRendered() %} + {% do form.contacts.setRendered() %} + {% do form.images.setRendered() %} + {% do form.attachments.setRendered() %} {{ form_end(form) }} diff --git a/templates/initiative/edit.html.twig b/templates/initiative/edit.html.twig index 8f75aa7..5388dcd 100644 --- a/templates/initiative/edit.html.twig +++ b/templates/initiative/edit.html.twig @@ -3,12 +3,12 @@ {% block title %}{{ 'initiative.edit.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} {% block body %} -
+
- {{ include('initiative/_form.html.twig', {button_label: 'action.save'}) }} + {{ include('initiative/_form.html.twig', {button_label: 'action.save', autosave: true}) }}
{% endblock %} diff --git a/templates/initiative/index.html.twig b/templates/initiative/index.html.twig index b4d822a..3314c02 100644 --- a/templates/initiative/index.html.twig +++ b/templates/initiative/index.html.twig @@ -61,7 +61,7 @@ {{ 'initiative.organizational_anchoring'|trans }} {{ h.sortlink('budget', 'initiative.budget', sort, direction) }} {{ h.sortlink('timePeriodStart', 'initiative.time_period', sort, direction) }} - {{ 'initiative.published'|trans }} + {{ 'autosave.completion'|trans }} @@ -87,11 +87,11 @@ {% else %}—{% endif %} - {% if initiative.published %} - {{ 'filter.published'|trans }} - {% else %} - {{ 'filter.draft'|trans }} - {% endif %} + {% set pct = initiative.completionPercentage %} +
+ + {{ pct }}% +
diff --git a/templates/initiative/new.html.twig b/templates/initiative/new.html.twig index ce9ddb9..580d5c3 100644 --- a/templates/initiative/new.html.twig +++ b/templates/initiative/new.html.twig @@ -3,13 +3,16 @@ {% block title %}{{ 'initiative.new.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} {% block body %} -
+
- {{ include('initiative/_form.html.twig', {button_label: 'action.create'}) }} + {{ include('initiative/_form.html.twig', {button_label: 'action.create', autosave: true}) }}
{% endblock %} diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index 4e5c9f2..b9aa81d 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -1,6 +1,23 @@ app: name: Projektdatabase +autosave: + saving: Gemmer… + saved: Gemt + unsaved: Ikke-gemte ændringer + error: Kunne ikke gemme — tjek de påkrævede felter + offline: Kunne ikke gemme — dine ændringer er bevaret her + files_hint: Klik på Upload for at tilføje filerne + completion: Udfyldningsgrad + upload_files: Upload filer + +activity: + title: Aktivitet + action: + created: oprettede + updated: opdaterede + deleted: slettede + nav: dashboard: Overblik initiatives: Initiativer diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 10ed45c..3d9956e 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -1,6 +1,23 @@ app: name: Project database +autosave: + saving: Saving… + saved: Saved + unsaved: Unsaved changes + error: Couldn’t save — check the required fields + offline: Save failed — your changes are kept here + files_hint: Click Upload to add the files + completion: Form completion + upload_files: Upload files + +activity: + title: Activity + action: + created: created + updated: updated + deleted: deleted + nav: dashboard: Overview initiatives: Initiatives