Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,10 @@ MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
# postgresql+advisory://db_user:db_password@localhost/db_name
LOCK_DSN=flock
###< symfony/lock ###

###> tidy-feedback ###
# See https://github.com/itk-dev/tidy-feedback#all-configuration-options
TIDY_FEEDBACK_DATABASE_URL=pdo-sqlite://localhost//app/var/tidy-feedback.sqlite
TIDY_FEEDBACK_DISABLE=false
TIDY_FEEDBACK_DISABLE_PATTERN=@^/tidy-feedback/@
###< tidy-feedback ###
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

* [PR-46](https://github.com/itk-dev/itk-projects/pull/46)
* Remove the "Summary" field.
* Ask "Er projektet godkendt" instead of "Er projektet vedtaget".
* Label the committee endorsement choice "Udvalg/Styregruppe".
* Rename "Emne" to "EU topic" and make it a single-line field.
* Show and accept thousands separators ("1.250.000") in the money fields,
added as you type.
* Mark the invalid fields and show their messages when a save fails, for
example a negative amount.
* Add [tidy-feedback](https://github.com/itk-dev/tidy-feedback) to the mix.
* [PR-40](https://github.com/itk-dev/itk-projects/pull/40)
Let a project belong to several areas, picked with a searchable multiselect.
* [PR-43](https://github.com/itk-dev/itk-projects/pull/43)
Expand Down
72 changes: 67 additions & 5 deletions assets/controllers/autosave_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export default class extends Controller {
unsavedText: { type: String, default: "Unsaved changes" },
errorText: {
type: String,
default: "Couldn’t save — check the required fields",
default: "Couldn’t save — check the marked fields",
},
offlineText: {
type: String,
Expand Down Expand Up @@ -73,9 +73,10 @@ export default class extends Controller {
);

try {
const { status, location } = await this.request();
const { status, location, body } = await this.request();

if (201 === status) {
this.clearErrors();
// The draft now exists — edit it in place from here on.
if (location) {
this.element.action = location;
Expand All @@ -92,12 +93,21 @@ export default class extends Controller {
"saved",
);
} else if (204 === status) {
this.clearErrors();
this.setStatus(
`${this.savedTextValue} · ${this.timestamp()}`,
"saved",
);
} else if (422 === status) {
this.setStatus(this.errorTextValue, "error");
// Mark the invalid fields; messages that belong to no field (errors
// on the form itself) go in the status line instead.
const unplaced = this.markErrors(this.parseErrors(body));
this.setStatus(
unplaced.length > 0
? unplaced.join(" ")
: this.errorTextValue,
"error",
);
} else {
this.setStatus(this.offlineTextValue, "error");
}
Expand All @@ -113,8 +123,8 @@ export default class extends Controller {
}

// POST the whole form via XHR so an in-flight save can be aborted when a
// newer one supersedes it. Resolves with the status and the
// X-Project-Location header (if any); rejects with an AbortError when
// newer one supersedes it. Resolves with the status, the response body and
// the X-Project-Location header (if any); rejects with an AbortError when
// superseded.
request() {
return new Promise((resolve, reject) => {
Expand All @@ -127,6 +137,7 @@ export default class extends Controller {
xhr.addEventListener("load", () =>
resolve({
status: xhr.status,
body: xhr.responseText,
location: xhr.getResponseHeader("X-Project-Location"),
}),
);
Expand All @@ -143,6 +154,57 @@ export default class extends Controller {
});
}

// The 422 body is {errors: {"project[budget]": ["message", …], …}}.
parseErrors(body) {
try {
return JSON.parse(body)?.errors ?? {};
} catch {
return {};
}
}

// Mark each invalid field the way a full render would: aria-invalid on the
// widget and a .form-errors list at the end of its row. Returns the
// messages that matched no field.
markErrors(errors) {
this.clearErrors();
const unplaced = [];

for (const [name, messages] of Object.entries(errors)) {
const escaped = CSS.escape(name);
const field = this.element.querySelector(
`[name="${escaped}"], [name="${escaped}[]"]`,
);
const row = field?.closest(".form-row");
if (!field || !row) {
unplaced.push(...messages);
continue;
}

field.setAttribute("aria-invalid", "true");
const list = document.createElement("ul");
list.className = "form-errors";
for (const message of messages) {
const item = document.createElement("li");
item.textContent = message;
list.append(item);
}
row.append(list);
}

return unplaced;
}

// Drops both our marks and any rendered by the server on a full submit.
clearErrors() {
this.element
.querySelectorAll('[aria-invalid="true"]')
.forEach((field) => field.removeAttribute("aria-invalid"));
this.element
.querySelectorAll(".form-errors")
.forEach((list) => list.remove());
}

hasEmptyRequiredField() {
return Array.from(
this.element.querySelectorAll("[data-autosave-required]"),
Expand Down
57 changes: 57 additions & 0 deletions assets/controllers/number_format_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Controller } from "@hotwired/stimulus";

/*
* Groups the digits of a money field as they are typed ("1250000" becomes
* "1.250.000" in Danish, "1,250,000" in English), in the page locale so the
* value reads the way the server renders and parses it. Only digits and a
* leading minus survive: amounts are whole kroner, and the minus is kept so a
* negative amount still reaches the server and gets its validation error.
*/
export default class extends Controller {
connect() {
const locale = document.documentElement.lang || "en";
this.formatter = new Intl.NumberFormat(locale, {
maximumFractionDigits: 0,
});
this.format();
}

format() {
const input = this.element;
const before = input.value;
const caret = input.selectionStart ?? before.length;
// The caret is anchored to the digits typed before it; the separators
// around them come and go.
const digitsBeforeCaret = before
.slice(0, caret)
.replace(/\D/g, "").length;

const after = this.group(before);
if (after === before) {
return;
}
input.value = after;

let position = 0;
let seen = 0;
while (position < after.length && seen < digitsBeforeCaret) {
if (/\d/.test(after[position])) {
seen += 1;
}
position += 1;
}
input.setSelectionRange(position, position);
}

// The sign is added by hand: some locales format a negative number with a
// typographic minus the server would not understand.
group(value) {
const negative = value.trimStart().startsWith("-");
const digits = value.replace(/\D/g, "").replace(/^0+(?=\d)/, "");
if ("" === digits) {
return negative ? "-" : "";
}

return (negative ? "-" : "") + this.formatter.format(BigInt(digits));
}
}
6 changes: 6 additions & 0 deletions assets/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -1281,6 +1281,12 @@ textarea {
margin-top: var(--itk-space-1);
}

input[aria-invalid="true"],
select[aria-invalid="true"],
textarea[aria-invalid="true"] {
border-color: var(--itk-danger);
}

.detail-grid {
display: grid;
grid-template-columns: 1.5fr 1fr;
Expand Down
63 changes: 32 additions & 31 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,48 +7,49 @@
"php": ">=8.4",
"ext-ctype": "*",
"ext-iconv": "*",
"doctrine/doctrine-bundle": "^3.2",
"doctrine/doctrine-migrations-bundle": "^4.0",
"doctrine/orm": "^3.6",
"doctrine/doctrine-bundle": "^3.3.2",
"doctrine/doctrine-migrations-bundle": "^4.0.1",
"doctrine/orm": "^3.7.4",
"itk-dev/entity-bundle": "^0.1",
"itk-dev/tidy-feedback": "^1.0",
"league/csv": "^9.28",
"symfony/asset": "~8.1.0",
"symfony/asset-mapper": "~8.1.0",
"symfony/console": "~8.1.0",
"symfony/dotenv": "~8.1.0",
"symfony/asset-mapper": "~8.1.8",
"symfony/console": "~8.1.8",
"symfony/dotenv": "~8.1.6",
"symfony/flex": "^2",
"symfony/form": "~8.1.0",
"symfony/framework-bundle": "~8.1.0",
"symfony/mercure-bundle": "^0.4.2",
"symfony/rate-limiter": "~8.1.0",
"symfony/form": "~8.1.8",
"symfony/framework-bundle": "~8.1.8",
"symfony/mercure-bundle": "^0.4.3",
"symfony/rate-limiter": "~8.1.6",
"symfony/runtime": "~8.1.0",
"symfony/security-bundle": "~8.1.0",
"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",
"twig/twig": "^2.12 || ^3.0"
"symfony/security-bundle": "~8.1.8",
"symfony/translation": "~8.1.5",
"symfony/twig-bundle": "~8.1.7",
"symfony/ux-turbo": "^3.5.1",
"symfony/ux-twig-component": "^3.5.1",
"symfony/validator": "~8.1.8",
"symfony/yaml": "~8.1.8",
"twig/extra-bundle": "^2.12 || ^3.29",
"twig/twig": "^2.12 || ^3.30"
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^4.3",
"ergebnis/composer-normalize": "^2.52",
"friendsofphp/php-cs-fixer": "^3.95",
"ergebnis/composer-normalize": "^2.54",
"friendsofphp/php-cs-fixer": "^3.95.27",
"phpstan/extension-installer": "^1.4",
"phpstan/phpstan": "^2.2",
"phpstan/phpstan-doctrine": "^2.0",
"phpstan/phpstan-symfony": "^2.0",
"phpunit/phpunit": "^13.2",
"phpstan/phpstan": "^2.3",
"phpstan/phpstan-doctrine": "^2.0.28",
"phpstan/phpstan-symfony": "^2.1",
"phpunit/phpunit": "^13.4.1",
"rregeer/phpunit-coverage-check": "^0.3.1",
"symfony/browser-kit": "~8.1.0",
"symfony/css-selector": "~8.1.0",
"symfony/browser-kit": "~8.1.5",
"symfony/css-selector": "~8.1.6",
"symfony/debug-bundle": "~8.1.0",
"symfony/maker-bundle": "^1.68",
"symfony/web-profiler-bundle": "~8.1.0",
"vincentlanglet/twig-cs-fixer": "^4.0",
"zenstruck/foundry": "^2.10"
"symfony/web-profiler-bundle": "~8.1.8",
"vincentlanglet/twig-cs-fixer": "^4.1.1",
"zenstruck/foundry": "^2.14"
},
"replace": {
"symfony/polyfill-ctype": "*",
Expand All @@ -72,7 +73,7 @@
"url": "https://github.com/itk-dev/entity-bundle.git"
}
],
"minimum-stability": "stable",
"minimum-stability": "dev",
"prefer-stable": true,
"autoload": {
"psr-4": {
Expand Down
Loading
Loading