diff --git a/CHANGELOG.md b/CHANGELOG.md index e8af74a..eecf410 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.0] - 2026-06-30 + +* [PR-23](https://github.com/itk-dev/itk-project-database/pull/23) + Introduce reusable Twig components (page header, card header, empty state, KPI) + to replace repeated markup. +* [PR-22](https://github.com/itk-dev/itk-project-database/pull/22) + Use the ITK logo in the nav and login, tidy the dashboard header, and refresh + the login screen. +* [PR-21](https://github.com/itk-dev/itk-project-database/pull/21) + Add a first-login guided tour where Glimt walks new users through the platform, + dashboard, creating initiatives and the admin panel. +* [PR-20](https://github.com/itk-dev/itk-project-database/pull/20) + Minor improvements to the user menu styling. +* [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-18](https://github.com/itk-dev/itk-project-database/pull/18) + Rework the dashboard with an outstanding-work panel and a redesigned activity + feed, add help text to every graph, and fix the mascot's finish nudges. +* [PR-17](https://github.com/itk-dev/itk-project-database/pull/17) + Fix the mascot nudges that never appeared, nudge users to finish incomplete + contacts with a link to their edit page. +* [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. +* [PR-15](https://github.com/itk-dev/itk-project-database/pull/15) + Make Kategori a user-defined Area entity with an admin CRUD, replacing the + fixed Category enum. + ## [0.1.0] - 2026-06-26 * [PR-9](https://github.com/itk-dev/itk-project-database/pull/9) @@ -30,5 +59,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * [PR-1](https://github.com/itk-dev/itk-project-database/pull/1) Initial Symfony 8 rebuild of the project database. -[Unreleased]: https://github.com/itk-dev/itk-project-database/compare/0.1.0...HEAD -[0.1.0]: https://github.com/itk-dev/itk-project-database/releases/tag/0.1.0 +[Unreleased]: https://github.com/itk-dev/itk-project-database/compare/0.2.0...HEAD +[0.2.0]: https://github.com/itk-dev/itk-project-database/compare/0.1.09i876rbhn%3E%20gvfcdevgbhjgnybtfr%20v8decsxz...0.2.0 +[0.1.0]: gvfcdevgbhjgnybtfr v8decsxz diff --git a/assets/app.js b/assets/app.js index b1550f8..1c342a4 100644 --- a/assets/app.js +++ b/assets/app.js @@ -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); }; @@ -47,20 +54,69 @@ function initCollections() { }); } -function initContactSelect() { - document.querySelectorAll("[data-contact-select]").forEach((select) => { - if (select.dataset.bound) { +const capitalize = (value) => + value ? value.charAt(0).toUpperCase() + value.slice(1) : value; + +// A chip multiselect whose dropdown is a shared pool served as inspiration. +// create lets a user add a brand-new value that is persisted on save and then +// shows up in everyone's pool on the next page load; a capitalised value also +// collapses onto an existing option of the same name instead of duplicating. +// `transform` normalises a freshly typed value before it becomes a chip. +function initCreatableSelect(selector, poolKey, transform) { + document.querySelectorAll(selector).forEach((input) => { + if (input.dataset.bound) { return; } - select.dataset.bound = "1"; - new TomSelect(select, { + input.dataset.bound = "1"; + + let pool = []; + try { + pool = JSON.parse(input.dataset[poolKey] || "[]"); + } catch { + pool = []; + } + + // The current values plus the pool become the selectable options; the + // current ones are kept selected. + const current = input.value + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + const options = [...new Set([...current, ...pool])].map((name) => ({ + value: name, + text: name, + })); + + new TomSelect(input, { plugins: ["remove_button"], + options, + items: current, + create: (typed) => { + const name = transform(typed.trim()); + return { value: name, text: name }; + }, + createOnBlur: true, + persist: false, hideSelected: true, maxOptions: null, }); }); } +// Free-tagging term fields (strategies, stakeholders, tags) capitalise new +// entries; contacts keep the typed name as-is. +function initTermSelect() { + initCreatableSelect("[data-term-select]", "termPool", capitalize); +} + +function initContactSelect() { + initCreatableSelect( + "[data-contact-select]", + "contactPool", + (value) => value, + ); +} + // One delegated handler on the document (which survives Turbo navigations and // cache restores) both opens the menu — when the click lands on the toggle — // and closes it on any outside click. Delegation avoids per-page binding, which @@ -86,4 +142,11 @@ document.addEventListener("click", (event) => { document.addEventListener("turbo:load", () => { initCollections(); 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(); }); diff --git a/assets/controllers/autosave_controller.js b/assets/controllers/autosave_controller.js index 847fc22..3af38c6 100644 --- a/assets/controllers/autosave_controller.js +++ b/assets/controllers/autosave_controller.js @@ -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 }, @@ -25,27 +27,26 @@ export default class extends Controller { type: String, default: "Save failed — your changes are kept here", }, - filesHintText: { type: String, default: "Click Save to upload files" }, + requiredText: { type: String, default: "Add a title to save" }, 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. @@ -55,25 +56,24 @@ export default class extends Controller { } 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"); + // A required field (the title) is empty: don't POST an invalid form and + // flash a save error. Show a hint and keep the last saved version — once + // a title is typed again, the next change saves normally. + if (this.hasEmptyRequiredField()) { + this.setStatus(this.requiredTextValue, "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. @@ -82,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; @@ -111,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"); @@ -127,20 +139,81 @@ 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"]'), ).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(); + hasEmptyRequiredField() { + return Array.from( + this.element.querySelectorAll("[data-autosave-required]"), + ).some((field) => "" === field.value.trim()); + } + + // 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; } } diff --git a/assets/controllers/dashboard_viz_controller.js b/assets/controllers/dashboard_viz_controller.js index 4313e75..bd24e8e 100644 --- a/assets/controllers/dashboard_viz_controller.js +++ b/assets/controllers/dashboard_viz_controller.js @@ -65,6 +65,10 @@ export default class extends Controller { "timeline", ]; + static values = { + empty: String, + }; + connect() { this.reduce = window.matchMedia( "(prefers-reduced-motion: reduce)", @@ -221,14 +225,26 @@ export default class extends Controller { buildHeatmap() { const d = this.viz; const el = this.heatmapTarget; - el.style.gridTemplateColumns = `minmax(120px, 168px) repeat(${d.categories.length}, minmax(40px, 1fr))`; + + // The heatmap only makes sense once there are both rows (departments) and + // columns (areas) to cross; until then show a placeholder, not bare headers. + if (!d.areas.length || !d.departments.length) { + el.style.gridTemplateColumns = ""; + el.classList.remove("heat--paused"); + el.innerHTML = `

${this.esc(this.emptyValue)}

`; + this.heatColHeads = []; + this.heatCells = []; + return; + } + + el.style.gridTemplateColumns = `minmax(120px, 168px) repeat(${d.areas.length}, minmax(40px, 1fr))`; if (!this.reduce) { el.classList.add("heat--paused"); } el.innerHTML = ""; el.appendChild(document.createElement("div")); - this.heatColHeads = d.categories.map((c) => { + this.heatColHeads = d.areas.map((c) => { const head = document.createElement("div"); head.className = "heat__collabel"; head.innerHTML = `${this.esc(c.label)}`; @@ -241,7 +257,7 @@ export default class extends Controller { label.className = "heat__rowlabel"; label.textContent = dep.label; el.appendChild(label); - return d.categories.map(() => { + return d.areas.map(() => { const cell = document.createElement("div"); cell.className = "heat__cell"; el.appendChild(cell); @@ -254,6 +270,9 @@ export default class extends Controller { updateHeatmap(initial = false) { const d = this.viz; + if (!d.areas.length || !d.departments.length) { + return; + } let max = 1; d.heatmap.forEach((row) => row.forEach((v) => { @@ -265,7 +284,7 @@ export default class extends Controller { row.forEach((v, ci) => { const cell = this.heatCells[di][ci]; cell.textContent = v === 0 ? "·" : v; - cell.title = `${d.departments[di].label} · ${d.categories[ci].label}: ${v}`; + cell.title = `${d.departments[di].label} · ${d.areas[ci].label}: ${v}`; const col = this.colorFor(v, max); if (col) { cell.classList.remove("is-zero"); @@ -278,12 +297,12 @@ export default class extends Controller { } if (initial && !this.reduce) { cell.style.animationDelay = - (di * d.categories.length + ci) * 14 + "ms"; + (di * d.areas.length + ci) * 14 + "ms"; } }), ); - d.categories.forEach((c, ci) => { + d.areas.forEach((c, ci) => { const depts = d.heatmap.reduce( (n, row) => n + (row[ci] > 0 ? 1 : 0), 0, @@ -308,7 +327,7 @@ export default class extends Controller { el.innerHTML = ""; if (!this.viz.collaboration.length) { el.innerHTML = - '

Ingen tværgående temaer endnu — kategorisér initiativer for at finde sammenfald.

'; + '

Ingen tværgående områder endnu — kategorisér initiativer for at finde sammenfald.

'; return; } // Hold the entrance paused until the panel scrolls into view; once seen, diff --git a/assets/controllers/file_field_controller.js b/assets/controllers/file_field_controller.js new file mode 100644 index 0000000..8558c11 --- /dev/null +++ b/assets/controllers/file_field_controller.js @@ -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; + } + } +} diff --git a/assets/controllers/form_progress_controller.js b/assets/controllers/form_progress_controller.js index 165b2df..d07f0bc 100644 --- a/assets/controllers/form_progress_controller.js +++ b/assets/controllers/form_progress_controller.js @@ -19,6 +19,7 @@ export default class extends Controller { static values = { fields: { type: Array, default: [] }, + stars: { type: Boolean, default: true }, }; connect() { @@ -127,11 +128,19 @@ export default class extends Controller { realRect(star) { const gone = star.classList.contains("completion-star--gone"); if (gone) { + // Drop the transition while measuring so the box reflects the star's + // settled size, not its collapsed (mid-transition) one. + star.style.transition = "none"; star.classList.remove("completion-star--gone"); } const rect = star.getBoundingClientRect(); if (gone) { star.classList.add("completion-star--gone"); + // Flush the collapsed state while the transition is still off, so + // re-enabling it below doesn't animate the star from full back to + // hidden — that was the brief flash beside the label. + void star.offsetWidth; + star.style.transition = ""; } return rect; @@ -174,7 +183,9 @@ export default class extends Controller { const dx = toRect.left + toRect.width / 2 - startX; const dy = toRect.top + toRect.height / 2 - startY; - if (this.prefersReducedMotion) { + // No flight when the user turned stars off (or prefers reduced motion): + // the bar still advances, the star just doesn't fly. + if (!this.animateStars) { onArrive(); return; @@ -250,7 +261,7 @@ export default class extends Controller { } popTrophy() { - if (!this.hasStarTarget || this.prefersReducedMotion) { + if (!this.hasStarTarget || !this.animateStars) { return; } @@ -268,6 +279,10 @@ export default class extends Controller { return window.matchMedia("(prefers-reduced-motion: reduce)").matches; } + get animateStars() { + return this.starsValue && !this.prefersReducedMotion; + } + // "initiative[links][0]" -> "links", "initiative[funding][]" -> "funding". fieldKey(el) { const match = el.name?.match(/\[([^\]]+)\]/); diff --git a/assets/controllers/lightbox_controller.js b/assets/controllers/lightbox_controller.js new file mode 100644 index 0000000..58130d6 --- /dev/null +++ b/assets/controllers/lightbox_controller.js @@ -0,0 +1,141 @@ +import { Controller } from "@hotwired/stimulus"; + +/* + * Opens an image preview in an in-page overlay instead of a new tab. Attached to + * the image link; the href is the full-size source. The scroll wheel zooms in and + * out; once zoomed, the image can be dragged to pan. Closes on the backdrop, the + * close button or Escape, and restores focus to the link that opened it. + */ +export default class extends Controller { + static values = { + src: String, + closeLabel: { type: String, default: "Close" }, + }; + + open(event) { + event.preventDefault(); + const src = this.srcValue || this.element.getAttribute("href"); + if (!src) { + return; + } + + const overlay = document.createElement("div"); + overlay.className = "lightbox"; + overlay.setAttribute("role", "dialog"); + overlay.setAttribute("aria-modal", "true"); + + const close = document.createElement("button"); + close.type = "button"; + close.className = "lightbox__close"; + close.setAttribute("aria-label", this.closeLabelValue); + close.textContent = "×"; + + const img = document.createElement("img"); + img.className = "lightbox__img"; + img.src = src; + img.alt = ""; + + overlay.append(close, img); + + const min = 1; + const max = 4; + let zoom = 1; + + const applyZoom = () => { + if (zoom <= 1) { + img.classList.remove("lightbox__img--zoomed"); + img.style.removeProperty("width"); + img.style.removeProperty("max-width"); + img.style.removeProperty("max-height"); + return; + } + // Capture the fitted width before lifting the size constraints, so the + // zoom factor is relative to what the user actually sees. + if (!img.dataset.baseWidth) { + img.dataset.baseWidth = String(img.clientWidth); + } + img.classList.add("lightbox__img--zoomed"); + img.style.maxWidth = "none"; + img.style.maxHeight = "none"; + img.style.width = `${Number(img.dataset.baseWidth) * zoom}px`; + }; + + const onWheel = (event) => { + event.preventDefault(); + // Normalise wheel deltas (line/page modes report small integers) and + // zoom multiplicatively, so a trackpad gesture doesn't jump to max. + let delta = event.deltaY; + if (1 === event.deltaMode) { + delta *= 16; + } else if (2 === event.deltaMode) { + delta *= window.innerHeight; + } + const previous = zoom; + zoom = Math.min( + max, + Math.max(min, zoom * Math.exp(-delta * 0.001)), + ); + if (zoom !== previous) { + applyZoom(); + } + }; + + let dragging = false; + let startX = 0; + let startY = 0; + let startLeft = 0; + let startTop = 0; + const onDown = (event) => { + if (zoom <= 1 || 0 !== event.button) { + return; + } + dragging = true; + startX = event.clientX; + startY = event.clientY; + startLeft = overlay.scrollLeft; + startTop = overlay.scrollTop; + img.style.cursor = "grabbing"; + event.preventDefault(); + }; + const onMove = (event) => { + if (!dragging) { + return; + } + overlay.scrollLeft = startLeft - (event.clientX - startX); + overlay.scrollTop = startTop - (event.clientY - startY); + }; + const onUp = () => { + dragging = false; + img.style.removeProperty("cursor"); + }; + + const dismiss = () => { + overlay.remove(); + document.removeEventListener("keydown", onKey); + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + document.body.classList.remove("is-lightbox-open"); + this.element.focus(); + }; + const onKey = (event) => { + if ("Escape" === event.key) { + dismiss(); + } + }; + + overlay.addEventListener("click", (event) => { + if (event.target === overlay || event.target === close) { + dismiss(); + } + }); + overlay.addEventListener("wheel", onWheel, { passive: false }); + img.addEventListener("mousedown", onDown); + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + document.addEventListener("keydown", onKey); + + document.body.classList.add("is-lightbox-open"); + document.body.appendChild(overlay); + close.focus(); + } +} diff --git a/assets/controllers/mascot_controller.js b/assets/controllers/mascot_controller.js index 19cfd19..cd61cba 100644 --- a/assets/controllers/mascot_controller.js +++ b/assets/controllers/mascot_controller.js @@ -8,7 +8,16 @@ import { Controller } from "@hotwired/stimulus"; * pointer until it tags it back. Messages arrive already translated. */ export default class extends Controller { - static targets = ["bubble", "text", "cta", "play", "finish"]; + static targets = [ + "bubble", + "text", + "cta", + "play", + "finish", + "finishContact", + "tourNext", + "tourSkip", + ]; static values = { messages: { type: Array, default: [] }, @@ -20,11 +29,20 @@ export default class extends Controller { giveup: String, escaped: String, finishTexts: { type: Array, default: [] }, + finishContactTexts: { type: Array, default: [] }, enabled: { type: Boolean, default: true }, farewell: String, welcome: String, - enableLabel: String, - disableLabel: String, + intro: String, + tour: { type: Array, default: [] }, + tourPropose: String, + tourStart: String, + tourDecline: String, + tourNext: String, + tourSkip: String, + tourDone: String, + tourSeenUrl: String, + tourSeenToken: String, }; connect() { @@ -34,13 +52,22 @@ export default class extends Controller { const state = this.loadState(); this.lastIndex = state.lastIndex ?? -1; this.lastShownAt = state.lastShownAt ?? 0; + this.introduced = state.introduced ?? false; // Only run the cheer cadence while enabled; a disabled mascot is rendered // parked off-screen (the `mascot--away` class) and stays silent until the // user turns it back on. Resume where the previous page left off so // navigating around doesn't pop a fresh message on every load. if (this.enabledValue) { - const sinceLast = Date.now() - this.lastShownAt; - this.scheduleNext(Math.max(1800, this.intervalValue - sinceLast)); + if (this.tourValue.length > 0) { + // A first-time visitor on the dashboard: offer Glimt's guided tour + // instead of the usual cheer cadence. + this.startTimer("cycle", () => this.proposeTour(), 1400); + } else { + const sinceLast = Date.now() - this.lastShownAt; + this.scheduleNext( + Math.max(1800, this.intervalValue - sinceLast), + ); + } } } @@ -98,9 +125,10 @@ export default class extends Controller { applyEnabled(enabled) { this.enabledValue = enabled; if (this.toggleButton) { - this.toggleButton.textContent = enabled - ? this.disableLabelValue - : this.enableLabelValue; + this.toggleButton.setAttribute( + "aria-checked", + enabled ? "true" : "false", + ); } window.clearTimeout(this.toggleTimer); @@ -192,24 +220,51 @@ export default class extends Controller { this.scheduleNext(this.intervalValue); } - // Clicking the avatar only does something during the play-catch game; a plain - // idle click is intentionally inert (no message, no animation). + // Clicking the avatar plays catch mid-game; an idle click pops a fresh + // message and resets the cadence so the next auto-message isn't right behind. poke() { if ("invited" === this.mode) { this.startFlee(); } else if ("flee" === this.mode) { this.caught(); + } else if ("idle" === this.mode) { + this.speak(); + this.scheduleNext(this.intervalValue); } } speak() { + // The first time it speaks in a session, Glimt introduces itself by name. + if (!this.introduced && this.introValue) { + this.introduced = true; + this.say(this.introValue, "cta"); + + return; + } + + // A contact created on the fly (name only) gets a gentle reminder to + // finish it, linking straight to its edit page. + const contactTexts = this.finishContactTextsValue; + if ( + this.hasFinishContactTarget && + contactTexts.length > 0 && + Math.random() < 0.15 + ) { + this.say( + contactTexts[Math.floor(Math.random() * contactTexts.length)], + "finishContact", + ); + + return; + } + // Now and then, nudge the user to finish their least-complete initiative, // picking one of the finish lines at random for variety. const finishTexts = this.finishTextsValue; if ( this.hasFinishTarget && finishTexts.length > 0 && - Math.random() < 0.4 + Math.random() < 0.15 ) { this.say( finishTexts[Math.floor(Math.random() * finishTexts.length)], @@ -379,6 +434,17 @@ export default class extends Controller { if (this.hasFinishTarget) { this.finishTarget.hidden = "finish" !== action; } + if (this.hasFinishContactTarget) { + this.finishContactTarget.hidden = "finishContact" !== action; + } + // A cheer message never carries the tour's own buttons; make sure none + // linger after the tour has ended. + if (this.hasTourNextTarget) { + this.tourNextTarget.hidden = true; + } + if (this.hasTourSkipTarget) { + this.tourSkipTarget.hidden = true; + } this.bubbleTarget.hidden = false; window.requestAnimationFrame(() => { this.bubbleTarget.classList.add("is-visible"); @@ -399,6 +465,11 @@ export default class extends Controller { // The × just closes the current bubble; the mascot stays and cheers again later. close() { + if ("tour" === this.mode) { + this.tourSkip(); + + return; + } if ("invited" === this.mode) { this.mode = "idle"; window.clearTimeout(this.inviteTimer); @@ -406,6 +477,238 @@ export default class extends Controller { this.hide(); } + // ---- Guided tour ------------------------------------------------------- + // Offered once to a first-time visitor on the dashboard. Glimt flies to each + // area, spotlights it and explains it; the user steps through with Next (or + // Skip). Either choice marks the tour seen so it is never proposed again. + proposeTour() { + this.mode = "tour"; + this.tourIndex = -1; + this.introduced = true; + this.tourSay(this.tourProposeValue, { + next: this.tourStartValue, + skip: this.tourDeclineValue, + }); + } + + tourNext() { + if ("tour" !== this.mode) { + return; + } + if (-1 === this.tourIndex) { + this.markTourSeen(); + } + this.tourIndex += 1; + if (this.tourIndex >= this.tourValue.length) { + this.endTour(); + + return; + } + this.showTourStep(); + } + + tourSkip() { + this.markTourSeen(); + this.endTour(); + } + + showTourStep() { + const step = this.tourValue[this.tourIndex]; + const last = this.tourIndex === this.tourValue.length - 1; + this.spotlight(step.targets); + if (!this.element.classList.contains("mascot--playing")) { + // Anchor at the avatar's current corner spot before switching to + // free positioning, so the first glide starts from where it sits. + const box = this.avatar.getBoundingClientRect(); + this.element.classList.add("mascot--playing"); + this.moveTo(box.left, box.top); + } + this.element.classList.add("mascot--tour"); + // The opening step has no target, so Glimt grows a little and speaks to + // the user directly. + this.element.classList.toggle("mascot--hero", 0 === this.tourIndex); + this.tourSay(step.text, { + next: last ? this.tourDoneValue : this.tourNextValue, + skip: last ? null : this.tourSkipValue, + }); + // Position after the bubble is laid out so it can be measured and kept + // clear of the spotlighted target. + window.requestAnimationFrame(() => this.positionNear(step)); + } + + endTour() { + this.spotlight(null); + if (this.spotlightEl) { + this.spotlightEl.remove(); + this.spotlightEl = null; + } + this.hide(); + this.element.classList.remove( + "mascot--playing", + "mascot--tour", + "mascot--hero", + ); + this.element.style.left = ""; + this.element.style.top = ""; + this.mode = "idle"; + this.scheduleNext(this.intervalValue); + } + + // The tour bubble carries its own Next/Skip buttons and never auto-hides — + // the user drives it. Hide the cheer-time actions while it is up. + tourSay(text, { next, skip } = {}) { + if (!this.hasTextTarget) { + return; + } + this.textTarget.textContent = text; + for (const target of [ + this.hasCtaTarget && this.ctaTarget, + this.hasPlayTarget && this.playTarget, + this.hasFinishTarget && this.finishTarget, + this.hasFinishContactTarget && this.finishContactTarget, + ]) { + if (target) { + target.hidden = true; + } + } + if (this.hasTourNextTarget) { + this.tourNextTarget.hidden = !next; + if (next) { + this.tourNextTarget.textContent = next; + } + } + if (this.hasTourSkipTarget) { + this.tourSkipTarget.hidden = !skip; + if (skip) { + this.tourSkipTarget.textContent = skip; + } + } + this.clearNamedTimer("show"); + this.bubbleTarget.hidden = false; + window.requestAnimationFrame(() => { + this.bubbleTarget.classList.add("is-visible"); + }); + } + + ensureSpotlight() { + if (!this.spotlightEl) { + this.spotlightEl = document.createElement("div"); + this.spotlightEl.className = "tour-spotlight"; + document.body.appendChild(this.spotlightEl); + } + + return this.spotlightEl; + } + + // One dimming overlay that punches a hole over each target via clip-path. + // Reusing a single element lets the holes glide (and grow/shrink) from section + // to section instead of the dim flashing off and back on. No targets = the + // whole screen dims. + spotlight(targets) { + const sp = this.ensureSpotlight(); + const first = !sp.classList.contains("is-visible"); + const rects = this.rectsFor(targets); + sp.style.clipPath = this.dimPath(rects); + if (first) { + window.requestAnimationFrame(() => sp.classList.add("is-visible")); + } + } + + rectsFor(targets) { + return (targets || []) + .map((selector) => document.querySelector(selector)) + .filter(Boolean) + .map((el) => el.getBoundingClientRect()); + } + + // A full-screen rectangle with up to two rectangular holes (even-odd fill). + // Always two holes — an unused one collapses to a point at the centre — so the + // path keeps a constant shape and animates between steps rather than jumping. + dimPath(rects) { + const w = window.innerWidth; + const h = window.innerHeight; + const pad = 6; + const cx = w / 2; + const cy = h / 2; + const hole = (rect) => { + if (!rect) { + return `M${cx} ${cy} L${cx} ${cy} L${cx} ${cy} L${cx} ${cy} Z`; + } + const x1 = rect.left - pad; + const y1 = rect.top - pad; + const x2 = rect.right + pad; + const y2 = rect.bottom + pad; + return `M${x1} ${y1} L${x2} ${y1} L${x2} ${y2} L${x1} ${y2} Z`; + }; + const outer = `M0 0 L${w} 0 L${w} ${h} L0 ${h} Z`; + + return `path(evenodd, "${outer} ${hole(rects[0])} ${hole(rects[1])}")`; + } + + unionRect(targets) { + const rects = this.rectsFor(targets); + if (0 === rects.length) { + return null; + } + const left = Math.min(...rects.map((r) => r.left)); + const top = Math.min(...rects.map((r) => r.top)); + const right = Math.max(...rects.map((r) => r.right)); + const bottom = Math.max(...rects.map((r) => r.bottom)); + + return { left, top, right, bottom, width: right - left }; + } + + // Place Glimt relative to the step's target area, per the step's placement. + // The bubble grows up-and-left from the avatar, so its measured size drives + // where the avatar lands and keeps the bubble on-screen. + positionNear(step) { + const avatar = 68; + const w = window.innerWidth; + const h = window.innerHeight; + const bubbleW = this.bubbleTarget.offsetWidth || 260; + const bubbleH = this.bubbleTarget.offsetHeight || 130; + const clampLeft = (x) => + Math.max(bubbleW - 56, Math.min(x, w - avatar - 12)); + const area = this.unionRect(step.targets); + const placement = step.placement || "below"; + + let left; + let top; + if ("corner" === placement) { + left = w - avatar - 24; + top = h - avatar - 24; + } else if (!area || "center" === placement) { + left = clampLeft(w / 2 - avatar + bubbleW / 2); + top = Math.max(bubbleH + 32, Math.round(h * 0.42)); + } else if ("right" === placement) { + left = w - avatar - 32; + top = Math.max(bubbleH + 24, Math.round(h / 2)); + } else if ("left" === placement) { + left = clampLeft(area.left - 20 - avatar); + top = Math.max(bubbleH + 16, area.bottom + 16); + } else { + const centre = area.left + area.width / 2; + left = clampLeft(centre - avatar + bubbleW / 2); + top = area.bottom + bubbleH + 22; + } + this.moveTo(left, top); + } + + markTourSeen() { + if (this.tourSeenSent || !this.tourSeenUrlValue) { + return; + } + this.tourSeenSent = true; + fetch(this.tourSeenUrlValue, { + method: "POST", + headers: { + "X-Requested-With": "fetch", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ _token: this.tourSeenTokenValue }), + }).catch(() => {}); + } + // Pick a message different from the last one shown, so it never repeats twice. nextMessage() { const messages = this.messagesValue; @@ -442,6 +745,7 @@ export default class extends Controller { JSON.stringify({ lastShownAt: this.lastShownAt ?? 0, lastIndex: this.lastIndex, + introduced: this.introduced, }), ); } catch { diff --git a/assets/images/itk-logo.png b/assets/images/itk-logo.png new file mode 100644 index 0000000..ca95a6a Binary files /dev/null and b/assets/images/itk-logo.png differ diff --git a/assets/styles/app.css b/assets/styles/app.css index 6ff8199..f26daac 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -129,12 +129,12 @@ h3 { } .top-nav__brand { - font-size: var(--itk-text-base); - font-weight: 700; - color: var(--itk-ink); display: inline-flex; align-items: center; gap: var(--itk-space-2); + font-size: var(--itk-text-base); + font-weight: 700; + color: var(--itk-ink); letter-spacing: -0.01em; } @@ -143,19 +143,25 @@ h3 { text-decoration: none; } -.top-nav__brand-mark { - width: 28px; - height: 28px; - border-radius: var(--itk-radius-2); - background: linear-gradient(135deg, var(--itk-blue), var(--itk-cyan)); - display: inline-block; +.top-nav__brand-logo { + height: 26px; + width: auto; + display: block; } .top-nav__menu { display: flex; align-items: center; gap: var(--itk-space-1); - margin-left: var(--itk-space-4); +} + +@media (min-width: 1520px) { + .top-nav__menu { + position: absolute; + top: 0; + left: calc((100vw - var(--itk-container)) / 2 + var(--itk-space-5)); + height: 64px; + } } .top-nav__link { @@ -230,9 +236,7 @@ h3 { } .user-menu__header { - padding: var(--itk-space-3); - border-bottom: 1px solid var(--itk-slate-100); - margin-bottom: var(--itk-space-2); + padding: var(--itk-space-2) var(--itk-space-3); } .user-menu__eyebrow { @@ -251,6 +255,22 @@ h3 { color: var(--itk-slate-500); } +.user-menu__divider { + height: 1px; + margin: var(--itk-space-2) 0; + background-color: var(--itk-slate-100); +} + +.user-menu__group { + display: flex; + flex-direction: column; + gap: var(--itk-space-1); +} + +.user-menu__group-label { + padding: var(--itk-space-1) var(--itk-space-3); +} + .user-menu__link, .user-menu__logout, .user-menu__button { @@ -281,12 +301,48 @@ h3 { cursor: pointer; } -.user-menu__logout { - color: var(--itk-danger); +.user-menu__pref { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--itk-space-3); + padding: var(--itk-space-2) var(--itk-space-3); + font-size: var(--itk-text-sm); + color: var(--itk-slate-700); } -.user-menu__section { - padding: var(--itk-space-2) var(--itk-space-3); +.toggle-switch { + position: relative; + flex: none; + width: 34px; + height: 20px; + border-radius: var(--itk-radius-pill); + background-color: var(--itk-slate-300); + transition: background-color 0.15s; +} + +.toggle-switch__knob { + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background-color: var(--itk-paper); + box-shadow: var(--itk-shadow-1); + transition: transform 0.15s; +} + +.user-menu__pref[aria-checked="true"] .toggle-switch { + background-color: var(--itk-blue); +} + +.user-menu__pref[aria-checked="true"] .toggle-switch__knob { + transform: translateX(14px); +} + +.user-menu__logout { + color: var(--itk-danger); } .lang-toggle { @@ -313,10 +369,6 @@ h3 { color: #fff; } -.user-menu__section .lang-toggle { - margin-top: 6px; -} - .page { max-width: var(--itk-container); margin: 0 auto; @@ -435,13 +487,31 @@ h3 { padding: var(--itk-space-5); } +.card--stretch { + align-self: stretch; + display: flex; + flex-direction: column; +} + +.feed-fill { + position: relative; + flex: 1; + min-height: 230px; +} + +.feed-fill .feed-scroll { + position: absolute; + inset: 0; + height: auto; +} + .card__header { padding: var(--itk-space-4) var(--itk-space-5); border-bottom: 1px solid var(--itk-slate-100); display: flex; - align-items: center; - justify-content: space-between; - gap: var(--itk-space-3); + flex-direction: column; + align-items: flex-start; + gap: var(--itk-space-1); } .card__title { @@ -537,7 +607,8 @@ h3 { .card__sub { font-size: var(--itk-text-sm); color: var(--itk-slate-500); - margin: 0 0 var(--itk-space-4); + margin: 0; + line-height: 1.4; } .feed-scroll { @@ -573,6 +644,12 @@ h3 { min-width: 560px; } +.heat-empty { + margin: 0; + color: var(--itk-slate-500); + font-size: var(--itk-text-sm); +} + .heat__collabel { display: flex; flex-direction: column; @@ -808,6 +885,75 @@ h3 { color: var(--itk-slate-500); } +a.recent-item { + color: inherit; + text-decoration: none; + transition: background-color 0.15s ease; +} + +a.recent-item:hover { + background-color: color-mix(in srgb, var(--itk-blue) 9%, transparent); +} + +.recent-item__cta { + display: none; + align-items: center; + gap: 4px; + font-size: var(--itk-text-xs); + font-weight: 600; + color: var(--itk-blue); + white-space: nowrap; +} + +a.recent-item:hover .badge { + display: none; +} + +a.recent-item:hover .recent-item__cta { + display: inline-flex; +} + +.recent-item__body { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.recent-item__body .recent-item__title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.todo-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--itk-space-2); + height: 100%; + padding: var(--itk-space-5); + text-align: center; +} + +.todo-empty__icon { + color: var(--itk-green); + line-height: 0; +} + +.todo-empty__title { + font-weight: 600; + color: var(--itk-ink); +} + +.todo-empty__hint { + max-width: 34ch; + margin: 0 0 var(--itk-space-2); + font-size: var(--itk-text-sm); + color: var(--itk-slate-500); +} + .table-wrap { overflow-x: auto; } @@ -1055,6 +1201,7 @@ h3 { } .form-row { + position: relative; display: flex; flex-direction: column; gap: var(--itk-space-2); @@ -1065,35 +1212,70 @@ h3 { margin-bottom: 0; } -.form-row > label { +.form-row__head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--itk-space-2); +} + +.form-row > label, +.form-row__head > label { font-size: var(--itk-text-sm); font-weight: 600; color: var(--itk-slate-700); } +.form-row__head > label { + display: inline-flex; + align-items: center; +} + .completion-star { - display: inline-block; - margin-left: var(--itk-space-1); + display: inline-flex; + align-items: center; + justify-content: center; + overflow: hidden; + width: 1.1em; + height: 1em; + margin-right: 2px; color: gold; - font-size: 1.1em; + font-size: 0.8em; line-height: 1; - vertical-align: text-top; - -webkit-text-stroke: 0.75px #000; + vertical-align: middle; + -webkit-text-stroke: 0.5px #000; paint-order: stroke fill; cursor: help; + transition: + width 0.25s ease, + margin-right 0.25s ease, + opacity 0.25s ease; } .completion-star--gone { width: 0; - margin-left: 0; + margin-right: 0; opacity: 0; - overflow: hidden; } .form-row .help-text, .form-row .form-help { font-size: var(--itk-text-xs); + font-weight: 400; color: var(--itk-slate-500); + opacity: 0; + transition: opacity 0.15s ease; +} + +.form-row .help-text::before, +.form-row .form-help::before { + content: "–"; + margin-right: var(--itk-space-1); +} + +.form-row:focus-within .help-text, +.form-row:focus-within .form-help { + opacity: 1; } input[type="text"], @@ -1123,8 +1305,15 @@ input:focus, select:focus, textarea:focus { outline: none; - border-color: var(--itk-blue); - box-shadow: var(--itk-focus); + border-color: var(--itk-slate-500); + box-shadow: none; +} + +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + box-shadow: none; + border-radius: var(--itk-radius-2); } textarea { @@ -1163,14 +1352,54 @@ textarea { gap: var(--itk-space-3); } +.btn-add { + display: inline-flex; + align-self: flex-start; + align-items: center; + gap: var(--itk-space-2); + padding: var(--itk-space-2) var(--itk-space-3); + font: inherit; + font-size: var(--itk-text-sm); + font-weight: 600; + color: var(--itk-slate-600); + background: none; + border: 1px dashed var(--itk-slate-300); + border-radius: var(--itk-radius-2); + cursor: pointer; + transition: + border-color 0.15s, + background-color 0.15s, + color 0.15s; +} + +.btn-add:hover { + color: var(--itk-blue); + background-color: color-mix(in srgb, var(--itk-blue) 5%, transparent); + border-color: var(--itk-blue); +} + +.btn-add svg { + width: 16px; + height: 16px; +} + .collection__item { display: flex; gap: var(--itk-space-3); - align-items: flex-start; - padding: var(--itk-space-3); - border: 1px solid var(--itk-slate-200); - border-radius: var(--itk-radius-2); - background-color: var(--itk-slate-50); + align-items: center; +} + +[data-collection-remove] { + flex: none; + color: var(--itk-slate-500); + background: none; + border-color: transparent; +} + +[data-collection-remove]:hover { + color: var(--itk-danger); + background-color: color-mix(in srgb, var(--itk-danger) 8%, transparent); + border-color: transparent; } .collection__item .form-grid { @@ -1185,6 +1414,187 @@ textarea { flex: 1; } +.upload-field { + display: flex; + flex-direction: column; + gap: var(--itk-space-2); +} + +.collection__item > .upload-field { + flex: 1; + min-width: 0; +} + +.upload-field__drop { + display: flex; + align-items: center; + gap: var(--itk-space-2); + min-height: 64px; + padding: var(--itk-space-3); + border: 1px dashed var(--itk-slate-300); + border-radius: var(--itk-radius-2); + background-color: var(--itk-surface); + color: var(--itk-slate-500); + font-size: var(--itk-text-sm); + cursor: pointer; + transition: + border-color 0.15s, + background-color 0.15s, + color 0.15s; +} + +.upload-field__drop:hover { + border-color: var(--itk-blue); + color: var(--itk-blue); + background-color: color-mix(in srgb, var(--itk-blue) 5%, transparent); +} + +.upload-field__icon { + width: 18px; + height: 18px; + flex: none; +} + +.upload-field__cta { + font-weight: 600; +} + +.upload-field__filename { + overflow: hidden; + color: var(--itk-slate-700); + text-overflow: ellipsis; + white-space: nowrap; +} + +.upload-field__drop input[type="file"] { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + +.upload-field__progress { + height: 6px; + overflow: hidden; + background-color: var(--itk-slate-100); + border-radius: var(--itk-radius-pill); +} + +.upload-field__bar { + width: 0; + height: 100%; + background-color: var(--itk-blue); + border-radius: inherit; + transition: width 0.2s ease; +} + +.upload-done { + display: flex; + align-items: center; + gap: var(--itk-space-3); + min-height: 64px; + padding: var(--itk-space-2); + background-color: var(--itk-surface); + border: 1px solid var(--itk-slate-200); + border-radius: var(--itk-radius-2); +} + +.collection__item--uploaded .upload-field { + display: none; +} + +.upload-done__thumb { + display: block; + flex: none; + width: 48px; + height: 48px; + overflow: hidden; + cursor: pointer; + background-color: var(--itk-slate-100); + border-radius: var(--itk-radius-1); + transition: opacity 0.15s; +} + +.upload-done__thumb:hover { + opacity: 0.85; +} + +.upload-done__thumb img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.upload-done__name { + overflow: hidden; + font-size: var(--itk-text-sm); + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +.upload-done__name:first-child { + margin-left: var(--itk-space-2); +} + +.lightbox { + position: fixed; + inset: 0; + z-index: 300; + padding: var(--itk-space-6); + overflow: auto; + text-align: center; + white-space: nowrap; + background-color: rgba(17, 19, 24, 0.82); +} + +.lightbox::before { + display: inline-block; + height: 100%; + vertical-align: middle; + content: ""; +} + +.lightbox__img { + display: inline-block; + max-width: 100%; + max-height: 100%; + vertical-align: middle; + border-radius: var(--itk-radius-2); + box-shadow: var(--itk-shadow-3); +} + +.lightbox__img--zoomed { + cursor: grab; +} + +.lightbox__close { + position: fixed; + top: var(--itk-space-4); + right: var(--itk-space-5); + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + font-size: 28px; + line-height: 1; + color: #fff; + cursor: pointer; + background: none; + border: none; +} + +body.is-lightbox-open { + overflow: hidden; +} + .form-actions { display: flex; gap: var(--itk-space-3); @@ -1377,15 +1787,25 @@ textarea { .auth__brand { display: flex; align-items: center; + justify-content: center; gap: var(--itk-space-2); - font-weight: 700; font-size: var(--itk-text-md); - margin-bottom: var(--itk-space-2); + font-weight: 700; + color: var(--itk-ink); + margin-bottom: var(--itk-space-6); +} + +.auth__logo { + height: 40px; + width: auto; + display: block; } .auth__title { - font-size: var(--itk-text-xl); - margin-bottom: var(--itk-space-6); + font-size: var(--itk-text-base); + font-weight: 600; + text-align: center; + margin-bottom: var(--itk-space-3); } .auth .form-row { @@ -1750,6 +2170,33 @@ textarea { overflow: visible; } +.mascot__gold-stop { + animation: mascot-gold 9s ease-in-out infinite; +} + +.mascot__gold-stop:nth-child(2) { + animation-delay: -3s; +} + +.mascot__gold-stop:nth-child(3) { + animation-delay: -6s; +} + +@keyframes mascot-gold { + 0% { + stop-color: #ffe488; + } + 33% { + stop-color: #fcc24a; + } + 66% { + stop-color: #eaa326; + } + 100% { + stop-color: #ffe488; + } +} + @keyframes mascot-bob { 0%, 100% { @@ -1850,6 +2297,68 @@ textarea { color: var(--itk-ink); } +.mascot__tour-actions { + display: flex; + align-items: center; + gap: var(--itk-space-2); +} + +.mascot__tour-actions:has(> :not([hidden])) { + margin-top: var(--itk-space-3); +} + +.mascot__tour-next { + padding: var(--itk-space-1) var(--itk-space-3); + border: none; + border-radius: var(--itk-radius-2); + background-color: var(--itk-primary); + color: #fff; + font: inherit; + font-size: var(--itk-text-xs); + font-weight: 600; + cursor: pointer; + transition: background-color 0.15s; +} + +.mascot__tour-next:hover { + background-color: var(--itk-primary-hover); +} + +.mascot__tour-skip { + padding: var(--itk-space-1) var(--itk-space-2); + border: none; + background: none; + color: var(--itk-slate-500); + font: inherit; + font-size: var(--itk-text-xs); + cursor: pointer; +} + +.mascot__tour-skip:hover { + color: var(--itk-ink); +} + +.mascot__tour-next[hidden], +.mascot__tour-skip[hidden] { + display: none; +} + +.tour-spotlight { + position: fixed; + inset: 0; + z-index: 150; + background-color: rgba(17, 19, 24, 0.55); + opacity: 0; + pointer-events: none; +} + +.tour-spotlight.is-visible { + opacity: 1; + transition: + clip-path 0.5s ease, + opacity 0.3s ease; +} + .mascot--playing { right: auto; bottom: auto; @@ -1867,6 +2376,20 @@ textarea { max-width: 280px; } +.mascot--tour { + transition: + left 0.6s ease, + top 0.6s ease; +} + +.mascot--hero .mascot__avatar { + transform: scale(1.7); +} + +.mascot--hero .mascot__bubble { + bottom: calc(100% + var(--itk-space-6)); +} + .mascot--chasing { animation: none; transition: none; @@ -1895,7 +2418,7 @@ textarea { gap: var(--itk-space-1); width: 100%; min-height: 38px; - padding: 3px var(--itk-space-3); + padding: var(--itk-space-1) var(--itk-space-3); font-size: var(--itk-text-sm); color: var(--itk-ink); background-color: var(--itk-paper); @@ -1907,15 +2430,19 @@ textarea { box-shadow 0.15s; } +.ts-wrapper.multi.has-items .ts-control { + padding-left: var(--itk-space-3); +} + .ts-wrapper.focus .ts-control { - border-color: var(--itk-blue); - box-shadow: var(--itk-focus); + border-color: var(--itk-slate-500); + box-shadow: none; } .ts-wrapper .ts-control > input { flex: 1 1 auto; width: auto; - min-width: 6rem; + min-width: 8rem; margin: 0; padding: var(--itk-space-1) 0; border: none; @@ -1926,40 +2453,61 @@ textarea { color: var(--itk-ink); } +.ts-wrapper .ts-control > input::placeholder { + color: var(--itk-slate-500); +} + .ts-wrapper .ts-control > input:focus { outline: none; border: none; box-shadow: none; } -.ts-wrapper .ts-control .item { +.ts-wrapper.multi .ts-control .item { display: inline-flex; align-items: center; - gap: var(--itk-space-1); padding: 2px var(--itk-space-2); - font-size: var(--itk-text-xs); - font-weight: 500; - color: var(--itk-blue); - background-color: color-mix(in srgb, var(--itk-blue) 12%, transparent); - border-radius: var(--itk-radius-pill); + font-size: var(--itk-text-sm); + font-weight: 400; + line-height: 1.25; + color: var(--itk-ink); + background: var(--itk-slate-50); + background-image: none; + border: 1px solid var(--itk-slate-200); + border-radius: var(--itk-radius-2); + box-shadow: none; + text-shadow: none; } -.ts-wrapper .ts-control .item .remove { - padding: 0 2px; +.ts-wrapper.multi .ts-control .item.active { + color: var(--itk-ink); + background: var(--itk-slate-200); + background-image: none; + border-color: var(--itk-slate-300); + box-shadow: none; +} + +.ts-wrapper.plugin-remove_button .ts-control .item .remove { + margin: 0; + padding: 0 var(--itk-space-2) 0 var(--itk-space-1); border: none; - color: inherit; - opacity: 0.6; + border-radius: 0; + background: transparent; + color: var(--itk-slate-500); + font-size: 1.05em; + line-height: 1; } -.ts-wrapper .ts-control .item .remove:hover { +.ts-wrapper.plugin-remove_button .ts-control .item .remove:hover { background: transparent; - opacity: 1; + color: var(--itk-ink); } .ts-dropdown { - margin-top: var(--itk-space-1); + margin-top: var(--itk-space-2); + padding: var(--itk-space-1); border: 1px solid var(--itk-slate-200); - border-radius: var(--itk-radius-2); + border-radius: var(--itk-radius-3); background-color: var(--itk-paper); box-shadow: var(--itk-shadow-2); font-size: var(--itk-text-sm); @@ -1967,12 +2515,16 @@ textarea { overflow: hidden; } -.ts-dropdown .option { +.ts-dropdown .option, +.ts-dropdown .create { padding: var(--itk-space-2) var(--itk-space-3); + border-radius: var(--itk-radius-2); + cursor: pointer; } -.ts-dropdown .active { - background-color: color-mix(in srgb, var(--itk-blue) 10%, transparent); +.ts-dropdown .active, +.ts-dropdown .active.create { + background-color: var(--itk-slate-100); color: var(--itk-ink); } diff --git a/composer.json b/composer.json index 21aa8f6..7f3eebb 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ "symfony/translation": "~8.1.0", "symfony/twig-bundle": "~8.1.0", "symfony/ux-turbo": "^3.2", + "symfony/ux-twig-component": "^3.2", "symfony/validator": "~8.1.0", "symfony/yaml": "~8.1.0", "twig/extra-bundle": "^2.12 || ^3.0", diff --git a/composer.lock b/composer.lock index e047caf..8d4a2e5 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": "12a9cfde9017aae91381d9a166055131", + "content-hash": "1690432fc2541ccfd5e69c603125ca0e", "packages": [ { "name": "composer/semver", @@ -6863,6 +6863,95 @@ ], "time": "2026-06-19T07:14:15+00:00" }, + { + "name": "symfony/ux-twig-component", + "version": "v3.2.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/ux-twig-component.git", + "reference": "a9b93b10fe2056a6f93ebc1f9e3509f7fafb5bf2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/ux-twig-component/zipball/a9b93b10fe2056a6f93ebc1f9e3509f7fafb5bf2", + "reference": "a9b93b10fe2056a6f93ebc1f9e3509f7fafb5bf2", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.2|^3.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "twig/twig": "^3.10.3" + }, + "conflict": { + "symfony/config": "<6.4" + }, + "require-dev": { + "phpunit/phpunit": "^11.1|^12.0", + "symfony/console": "^7.4|^8.0", + "symfony/css-selector": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/dom-crawler": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/stimulus-bundle": "^2.9.1|^3.0", + "symfony/twig-bundle": "^7.4|^8.0", + "twig/extra-bundle": "^3.10.3", + "twig/html-extra": "^3.10.3" + }, + "type": "symfony-bundle", + "extra": { + "thanks": { + "url": "https://github.com/symfony/ux", + "name": "symfony/ux" + } + }, + "autoload": { + "psr-4": { + "Symfony\\UX\\TwigComponent\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Twig components for Symfony", + "homepage": "https://symfony.com", + "keywords": [ + "components", + "symfony-ux", + "twig" + ], + "support": { + "source": "https://github.com/symfony/ux-twig-component/tree/v3.2.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-11T20:32:06+00:00" + }, { "name": "symfony/validator", "version": "v8.1.0", diff --git a/config/bundles.php b/config/bundles.php index 2d685b3..f4c7831 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -17,4 +17,5 @@ Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true], DH\AuditorBundle\DHAuditorBundle::class => ['all' => true], ITKDev\EntityBundle\ITKDevEntityBundle::class => ['all' => true], + Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true], ]; diff --git a/config/packages/twig_component.yaml b/config/packages/twig_component.yaml new file mode 100644 index 0000000..1d7b9c4 --- /dev/null +++ b/config/packages/twig_component.yaml @@ -0,0 +1,5 @@ +twig_component: + anonymous_template_directory: 'components/' + defaults: + # Namespace & directory for components + App\Twig\Components\: 'components/' diff --git a/docker-compose.server.prod.yml b/docker-compose.server.prod.yml index bf63602..a7859b9 100644 --- a/docker-compose.server.prod.yml +++ b/docker-compose.server.prod.yml @@ -2,3 +2,4 @@ services: phpfpm: volumes: - ../../shared/.env.local:/app/.env.local + - ../../shared/uploads:/app/var/uploads diff --git a/migrations/Version20260627134918.php b/migrations/Version20260627134918.php new file mode 100644 index 0000000..e26eb99 --- /dev/null +++ b/migrations/Version20260627134918.php @@ -0,0 +1,41 @@ +addSql('CREATE TABLE area (id BINARY(16) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, name VARCHAR(255) NOT NULL, created_by_id BINARY(16) DEFAULT NULL, modified_by_id BINARY(16) DEFAULT NULL, UNIQUE INDEX UNIQ_D7943D685E237E06 (name), INDEX IDX_D7943D68B03A8386 (created_by_id), INDEX IDX_D7943D6899049ECE (modified_by_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE area ADD CONSTRAINT FK_D7943D68B03A8386 FOREIGN KEY (created_by_id) REFERENCES `user` (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE area ADD CONSTRAINT FK_D7943D6899049ECE FOREIGN KEY (modified_by_id) REFERENCES `user` (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE initiative ADD area_id BINARY(16) DEFAULT NULL, DROP category'); + $this->addSql('ALTER TABLE initiative ADD CONSTRAINT FK_E115DEFEBD0F409C FOREIGN KEY (area_id) REFERENCES area (id) ON DELETE SET NULL'); + $this->addSql('CREATE INDEX IDX_E115DEFEBD0F409C ON initiative (area_id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE area DROP FOREIGN KEY FK_D7943D68B03A8386'); + $this->addSql('ALTER TABLE area DROP FOREIGN KEY FK_D7943D6899049ECE'); + $this->addSql('DROP TABLE area'); + $this->addSql('ALTER TABLE initiative DROP FOREIGN KEY FK_E115DEFEBD0F409C'); + $this->addSql('DROP INDEX IDX_E115DEFEBD0F409C ON initiative'); + $this->addSql('ALTER TABLE initiative ADD category VARCHAR(32) DEFAULT NULL, DROP area_id'); + } +} diff --git a/src/Controller/Admin/AreaController.php b/src/Controller/Admin/AreaController.php new file mode 100644 index 0000000..0278257 --- /dev/null +++ b/src/Controller/Admin/AreaController.php @@ -0,0 +1,78 @@ +render('admin/areas/index.html.twig', [ + 'areas' => $areas->findAllOrdered(), + ]); + } + + #[Route('/new', name: 'admin_area_new', methods: ['GET', 'POST'])] + public function new(Request $request, EntityManagerInterface $entityManager): Response + { + $area = new Area(); + $form = $this->createForm(AreaType::class, $area); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $entityManager->persist($area); + $entityManager->flush(); + $this->addFlash('success', 'flash.area.created'); + + return $this->redirectToRoute('admin_areas'); + } + + return $this->render('admin/areas/new.html.twig', ['form' => $form]); + } + + #[Route('/{id}/edit', name: 'admin_area_edit', requirements: ['id' => Requirement::ULID], methods: ['GET', 'POST'])] + public function edit(Request $request, Area $area, EntityManagerInterface $entityManager): Response + { + $form = $this->createForm(AreaType::class, $area); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $entityManager->flush(); + $this->addFlash('success', 'flash.area.updated'); + + return $this->redirectToRoute('admin_areas'); + } + + return $this->render('admin/areas/edit.html.twig', [ + 'form' => $form, + 'area' => $area, + ]); + } + + #[Route('/{id}/delete', name: 'admin_area_delete', requirements: ['id' => Requirement::ULID], methods: ['POST'])] + public function delete(Request $request, Area $area, EntityManagerInterface $entityManager): Response + { + if ($this->isCsrfTokenValid('delete-area-'.$area->getId(), (string) $request->request->get('_token'))) { + $entityManager->remove($area); + $entityManager->flush(); + $this->addFlash('success', 'flash.area.deleted'); + } + + return $this->redirectToRoute('admin_areas'); + } +} diff --git a/src/Controller/DashboardController.php b/src/Controller/DashboardController.php index 8d63386..2053b0d 100644 --- a/src/Controller/DashboardController.php +++ b/src/Controller/DashboardController.php @@ -4,6 +4,8 @@ namespace App\Controller; +use App\Entity\User; +use App\Repository\ContactRepository; use App\Repository\InitiativeRepository; use App\Service\DashboardData; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -13,11 +15,15 @@ class DashboardController extends AbstractController { #[Route('/', name: 'app_dashboard', methods: ['GET'])] - public function index(InitiativeRepository $initiatives, DashboardData $dashboardData): Response + public function index(InitiativeRepository $initiatives, ContactRepository $contacts, DashboardData $dashboardData): Response { + $user = $this->getUser(); + return $this->render('dashboard/index.html.twig', [ 'recent' => $initiatives->findRecent(8), 'viz' => $dashboardData->build(), + 'unfinishedInitiatives' => $user instanceof User ? $initiatives->findUnfinishedListByCreator($user) : [], + 'incompleteContacts' => $user instanceof User ? $contacts->findIncompleteListByCreator($user) : [], ]); } } diff --git a/src/Controller/InitiativeController.php b/src/Controller/InitiativeController.php index f4374b1..7b2f8d0 100644 --- a/src/Controller/InitiativeController.php +++ b/src/Controller/InitiativeController.php @@ -65,7 +65,7 @@ public function export(Request $request, InitiativeRepository $initiatives, Tran $csv = Writer::createFromStream(fopen('php://output', 'w')); $csv->insertOne([ 'id', $translator->trans('initiative.title'), $translator->trans('initiative.status'), - $translator->trans('initiative.category'), $translator->trans('initiative.initiative_type'), + $translator->trans('initiative.area'), $translator->trans('initiative.initiative_type'), $translator->trans('initiative.organizational_anchoring'), $translator->trans('initiative.endorsement'), $translator->trans('initiative.endorsement_author'), $translator->trans('initiative.budget'), $translator->trans('initiative.funding'), $translator->trans('initiative.stakeholders'), @@ -81,7 +81,7 @@ public function export(Request $request, InitiativeRepository $initiatives, Tran (string) $row->getId(), $row->getTitle(), $translate($row->getStatus()), - $translate($row->getCategory()), + $row->getArea()?->getName(), $translate($row->getInitiativeType()), $row->getOrganizationalAnchoring()?->getName(), $row->isEndorsement() ? $translator->trans('filter.yes') : $translator->trans('filter.no'), diff --git a/src/Controller/UserSettingsController.php b/src/Controller/UserSettingsController.php index f52b4d8..3b7582c 100644 --- a/src/Controller/UserSettingsController.php +++ b/src/Controller/UserSettingsController.php @@ -35,6 +35,44 @@ public function toggleMascot(Request $request, EntityManagerInterface $entityMan return $this->redirect($this->safeReturn($request)); } + /** + * Toggle whether the completion stars fly into the trophy. A plain redirect: + * the page re-renders with the new preference and the bar is unaffected. + */ + #[Route('/settings/stars/toggle', name: 'app_settings_stars_toggle', methods: ['POST'])] + public function toggleStars(Request $request, EntityManagerInterface $entityManager): Response + { + $user = $this->getUser(); + if ($user instanceof User + && $this->isCsrfTokenValid('toggle-stars', (string) $request->request->get('_token'))) { + $user->setStarsEnabled(!$user->isStarsEnabled()); + $entityManager->flush(); + } + + return $this->redirect($this->safeReturn($request)); + } + + /** + * Mark Glimt's guided tour as seen, so it is proposed only once. Answers 204 + * to the mascot's fetch and otherwise redirects back for the no-JS path. + */ + #[Route('/settings/tour/seen', name: 'app_settings_tour_seen', methods: ['POST'])] + public function markTourSeen(Request $request, EntityManagerInterface $entityManager): Response + { + $user = $this->getUser(); + if ($user instanceof User + && $this->isCsrfTokenValid('tour-seen', (string) $request->request->get('_token'))) { + $user->setTourSeen(true); + $entityManager->flush(); + } + + if ('fetch' === $request->headers->get('X-Requested-With')) { + return new Response(null, Response::HTTP_NO_CONTENT); + } + + return $this->redirect($this->safeReturn($request)); + } + /** * Only follow local, relative return paths to avoid open redirects (same * guard as the locale switcher). diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php index a77c7c1..aa04607 100644 --- a/src/DataFixtures/AppFixtures.php +++ b/src/DataFixtures/AppFixtures.php @@ -4,12 +4,12 @@ namespace App\DataFixtures; +use App\Entity\Area; use App\Entity\Contact; use App\Entity\Department; use App\Entity\Initiative; use App\Entity\Term; use App\Entity\User; -use App\Enum\Category; use App\Enum\EndorsementAuthor; use App\Enum\Funding; use App\Enum\InitiativeType; @@ -25,6 +25,7 @@ class AppFixtures extends Fixture private const array STAKEHOLDERS = ['Aarhus Kommune', 'Region Midtjylland', 'Aarhus Universitet', 'Erhverv Aarhus', 'Lokale foreninger', 'Boligforeninger', 'VIA University College', 'Business Region Aarhus']; private const array STRATEGIES = ['Klimaplan 2030', 'Erhvervsplan', 'Børn- og ungepolitik', 'Mobilitetsplan', 'Digitaliseringsstrategi', 'Sundhedspolitik']; private const array DEPARTMENTS = ['ITK Development', 'CFIA', 'Aarhus CityLab', 'Stab', 'OS2', 'AI Lab', 'IOT Lab', 'GTM', 'Fut Lab']; + private const array AREAS = ['Klima og miljø', 'Mobilitet', 'Velfærd', 'Kultur og fritid', 'Uddannelse', 'Erhverv', 'Digitalisering', 'Byudvikling']; public function __construct(private readonly UserPasswordHasherInterface $hasher) { @@ -61,6 +62,13 @@ public function load(ObjectManager $manager): void $departments[] = $department; } + $areas = []; + foreach (self::AREAS as $name) { + $area = (new Area())->setName($name); + $manager->persist($area); + $areas[] = $area; + } + $contacts = []; $firstNames = ['Anne', 'Mette', 'Lars', 'Søren', 'Camilla', 'Jens', 'Ida', 'Mads', 'Sofie', 'Peter', 'Louise', 'Thomas']; $lastNames = ['Jensen', 'Nielsen', 'Hansen', 'Pedersen', 'Andersen', 'Christensen', 'Larsen', 'Sørensen']; @@ -103,7 +111,6 @@ public function load(ObjectManager $manager): void ]; $statuses = Status::cases(); - $categories = Category::cases(); $types = InitiativeType::cases(); $endorsers = EndorsementAuthor::cases(); $fundings = Funding::cases(); @@ -111,7 +118,7 @@ public function load(ObjectManager $manager): void foreach ($titles as $index => $title) { $initiative = (new Initiative()) ->setTitle($title) - ->setCategory($categories[array_rand($categories)]) + ->setArea($areas[array_rand($areas)]) ->setInitiativeType($types[array_rand($types)]) ->setStatus($statuses[array_rand($statuses)]) ->setOrganizationalAnchoring($departments[array_rand($departments)]) diff --git a/src/Entity/Area.php b/src/Entity/Area.php new file mode 100644 index 0000000..68cc18e --- /dev/null +++ b/src/Entity/Area.php @@ -0,0 +1,36 @@ +name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function __toString(): string + { + return (string) $this->name; + } +} diff --git a/src/Entity/Initiative.php b/src/Entity/Initiative.php index 63bb893..a274d9c 100644 --- a/src/Entity/Initiative.php +++ b/src/Entity/Initiative.php @@ -4,7 +4,6 @@ namespace App\Entity; -use App\Enum\Category; use App\Enum\EndorsementAuthor; use App\Enum\Funding; use App\Enum\InitiativeType; @@ -27,8 +26,8 @@ class Initiative extends AbstractEntity * @var list */ public const array COMPLETION_FIELDS = [ - 'title', 'category', 'description', 'initiativeType', 'status', - 'organizationalAnchoring', 'endorsementAuthor', + 'title', 'area', 'description', 'initiativeType', 'status', + 'organizationalAnchoring', 'budget', 'funding', 'timePeriodStart', 'timePeriodEnd', ]; @@ -36,8 +35,9 @@ class Initiative extends AbstractEntity #[ORM\Column(length: 255)] private ?string $title = null; - #[ORM\Column(length: 32, nullable: true, enumType: Category::class)] - private ?Category $category = null; + #[ORM\ManyToOne(targetEntity: Area::class)] + #[ORM\JoinColumn(onDelete: 'SET NULL')] + private ?Area $area = null; #[ORM\Column(type: Types::TEXT, nullable: true)] private ?string $description = null; @@ -61,7 +61,7 @@ class Initiative extends AbstractEntity private ?Department $organizationalAnchoring = null; #[ORM\Column] - private bool $endorsement = true; + private bool $endorsement = false; #[ORM\Column(length: 32, nullable: true, enumType: EndorsementAuthor::class)] private ?EndorsementAuthor $endorsementAuthor = null; @@ -134,14 +134,14 @@ public function setTitle(?string $title): static return $this; } - public function getCategory(): ?Category + public function getArea(): ?Area { - return $this->category; + return $this->area; } - public function setCategory(?Category $category): static + public function setArea(?Area $area): static { - $this->category = $category; + $this->area = $area; return $this; } @@ -486,12 +486,11 @@ public function getCompletionPercentage(): int { $checks = [ null !== $this->title && '' !== $this->title, - null !== $this->category, + null !== $this->area, 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, @@ -501,6 +500,24 @@ public function getCompletionPercentage(): int return (int) round(\count(array_filter($checks)) / \count($checks) * 100); } + /** + * Whether the initiative has been edited since it was created — drives the + * "updated" vs "created" label in the dashboard activity feed. Only counts as + * an edit once it's more than a day past creation, so the initial save and any + * same-day tweaks still read as "created". + */ + public function wasUpdatedAfterCreation(): bool + { + $created = $this->getCreatedAt(); + $updated = $this->getUpdatedAt(); + + if (null === $created || null === $updated) { + return false; + } + + return $updated->getTimestamp() - $created->getTimestamp() > 60 * 60 * 24; + } + public function __toString(): string { return (string) $this->title; diff --git a/src/Entity/User.php b/src/Entity/User.php index 3b96016..afc4327 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -144,6 +144,38 @@ public function setMascotEnabled(bool $enabled): static return $this; } + /** + * The completion stars fly into the trophy unless the user turns it off; the + * udfyldningsgrad bar keeps working either way. + */ + public function isStarsEnabled(): bool + { + return (bool) ($this->userSettings['starsEnabled'] ?? true); + } + + public function setStarsEnabled(bool $enabled): static + { + $this->userSettings['starsEnabled'] = $enabled; + + return $this; + } + + /** + * Whether the user has been offered Glimt's guided tour of the platform. It + * is proposed once, on the first dashboard visit, then never again. + */ + public function isTourSeen(): bool + { + return (bool) ($this->userSettings['tourSeen'] ?? false); + } + + public function setTourSeen(bool $seen): static + { + $this->userSettings['tourSeen'] = $seen; + + return $this; + } + public function eraseCredentials(): void { } diff --git a/src/Enum/Category.php b/src/Enum/Category.php deleted file mode 100644 index 010e3d8..0000000 --- a/src/Enum/Category.php +++ /dev/null @@ -1,26 +0,0 @@ -value; - } -} diff --git a/src/Form/AreaType.php b/src/Form/AreaType.php new file mode 100644 index 0000000..43f4515 --- /dev/null +++ b/src/Form/AreaType.php @@ -0,0 +1,32 @@ + + */ +class AreaType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('name', TextType::class, [ + 'label' => 'area.name', + 'help' => 'area.name_help', + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Area::class, + ]); + } +} diff --git a/src/Form/ContactType.php b/src/Form/ContactType.php index 668f6d6..89b029e 100644 --- a/src/Form/ContactType.php +++ b/src/Form/ContactType.php @@ -22,18 +22,22 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $builder ->add('name', TextType::class, [ 'label' => 'contact.name', + 'help' => 'contact.name_help', ]) ->add('email', EmailType::class, [ 'label' => 'contact.email', 'required' => false, + 'help' => 'contact.email_help', ]) ->add('phone', TelType::class, [ 'label' => 'contact.phone', 'required' => false, + 'help' => 'contact.phone_help', ]) ->add('department', TextType::class, [ 'label' => 'contact.department', 'required' => false, + 'help' => 'contact.department_help', ]); } diff --git a/src/Form/ContactsTextType.php b/src/Form/ContactsTextType.php new file mode 100644 index 0000000..6b9bbb2 --- /dev/null +++ b/src/Form/ContactsTextType.php @@ -0,0 +1,63 @@ + + */ +final class ContactsTextType extends AbstractType +{ + public function __construct(private readonly ContactRepository $contactRepository) + { + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->addModelTransformer(new ContactsTextTransformer($this->contactRepository)); + } + + /** + * Expose the existing contacts so the client can offer them as a searchable + * pool (and let new ones join it). The names are rendered as a JSON data + * attribute the Tom Select initialiser reads. + */ + public function buildView(FormView $view, FormInterface $form, array $options): void + { + $pool = array_map( + static fn (Contact $contact): string => (string) $contact->getName(), + $this->contactRepository->findAllOrdered(), + ); + + $view->vars['attr'] = array_merge($view->vars['attr'], [ + 'data-contact-select' => '', + 'data-contact-pool' => json_encode($pool, \JSON_THROW_ON_ERROR), + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'invalid_message' => 'form.terms.invalid', + ]); + } + + public function getParent(): string + { + return TextType::class; + } +} diff --git a/src/Form/DataTransformer/ContactsTextTransformer.php b/src/Form/DataTransformer/ContactsTextTransformer.php new file mode 100644 index 0000000..efc4d0b --- /dev/null +++ b/src/Form/DataTransformer/ContactsTextTransformer.php @@ -0,0 +1,68 @@ + + */ +final readonly class ContactsTextTransformer implements DataTransformerInterface +{ + public function __construct(private ContactRepository $contactRepository) + { + } + + public function transform(mixed $value): string + { + if (!is_iterable($value)) { + return ''; + } + + $names = []; + foreach ($value as $contact) { + if ($contact instanceof Contact) { + $names[] = $contact->getName(); + } + } + + return implode(', ', $names); + } + + /** + * @return Collection + */ + public function reverseTransform(mixed $value): Collection + { + $contacts = new ArrayCollection(); + + if (!\is_string($value) || '' === trim($value)) { + return $contacts; + } + + $seen = []; + foreach (explode(',', $value) as $name) { + $name = trim($name); + $key = mb_strtolower($name); + if ('' === $name || isset($seen[$key])) { + continue; + } + $seen[$key] = true; + + $contacts->add($this->contactRepository->findOrCreate($name)); + } + + return $contacts; + } +} diff --git a/src/Form/DepartmentType.php b/src/Form/DepartmentType.php index 676b0e7..604e7c4 100644 --- a/src/Form/DepartmentType.php +++ b/src/Form/DepartmentType.php @@ -20,6 +20,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $builder ->add('name', TextType::class, [ 'label' => 'department.name', + 'help' => 'department.name_help', ]); } diff --git a/src/Form/InitiativeFilterType.php b/src/Form/InitiativeFilterType.php index b8ab7e0..98bfdd4 100644 --- a/src/Form/InitiativeFilterType.php +++ b/src/Form/InitiativeFilterType.php @@ -4,8 +4,8 @@ namespace App\Form; +use App\Entity\Area; use App\Entity\Department; -use App\Enum\Category; use App\Enum\InitiativeType as InitiativeTypeEnum; use App\Enum\Status; use App\Model\InitiativeFilter; @@ -39,12 +39,12 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'placeholder' => 'filter.all', 'choice_label' => static fn (Status $value): string => $value->labelKey(), ]) - ->add('category', EnumType::class, [ - 'label' => 'initiative.category', - 'class' => Category::class, + ->add('area', EntityType::class, [ + 'label' => 'initiative.area', + 'class' => Area::class, + 'choice_label' => 'name', 'required' => false, 'placeholder' => 'filter.all', - 'choice_label' => static fn (Category $value): string => $value->labelKey(), ]) ->add('initiativeType', EnumType::class, [ 'label' => 'initiative.initiative_type', diff --git a/src/Form/InitiativeImageType.php b/src/Form/InitiativeImageType.php index 0dba605..8447153 100644 --- a/src/Form/InitiativeImageType.php +++ b/src/Form/InitiativeImageType.php @@ -8,7 +8,6 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\FileType; -use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Validator\Constraints as Assert; @@ -34,10 +33,6 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'constraints' => [ new Assert\Image(maxSize: $this->maxImageSize), ], - ]) - ->add('alt', TextType::class, [ - 'label' => 'initiative.image_alt', - 'required' => false, ]); } diff --git a/src/Form/InitiativeType.php b/src/Form/InitiativeType.php index 737d839..29a8d35 100644 --- a/src/Form/InitiativeType.php +++ b/src/Form/InitiativeType.php @@ -4,10 +4,9 @@ namespace App\Form; -use App\Entity\Contact; +use App\Entity\Area; use App\Entity\Department; use App\Entity\Initiative; -use App\Enum\Category; use App\Enum\EndorsementAuthor; use App\Enum\Funding; use App\Enum\InitiativeType as InitiativeTypeEnum; @@ -24,8 +23,6 @@ use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\UrlType; use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Component\Form\FormEvent; -use Symfony\Component\Form\FormEvents; use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormView; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -41,18 +38,21 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $builder ->add('title', TextType::class, [ 'label' => 'initiative.title', + 'help' => 'initiative.title_help', ]) - ->add('category', EnumType::class, [ - 'label' => 'initiative.category', - 'class' => Category::class, + ->add('area', EntityType::class, [ + 'label' => 'initiative.area', + 'class' => Area::class, + 'choice_label' => 'name', 'required' => false, 'placeholder' => 'form.choose', - 'choice_label' => static fn (Category $value): string => $value->labelKey(), + 'help' => 'initiative.area_help', ]) ->add('description', TextareaType::class, [ 'label' => 'initiative.description', 'required' => false, 'attr' => ['rows' => 4], + 'help' => 'initiative.description_help', ]) ->add('strategies', TermsTextType::class, [ 'label' => 'initiative.strategies', @@ -66,6 +66,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, 'placeholder' => 'form.choose', 'choice_label' => static fn (InitiativeTypeEnum $value): string => $value->labelKey(), + 'help' => 'initiative.initiative_type_help', ]) ->add('status', EnumType::class, [ 'label' => 'initiative.status', @@ -73,11 +74,13 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, 'placeholder' => 'form.choose', 'choice_label' => static fn (Status $value): string => $value->labelKey(), + 'help' => 'initiative.status_help', ]) ->add('statusAdditional', TextareaType::class, [ 'label' => 'initiative.status_additional', 'required' => false, 'attr' => ['rows' => 3], + 'help' => 'initiative.status_additional_help', ]) ->add('organizationalAnchoring', EntityType::class, [ 'label' => 'initiative.organizational_anchoring', @@ -85,6 +88,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'choice_label' => 'name', 'required' => false, 'placeholder' => 'form.choose', + 'help' => 'initiative.organizational_anchoring_help', ]) ->add('endorsement', CheckboxType::class, [ 'label' => 'initiative.endorsement', @@ -96,11 +100,13 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, 'placeholder' => 'form.choose', 'choice_label' => static fn (EndorsementAuthor $value): string => $value->labelKey(), + 'help' => 'initiative.endorsement_author_help', ]) ->add('budget', IntegerType::class, [ 'label' => 'initiative.budget', 'required' => false, 'attr' => ['min' => 0], + 'help' => 'initiative.budget_help', ]) ->add('funding', EnumType::class, [ 'label' => 'initiative.funding', @@ -127,12 +133,14 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'widget' => 'single_text', 'input' => 'datetime_immutable', 'required' => false, + 'help' => 'initiative.time_period_start_help', ]) ->add('timePeriodEnd', DateType::class, [ 'label' => 'initiative.time_period_end', 'widget' => 'single_text', 'input' => 'datetime_immutable', 'required' => false, + 'help' => 'initiative.time_period_end_help', ]) ->add('links', CollectionType::class, [ 'label' => 'initiative.links', @@ -151,25 +159,10 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, 'prototype' => true, ]) - ->add('contacts', EntityType::class, [ + ->add('contacts', ContactsTextType::class, [ 'label' => 'initiative.contacts', - 'class' => Contact::class, - 'choice_label' => 'name', - 'multiple' => true, 'required' => false, - 'by_reference' => false, - 'attr' => ['data-contact-select' => true], - ]) - ->add('newContacts', CollectionType::class, [ - 'label' => 'initiative.new_contacts', - 'entry_type' => ContactType::class, - 'allow_add' => true, - 'allow_delete' => true, - 'delete_empty' => static fn (?Contact $contact): bool => null === $contact || null === $contact->getName() || '' === trim((string) $contact->getName()), - 'by_reference' => false, - 'required' => false, - 'prototype' => true, - 'mapped' => false, + 'help' => 'initiative.terms_help', ]) ->add('images', CollectionType::class, [ 'label' => 'initiative.images', @@ -189,17 +182,6 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, 'prototype' => true, ]); - - // Existing contacts bind directly through the select; brand-new ones are - // built in the unmapped "newContacts" collection and merged in here. - $builder->addEventListener(FormEvents::POST_SUBMIT, static function (FormEvent $event): void { - $initiative = $event->getData(); - if ($initiative instanceof Initiative) { - foreach ($event->getForm()->get('newContacts')->getData() as $contact) { - $initiative->addContact($contact); - } - } - }); } /** diff --git a/src/Form/TermsTextType.php b/src/Form/TermsTextType.php index 5f70b77..d067f00 100644 --- a/src/Form/TermsTextType.php +++ b/src/Form/TermsTextType.php @@ -4,17 +4,20 @@ namespace App\Form; +use App\Entity\Term; use App\Enum\Vocabulary; use App\Form\DataTransformer\TermsTextTransformer; use App\Repository\TermRepository; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; use Symfony\Component\OptionsResolver\OptionsResolver; /** * A text input mapping a comma-separated list of names to a collection of - * free-tagging {@see \App\Entity\Term}s. Pass the target `vocabulary`. + * free-tagging {@see Term}s. Pass the target `vocabulary`. * * @extends AbstractType */ @@ -29,6 +32,24 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $builder->addModelTransformer(new TermsTextTransformer($this->termRepository, $options['vocabulary'])); } + /** + * Expose the vocabulary's existing terms so the client can offer them as a + * searchable pool (and let new ones join it). The names are rendered as a + * JSON data attribute the Tom Select initialiser reads. + */ + public function buildView(FormView $view, FormInterface $form, array $options): void + { + $pool = array_map( + static fn (Term $term): string => (string) $term->getName(), + $this->termRepository->findByVocabulary($options['vocabulary']), + ); + + $view->vars['attr'] = array_merge($view->vars['attr'], [ + 'data-term-select' => '', + 'data-term-pool' => json_encode($pool, \JSON_THROW_ON_ERROR), + ]); + } + public function configureOptions(OptionsResolver $resolver): void { $resolver->setRequired('vocabulary'); diff --git a/src/Form/UserType.php b/src/Form/UserType.php index 258cb90..2f654c8 100644 --- a/src/Form/UserType.php +++ b/src/Form/UserType.php @@ -34,10 +34,12 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $builder ->add('email', EmailType::class, [ 'label' => 'user.email', + 'help' => 'user.email_help', ]) ->add('name', TextType::class, [ 'label' => 'user.name', 'required' => false, + 'help' => 'user.name_help', ]) // Mapped onto User::$roles, so this field can grant ROLE_ADMIN. It // relies on UserController being gated by #[IsGranted('ROLE_ADMIN')]; diff --git a/src/Model/InitiativeFilter.php b/src/Model/InitiativeFilter.php index 64e0a1e..d609074 100644 --- a/src/Model/InitiativeFilter.php +++ b/src/Model/InitiativeFilter.php @@ -4,8 +4,8 @@ namespace App\Model; +use App\Entity\Area; use App\Entity\Department; -use App\Enum\Category; use App\Enum\InitiativeType; use App\Enum\Status; @@ -19,7 +19,7 @@ class InitiativeFilter public ?Status $status = null; - public ?Category $category = null; + public ?Area $area = null; public ?InitiativeType $initiativeType = null; diff --git a/src/Repository/AreaRepository.php b/src/Repository/AreaRepository.php new file mode 100644 index 0000000..3cee73d --- /dev/null +++ b/src/Repository/AreaRepository.php @@ -0,0 +1,31 @@ + + */ +class AreaRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Area::class); + } + + /** + * @return Area[] + */ + public function findAllOrdered(): array + { + return $this->createQueryBuilder('a') + ->orderBy('a.name', 'ASC') + ->getQuery() + ->getResult(); + } +} diff --git a/src/Repository/ContactRepository.php b/src/Repository/ContactRepository.php index 96e5bd1..8aa5f2f 100644 --- a/src/Repository/ContactRepository.php +++ b/src/Repository/ContactRepository.php @@ -5,6 +5,7 @@ namespace App\Repository; use App\Entity\Contact; +use App\Entity\User; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; @@ -28,4 +29,63 @@ public function findAllOrdered(): array ->getQuery() ->getResult(); } + + /** + * Return an existing contact matched on name (case-insensitive) or a new, + * unflushed one. Lets people be picked from the shared pool or typed in on + * the fly; the extra fields (email, phone, department) are filled in later + * under the contacts admin. + */ + public function findOrCreate(string $name): Contact + { + $name = trim($name); + + $existing = $this->createQueryBuilder('c') + ->andWhere('LOWER(c.name) = :name') + ->setParameter('name', mb_strtolower($name)) + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + + if ($existing instanceof Contact) { + return $existing; + } + + $contact = (new Contact())->setName($name); + $this->getEntityManager()->persist($contact); + + return $contact; + } + + /** + * The user's most recent contact that still lacks an email — typically one + * they created on the fly from an initiative's contact picker (name only). + * Used by the mascot to nudge them to fill in the rest. + */ + public function findIncompleteByCreator(User $user): ?Contact + { + return $this->findIncompleteListByCreator($user, 1)[0] ?? null; + } + + /** + * The user's contacts that still lack an email (created name-only and not yet + * finished), most recent first, capped at $limit. Surfaced on the dashboard + * as outstanding work. + * + * @return Contact[] + */ + public function findIncompleteListByCreator(User $user, int $limit = 6): array + { + // createdBy is a ManyToOne to the UserInterface (resolved to User via + // resolve_target_entities); binding the entity to a ULID FK doesn't match, + // so compare the raw FK against the user's id with the ulid type applied. + return $this->createQueryBuilder('c') + ->andWhere('IDENTITY(c.createdBy) = :user') + ->andWhere("(c.email IS NULL OR c.email = '')") + ->setParameter('user', $user->getId(), 'ulid') + ->orderBy('c.createdAt', 'DESC') + ->setMaxResults($limit) + ->getQuery() + ->getResult(); + } } diff --git a/src/Repository/InitiativeRepository.php b/src/Repository/InitiativeRepository.php index 4488808..18ce917 100644 --- a/src/Repository/InitiativeRepository.php +++ b/src/Repository/InitiativeRepository.php @@ -5,7 +5,7 @@ namespace App\Repository; use App\Entity\Initiative; -use App\Enum\Category; +use App\Entity\User; use App\Enum\EndorsementAuthor; use App\Enum\Funding; use App\Enum\InitiativeType; @@ -15,7 +15,6 @@ use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; -use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Contracts\Translation\TranslatorInterface; /** @@ -52,16 +51,16 @@ public function search(InitiativeFilter $filter): QueryBuilder sprintf('i.id IN (SELECT istr.id FROM %s istr JOIN istr.strategies st WHERE LOWER(st.name) LIKE :q)', Initiative::class), sprintf('i.id IN (SELECT isth.id FROM %s isth JOIN isth.stakeholders sh WHERE LOWER(sh.name) LIKE :q)', Initiative::class), sprintf('i.id IN (SELECT icon.id FROM %s icon JOIN icon.contacts co WHERE LOWER(co.name) LIKE :q)', Initiative::class), - // Department is a related entity searched by its stored name - // ("nik" should find "Teknik og Miljø"). + // Department and area are related entities searched by their stored + // name ("nik" should find "Teknik og Miljø"). sprintf('i.id IN (SELECT idep.id FROM %s idep JOIN idep.organizationalAnchoring dep WHERE LOWER(dep.name) LIKE :q)', Initiative::class), + sprintf('i.id IN (SELECT iare.id FROM %s iare JOIN iare.area ar WHERE LOWER(ar.name) LIKE :q)', Initiative::class), ]; // Enum columns store slugs, but the user searches their translated // labels ("nik" should find "Teknik og Miljø"); map labels to values. $enumFields = [ 'status' => Status::cases(), - 'category' => Category::cases(), 'initiativeType' => InitiativeType::cases(), 'endorsementAuthor' => EndorsementAuthor::cases(), ]; @@ -87,8 +86,8 @@ public function search(InitiativeFilter $filter): QueryBuilder $qb->andWhere('i.status = :status')->setParameter('status', $filter->status->value); } - if (null !== $filter->category) { - $qb->andWhere('i.category = :category')->setParameter('category', $filter->category->value); + if (null !== $filter->area) { + $qb->andWhere('i.area = :area')->setParameter('area', $filter->area); } if (null !== $filter->initiativeType) { @@ -166,26 +165,41 @@ public function countAll(): int ->getSingleScalarResult(); } - public function countByCreator(UserInterface $user): int + public function countByCreator(User $user): int { + // createdBy is a ManyToOne to the UserInterface (resolved to User via + // resolve_target_entities); binding the entity to a ULID FK doesn't match, + // so compare the raw FK against the user's id with the ulid type applied. return (int) $this->createQueryBuilder('i') ->select('COUNT(i.id)') - ->andWhere('i.createdBy = :user') - ->setParameter('user', $user) + ->andWhere('IDENTITY(i.createdBy) = :user') + ->setParameter('user', $user->getId(), 'ulid') ->getQuery() ->getSingleScalarResult(); } /** * The creator's least-complete initiative that isn't fully filled in yet, or - * null if none are outstanding. Completion is computed in PHP (not a stored - * column), so this scans only the creator's 50 most recent initiatives. + * null if none are outstanding. */ - public function findUnfinishedByCreator(UserInterface $user): ?Initiative + public function findUnfinishedByCreator(User $user): ?Initiative { + return $this->findUnfinishedListByCreator($user, 1)[0] ?? null; + } + + /** + * The creator's incomplete initiatives (completion below 100 %), least-complete + * first, capped at $limit. Completion is computed in PHP (not a stored column), + * so this scans only the creator's 50 most recent initiatives. + * + * @return Initiative[] + */ + public function findUnfinishedListByCreator(User $user, int $limit = 6): array + { + // See countByCreator: match the raw ULID FK, not the entity. $initiatives = $this->createQueryBuilder('i') - ->andWhere('i.createdBy = :user') - ->setParameter('user', $user) + ->andWhere('IDENTITY(i.createdBy) = :user') + ->setParameter('user', $user->getId(), 'ulid') ->orderBy('i.createdAt', 'DESC') ->setMaxResults(50) ->getQuery() @@ -201,7 +215,7 @@ public function findUnfinishedByCreator(UserInterface $user): ?Initiative static fn (Initiative $a, Initiative $b): int => $a->getCompletionPercentage() <=> $b->getCompletionPercentage(), ); - return $unfinished[0] ?? null; + return \array_slice($unfinished, 0, $limit); } /** @@ -229,12 +243,15 @@ public function countByStatus(): array } /** + * Most recently touched initiatives (created or edited) for the activity feed, + * newest first. Ordered by updatedAt so an edit resurfaces the initiative. + * * @return Initiative[] */ public function findRecent(int $limit = 5): array { return $this->createQueryBuilder('i') - ->orderBy('i.createdAt', 'DESC') + ->orderBy('i.updatedAt', 'DESC') ->setMaxResults($limit) ->getQuery() ->getResult(); @@ -249,13 +266,13 @@ public function findRecent(int $limit = 5): array */ public function dashboardRows(): array { - // Join and select the department id (rather than IDENTITY()) so Doctrine + // Join and select the related ids (rather than IDENTITY()) so Doctrine // applies the ULID type: IDENTITY() returns the raw binary FK, which would // not match the canonical ULID strings the rest of build() keys on. return $this->createQueryBuilder('i') ->select( 'i.title', - 'i.category', + 'ar.id AS area', 'i.status', 'department.id AS organizationalAnchoring', 'i.budget', @@ -263,6 +280,7 @@ public function dashboardRows(): array 'i.timePeriodStart', 'i.timePeriodEnd', ) + ->leftJoin('i.area', 'ar') ->leftJoin('i.organizationalAnchoring', 'department') ->getQuery() ->getArrayResult(); diff --git a/src/Repository/TermRepository.php b/src/Repository/TermRepository.php index f20c9d8..d10a05e 100644 --- a/src/Repository/TermRepository.php +++ b/src/Repository/TermRepository.php @@ -53,6 +53,10 @@ public function findOrCreate(string $name, Vocabulary $vocabulary): Term return $existing; } + // Store new terms capitalised so user-typed lowercase tags join the pool + // looking like the rest; the lookup above stays case-insensitive. + $name = mb_strtoupper(mb_substr($name, 0, 1)).mb_substr($name, 1); + $term = (new Term($vocabulary))->setName($name); $this->getEntityManager()->persist($term); diff --git a/src/Service/DashboardData.php b/src/Service/DashboardData.php index 15edba2..b9c16ce 100644 --- a/src/Service/DashboardData.php +++ b/src/Service/DashboardData.php @@ -4,10 +4,11 @@ namespace App\Service; +use App\Entity\Area; use App\Entity\Department; -use App\Enum\Category; use App\Enum\Funding; use App\Enum\Status; +use App\Repository\AreaRepository; use App\Repository\DepartmentRepository; use App\Repository\InitiativeRepository; use Symfony\Contracts\Translation\TranslatorInterface; @@ -23,6 +24,7 @@ final class DashboardData public function __construct( private readonly InitiativeRepository $initiatives, private readonly DepartmentRepository $departments, + private readonly AreaRepository $areas, private readonly TranslatorInterface $translator, ) { } @@ -32,31 +34,34 @@ public function __construct( */ public function build(): array { - // Departments are managed entities now, so normalise them to the same - // {key, label} shape the enums get; the key is the id as a string, which - // is what dashboardRows() returns via IDENTITY(). + // Departments and areas are managed entities, so normalise them to the same + // {key, label} shape the enums get; the key is the id as a string, which is + // what dashboardRows() returns by joining and selecting the id. $departments = array_map( static fn (Department $department): array => ['key' => (string) $department->getId(), 'label' => (string) $department->getName()], $this->departments->findAllOrdered(), ); - $categories = Category::cases(); + $areas = array_map( + static fn (Area $area): array => ['key' => (string) $area->getId(), 'label' => (string) $area->getName()], + $this->areas->findAllOrdered(), + ); $statuses = Status::cases(); $fundings = Funding::cases(); $deptIndex = $this->indexKeys($departments); - $catIndex = $this->index($categories); + $areaIndex = $this->indexKeys($areas); $statusIndex = $this->index($statuses); $fundingIndex = $this->index($fundings); - $heatmap = $this->zeroMatrix(\count($departments), \count($categories)); + $heatmap = $this->zeroMatrix(\count($departments), \count($areas)); $statusByDept = $this->zeroMatrix(\count($statuses), \count($departments)); $statusDistribution = array_fill(0, \count($statuses), 0); $budgetByDept = array_fill(0, \count($departments), 0); $fundingCount = array_fill(0, \count($fundings), 0); - $reachByDept = array_fill(0, \count($categories), []); + $reachByDept = array_fill(0, \count($areas), []); - /** @var array>> $titlesByCategoryDept */ - $titlesByCategoryDept = []; + /** @var array>> $titlesByAreaDept */ + $titlesByAreaDept = []; $timeline = []; $total = 0; $deptsSeen = []; @@ -64,19 +69,19 @@ public function build(): array foreach ($this->initiatives->dashboardRows() as $row) { ++$total; $dept = $this->enumValue($row['organizationalAnchoring'] ?? null); - $cat = $this->enumValue($row['category'] ?? null); + $area = $this->enumValue($row['area'] ?? null); $status = $this->enumValue($row['status'] ?? null); $di = null !== $dept ? ($deptIndex[$dept] ?? null) : null; - $ci = null !== $cat ? ($catIndex[$cat] ?? null) : null; + $ai = null !== $area ? ($areaIndex[$area] ?? null) : null; $si = null !== $status ? ($statusIndex[$status] ?? null) : null; if (null !== $di) { $deptsSeen[$di] = true; } - if (null !== $di && null !== $ci) { - ++$heatmap[$di][$ci]; - $reachByDept[$ci][$di] = true; - $titlesByCategoryDept[$cat][$dept][] = (string) $row['title']; + if (null !== $di && null !== $ai) { + ++$heatmap[$di][$ai]; + $reachByDept[$ai][$di] = true; + $titlesByAreaDept[$area][$dept][] = (string) $row['title']; } if (null !== $si) { ++$statusDistribution[$si]; @@ -108,8 +113,8 @@ public function build(): array $inProgress = $statusDistribution[$statusIndex[Status::Active->value]] ?? 0; $collaborationCount = 0; - foreach ($categories as $category) { - if (\count($titlesByCategoryDept[$category->value] ?? []) >= 2) { + foreach ($areas as $area) { + if (\count($titlesByAreaDept[$area['key']] ?? []) >= 2) { ++$collaborationCount; } } @@ -123,7 +128,7 @@ public function build(): array 'collaboration' => $collaborationCount, ], 'departments' => $departments, - 'categories' => $this->labelled($categories), + 'areas' => $areas, 'statuses' => $this->labelled($statuses), 'fundings' => $this->labelled($fundings), 'heatmap' => $heatmap, @@ -131,23 +136,23 @@ public function build(): array 'statusDistribution' => $statusDistribution, 'budgetByDept' => $budgetByDept, 'fundingCount' => $fundingCount, - 'reach' => $this->reach($categories, $reachByDept), - 'collaboration' => $this->collaboration($categories, $departments, $titlesByCategoryDept), + 'reach' => $this->reach($areas, $reachByDept), + 'collaboration' => $this->collaboration($areas, $departments, $titlesByAreaDept), 'timeline' => \array_slice($timeline, 0, 10), ]; } /** - * Cross-department themes: a category worked on in two or more departments + * Cross-department themes: an area worked on in two or more departments * is a candidate for "sammenfald". Ranked by how broadly it spans. * - * @param list $categories + * @param list $areas * @param list $departments - * @param array>> $titlesByCategoryDept + * @param array>> $titlesByAreaDept * * @return list> */ - private function collaboration(array $categories, array $departments, array $titlesByCategoryDept): array + private function collaboration(array $areas, array $departments, array $titlesByAreaDept): array { $deptLabel = []; foreach ($departments as $d) { @@ -155,8 +160,8 @@ private function collaboration(array $categories, array $departments, array $tit } $opportunities = []; - foreach ($categories as $category) { - $byDept = $titlesByCategoryDept[$category->value] ?? []; + foreach ($areas as $area) { + $byDept = $titlesByAreaDept[$area['key']] ?? []; if (\count($byDept) < 2) { continue; } @@ -173,8 +178,8 @@ private function collaboration(array $categories, array $departments, array $tit $distinctDepts = \count($byDept); $strength = min(100, $distinctDepts * 22 + min($total, 8) * 4); $opportunities[] = [ - 'theme' => $this->t($category->labelKey()), - 'themeKey' => $category->value, + 'theme' => $area['label'], + 'themeKey' => $area['key'], 'departmentCount' => $distinctDepts, 'initiativeCount' => $total, 'strength' => $strength, @@ -190,19 +195,19 @@ private function collaboration(array $categories, array $departments, array $tit } /** - * @param list $categories - * @param list> $reachByDept + * @param list $areas + * @param list> $reachByDept * * @return list> */ - private function reach(array $categories, array $reachByDept): array + private function reach(array $areas, array $reachByDept): array { $reach = []; - foreach ($categories as $ci => $category) { - $reach[] = ['label' => $this->t($category->labelKey()), 'depts' => \count($reachByDept[$ci])]; + foreach ($areas as $ai => $area) { + $reach[] = ['label' => $area['label'], 'depts' => \count($reachByDept[$ai])]; } - // Kept in category order (not sorted by count) so the radar axes stay stable + // Kept in area order (not sorted by count) so the radar axes stay stable // across live updates rather than rotating when a count changes. return $reach; } diff --git a/src/Twig/MascotExtension.php b/src/Twig/MascotExtension.php index 8037634..0ae8aab 100644 --- a/src/Twig/MascotExtension.php +++ b/src/Twig/MascotExtension.php @@ -4,8 +4,10 @@ namespace App\Twig; +use App\Entity\Contact; use App\Entity\Initiative; use App\Entity\User; +use App\Repository\ContactRepository; use App\Repository\InitiativeRepository; use Symfony\Bundle\SecurityBundle\Security; use Twig\Extension\AbstractExtension; @@ -21,6 +23,7 @@ class MascotExtension extends AbstractExtension public function __construct( private readonly Security $security, private readonly InitiativeRepository $initiatives, + private readonly ContactRepository $contacts, ) { } @@ -32,18 +35,19 @@ public function getFunctions(): array } /** - * @return array{count: int, unfinished: Initiative|null} + * @return array{count: int, unfinished: Initiative|null, incompleteContact: Contact|null} */ public function context(): array { $user = $this->security->getUser(); if (!$user instanceof User) { - return ['count' => 0, 'unfinished' => null]; + return ['count' => 0, 'unfinished' => null, 'incompleteContact' => null]; } return [ 'count' => $this->initiatives->countByCreator($user), 'unfinished' => $this->initiatives->findUnfinishedByCreator($user), + 'incompleteContact' => $this->contacts->findIncompleteByCreator($user), ]; } } diff --git a/symfony.lock b/symfony.lock index 9cb14e8..d301674 100644 --- a/symfony.lock +++ b/symfony.lock @@ -310,6 +310,18 @@ "config/packages/ux_turbo.yaml" ] }, + "symfony/ux-twig-component": { + "version": "3.2", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "2.13", + "ref": "f367ae2a1faf01c503de2171f1ec22567febeead" + }, + "files": [ + "config/packages/twig_component.yaml" + ] + }, "symfony/validator": { "version": "8.1", "recipe": { diff --git a/templates/activity/_item.html.twig b/templates/activity/_item.html.twig index 8adc949..8afa337 100644 --- a/templates/activity/_item.html.twig +++ b/templates/activity/_item.html.twig @@ -1,9 +1,9 @@ -
-
- {% if url %}{{ title }}{% else %}{{ title }}{% endif %} -
- {%- if actor %}{{ actor }} · {% endif -%} - {{ ('activity.action.' ~ action)|trans }} · {{ at|date('d.m.Y H:i') }} -
-
-
+{% set row_tag = url ? 'a' : 'div' %} +<{{ row_tag }} id="activity-item-{{ id }}" class="recent-item"{% if url %} href="{{ url }}"{% endif %}> + + {{ title }} + {%- if actor %}{{ actor }} · {% endif -%}{{ at|date('d.m.Y H:i') }} + + {{ ('activity.action.' ~ action)|trans|capitalize }} + {% if url %}{{ 'action.view'|trans }} →{% endif %} + diff --git a/templates/admin/areas/_form.html.twig b/templates/admin/areas/_form.html.twig new file mode 100644 index 0000000..b8b51d2 --- /dev/null +++ b/templates/admin/areas/_form.html.twig @@ -0,0 +1,10 @@ +{{ form_start(form, {attr: {class: 'form'}}) }} +
+ {{ form_errors(form) }} + {{ form_row(form.name) }} +
+
+ + {{ 'action.cancel'|trans }} +
+{{ form_end(form) }} diff --git a/templates/admin/areas/edit.html.twig b/templates/admin/areas/edit.html.twig new file mode 100644 index 0000000..62c59aa --- /dev/null +++ b/templates/admin/areas/edit.html.twig @@ -0,0 +1,17 @@ +{% extends 'admin/base.html.twig' %} + +{% block title %}{{ 'area.edit.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} + +{% block admin_content %} + + + + +
+ + +
+
+ {{ include('admin/areas/_form.html.twig', {button_label: 'action.save'}) }} +{% endblock %} diff --git a/templates/admin/areas/index.html.twig b/templates/admin/areas/index.html.twig new file mode 100644 index 0000000..123a90f --- /dev/null +++ b/templates/admin/areas/index.html.twig @@ -0,0 +1,43 @@ +{% extends 'admin/base.html.twig' %} + +{% block title %}{{ 'area.index.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} + +{% block admin_content %} + + {{ 'action.new'|trans }} + + +
+ {% if areas is empty %} + + {% else %} +
+ + + + + + + + + {% for area in areas %} + + + + + {% endfor %} + +
{{ 'area.name'|trans }}
{{ area.name }} +
+ {{ 'action.edit'|trans }} +
+ + +
+
+
+
+ {% endif %} +
+{% endblock %} diff --git a/templates/admin/areas/new.html.twig b/templates/admin/areas/new.html.twig new file mode 100644 index 0000000..7688242 --- /dev/null +++ b/templates/admin/areas/new.html.twig @@ -0,0 +1,12 @@ +{% extends 'admin/base.html.twig' %} + +{% block title %}{{ 'area.new.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} + +{% block admin_content %} + + + + + + {{ include('admin/areas/_form.html.twig', {button_label: 'action.create'}) }} +{% endblock %} diff --git a/templates/admin/base.html.twig b/templates/admin/base.html.twig index ffce036..6fc9042 100644 --- a/templates/admin/base.html.twig +++ b/templates/admin/base.html.twig @@ -9,6 +9,7 @@ {% endif %} {{ 'nav.contacts'|trans }} {{ 'nav.departments'|trans }} + {{ 'nav.areas'|trans }}
{% block admin_content %}{% endblock %} diff --git a/templates/admin/contacts/edit.html.twig b/templates/admin/contacts/edit.html.twig index 556bf1a..fb95e98 100644 --- a/templates/admin/contacts/edit.html.twig +++ b/templates/admin/contacts/edit.html.twig @@ -3,17 +3,14 @@ {% block title %}{{ 'contact.edit.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} {% block admin_content %} - + +
+ + +
+ {{ include('admin/contacts/_form.html.twig', {button_label: 'action.save'}) }} {% endblock %} diff --git a/templates/admin/contacts/index.html.twig b/templates/admin/contacts/index.html.twig index 5b6ce36..201ca01 100644 --- a/templates/admin/contacts/index.html.twig +++ b/templates/admin/contacts/index.html.twig @@ -3,22 +3,13 @@ {% block title %}{{ 'contact.index.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} {% block admin_content %} - + + {{ 'action.new'|trans }} +
{% if contacts is empty %} -
-
{{ 'contact.empty.title'|trans }}
-

{{ 'contact.empty.hint'|trans }}

-
+ {% else %}
diff --git a/templates/admin/contacts/new.html.twig b/templates/admin/contacts/new.html.twig index 572ab03..032dd02 100644 --- a/templates/admin/contacts/new.html.twig +++ b/templates/admin/contacts/new.html.twig @@ -3,11 +3,10 @@ {% block title %}{{ 'contact.new.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} {% block admin_content %} - + + {{ include('admin/contacts/_form.html.twig', {button_label: 'action.create'}) }} {% endblock %} diff --git a/templates/admin/departments/edit.html.twig b/templates/admin/departments/edit.html.twig index be49636..4f4fe63 100644 --- a/templates/admin/departments/edit.html.twig +++ b/templates/admin/departments/edit.html.twig @@ -3,17 +3,14 @@ {% block title %}{{ 'department.edit.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} {% block admin_content %} - + + + + + + {{ include('admin/departments/_form.html.twig', {button_label: 'action.save'}) }} {% endblock %} diff --git a/templates/admin/departments/index.html.twig b/templates/admin/departments/index.html.twig index cce96b6..8f2724e 100644 --- a/templates/admin/departments/index.html.twig +++ b/templates/admin/departments/index.html.twig @@ -3,22 +3,13 @@ {% block title %}{{ 'department.index.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} {% block admin_content %} - + + {{ 'action.new'|trans }} +
{% if departments is empty %} -
-
{{ 'department.empty.title'|trans }}
-

{{ 'department.empty.hint'|trans }}

-
+ {% else %}
diff --git a/templates/admin/departments/new.html.twig b/templates/admin/departments/new.html.twig index b7e40da..4733285 100644 --- a/templates/admin/departments/new.html.twig +++ b/templates/admin/departments/new.html.twig @@ -3,11 +3,10 @@ {% block title %}{{ 'department.new.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} {% block admin_content %} - + + {{ include('admin/departments/_form.html.twig', {button_label: 'action.create'}) }} {% endblock %} diff --git a/templates/admin/users/edit.html.twig b/templates/admin/users/edit.html.twig index 5d8d3c5..3d7109c 100644 --- a/templates/admin/users/edit.html.twig +++ b/templates/admin/users/edit.html.twig @@ -3,17 +3,14 @@ {% block title %}{{ 'admin.users.edit'|trans }} · {{ 'app.name'|trans }}{% endblock %} {% block admin_content %} - + {{ form_start(form, {attr: {class: 'form'}}) }}
diff --git a/templates/admin/users/index.html.twig b/templates/admin/users/index.html.twig index 53fb1da..74ffef2 100644 --- a/templates/admin/users/index.html.twig +++ b/templates/admin/users/index.html.twig @@ -3,15 +3,9 @@ {% block title %}{{ 'admin.users.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} {% block admin_content %} - + + {{ 'admin.users.new'|trans }} +
diff --git a/templates/base.html.twig b/templates/base.html.twig index 09ec060..91c940b 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -19,12 +19,12 @@ {% set route = app.request.attributes.get('_route') %}
diff --git a/templates/components/Page/Header.html.twig b/templates/components/Page/Header.html.twig new file mode 100644 index 0000000..b1804c2 --- /dev/null +++ b/templates/components/Page/Header.html.twig @@ -0,0 +1,12 @@ +{% props title = null, subtitle = null %} + +{% set subtitleContent %}{% block subtitle %}{{ subtitle }}{% endblock %}{% endset %} + + +
+ {% block breadcrumb %}{% endblock %} +

{% block title %}{{ title }}{% endblock %}

+ {% if subtitleContent|trim %}

{{ subtitleContent }}

{% endif %} +
+
{% block content %}{% endblock %}
+
diff --git a/templates/dashboard/_stats.html.twig b/templates/dashboard/_stats.html.twig index 536e4c1..51107e4 100644 --- a/templates/dashboard/_stats.html.twig +++ b/templates/dashboard/_stats.html.twig @@ -1,20 +1,4 @@ -
-
{{ 'dashboard.total'|trans }}
-
{{ viz.kpis.total }}
-
{{ 'dashboard.total_hint'|trans }}
-
-
-
{{ 'dashboard.in_progress'|trans }}
-
{{ viz.kpis.inProgress }}
-
{{ 'dashboard.in_progress_hint'|trans }}
-
-
-
{{ 'dashboard.departments_involved'|trans }}
-
{{ viz.kpis.departments }}
-
{{ 'dashboard.departments_involved_hint'|trans }}
-
-
-
{{ 'dashboard.collaboration_count'|trans }}
-
{{ viz.kpis.collaboration }}
-
{{ 'dashboard.collaboration_count_hint'|trans }}
-
+ + + + diff --git a/templates/dashboard/index.html.twig b/templates/dashboard/index.html.twig index 1b6dcfe..832ef41 100644 --- a/templates/dashboard/index.html.twig +++ b/templates/dashboard/index.html.twig @@ -3,89 +3,122 @@ {% block title %}{{ 'dashboard.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} {% block body %} -
- +
+ + {{ 'action.new_initiative'|trans }} + {{ turbo_stream_from('activities') }} +
{{ include('dashboard/_stats.html.twig') }}
-
-
- {{ 'dashboard.themes_heatmap'|trans }} +
+ +
+ {% if unfinishedInitiatives is empty and incompleteContacts is empty %} +
+
{{ 'dashboard.unfinished.empty_title'|trans }}
+
+ {% else %} + {% for initiative in unfinishedInitiatives %} + + + {{ initiative.title }} + {{ 'dashboard.unfinished.initiative'|trans }} + + {{ 'dashboard.unfinished.percent_done'|trans({'%percent%': initiative.completionPercentage}) }} + {{ 'dashboard.unfinished.cta'|trans }} → + + {% endfor %} + {% for contact in incompleteContacts %} + + + {{ contact.name }} + {{ 'dashboard.unfinished.contact'|trans }} + + {{ 'dashboard.unfinished.contact_badge'|trans }} + {{ 'dashboard.unfinished.cta'|trans }} → + + {% endfor %} + {% endif %}
-
-

{{ 'dashboard.themes_heatmap_sub'|trans }}

-
+
+ +
+ +
+
+ {% if recent is empty %} +
+
{{ 'activity.empty'|trans }}
+
+ {% else %} + {% for initiative in recent %} + {% set updated = initiative.wasUpdatedAfterCreation %} + {% set actor = updated ? initiative.modifiedBy : initiative.createdBy %} + {{ include('activity/_item.html.twig', { + id: initiative.id, + action: updated ? 'updated' : 'created', + title: initiative.title, + url: path('app_initiative_show', {id: initiative.id}), + actor: actor ? actor.name : null, + at: updated ? initiative.updatedAt : initiative.createdAt, + }) }} + {% endfor %} + {% endif %} +
-
{{ 'dashboard.collaboration'|trans }}
+
-

{{ 'dashboard.collaboration_sub'|trans }}

-
+
-
{{ 'activity.title'|trans }}
-
- {% for initiative in recent %} - {{ include('activity/_item.html.twig', { - id: initiative.id, - action: 'created', - title: initiative.title, - url: path('app_initiative_show', {id: initiative.id}), - actor: initiative.createdBy ? initiative.createdBy.name : null, - at: initiative.createdAt, - }) }} - {% endfor %} + +
+
-
{{ 'dashboard.status_by_dept'|trans }}
+
-
{{ 'dashboard.by_status'|trans }}
+
-
{{ 'dashboard.funding_sources'|trans }}
+
-
{{ 'dashboard.budget_by_dept'|trans }}
+
-
{{ 'dashboard.timeline'|trans }}
+
-
{{ 'dashboard.reach'|trans }}
+
+
{% endblock %} diff --git a/templates/form/fields.html.twig b/templates/form/fields.html.twig index 83c0b77..653fc1d 100644 --- a/templates/form/fields.html.twig +++ b/templates/form/fields.html.twig @@ -1,21 +1,23 @@ {% use 'form_div_layout.html.twig' %} {% block form_label_content %} - {{- parent() -}} {%- if completion_field|default(false) -%} {%- endif -%} + {{- parent() -}} {% endblock %} {% block form_row %} {% set row_class = 'form-row' %}
- {{ form_label(form) }} +
+ {{ form_label(form) }} + {%- if help is not empty -%} +
{{ help|trans(help_translation_parameters, translation_domain) }}
+ {%- endif -%} +
{{ form_widget(form) }} {{ form_errors(form) }} - {%- if help is not empty -%} -
{{ help|trans(help_translation_parameters, translation_domain) }}
- {%- endif -%}
{% endblock %} @@ -52,3 +54,20 @@ {%- endif -%} {% endblock %} + +{% block file_widget %} +
+ + +
+{% endblock %} diff --git a/templates/initiative/_form.html.twig b/templates/initiative/_form.html.twig index ee58980..3cecdae 100644 --- a/templates/initiative/_form.html.twig +++ b/templates/initiative/_form.html.twig @@ -3,6 +3,7 @@ '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, + 'data-form-progress-stars-value': (app.user.starsEnabled ?? true) ? 'true' : 'false', } %} {% if autosave|default(false) %} {% set form_attr = form_attr|merge({ @@ -14,7 +15,7 @@ '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-required-text-value': 'autosave.required'|trans, 'data-autosave-is-new-value': initiative.id ? 'false' : 'true', }) %} {% endif %} @@ -42,11 +43,11 @@
{{ 'initiative.section.basics'|trans }}
- {% set title_attr = {'data-action': 'input->title-mirror#update'} %} + {% set title_attr = {'data-action': 'input->title-mirror#update', 'data-autosave-required': true} %} {% 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.area) }} {{ form_row(form.initiativeType) }}
{{ form_row(form.description) }} @@ -89,7 +90,10 @@
{% endfor %}
- +
@@ -99,79 +103,78 @@ {{ form_row(form.stakeholders) }} {{ form_row(form.contacts) }} - -
- -
-
- {% for contact in form.newContacts %} -
-
- {{ form_widget(contact) }} -
-
- {% endfor %} -
- -
-
{{ 'initiative.section.media'|trans }}
-
- -
-
- {% for image in form.images %} -
-
- {% if image.vars.value and image.vars.value.id and image.vars.value.imageName %} - {{ image.vars.value.originalName ?: 'image' }} - {% endif %} - {{ form_widget(image) }} + +
+ +
+
+ {% for image in form.images %} + {% set uploaded = image.vars.value and image.vars.value.id and image.vars.value.imageName %} +
+
+ {% if uploaded %} +
+ + + + {{ image.vars.value.originalName ?: 'image' }} +
+ {% endif %} + {{ form_widget(image.imageFile) }} +
-
- {% endfor %} + {% endfor %} +
+
-
-
-
- -
-
- {% for attachment in form.attachments %} -
-
- {% if attachment.vars.value and attachment.vars.value.id and attachment.vars.value.fileName %} - {{ attachment.vars.value.originalName ?: 'file' }} - {% endif %} - {{ form_widget(attachment) }} +
+ +
+
+ {% for attachment in form.attachments %} + {% set uploaded = attachment.vars.value and attachment.vars.value.id and attachment.vars.value.fileName %} +
+
+ {% if uploaded %} + + {% endif %} + {{ form_widget(attachment.file) }} +
-
- {% endfor %} + {% endfor %} +
+
-
-
+
-
- {% if autosave|default(false) %} - - {% else %} + {% if not (autosave|default(false)) %} +
{{ 'action.cancel'|trans }} - {% endif %} -
+
+ {% 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/_results.html.twig b/templates/initiative/_results.html.twig index ea725b4..982f330 100644 --- a/templates/initiative/_results.html.twig +++ b/templates/initiative/_results.html.twig @@ -19,10 +19,7 @@ {% endmacro %} {% if pagination.items is empty %} -
-
{{ 'initiative.empty.title'|trans }}
-

{{ 'initiative.empty.hint'|trans }}

-
+ {% else %}
diff --git a/templates/initiative/edit.html.twig b/templates/initiative/edit.html.twig index 5388dcd..0934648 100644 --- a/templates/initiative/edit.html.twig +++ b/templates/initiative/edit.html.twig @@ -4,21 +4,18 @@ {% block body %}
- + +
+ + + + {{ 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 599a002..edf52f8 100644 --- a/templates/initiative/index.html.twig +++ b/templates/initiative/index.html.twig @@ -4,16 +4,10 @@ {% block body %}
- + + + {{ 'action.new'|trans }} + {{ form_start(form, {attr: { class: 'filters', @@ -28,7 +22,7 @@
{{ form_row(form.status) }} - {{ form_row(form.category) }} + {{ form_row(form.area) }} {{ form_row(form.initiativeType) }} {{ form_row(form.organizationalAnchoring) }} {{ form_row(form.endorsement) }} diff --git a/templates/initiative/new.html.twig b/templates/initiative/new.html.twig index 580d5c3..97e6714 100644 --- a/templates/initiative/new.html.twig +++ b/templates/initiative/new.html.twig @@ -4,15 +4,17 @@ {% block body %}
- + + + {{ 'initiative.new.title'|trans }} + + {{ include('initiative/_form.html.twig', {button_label: 'action.create', autosave: true}) }}
{% endblock %} diff --git a/templates/initiative/show.html.twig b/templates/initiative/show.html.twig index 7257e73..1102366 100644 --- a/templates/initiative/show.html.twig +++ b/templates/initiative/show.html.twig @@ -13,23 +13,20 @@ {% block body %} {% import _self as h %}
- + + + {% if initiative.status %}{{ initiative.status.labelKey|trans }}{% endif %} + + {{ 'action.edit'|trans }} +
-
{{ 'initiative.show.details'|trans }}
+
{% if initiative.description %}

{{ initiative.description }}

{% else %}

{% endif %} {% if initiative.statusAdditional %} @@ -46,7 +43,7 @@
-
{{ 'initiative.show.contacts'|trans }}
+
{% if initiative.contacts|length > 0 %} {% for contact in initiative.contacts %} @@ -67,12 +64,14 @@ {% if initiative.images|length > 0 or initiative.attachments|length > 0 %}
-
{{ 'initiative.show.media'|trans }}
+
{% if initiative.images|length > 0 %}
{% for image in initiative.images %} - + {{ image.alt }} {% endfor %} @@ -93,11 +92,11 @@
-
{{ 'initiative.show.classification'|trans }}
+
-
{{ 'initiative.category'|trans }}
-
{{ initiative.category ? initiative.category.labelKey|trans : '—' }}
+
{{ 'initiative.area'|trans }}
+
{{ initiative.area ? initiative.area.name : '—' }}
{{ 'initiative.initiative_type'|trans }}
{{ initiative.initiativeType ? initiative.initiativeType.labelKey|trans : '—' }}
diff --git a/templates/security/login.html.twig b/templates/security/login.html.twig index 1e79495..baeed3e 100644 --- a/templates/security/login.html.twig +++ b/templates/security/login.html.twig @@ -6,7 +6,7 @@
- + {{ 'app.name'|trans }}

{{ 'login.title'|trans }}

diff --git a/tests/Controller/Admin/AreaControllerTest.php b/tests/Controller/Admin/AreaControllerTest.php new file mode 100644 index 0000000..2c3c040 --- /dev/null +++ b/tests/Controller/Admin/AreaControllerTest.php @@ -0,0 +1,117 @@ +loginAsEditor(); + $this->client->request('GET', '/admin/areas'); + + $this->assertResponseIsSuccessful(); + } + + public function testNewCreatesArea(): void + { + $this->loginAsAdmin(); + $crawler = $this->client->request('GET', '/admin/areas/new'); + $this->assertResponseIsSuccessful(); + + $name = 'Test Area '.uniqid(); + $form = $crawler->filter('button.btn--primary')->form(['area[name]' => $name]); + $this->client->submit($form); + + $this->assertResponseRedirects('/admin/areas'); + + $area = $this->areas()->findOneBy(['name' => $name]); + self::assertInstanceOf(Area::class, $area); + $this->removeArea((string) $area->getId()); + } + + public function testNewRejectsADuplicateName(): void + { + $this->loginAsAdmin(); + $name = 'Duplicate Area '.uniqid(); + $id = (string) $this->createArea($name)->getId(); + + $crawler = $this->client->request('GET', '/admin/areas/new'); + $form = $crawler->filter('button.btn--primary')->form(['area[name]' => $name]); + $this->client->submit($form); + + // UniqueEntity rejects the second one: the form redisplays with a 422 + // (Symfony's status for an invalid submitted form) and nothing is saved. + $this->assertResponseStatusCodeSame(422); + self::assertCount(1, $this->areas()->findBy(['name' => $name])); + + $this->removeArea($id); + } + + public function testEditUpdatesArea(): void + { + $this->loginAsAdmin(); + $id = (string) $this->createArea('Editable Area '.uniqid())->getId(); + + $crawler = $this->client->request('GET', sprintf('/admin/areas/%s/edit', $id)); + $this->assertResponseIsSuccessful(); + + $form = $crawler->filter('button.btn--primary')->form(['area[name]' => 'Edited Area '.uniqid()]); + $this->client->submit($form); + + $this->assertResponseRedirects('/admin/areas'); + $this->removeArea($id); + } + + public function testDeleteRemovesAreaWithAValidToken(): void + { + $this->loginAsAdmin(); + $id = (string) $this->createArea('Deletable Area '.uniqid())->getId(); + + $crawler = $this->client->request('GET', sprintf('/admin/areas/%s/edit', $id)); + $form = $crawler->filter('form[action$="/delete"]')->form(); + $this->client->submit($form); + + $this->assertResponseRedirects('/admin/areas'); + $this->entityManager()->clear(); + self::assertNull($this->areas()->find($id)); + } + + public function testDeleteIgnoresAnInvalidToken(): void + { + $this->loginAsAdmin(); + $id = (string) $this->createArea('Surviving Area '.uniqid())->getId(); + + $this->client->request('POST', sprintf('/admin/areas/%s/delete', $id), ['_token' => 'invalid']); + + $this->assertResponseRedirects('/admin/areas'); + $this->entityManager()->clear(); + self::assertNotNull($this->areas()->find($id)); + $this->removeArea($id); + } + + private function createArea(string $name): Area + { + $area = (new Area())->setName($name); + $em = $this->entityManager(); + $em->persist($area); + $em->flush(); + + return $area; + } + + private function removeArea(string $id): void + { + $this->entityManager()->clear(); + $area = $this->areas()->find($id); + if (null !== $area) { + $em = $this->entityManager(); + $em->remove($area); + $em->flush(); + } + } +} diff --git a/tests/Controller/InitiativeControllerTest.php b/tests/Controller/InitiativeControllerTest.php index 8d26a98..f3b1f4f 100644 --- a/tests/Controller/InitiativeControllerTest.php +++ b/tests/Controller/InitiativeControllerTest.php @@ -6,6 +6,7 @@ use App\Entity\Initiative; use App\Entity\InitiativeAttachment; +use App\Entity\InitiativeImage; use App\Tests\FunctionalTestCase; use Symfony\Component\HttpFoundation\Response; @@ -21,9 +22,8 @@ public function testNewPersistsInitiativeWithInlineContactAndDropsEmptyMedia(): $this->client->request('POST', '/initiatives/new', [ 'initiative' => [ 'title' => 'Coverage initiative', - 'newContacts' => [['name' => 'Coverage Contact']], - // An empty image row exercises the image branch of removeEmptyMedia(). - 'images' => [['alt' => 'empty image row']], + // A typed name creates a new contact on the fly and attaches it. + 'contacts' => 'Coverage Contact', '_token' => $token, ], ]); @@ -33,7 +33,6 @@ public function testNewPersistsInitiativeWithInlineContactAndDropsEmptyMedia(): $em = $this->entityManager(); $initiative = $this->initiatives()->findOneBy(['title' => 'Coverage initiative']); self::assertInstanceOf(Initiative::class, $initiative); - self::assertCount(0, $initiative->getImages(), 'Empty image rows should be dropped.'); self::assertGreaterThanOrEqual(1, $initiative->getContacts()->count(), 'Inline contact should be merged in.'); $em->remove($initiative); @@ -58,8 +57,8 @@ public function testEditUpdatesInitiative(): void $this->client->request('POST', sprintf('/initiatives/%s/edit', $id), [ 'initiative' => [ 'title' => 'Edited initiative', - 'images' => [['alt' => 'empty']], - 'attachments' => [[]], + 'images' => [['imageFile' => '']], + 'attachments' => [['file' => '']], '_token' => $token, ], ]); @@ -131,6 +130,37 @@ public function testEditDropsAttachmentsLeftWithoutAFile(): void $this->removeInitiative($id); } + public function testEditDropsImagesLeftWithoutAFile(): void + { + $this->loginAsAdmin(); + $initiative = $this->createInitiative('Has empty image'); + $initiative->addImage(new InitiativeImage()); + $em = $this->entityManager(); + $em->flush(); + $id = (string) $initiative->getId(); + + $crawler = $this->client->request('GET', sprintf('/initiatives/%s/edit', $id)); + $token = (string) $crawler->filter('input[name="initiative[_token]"]')->attr('value'); + $this->client->request('POST', sprintf('/initiatives/%s/edit', $id), [ + 'initiative' => [ + 'title' => 'Has empty image', + // Re-submit the file-less image so the form keeps it; the + // controller's removeEmptyMedia() then drops it. + 'images' => [['imageFile' => '']], + '_token' => $token, + ], + ]); + + $this->assertResponseRedirects(sprintf('/initiatives/%s', $id)); + + $this->entityManager()->clear(); + $reloaded = $this->initiatives()->find($id); + self::assertNotNull($reloaded); + self::assertCount(0, $reloaded->getImages(), 'A file-less image should be dropped.'); + + $this->removeInitiative($id); + } + public function testNewAutosaveReturnsCreatedWithLocationHeader(): void { $this->loginAsAdmin(); diff --git a/tests/Controller/UserSettingsControllerTest.php b/tests/Controller/UserSettingsControllerTest.php index d256e5a..3a3119b 100644 --- a/tests/Controller/UserSettingsControllerTest.php +++ b/tests/Controller/UserSettingsControllerTest.php @@ -60,6 +60,40 @@ public function testInvalidTokenIsIgnoredAndRedirectsToDashboard(): void self::assertTrue($this->reloadEditor()->isMascotEnabled()); } + public function testStarsToggleRedirectsAndFlipsThePreference(): void + { + $this->loginAsEditor(); + $crawler = $this->client->request('GET', '/'); + $this->assertResponseIsSuccessful(); + $token = (string) $crawler->filter('#starsToggleForm input[name="_token"]')->attr('value'); + + $this->client->request('POST', '/settings/stars/toggle', ['_token' => $token, 'return' => '/initiatives']); + + $this->assertResponseRedirects('/initiatives'); + self::assertFalse($this->reloadEditor()->isStarsEnabled()); + } + + public function testTourSeenAjaxReturnsNoContentAndPersists(): void + { + $this->loginAsEditor(); + $token = $this->tourSeenToken(); + + $this->client->request('POST', '/settings/tour/seen', ['_token' => $token], [], ['HTTP_X-Requested-With' => 'fetch']); + $this->assertResponseStatusCodeSame(204); + self::assertTrue($this->reloadEditor()->isTourSeen()); + } + + public function testTourSeenPlainFormRedirectsToReturn(): void + { + $this->loginAsEditor(); + $token = $this->tourSeenToken(); + + $this->client->request('POST', '/settings/tour/seen', ['_token' => $token, 'return' => '/initiatives']); + + $this->assertResponseRedirects('/initiatives'); + self::assertTrue($this->reloadEditor()->isTourSeen()); + } + private function mascotToggleToken(): string { $crawler = $this->client->request('GET', '/'); @@ -68,6 +102,14 @@ private function mascotToggleToken(): string return (string) $crawler->filter('#mascotToggleForm input[name="_token"]')->attr('value'); } + private function tourSeenToken(): string + { + $crawler = $this->client->request('GET', '/'); + $this->assertResponseIsSuccessful(); + + return (string) $crawler->filter('.mascot')->attr('data-mascot-tour-seen-token-value'); + } + private function reloadEditor(): User { $this->entityManager()->clear(); diff --git a/tests/FunctionalTestCase.php b/tests/FunctionalTestCase.php index 16c9399..0e1d48a 100644 --- a/tests/FunctionalTestCase.php +++ b/tests/FunctionalTestCase.php @@ -5,6 +5,7 @@ namespace App\Tests; use App\Entity\User; +use App\Repository\AreaRepository; use App\Repository\ContactRepository; use App\Repository\DepartmentRepository; use App\Repository\InitiativeRepository; @@ -69,6 +70,14 @@ protected function departments(): DepartmentRepository return $repository; } + protected function areas(): AreaRepository + { + $repository = static::getContainer()->get(AreaRepository::class); + \assert($repository instanceof AreaRepository); + + return $repository; + } + protected function terms(): TermRepository { $repository = static::getContainer()->get(TermRepository::class); diff --git a/tests/Repository/AreaRepositoryTest.php b/tests/Repository/AreaRepositoryTest.php new file mode 100644 index 0000000..c9615eb --- /dev/null +++ b/tests/Repository/AreaRepositoryTest.php @@ -0,0 +1,25 @@ +get(AreaRepository::class); + \assert($repository instanceof AreaRepository); + + $areas = $repository->findAllOrdered(); + + // Ordering is delegated to the database collation, so we only assert the + // method returns the persisted areas. + self::assertNotEmpty($areas); + self::assertNotNull($areas[0]->getId()); + } +} diff --git a/tests/Repository/ContactRepositoryTest.php b/tests/Repository/ContactRepositoryTest.php index 6b34b1a..a3a5a58 100644 --- a/tests/Repository/ContactRepositoryTest.php +++ b/tests/Repository/ContactRepositoryTest.php @@ -4,7 +4,9 @@ namespace App\Tests\Repository; +use App\Entity\Contact; use App\Repository\ContactRepository; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; final class ContactRepositoryTest extends KernelTestCase @@ -21,4 +23,37 @@ public function testFindAllOrderedReturnsContactsSortedByName(): void // method returns the persisted contacts. self::assertNotEmpty($contacts); } + + public function testFindOrCreateReturnsAnExistingContactCaseInsensitively(): void + { + self::bootKernel(); + $repository = static::getContainer()->get(ContactRepository::class); + \assert($repository instanceof ContactRepository); + $em = static::getContainer()->get(EntityManagerInterface::class); + \assert($em instanceof EntityManagerInterface); + + $name = 'Findme Contact '.uniqid(); + $contact = (new Contact())->setName($name); + $em->persist($contact); + $em->flush(); + + $found = $repository->findOrCreate(mb_strtolower($name)); + self::assertSame($contact->getId(), $found->getId()); + + $em->remove($contact); + $em->flush(); + } + + public function testFindOrCreateBuildsANewUnflushedContact(): void + { + self::bootKernel(); + $repository = static::getContainer()->get(ContactRepository::class); + \assert($repository instanceof ContactRepository); + + $name = 'BrandNewContact-'.uniqid(); + $contact = $repository->findOrCreate($name); + + self::assertSame($name, $contact->getName()); + self::assertCount(0, $repository->findBy(['name' => $name]), 'A freshly created contact is not yet flushed to the database.'); + } } diff --git a/tests/Repository/InitiativeRepositoryTest.php b/tests/Repository/InitiativeRepositoryTest.php index 9bc0438..fba6205 100644 --- a/tests/Repository/InitiativeRepositoryTest.php +++ b/tests/Repository/InitiativeRepositoryTest.php @@ -5,10 +5,10 @@ namespace App\Tests\Repository; use App\Entity\Initiative; -use App\Enum\Category; use App\Enum\InitiativeType; use App\Enum\Status; use App\Model\InitiativeFilter; +use App\Repository\AreaRepository; use App\Repository\DepartmentRepository; use App\Repository\InitiativeRepository; use Doctrine\ORM\EntityManagerInterface; @@ -30,11 +30,13 @@ public function testSearchAppliesEveryFilterBranch(): void { $departments = static::getContainer()->get(DepartmentRepository::class); \assert($departments instanceof DepartmentRepository); + $areas = static::getContainer()->get(AreaRepository::class); + \assert($areas instanceof AreaRepository); $filter = new InitiativeFilter(); $filter->q = '100%_'; // also exercises LIKE wildcard escaping $filter->status = Status::Active; - $filter->category = Category::Climate; + $filter->area = $areas->findAllOrdered()[0]; $filter->initiativeType = InitiativeType::Project; $filter->organizationalAnchoring = $departments->findAllOrdered()[0]; $filter->endorsement = true; diff --git a/tests/Repository/TermRepositoryTest.php b/tests/Repository/TermRepositoryTest.php index 250ca39..5475281 100644 --- a/tests/Repository/TermRepositoryTest.php +++ b/tests/Repository/TermRepositoryTest.php @@ -48,4 +48,11 @@ public function testFindOrCreateBuildsANewUnflushedTerm(): void self::assertSame(Vocabulary::Strategy, $term->getVocabulary()); self::assertCount(0, $this->repository->findBy(['name' => $name]), 'A freshly created term is not yet flushed to the database.'); } + + public function testFindOrCreateCapitalisesANewTerm(): void + { + $term = $this->repository->findOrCreate('grøn omstilling '.uniqid(), Vocabulary::Strategy); + + self::assertSame('G', mb_substr((string) $term->getName(), 0, 1)); + } } diff --git a/tests/Service/DashboardDataTest.php b/tests/Service/DashboardDataTest.php index 157a55e..f895f0f 100644 --- a/tests/Service/DashboardDataTest.php +++ b/tests/Service/DashboardDataTest.php @@ -4,8 +4,16 @@ namespace App\Tests\Service; +use App\Entity\Area; +use App\Entity\Department; +use App\Enum\Funding; +use App\Enum\Status; +use App\Repository\AreaRepository; +use App\Repository\DepartmentRepository; +use App\Repository\InitiativeRepository; use App\Service\DashboardData; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Contracts\Translation\TranslatorInterface; final class DashboardDataTest extends KernelTestCase { @@ -24,4 +32,47 @@ public function testBuildAggregatesDepartmentData(): void self::assertGreaterThan(0, $data['kpis']['departments'], 'deptsSeen should be > 0'); self::assertGreaterThan(0, array_sum(array_map('array_sum', $data['heatmap'])), 'heatmap should have entries'); } + + /** + * Drives build() from controlled rows so every aggregation branch is + * exercised deterministically — independent of the random dev fixtures. + */ + public function testBuildFromControlledRows(): void + { + $deptA = (new Department())->setName('A'); + $deptB = (new Department())->setName('B'); + $shared = (new Area())->setName('Shared'); + $solo = (new Area())->setName('Solo'); + + $departments = $this->createStub(DepartmentRepository::class); + $departments->method('findAllOrdered')->willReturn([$deptA, $deptB]); + $areas = $this->createStub(AreaRepository::class); + $areas->method('findAllOrdered')->willReturn([$shared, $solo]); + + $start = new \DateTimeImmutable('2025-01-01'); + $end = new \DateTimeImmutable('2025-06-01'); + $initiatives = $this->createStub(InitiativeRepository::class); + $initiatives->method('dashboardRows')->willReturn([ + // "Shared" worked on in two departments -> a collaboration opportunity; + // also carries budget, funding (a known and an unknown slug) and a span. + ['title' => 'Shared A', 'area' => (string) $shared->getId(), 'status' => Status::Active, 'organizationalAnchoring' => (string) $deptA->getId(), 'budget' => 1000, 'funding' => [Funding::MunicipalBudget->value, 'unknown'], 'timePeriodStart' => $start, 'timePeriodEnd' => $end], + ['title' => 'Shared B', 'area' => (string) $shared->getId(), 'status' => Status::Active, 'organizationalAnchoring' => (string) $deptB->getId(), 'budget' => 2000, 'funding' => [], 'timePeriodStart' => null, 'timePeriodEnd' => null], + // "Solo" only in one department -> skipped by collaboration(). + ['title' => 'Solo', 'area' => (string) $solo->getId(), 'status' => Status::Active, 'organizationalAnchoring' => (string) $deptA->getId(), 'budget' => null, 'funding' => [], 'timePeriodStart' => null, 'timePeriodEnd' => null], + // No area/department/status -> exercises the null guards. + ['title' => 'Loose', 'area' => null, 'status' => null, 'organizationalAnchoring' => null, 'budget' => null, 'funding' => [], 'timePeriodStart' => null, 'timePeriodEnd' => null], + ]); + + $translator = $this->createStub(TranslatorInterface::class); + $translator->method('trans')->willReturnArgument(0); + + $data = (new DashboardData($initiatives, $departments, $areas, $translator))->build(); + + self::assertSame(4, $data['kpis']['total']); + // Only "Shared" spans >= 2 departments; "Solo" is skipped. + self::assertSame(1, $data['kpis']['collaboration']); + self::assertCount(1, $data['collaboration']); + self::assertSame('Shared', $data['collaboration'][0]['theme']); + self::assertCount(1, $data['timeline']); + } } diff --git a/tests/Twig/MascotExtensionTest.php b/tests/Twig/MascotExtensionTest.php index 91fe500..93d363b 100644 --- a/tests/Twig/MascotExtensionTest.php +++ b/tests/Twig/MascotExtensionTest.php @@ -16,7 +16,7 @@ public function testContextIsEmptyWhenNoUserIsAuthenticated(): void \assert($extension instanceof MascotExtension); // No user is logged in, so the mascot has no personal numbers to show. - self::assertSame(['count' => 0, 'unfinished' => null], $extension->context()); + self::assertSame(['count' => 0, 'unfinished' => null, 'incompleteContact' => null], $extension->context()); } public function testRegistersTheMascotContextFunction(): void diff --git a/tests/Unit/Entity/AreaTest.php b/tests/Unit/Entity/AreaTest.php new file mode 100644 index 0000000..344b6ee --- /dev/null +++ b/tests/Unit/Entity/AreaTest.php @@ -0,0 +1,27 @@ +getName()); + self::assertSame('', (string) $area); + } + + public function testAccessors(): void + { + $area = (new Area())->setName('Klima og miljø'); + + self::assertSame('Klima og miljø', $area->getName()); + self::assertSame('Klima og miljø', (string) $area); + } +} diff --git a/tests/Unit/Entity/InitiativeTest.php b/tests/Unit/Entity/InitiativeTest.php index ff7196f..9613b3f 100644 --- a/tests/Unit/Entity/InitiativeTest.php +++ b/tests/Unit/Entity/InitiativeTest.php @@ -4,13 +4,13 @@ namespace App\Tests\Unit\Entity; +use App\Entity\Area; use App\Entity\Contact; use App\Entity\Department; use App\Entity\Initiative; use App\Entity\InitiativeAttachment; use App\Entity\InitiativeImage; use App\Entity\Term; -use App\Enum\Category; use App\Enum\EndorsementAuthor; use App\Enum\Funding; use App\Enum\InitiativeType; @@ -25,7 +25,7 @@ public function testDefaults(): void $initiative = new Initiative(); self::assertNull($initiative->getTitle()); - self::assertTrue($initiative->isEndorsement()); + self::assertFalse($initiative->isEndorsement()); self::assertSame([], $initiative->getFunding()); self::assertSame([], $initiative->getLinks()); self::assertCount(0, $initiative->getStrategies()); @@ -44,10 +44,11 @@ public function testScalarAccessors(): void $start = new \DateTimeImmutable('2025-01-01'); $end = new \DateTimeImmutable('2025-12-31'); $department = (new Department())->setName('Teknik og Miljø'); + $area = (new Area())->setName('Klima og miljø'); $initiative = (new Initiative()) ->setTitle('Grøn omstilling') - ->setCategory(Category::Climate) + ->setArea($area) ->setDescription('Beskrivelse') ->setInitiativeType(InitiativeType::Project) ->setStatus(Status::Active) @@ -60,7 +61,7 @@ public function testScalarAccessors(): void ->setTimePeriodEnd($end); self::assertSame('Grøn omstilling', $initiative->getTitle()); - self::assertSame(Category::Climate, $initiative->getCategory()); + self::assertSame($area, $initiative->getArea()); self::assertSame('Beskrivelse', $initiative->getDescription()); self::assertSame(InitiativeType::Project, $initiative->getInitiativeType()); self::assertSame(Status::Active, $initiative->getStatus()); @@ -80,20 +81,41 @@ public function testCompletionPercentage(): void $full = (new Initiative()) ->setTitle('T') - ->setCategory(Category::Climate) + ->setArea((new Area())->setName('Klima og miljø')) ->setDescription('D') ->setInitiativeType(InitiativeType::Project) ->setStatus(Status::Active) ->setOrganizationalAnchoring((new Department())->setName('Teknik og Miljø')) - ->setEndorsementAuthor(EndorsementAuthor::CityCouncil) ->setBudget(1000) ->setFunding([Funding::EuFunds]) ->setTimePeriodStart(new \DateTimeImmutable()) ->setTimePeriodEnd(new \DateTimeImmutable()); + // The Vedtagelse (endorsement) fields are intentionally excluded, so this + // reaches 100% without setting an endorsement author. self::assertSame(100, $full->getCompletionPercentage()); } + public function testWasUpdatedAfterCreation(): void + { + // No timestamps yet (entity not persisted) — treated as not updated. + self::assertFalse((new Initiative())->wasUpdatedAfterCreation()); + + $created = new \DateTimeImmutable('2025-01-01 10:00:00'); + + // Edited within a day of creation: still reads as "created". + $fresh = new Initiative(); + $fresh->setCreatedAt($created); + $fresh->setUpdatedAt($created->modify('+5 hours')); + self::assertFalse($fresh->wasUpdatedAfterCreation()); + + // Edited more than a day after creation. + $edited = new Initiative(); + $edited->setCreatedAt($created); + $edited->setUpdatedAt($created->modify('+2 days')); + self::assertTrue($edited->wasUpdatedAfterCreation()); + } + public function testFundingRoundTrip(): void { $initiative = (new Initiative())->setFunding([Funding::MunicipalBudget, Funding::EuFunds]); diff --git a/tests/Unit/Entity/UserTest.php b/tests/Unit/Entity/UserTest.php index 43c48a9..30223ef 100644 --- a/tests/Unit/Entity/UserTest.php +++ b/tests/Unit/Entity/UserTest.php @@ -77,6 +77,33 @@ public function testMascotPreferenceTogglesThroughSettings(): void self::assertTrue($user->isMascotEnabled()); } + public function testStarsPreferenceDefaultsEnabledAndToggles(): void + { + $user = new User(); + + // No stored preference means the stars fly. + self::assertTrue($user->isStarsEnabled()); + + $user->setStarsEnabled(false); + self::assertFalse($user->isStarsEnabled()); + self::assertSame(['starsEnabled' => false], $user->getUserSettings()); + + $user->setStarsEnabled(true); + self::assertTrue($user->isStarsEnabled()); + } + + public function testTourSeenDefaultsFalseAndPersists(): void + { + $user = new User(); + + // The guided tour has not been offered to a fresh user yet. + self::assertFalse($user->isTourSeen()); + + $user->setTourSeen(true); + self::assertTrue($user->isTourSeen()); + self::assertSame(['tourSeen' => true], $user->getUserSettings()); + } + public function testEraseCredentialsDoesNothing(): void { $user = new User(); diff --git a/tests/Unit/Enum/TranslatableEnumTest.php b/tests/Unit/Enum/TranslatableEnumTest.php index 2852b92..7b7a6d3 100644 --- a/tests/Unit/Enum/TranslatableEnumTest.php +++ b/tests/Unit/Enum/TranslatableEnumTest.php @@ -4,7 +4,6 @@ namespace App\Tests\Unit\Enum; -use App\Enum\Category; use App\Enum\EndorsementAuthor; use App\Enum\Funding; use App\Enum\InitiativeType; @@ -17,7 +16,6 @@ final class TranslatableEnumTest extends TestCase { public function testEveryCaseExposesAPrefixedLabelKey(): void { - $this->assertLabelKeys(Category::cases(), 'enum.category.'); $this->assertLabelKeys(EndorsementAuthor::cases(), 'enum.endorsement_author.'); $this->assertLabelKeys(Funding::cases(), 'enum.funding.'); $this->assertLabelKeys(InitiativeType::cases(), 'enum.initiative_type.'); diff --git a/tests/Unit/Form/DataTransformer/ContactsTextTransformerTest.php b/tests/Unit/Form/DataTransformer/ContactsTextTransformerTest.php new file mode 100644 index 0000000..cf4a833 --- /dev/null +++ b/tests/Unit/Form/DataTransformer/ContactsTextTransformerTest.php @@ -0,0 +1,56 @@ +transformer()->transform(null)); + } + + public function testTransformJoinsContactNames(): void + { + $contacts = [ + (new Contact())->setName('Anne Jensen'), + (new Contact())->setName('Lars Holm'), + ]; + + self::assertSame('Anne Jensen, Lars Holm', $this->transformer()->transform($contacts)); + } + + public function testReverseTransformOfNonStringReturnsEmptyCollection(): void + { + self::assertCount(0, $this->transformer()->reverseTransform(null)); + } + + public function testReverseTransformOfBlankReturnsEmptyCollection(): void + { + self::assertCount(0, $this->transformer()->reverseTransform(' ')); + } + + public function testReverseTransformTrimsDeduplicatesAndResolvesContacts(): void + { + $repository = $this->createMock(ContactRepository::class); + $repository->expects(self::exactly(2)) + ->method('findOrCreate') + ->willReturnCallback(static fn (string $name): Contact => (new Contact())->setName($name)); + + $transformer = new ContactsTextTransformer($repository); + + // "anne jensen" duplicates "Anne Jensen" (case-insensitive) and the empty segment is skipped. + self::assertCount(2, $transformer->reverseTransform('Anne Jensen, Lars Holm, , anne jensen')); + } + + private function transformer(): ContactsTextTransformer + { + return new ContactsTextTransformer($this->createStub(ContactRepository::class)); + } +} diff --git a/tests/Unit/Model/InitiativeFilterTest.php b/tests/Unit/Model/InitiativeFilterTest.php index 754b5fc..e1fb801 100644 --- a/tests/Unit/Model/InitiativeFilterTest.php +++ b/tests/Unit/Model/InitiativeFilterTest.php @@ -4,8 +4,8 @@ namespace App\Tests\Unit\Model; +use App\Entity\Area; use App\Entity\Department; -use App\Enum\Category; use App\Enum\InitiativeType; use App\Enum\Status; use App\Model\InitiativeFilter; @@ -19,7 +19,7 @@ public function testDefaults(): void self::assertNull($filter->q); self::assertNull($filter->status); - self::assertNull($filter->category); + self::assertNull($filter->area); self::assertNull($filter->initiativeType); self::assertNull($filter->organizationalAnchoring); self::assertNull($filter->endorsement); @@ -32,7 +32,7 @@ public function testIsMutable(): void $filter = new InitiativeFilter(); $filter->q = 'klima'; $filter->status = Status::Active; - $filter->category = Category::Climate; + $filter->area = (new Area())->setName('Klima og miljø'); $filter->initiativeType = InitiativeType::Project; $filter->organizationalAnchoring = (new Department())->setName('Sundhed og Omsorg'); $filter->endorsement = true; diff --git a/tests/Unit/Service/ActivityPublisherTest.php b/tests/Unit/Service/ActivityPublisherTest.php index 1771be1..6148e79 100644 --- a/tests/Unit/Service/ActivityPublisherTest.php +++ b/tests/Unit/Service/ActivityPublisherTest.php @@ -5,6 +5,7 @@ namespace App\Tests\Unit\Service; use App\Entity\Initiative; +use App\Repository\AreaRepository; use App\Repository\DepartmentRepository; use App\Repository\InitiativeRepository; use App\Service\ActivityPublisher; @@ -34,9 +35,11 @@ public function testPublishSwallowsHubFailuresAndLogsThem(): void $initiatives->method('dashboardRows')->willReturn([]); $departments = $this->createStub(DepartmentRepository::class); $departments->method('findAllOrdered')->willReturn([]); + $areas = $this->createStub(AreaRepository::class); + $areas->method('findAllOrdered')->willReturn([]); $translator = $this->createStub(TranslatorInterface::class); $translator->method('trans')->willReturnArgument(0); - $dashboardData = new DashboardData($initiatives, $departments, $translator); + $dashboardData = new DashboardData($initiatives, $departments, $areas, $translator); $logger = $this->createMock(LoggerInterface::class); $logger->expects(self::once())->method('warning'); diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index 8b9db83..e1cdcbe 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -1,5 +1,5 @@ app: - name: Projektdatabase + name: Projects mascot: label: Din kreative makker @@ -7,6 +7,7 @@ mascot: cta: Opret nyt initiativ farewell: "Vi ses — jeg er her, hvis du får brug for mig! 👋" welcome: "Yes, jeg er tilbage! Lad os skabe noget sammen. 🌟" + intro: "Hej, jeg hedder Glimt! Skal vi være produktive i dag?" praise: "{1}Du har skabt ét initiativ — godt begyndt! 🌟|]1,Inf[Du har skabt %count% initiativer — flot arbejde! 🌟" finish: almost: "“%title%” er %percent% % færdig — skal vi gøre den helt færdig?" @@ -15,7 +16,11 @@ mascot: blanks: "“%title%” er %percent% % udfyldt — der mangler stadig et par felter. 📝" ending: "“%title%” er %percent% % færdig. Giv den en værdig afslutning! 🏁" strong: "“%title%” er %percent% % færdig — lad os tage den sidste bid! 💪" - finish_cta: Gør færdig + finish_cta: Gå til redigering + contact: + details: "Kontaktpersonen “%name%” som du har oprettet mangler stadig oplysninger — Vil du gøre den færdig?" + quick: "Du tilføjede “%name%” i farten — vil du udfylde resten? ✏️" + name_only: "“%name%” har kun et navn endnu. Skal vi tilføje detaljerne?" msg: idea: "Har du en ny idé? Så er det her, den hører til. ✨" roll: "Du er godt i gang — hvad bliver det næste?" @@ -42,18 +47,33 @@ mascot: giveup: "Pyha… lad os kalde det uafgjort. 😅" escaped: "Argh, du slap væk! Du er alt for hurtig. 🏳️" +tour: + propose: "Velkommen! Skal jeg vise dig kort rundt på platformen?" + start: Ja tak, vis rundt + decline: Nej tak + next: Næste + skip: Spring over + done: Så er jeg klar + steps: + intro: "Det her er Projektdatabasen — her samler vi afdelingernes initiativer og projekter ét sted, så vi kan se, hvad der sker på tværs." + dashboard: "På overblikket ser du nøgletal, status og eventuelle muligheder for samarbejds på tværs af afdelinger." + create: "Se alle initiativer via menupunktet Initiativer — eller opret et nyt med det samme via Opret-knappen." + admin: "Inde i menuen her finder du menupunktet Administration, hvor du kan oprette, se og redigere afdelinger, områder og kontaktpersoner." + outro: "Så er du klar! Jeg er altid lige her i hjørnet, hvis du får brug for et tip. 🌟" + autosave: saving: Gemmer… saved: Gemt unsaved: Ikke-gemte ændringer + required: Tilføj en titel for at gemme. 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 + subtitle: Seneste aktivitet på platformen + empty: Ingen aktivitet at vise action: created: oprettede updated: opdaterede @@ -64,16 +84,19 @@ nav: initiatives: Initiativer contacts: Kontaktpersoner departments: Afdelinger + areas: Områder users: Brugere admin: Administration + preferences: Indstillinger language: Sprog signed_in_as: Logget ind som logout: Log ud - mascot_disable: Slå makker fra - mascot_enable: Slå makker til + show_mascot: Din ven Glimt ⭐ + show_stars: Flyvende stjerner 💫 action: new: Opret + new_initiative: Opret nyt initiativ edit: Rediger delete: Slet save: Gem @@ -86,7 +109,6 @@ action: reset: Nulstil export: Eksportér CSV add_link: Tilføj link - add_contact: Tilføj kontaktperson add_image: Tilføj billede add_attachment: Tilføj fil remove: Fjern @@ -100,12 +122,16 @@ common: created: Oprettet updated: Opdateret optional: valgfri + close: Luk form: choose: Vælg … terms: invalid: Ugyldige værdier. +media: + choose: Vælg fil + filter: search: Søg search_placeholder: Søg i titel, beskrivelse, forfatter … @@ -117,7 +143,6 @@ filter: dashboard: title: Overblik - subtitle: Status på alle initiativer total: Initiativer i alt total_hint: registrerede i alt in_progress: Igangværende @@ -125,49 +150,74 @@ dashboard: departments_involved: Afdelinger involveret departments_involved_hint: med mindst ét initiativ collaboration_count: Mulige samarbejder - collaboration_count_hint: temaer på tværs af afdelinger + collaboration_count_hint: områder på tværs af afdelinger by_status: Fordeling på status + by_status_sub: Alle initiativer fordelt på deres aktuelle status. recent: Senest oprettede no_status: Uden status empty: Der er endnu ikke oprettet nogen initiativer. - themes_heatmap: Temaer på tværs af afdelinger - themes_heatmap_sub: Mørkere = flere initiativer. Markerede temaer går på tværs af mindst tre afdelinger. + themes_heatmap: Områder på tværs af afdelinger + themes_heatmap_sub: Antal initiativer pr. område i hver afdeling. + themes_heatmap_empty: Tilføj både afdelinger og områder for at se denne graf. collaboration: Muligt samarbejde - collaboration_sub: Initiativer i forskellige afdelinger med fælles tema. + collaboration_sub: Initiativer i forskellige afdelinger med fælles område. status_by_dept: Status pr. afdeling + status_by_dept_sub: Initiativernes status fordelt på hver afdeling. funding_sources: Finansieringskilder + funding_sources_sub: Antal initiativer pr. finansieringskilde. budget_by_dept: Budget pr. afdeling - reach: Temaer med flest afdelinger + budget_by_dept_sub: Samlet budget for initiativerne i hver afdeling. + reach: Områder med flest afdelinger + reach_sub: Hvor mange afdelinger hvert område er repræsenteret i. timeline: Tidslinje + timeline_sub: Hvert initiativs tidsperiode på en tidslinje. + unfinished: + title: Dit igangværende arbejde + sub: Dine initiativer og kontaktpersoner der mangler de sidste detaljer. + initiative: Initiativ + contact: Kontaktperson + contact_badge: Mangler oplysninger + percent_done: "%percent% % udfyldt" + cta: Gå til redigering + empty_title: Intet igangværende arbejde initiative: title: Titel - category: Kategori + title_help: Et kort, sigende navn på initiativet. + area: Område + area_help: Det tematiske område initiativet hører under. description: Kort beskrivelse + description_help: Kort opsummering af formål og indhold. strategies: Strategier og planer initiative_type: Initiativets karakter + initiative_type_help: Initiativets karakter, fx projekt eller drift. status: Status + status_help: Hvor langt initiativet er. status_additional: Status – supplerende tekst + status_additional_help: Uddyb status, hvis der er behov. organizational_anchoring: Afdeling + organizational_anchoring_help: Den afdeling der er ansvarlig for initiativet. endorsement: Er initiativet vedtaget endorsement_author: Hvor er det vedtaget + endorsement_author_help: Hvor initiativet er vedtaget. budget: Budget (kr.) + budget_help: Samlet budget i hele kroner. funding: Fundingkilder stakeholders: Interessenter og samarbejdspartnere tags: Tags time_period_start: Tidshorisont – start + time_period_start_help: Hvornår initiativet starter. time_period_end: Tidshorisont – slut + time_period_end_help: Hvornår initiativet forventes afsluttet. time_period: Tidshorisont links: Relevante links contacts: Kontaktpersoner - new_contacts: Opret nye kontaktpersoner author: Udfyldt af - terms_help: Adskil flere værdier med komma. + terms_help: Vælg eksisterende, eller skriv en ny og tryk Enter. completion_field_hint: Dette felt tæller med i udfyldningsgraden. images: Billeder attachments: Filer image_file: Billedfil - image_alt: Alternativ tekst attachment_file: Fil attachment_invalid_type: "Filtypen er ikke tilladt (tilladt: pdf, doc, docx, xls, xlsx)." index: @@ -197,9 +247,13 @@ initiative: contact: name: Navn + name_help: Kontaktpersonens fulde navn. email: E-mail + email_help: Arbejds-e-mail, hvis den kendes. phone: Telefon + phone_help: Telefonnummer, hvis det kendes. department: Afdeling + department_help: Afdeling eller enhed, personen hører til. index: title: Kontaktpersoner subtitle: Personer der er tilknyttet initiativer. @@ -213,6 +267,7 @@ contact: department: name: Navn + name_help: Afdelingens navn, som det vises på initiativer. name_duplicate: Der findes allerede en afdeling med dette navn. index: title: Afdelinger @@ -225,9 +280,26 @@ department: title: Ingen afdelinger endnu hint: Opret en afdeling, så den kan vælges på initiativer. +area: + name: Navn + name_help: Områdets navn, som det vises på initiativer. + name_duplicate: Der findes allerede et område med dette navn. + index: + title: Områder + subtitle: Tematisk område der kan vælges på initiativer. + new: + title: Nyt område + edit: + title: Rediger område + empty: + title: Ingen områder endnu + hint: Opret et område, så det kan vælges på initiativer. + user: email: E-mail + email_help: Bruges som login og til notifikationer. name: Navn + name_help: Vises i aktivitet og som forfatter. roles: Roller password: Adgangskode email_duplicate: Der findes allerede en bruger med denne e-mail. @@ -266,6 +338,10 @@ flash: created: Afdelingen blev oprettet. updated: Afdelingen blev opdateret. deleted: Afdelingen blev slettet. + area: + created: Området blev oprettet. + updated: Området blev opdateret. + deleted: Området blev slettet. user: created: Brugeren blev oprettet. updated: Brugeren blev opdateret. @@ -280,15 +356,6 @@ enum: on_hold: Sat i bero completed: Afsluttet cancelled: Annulleret - category: - climate: Klima og miljø - mobility: Mobilitet - welfare: Velfærd - culture: Kultur og fritid - education: Uddannelse - business: Erhverv - digitalisation: Digitalisering - urban_development: Byudvikling initiative_type: project: Projekt programme: Program diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index a75172d..607bf6d 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -1,5 +1,5 @@ app: - name: Project database + name: Projects mascot: label: Your creative buddy @@ -7,6 +7,7 @@ mascot: cta: Create a new initiative farewell: "See you around — I'm here whenever you need me! 👋" welcome: "Yay, I'm back! Let's make something together. 🌟" + intro: "Hi, I'm Glimt — your friend in the corner. Shall we create something?" praise: "{1}You've created one initiative — nice start! 🌟|]1,Inf[You've created %count% initiatives — great work! 🌟" finish: almost: "“%title%” is %percent%% done — want to finish it off?" @@ -16,6 +17,10 @@ mascot: ending: "“%title%” is %percent%% done. Give it the ending it deserves! 🏁" strong: "“%title%” is %percent%% there — let’s finish strong! 💪" finish_cta: Finish it + contact: + details: "“%name%” is still missing details — shall we finish the contact?" + quick: "You added “%name%” on the fly — want to fill in the rest? ✏️" + name_only: "“%name%” only has a name so far. Shall we add the details?" msg: idea: "Got a fresh idea? Let’s give it a home. ✨" roll: "You’re on a roll — what’s next?" @@ -42,18 +47,33 @@ mascot: giveup: "Phew… let's call it a draw. 😅" escaped: "Argh, you got away! Too quick for me. 🏳️" +tour: + propose: "Welcome! Shall I give you a quick tour of the platform?" + start: "Yes, show me around" + decline: No thanks + next: Next + skip: Skip + done: I'm ready + steps: + intro: "This is the Project database — where we gather the departments' initiatives and projects in one place, so we can see what's happening across them." + dashboard: "The overview shows key figures, status and any opportunities for collaboration across departments." + create: "Browse every initiative from the Initiativer menu — or create a new one right away with the Opret button." + admin: "Inside this menu you'll find Administration, where you can create, view and edit departments, areas and contacts." + outro: "You're all set! I'm always right here in the corner if you need a tip. 🌟" + autosave: saving: Saving… saved: Saved unsaved: Unsaved changes + required: Add a title to save. 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 + subtitle: Latest activity on the platform + empty: No activity to show action: created: created updated: updated @@ -64,16 +84,19 @@ nav: initiatives: Initiatives contacts: Contacts departments: Departments + areas: Areas users: Users admin: Administration + preferences: Preferences language: Language signed_in_as: Signed in as logout: Sign out - mascot_disable: Disable mascot - mascot_enable: Enable mascot + show_mascot: Show Glimt ⭐ + show_stars: Show flying stars 💫x action: new: New + new_initiative: Create a new initiative edit: Edit delete: Delete save: Save @@ -86,7 +109,6 @@ action: reset: Reset export: Export CSV add_link: Add link - add_contact: Add contact add_image: Add image add_attachment: Add file remove: Remove @@ -100,12 +122,16 @@ common: created: Created updated: Updated optional: optional + close: Close form: choose: Choose … terms: invalid: Invalid values. +media: + choose: Choose file + filter: search: Search search_placeholder: Search title, description, author … @@ -117,7 +143,6 @@ filter: dashboard: title: Overview - subtitle: The status of every initiative total: Total initiatives total_hint: registered in total in_progress: In progress @@ -125,49 +150,74 @@ dashboard: departments_involved: Departments involved departments_involved_hint: with at least one initiative collaboration_count: Possible collaborations - collaboration_count_hint: themes across departments + collaboration_count_hint: areas across departments by_status: By status + by_status_sub: All initiatives grouped by their current status. recent: Recently created no_status: No status empty: No initiatives have been created yet. - themes_heatmap: Themes across departments - themes_heatmap_sub: Darker = more initiatives. Marked themes span at least three departments. + themes_heatmap: Areas across departments + themes_heatmap_sub: Number of initiatives per area in each department. Darker = more; marked areas span at least three departments. + themes_heatmap_empty: Add both departments and areas to see this chart. collaboration: Possible collaboration - collaboration_sub: Initiatives in different departments sharing a theme. + collaboration_sub: Initiatives in different departments sharing an area. status_by_dept: Status by department + status_by_dept_sub: Initiative statuses broken down by department. funding_sources: Funding sources + funding_sources_sub: Number of initiatives per funding source. budget_by_dept: Budget by department - reach: Themes by department reach + budget_by_dept_sub: Total budget of the initiatives in each department. + reach: Areas by department reach + reach_sub: How many departments each area appears in. timeline: Timeline + timeline_sub: Each initiative's time period on a timeline. + unfinished: + title: Your work + sub: Your initiatives and contacts that are still missing details. + initiative: Initiative + contact: Contact + contact_badge: Missing details + percent_done: "%percent% % filled in" + cta: Finish + empty_title: You're all caught up initiative: title: Title - category: Category + title_help: A short, descriptive name for the initiative. + area: Area + area_help: The thematic area the initiative belongs to. description: Short description + description_help: A brief summary of the purpose and content. strategies: Strategies and plans initiative_type: Type of initiative + initiative_type_help: The nature of the initiative, e.g. project or operation. status: Status + status_help: How far along the initiative is. status_additional: Status – additional text + status_additional_help: Add detail to the status if needed. organizational_anchoring: Department + organizational_anchoring_help: The department responsible for the initiative. endorsement: Has the initiative been endorsed endorsement_author: Where it was endorsed + endorsement_author_help: Where the initiative was endorsed. budget: Budget (DKK) + budget_help: Total budget in whole kroner. funding: Funding sources stakeholders: Stakeholders and partners tags: Tags time_period_start: Time period – start + time_period_start_help: When the initiative starts. time_period_end: Time period – end + time_period_end_help: When the initiative is expected to end. time_period: Time period links: Relevant links contacts: Contacts - new_contacts: Add new contacts author: Filled out by - terms_help: Separate multiple values with commas. + terms_help: Choose an existing one, or type a new and press Enter. completion_field_hint: This field counts towards the completion rate. images: Images attachments: Files image_file: Image file - image_alt: Alternative text attachment_file: File attachment_invalid_type: "The file type is not allowed (allowed: pdf, doc, docx, xls, xlsx)." index: @@ -197,9 +247,13 @@ initiative: contact: name: Name + name_help: The contact's full name. email: Email + email_help: Work email, if known. phone: Phone + phone_help: Phone number, if known. department: Department + department_help: The department or unit the person belongs to. index: title: Contacts subtitle: People attached to initiatives. @@ -213,6 +267,7 @@ contact: department: name: Name + name_help: The department name as shown on initiatives. name_duplicate: A department with this name already exists. index: title: Departments @@ -225,9 +280,26 @@ department: title: No departments yet hint: Create a department so it can be chosen on initiatives. +area: + name: Name + name_help: The area name as shown on initiatives. + name_duplicate: An area with this name already exists. + index: + title: Areas + subtitle: Thematic area that can be chosen on initiatives. + new: + title: New area + edit: + title: Edit area + empty: + title: No areas yet + hint: Create an area so it can be chosen on initiatives. + user: email: Email + email_help: Used to sign in and for notifications. name: Name + name_help: Shown in activity and as the author. roles: Roles password: Password email_duplicate: A user with this email already exists. @@ -266,6 +338,10 @@ flash: created: The department was created. updated: The department was updated. deleted: The department was deleted. + area: + created: The area was created. + updated: The area was updated. + deleted: The area was deleted. user: created: The user was created. updated: The user was updated. @@ -280,15 +356,6 @@ enum: on_hold: On hold completed: Completed cancelled: Cancelled - category: - climate: Climate and environment - mobility: Mobility - welfare: Welfare - culture: Culture and leisure - education: Education - business: Business - digitalisation: Digitalisation - urban_development: Urban development initiative_type: project: Project programme: Programme