Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(frontend): improve error handling for MaterialTable #3465

Merged
merged 8 commits into from
May 22, 2023
12 changes: 11 additions & 1 deletion api/config/packages/sentry.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,21 @@ services:
- Symfony\Component\HttpKernel\Exception\NotFoundHttpException
- Symfony\Component\Routing\Exception\ResourceNotFoundException
- Symfony\Component\Security\Core\Exception\AccessDeniedException
- Symfony\Component\HttpKernel\Exception\HttpException
# The following exceptions should not be reported by the API, because they are client errors (400) and should hence be reported by the client.
# For the moment we keep them in, in order to track some frontend issues in Sentry.
# - Symfony\Component\Serializer\Exception\ExtraAttributesException
# - Symfony\Component\Serializer\Exception\UnexpectedValueException

monolog:
handlers:
sentry:
type: fingers_crossed
action_level: error
handler: sentry_main
excluded_http_codes: [400, 401, 403, 404, 405, 422] # This exclusions only work for exceptions of type Symfony\Component\HttpKernel\Exception\HttpException
buffer_size: 50 # How many messages should be saved? Prevent memory leaks

sentry_main:
type: sentry
level: !php/const Monolog\Logger::ERROR
hub_id: Sentry\State\HubInterface
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ public function normalize(mixed $object, string $format = null, array $context =

$translations = $this->translateToAllLocalesService->translate(
$violation->getMessageTemplate(),
$violation->getParameters()
array_merge(
$violation->getPlural() ? ['%count%' => $violation->getPlural()] : [],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not see directly in which case you needed this.
A test which covers this path would be nice.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test added in f4baba3

Many symfony internal validators have violations messages that have a singular and a plural version. The %count% parameter needs to be provided to the translator such that it selects the right version:
https://github.com/symfony/symfony/blob/6.3/src/Symfony/Contracts/Translation/TranslatorInterface.php#L22
https://github.com/symfony/symfony/blob/6.3/src/Symfony/Component/Validator/Violation/ConstraintViolationBuilder.php#L123

Without this adjustment, the output would be the following (both singular+plural version returned):

'translations' => [
    'en' => 'This value is too long. It should have 32 character or less.|This value is too long. It should have 32 characters or less.',
    'de' => 'Diese Zeichenkette ist zu lang. Sie sollte höchstens 32 Zeichen haben.|Diese Zeichenkette ist zu lang. Sie sollte höchstens 32 Zeichen haben.',
    'fr' => 'Cette chaîne est trop longue. Elle doit avoir au maximum 32 caractère.|Cette chaîne est trop longue. Elle doit avoir au maximum 32 caractères.',
    'it' => 'Questo valore è troppo lungo. Dovrebbe essere al massimo di 32 carattere.|Questo valore è troppo lungo. Dovrebbe essere al massimo di 32 caratteri.',
],

$violation->getParameters()
)
);

$resultItem = [
Expand Down
27 changes: 27 additions & 0 deletions api/tests/Api/Camps/CreateCampTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,33 @@ public function testCreateCampReturnsProperDatesInTimezoneBehindUTC() {
]);
}

public function testCreateCampValidatesTooLongNameAndIncludesTranslationInfo() {
static::createClientWithCredentials()->request('POST', '/camps', ['json' => $this->getExampleWritePayload([
'name' => 'This camp name has 33 characters!',
])]);

$this->assertResponseStatusCodeSame(422);
$this->assertJsonContains([
'violations' => [
[
'i18n' => [
'key' => 'symfony.component.validator.constraints.length',
'parameters' => [
'value' => '"This camp name has 33 characters!"',
'limit' => '32',
],
'translations' => [
'en' => 'This value is too long. It should have 32 characters or less.',
'de' => 'Diese Zeichenkette ist zu lang. Sie sollte höchstens 32 Zeichen haben.',
'fr' => 'Cette chaîne est trop longue. Elle doit avoir au maximum 32 caractères.',
'it' => 'Questo valore è troppo lungo. Dovrebbe essere al massimo di 32 caratteri.',
],
],
],
],
]);
}

public function getExampleWritePayload($attributes = [], $except = []) {
return $this->getExamplePayload(Camp::class, Post::class, $attributes, [], $except);
}
Expand Down
4 changes: 2 additions & 2 deletions frontend/.jest/environment.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ window.environment = {
COOKIE_PREFIX: 'localhost_',
PRINT_URL: '/print',
SENTRY_FRONTEND_DSN: null,
SENTRY_ENVIRONMENT: "http://localhost:3000",
SENTRY_ENVIRONMENT: 'local',
DEPLOYMENT_TIME: '',
VERSION: '',
VERSION_LINK_TEMPLATE: 'https://github.com/ecamp/ecamp3/commit/{version}',
FEATURE_DEVELOPER: true,
LOGIN_INFO_TEXT_KEY: 'dev'
LOGIN_INFO_TEXT_KEY: 'dev',
}
2 changes: 1 addition & 1 deletion frontend/public/environment.dist
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ window.environment = {
COOKIE_PREFIX: 'localhost_',
PRINT_URL: '/print',
SENTRY_FRONTEND_DSN: null,
SENTRY_ENVIRONMENT: "http://localhost:3000",
SENTRY_ENVIRONMENT: "local",
DEPLOYMENT_TIME: '',
VERSION: '',
VERSION_LINK_TEMPLATE: 'https://github.com/ecamp/ecamp3/commit/{version}',
Expand Down
2 changes: 1 addition & 1 deletion frontend/public/environment.docker.dist
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ window.environment = {
COOKIE_PREFIX: 'localhost_',
PRINT_URL: '/print',
SENTRY_FRONTEND_DSN: null,
SENTRY_ENVIRONMENT: "http://localhost:3000",
SENTRY_ENVIRONMENT: "local",
DEPLOYMENT_TIME: '',
VERSION: '',
VERSION_LINK_TEMPLATE: 'https://github.com/ecamp/ecamp3/commit/{version}',
Expand Down
41 changes: 14 additions & 27 deletions frontend/src/components/form/ServerErrorContent.vue
Original file line number Diff line number Diff line change
@@ -1,35 +1,17 @@
<template>
<div v-if="!!serverError.name || !!serverError.response">
<template
v-if="
serverError.name === 'ServerException' &&
serverError.response &&
serverError.response.status === 422
"
>
<div class="title">Validation error</div>
<ul>
<li
v-for="(violation, index) in serverError.response.data.violations"
:key="index"
>
<div>
<b>{{ violation.propertyPath }}</b
>: {{ violation.message }}
</div>
</li>
</ul>
</template>
<template v-else>
{{ serverError.message }}
</template>
</div>
<div v-else>
{{ serverError }}
<div>
<span v-if="errorList.length === 1">{{ errorList[0] }}</span>
<ul v-else>
<li v-for="(error, index) in errorList" :key="index">
{{ error }}
</li>
</ul>
</div>
</template>

<script>
import { violationsToFlatArray } from '@/helpers/serverError'

export default {
name: 'ServerErrorContent',
props: {
Expand All @@ -38,5 +20,10 @@ export default {
default: null,
},
},
computed: {
errorList() {
return violationsToFlatArray(this.serverError, this.$i18n)
},
},
}
</script>
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export default {
showDialog: function (showDialog) {
if (showDialog) {
const entityData = {
quantity: '',
quantity: null,
unit: '',
article: '',
materialList: null,
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/material/DialogMaterialItemForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@
<e-text-field
v-model="localMaterialItem.unit"
:name="$tc('entity.materialItem.fields.unit')"
maxlength="32"
/>
<e-text-field
v-model="localMaterialItem.article"
:name="$tc('entity.materialItem.fields.article')"
vee-rules="required"
maxlength="64"
/>
<e-select
v-model="localMaterialItem.materialList"
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/material/MaterialCreateItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
ref="quantity"
v-model="materialItem.quantity"
dense
vee-rules="numeric"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that no longer useful?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Numeric only allows integer values. Our API allows float values. There's a built-in validation rule called "double" for float values. However, the "double" validation fails for empty values, which we explicitly allow.

Because we already set the html attribute type="number", the validation is not really needed, because the user cannot enter anything else than numbers anyway.

type="number"
:name="$tc('entity.materialItem.fields.quantity')"
fieldname="quantity"
Expand All @@ -23,6 +22,7 @@
dense
:name="$tc('entity.materialItem.fields.unit')"
fieldname="unit"
maxlength="32"
/>
</td>
<td>
Expand All @@ -32,6 +32,7 @@
vee-rules="required"
:name="$tc('entity.materialItem.fields.article')"
fieldname="article"
maxlength="64"
/>
</td>
<td :colspan="columns - 4">
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/components/material/MaterialTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
dense
:uri="item.uri"
fieldname="unit"
maxlength="32"
/>
<span v-if="item.readonly">{{ item.unit }}</span>
</template>
Expand All @@ -65,6 +66,7 @@
dense
:uri="item.uri"
fieldname="article"
maxlength="64"
/>
<span v-if="item.readonly">{{ item.article }}</span>
</template>
Expand Down Expand Up @@ -198,6 +200,8 @@ import ServerErrorContent from '@/components/form/ServerErrorContent.vue'
import ButtonRetry from '@/components/buttons/ButtonRetry.vue'
import ButtonCancel from '@/components/buttons/ButtonCancel.vue'
import { errorToMultiLineToast } from '@/components/toast/toasts'
import * as Sentry from '@sentry/browser'
import { serverErrorToString } from '@/helpers/serverError.js'

export default {
name: 'MaterialTable',
Expand Down Expand Up @@ -375,7 +379,7 @@ export default {
// retry to save to API (after server error)
retry(item) {
// reset error
this.$set(this.newMaterialItems[item.id], 'serverError', null)
this.$set(this.newMaterialItems[item.id], 'serverError', undefined)

// try to save same data again
this.postToApi(item.id, this.newMaterialItems[item.id])
Expand All @@ -399,6 +403,8 @@ export default {
// catch server error
.catch((error) => {
this.$set(this.newMaterialItems[key], 'serverError', error)
this.$toast.error(errorToMultiLineToast(error))
Sentry.captureMessage(serverErrorToString(error))
})
},

Expand Down
30 changes: 3 additions & 27 deletions frontend/src/components/toast/toasts.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import MultiLineToast from '@/components/toast/MultiLineToast.vue'
import i18n from '@/plugins/i18n'
import { transformViolations } from '@/helpers/serverError'
import { violationsToFlatArray } from '@/helpers/serverError'

function multiLineToast(lines) {
return {
Expand All @@ -12,32 +12,8 @@ function multiLineToast(lines) {
}
}

function violationsToFlatArray(e) {
const violationsObject = transformViolations(e, i18n)
const violations = Object.entries(violationsObject)
if (violations.length === 0) {
return []
}
if (violations.length === 1 && violationsObject[0]) {
return [violationsObject[0]]
}
const toArray = (element) => {
if (Array.isArray(element)) {
return element
}
return [element]
}
const result = []
for (const [key, value] of Object.entries(violationsObject)) {
for (const message of toArray(value)) {
result.push(`${key}: ${message}`)
}
}
return result
}

function errorToMultiLineToast(error) {
return multiLineToast(violationsToFlatArray(error))
return multiLineToast(violationsToFlatArray(error, i18n))
}

export { errorToMultiLineToast, multiLineToast, violationsToFlatArray }
export { errorToMultiLineToast, multiLineToast }
26 changes: 25 additions & 1 deletion frontend/src/helpers/serverError.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,28 @@ const transformViolations = (error, i18n = null) => {
return serverErrorMessages
}

export { serverErrorToString, transformViolations }
function violationsToFlatArray(e, i18n) {
const violationsObject = transformViolations(e, i18n)
const violations = Object.entries(violationsObject)
if (violations.length === 0) {
return []
}
if (violations.length === 1 && violationsObject[0]) {
return [violationsObject[0]]
}
const toArray = (element) => {
if (Array.isArray(element)) {
return element
}
return [element]
}
const result = []
for (const [key, value] of Object.entries(violationsObject)) {
for (const message of toArray(value)) {
result.push(`${key}: ${message}`)
}
}
return result
}

export { serverErrorToString, transformViolations, violationsToFlatArray }
2 changes: 1 addition & 1 deletion frontend/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { Resize } from 'vuetify/lib/directives'
import ResizeObserver from 'v-resize-observer'

if (window.environment && window.environment.SENTRY_FRONTEND_DSN) {
const environment = window.environment.SENTRY_ENVIRONMENT ?? 'http://localhost:3000'
const environment = window.environment.SENTRY_ENVIRONMENT ?? 'local'
Sentry.init({
Vue,
dsn: window.environment.SENTRY_FRONTEND_DSN,
Expand Down
2 changes: 1 addition & 1 deletion print/nuxt.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export default {
dsn: process.env.SENTRY_PRINT_DSN || '',
disabled: process.env.NODE_ENV === 'development',
config: {
environment: process.env.SENTRY_ENVIRONMENT ?? 'http://localhost:3000',
environment: process.env.SENTRY_ENVIRONMENT ?? 'local',
},
},

Expand Down
2 changes: 1 addition & 1 deletion print/print.env
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
INTERNAL_API_ROOT_URL=http://caddy:3000/api
FRONTEND_URL=http://localhost:3000
SENTRY_PRINT_DSN=
SENTRY_ENVIRONMENT=http://localhost:3000
SENTRY_ENVIRONMENT=local
BROWSER_WS_ENDPOINT=ws://browserless:3000
PRINT_URL=http://print:3003
COOKIE_PREFIX=localhost_