Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
caeca33
Merge branch 'main' into develop
jeppekroghitk Jun 27, 2026
c6d91fb
Add Area entity with admin CRUD
jeppekroghitk Jun 27, 2026
f6e833d
Replace Initiative category enum with Area relation
jeppekroghitk Jun 27, 2026
0a33015
Updated changelog
jeppekroghitk Jun 27, 2026
46cd337
Make strategies and tags a searchable, shared stategy and tags pool f…
jeppekroghitk Jun 27, 2026
75338f5
Updated changelog
jeppekroghitk Jun 27, 2026
c9f056c
Fix tests
jeppekroghitk Jun 27, 2026
1914368
Updated changelog
jeppekroghitk Jun 27, 2026
88f4c9d
Improve styling of multiselects and remove blue highlight effect from…
jeppekroghitk Jun 27, 2026
a5019e3
Add help text for input, textarea, selects
jeppekroghitk Jun 27, 2026
1186feb
Improved star behaviour
jeppekroghitk Jun 27, 2026
0efc80f
Added message when deleting title on saved initiative
jeppekroghitk Jun 27, 2026
02d414f
Added option to disable flying star animations
jeppekroghitk Jun 29, 2026
1cdae09
Changed design and translations of mascot and animation toggles
jeppekroghitk Jun 29, 2026
926ff11
Add Glimt introduction
jeppekroghitk Jun 29, 2026
1498f90
Updated vol mounting for prod
cableman Jun 29, 2026
6f5417a
Added nudge for unfinished contacts, and fixed bug regarding finding …
jeppekroghitk Jun 29, 2026
b3fa5e0
Add help text for all dashboard elements
jeppekroghitk Jun 29, 2026
7d087df
Move Aktivitet up, and add Dit igangværende arbejde boks, showing the…
jeppekroghitk Jun 29, 2026
ea77c6a
Updated changelog
jeppekroghitk Jun 29, 2026
4a036ed
Display default empty text in Områder på tværs af afdelinger, until b…
jeppekroghitk Jun 29, 2026
f800a4f
Enable live upload and overhauled upload form and image preview
jeppekroghitk Jun 29, 2026
ea5cccb
Updated changelog
jeppekroghitk Jun 29, 2026
539ca8b
Add some spacing and styling to the user menu
jeppekroghitk Jun 29, 2026
50af132
Add interactive platform tour
jeppekroghitk Jun 29, 2026
dba97eb
Fixed spacing between Glimt and speech bubble upon scaling
jeppekroghitk Jun 29, 2026
39bb09a
Updated changelog
jeppekroghitk Jun 29, 2026
1e6333f
Added ITK logo and redesigned login screen
jeppekroghitk Jun 29, 2026
ae73dd2
Tweak login screen styling
jeppekroghitk Jun 29, 2026
46a4771
Fix spacing in menu
jeppekroghitk Jun 29, 2026
c18404f
Updated changelog
jeppekroghitk Jun 29, 2026
7f02361
Exclude Vedtagelse from udfyldningsgrad, and unselect Er initiativet …
jeppekroghitk Jun 29, 2026
9de862c
Merge branch 'feature/7792-form-improvements' into feature/7792-masco…
jeppekroghitk Jun 29, 2026
e3378a6
Merge branch 'feature/7792-mascot-dialogue-bug-fix' into feature/7792…
jeppekroghitk Jun 29, 2026
f1ce516
Updated changelog
jeppekroghitk Jun 29, 2026
ce7dcd6
Merge branch 'feature/7792-mascot-dialogue-bug-fix' into feature/7792…
jeppekroghitk Jun 29, 2026
7250350
Merge branch 'feature/7792-dashboard-improvements' into feature/7792-…
jeppekroghitk Jun 29, 2026
80e52f2
Merge branch 'feature/7792-polish-usermenu' into feature/7792-platfor…
jeppekroghitk Jun 29, 2026
5bdb430
Updated changelog
jeppekroghitk Jun 29, 2026
eeb12d3
Merge branch 'feature/7792-polish-usermenu' into feature/7792-platfor…
jeppekroghitk Jun 29, 2026
1309cca
Merge branch 'feature/7792-form-improvements' into feature/7792-auto-…
jeppekroghitk Jun 29, 2026
9e83a36
Merge pull request #15 from itk-dev/feature/7792-area-entity
jeppekroghitk Jun 30, 2026
947d245
Merge pull request #19 from itk-dev/feature/7792-auto-upload-files-an…
jeppekroghitk Jun 30, 2026
c5a90ec
Merge pull request #16 from itk-dev/feature/7792-form-improvements
jeppekroghitk Jun 30, 2026
a5e6b3e
Merge branch 'develop' into feature/7792-mascot-dialogue-bug-fix
jeppekroghitk Jun 30, 2026
9b861c2
Merge pull request #17 from itk-dev/feature/7792-mascot-dialogue-bug-fix
jeppekroghitk Jun 30, 2026
147fd06
Merge pull request #22 from itk-dev/feature/7792-logo-and-login-screen
jeppekroghitk Jun 30, 2026
610a14a
Merge branch 'develop' into feature/7792-dashboard-improvements
jeppekroghitk Jun 30, 2026
622d515
Add components for page header, card header, emptystate and kpi
jeppekroghitk Jun 30, 2026
7962b82
Merge branch 'feature/7792-dashboard-improvements' into feature/7792-…
jeppekroghitk Jun 30, 2026
841a048
Merge pull request #18 from itk-dev/feature/7792-dashboard-improvements
jeppekroghitk Jun 30, 2026
fcf5509
Merge branch 'develop' into feature/7792-polish-usermenu
jeppekroghitk Jun 30, 2026
4bf3c79
Merge pull request #20 from itk-dev/feature/7792-polish-usermenu
jeppekroghitk Jun 30, 2026
056c294
Merge branch 'develop' into feature/7792-platform-introduction
jeppekroghitk Jun 30, 2026
3848542
Merge pull request #21 from itk-dev/feature/7792-platform-introduction
jeppekroghitk Jun 30, 2026
c164051
Merge branch 'develop' into feature/7792-introduce-twig-components
jeppekroghitk Jun 30, 2026
2064f87
Updated changelog
jeppekroghitk Jun 30, 2026
0b8a258
Coding standards
jeppekroghitk Jun 30, 2026
a74172a
Merge pull request #23 from itk-dev/feature/7792-introduce-twig-compo…
jeppekroghitk Jun 30, 2026
3ebb3df
Updated changelog
jeppekroghitk Jun 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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]: <https://github.com/itk-dev/itk-project-database/releases/tag/0.1.09i876rbhn> gvfcdevgbhjgnybtfr v8decsxz
75 changes: 69 additions & 6 deletions assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,14 @@ function initCollections() {
remove.className = "btn btn--danger btn--sm";
remove.dataset.collectionRemove = "";
remove.textContent = collection.dataset.removeLabel || "Remove";
remove.addEventListener("click", () => item.remove());
remove.addEventListener("click", () => {
item.remove();
// Autosave is the only save path now — tell it the form changed so
// the removed row is persisted (and the media frame re-renders).
collection.dispatchEvent(
new Event("change", { bubbles: true }),
);
});
item.appendChild(remove);
};

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

static values = {
debounce: { type: Number, default: 800 },
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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;
Expand All @@ -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");
Expand All @@ -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;
}
}

Expand Down
Loading
Loading