Compare commits

..

9 Commits

7 changed files with 196 additions and 228 deletions

View File

@@ -1,107 +0,0 @@
Translations
************
One source of truth
===================
As of January 2025 we have opted to use one source of truth for translations in our backend as well as our frontend.
You will find translations still being present in i18ns files for our vue components, but these will slowly be replaced.
The goal is to only use the messages.{locale}.yaml files to create our translations and keys.
Each time we do `symfony console cache:clear` a javascript and typescript file are generated containing all the keys and the corresponding translations.
These can then be imported into our vue components together with the `trans` method, for use in the vue templates.
Vue import example
^^^^^^^^^^^^^^^^^^
. code-block:: js
import {
ACTIVITY_BLOC_PERSONS,
ACTIVITY_BLOC_PERSONS_ASSOCIATED,
ACTIVITY_BLOC_THIRDPARTY,
ACTIVITY_BLOC_USERS,
ACTIVITY_ADD_PERSONS,
trans,
} from "translator";
Setup
=====
For development purposes we generally make use of the chill-bundles standalone project. Here the new translation setup will work out of the box.
However when working on a customer chill instance (with a root project and a chill-bundles implementation) it is required to execute the chill translations recipe
using the command
Translation key conventions
===========================
When adding new translation keys we have chosen to adhere to the following conventions as of April 2025.
Older translation keys will gradually be adapted to respect these conventions.
Conventions
^^^^^^^^^^^
Entity related messages
-----------------------
Translation keys will be structured as followed as follows:
`[bundle].[entity].[page or component].[action / message]`
. code-block:: yaml
person:
household:
index:
edit_comment: "Mettre à jour le commentaire"
So the key to be used will be `person.household.index.edit_comment` when used in a twig template
or
`PERSON_HOUSEHOLD_INDEX_EDIT_COMMENT` when used in a vue component.
Export related messages
-----------------------
Translation keys will be structured as followed as follows:
[bundle]
|
|__ export
|
|__ ['count | list' | 'filter' | 'aggregator']
|
|__ [export | filter | aggregator - name] OR [ properties (end of hierarchy) ]
|
|__ [action / message]
ex. Filter
. code-block:: yaml
activity:
export:
filter:
by_users_job:
title: Filtrer les échanges par type
'Filtered activity by users job: only %jobs%': 'Filtré par métier d''au moins un utilisateur participant: seulement %jobs%'
ex. Export type
. code-block:: yaml
activity:
export:
count:
count_persons_on_activity:
title: Nombre d'usagers concernés par les échanges
list:
activities_by_parcours:
title: Liste des échanges liés à un parcours
ex. Export properties shared by a certain export type
. code-block:: yaml
activity:
export:
list:
users ids: Identifiant des utilisateurs

View File

@@ -66,8 +66,11 @@ class EntityToJsonTransformer implements DataTransformerInterface
]); ]);
} }
private function denormalizeOne(array $item) private function denormalizeOne(array|string $item)
{ {
if ('me' === $item) {
return $item;
}
if (!\array_key_exists('type', $item)) { if (!\array_key_exists('type', $item)) {
throw new TransformationFailedException('the key "type" is missing on element'); throw new TransformationFailedException('the key "type" is missing on element');
} }
@@ -98,5 +101,6 @@ class EntityToJsonTransformer implements DataTransformerInterface
'json', 'json',
$context, $context,
); );
} }
} }

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Form\Type;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Form\Type\DataTransformer\EntityToJsonTransformer;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\SerializerInterface;
/**
* Pick user dymically, using vuejs module "AddPerson".
*
* Possible options:
*
* - `multiple`: pick one or more users
* - `suggested`: a list of suggested users
* - `suggest_myself`: append the current user to the list of suggested
* - `as_id`: only the id will be set in the returned data
* - `submit_on_adding_new_entity`: the browser will immediately submit the form when new users are checked
*/
class PickUserOrMeDynamicType extends AbstractType
{
public function __construct(
private readonly DenormalizerInterface $denormalizer,
private readonly SerializerInterface $serializer,
private readonly NormalizerInterface $normalizer,
) {}
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->addViewTransformer(new EntityToJsonTransformer($this->denormalizer, $this->serializer, $options['multiple'], 'user'));
}
public function buildView(FormView $view, FormInterface $form, array $options)
{
$view->vars['multiple'] = $options['multiple'];
$view->vars['types'] = ['user'];
$view->vars['uniqid'] = uniqid('pick_user_or_me_dyn');
$view->vars['suggested'] = [];
$view->vars['as_id'] = true === $options['as_id'] ? '1' : '0';
$view->vars['submit_on_adding_new_entity'] = true === $options['submit_on_adding_new_entity'] ? '1' : '0';
foreach ($options['suggested'] as $user) {
$view->vars['suggested'][] = $this->normalizer->normalize($user, 'json', ['groups' => 'read']);
}
// $user = /* should come from context */ $options['context'];
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver
->setDefault('multiple', false)
->setAllowedTypes('multiple', ['bool'])
->setDefault('compound', false)
->setDefault('suggested', [])
// if set to true, only the id will be set inside the content. The denormalization will not work.
->setDefault('as_id', false)
->setAllowedTypes('as_id', ['bool'])
->setDefault('submit_on_adding_new_entity', false)
->setAllowedTypes('submit_on_adding_new_entity', ['bool']);
}
public function getBlockPrefix()
{
return 'pick_entity_dynamic';
}
}

View File

@@ -12,6 +12,11 @@ function loadDynamicPicker(element) {
let apps = element.querySelectorAll('[data-module="pick-dynamic"]'); let apps = element.querySelectorAll('[data-module="pick-dynamic"]');
apps.forEach(function (el) { apps.forEach(function (el) {
let suggested;
let as_id;
let submit_on_adding_new_entity;
let label;
let isCurrentUserPicker;
const isMultiple = parseInt(el.dataset.multiple) === 1, const isMultiple = parseInt(el.dataset.multiple) === 1,
uniqId = el.dataset.uniqid, uniqId = el.dataset.uniqid,
input = element.querySelector( input = element.querySelector(
@@ -22,12 +27,13 @@ function loadDynamicPicker(element) {
? JSON.parse(input.value) ? JSON.parse(input.value)
: input.value === "[]" || input.value === "" : input.value === "[]" || input.value === ""
? null ? null
: [JSON.parse(input.value)], : [JSON.parse(input.value)];
suggested = JSON.parse(el.dataset.suggested), suggested = JSON.parse(el.dataset.suggested);
as_id = parseInt(el.dataset.asId) === 1, as_id = parseInt(el.dataset.asId) === 1;
submit_on_adding_new_entity = submit_on_adding_new_entity =
parseInt(el.dataset.submitOnAddingNewEntity) === 1, parseInt(el.dataset.submitOnAddingNewEntity) === 1;
label = el.dataset.label; label = el.dataset.label;
isCurrentUserPicker = uniqId.startsWith("pick_user_or_me_dyn");
if (!isMultiple) { if (!isMultiple) {
if (input.value === "[]") { if (input.value === "[]") {
@@ -44,6 +50,7 @@ function loadDynamicPicker(element) {
':uniqid="uniqid" ' + ':uniqid="uniqid" ' +
':suggested="notPickedSuggested" ' + ':suggested="notPickedSuggested" ' +
':label="label" ' + ':label="label" ' +
':isCurrentUserPicker="isCurrentUserPicker" ' +
'@addNewEntity="addNewEntity" ' + '@addNewEntity="addNewEntity" ' +
'@removeEntity="removeEntity" ' + '@removeEntity="removeEntity" ' +
'@addNewEntityProcessEnded="addNewEntityProcessEnded"' + '@addNewEntityProcessEnded="addNewEntityProcessEnded"' +
@@ -61,6 +68,7 @@ function loadDynamicPicker(element) {
as_id, as_id,
submit_on_adding_new_entity, submit_on_adding_new_entity,
label, label,
isCurrentUserPicker,
}; };
}, },
computed: { computed: {
@@ -89,7 +97,8 @@ function loadDynamicPicker(element) {
const ids = this.picked.map((el) => el.id); const ids = this.picked.map((el) => el.id);
input.value = ids.join(","); input.value = ids.join(",");
} }
console.log(entity); console.log(this.picked);
// console.log(entity);
} }
} else { } else {
if ( if (

View File

@@ -1,10 +1,25 @@
<template> <template>
<ul :class="listClasses" v-if="picked.length && displayPicked"> <ul :class="listClasses" v-if="picked.length && displayPicked">
<li v-for="p in picked" @click="removeEntity(p)" :key="p.type + p.id"> <li v-for="p in picked" @click="removeEntity(p)" :key="p.type + p.id">
<span class="chill_denomination">{{ p.text }}</span> <span
v-if="'me' === p"
class="chill_denomination current-user updatedBy"
>{{ trans(USER_CURRENT_USER) }}</span
>
<span v-else class="chill_denomination">{{ p.text }}</span>
</li> </li>
</ul> </ul>
<ul class="record_actions"> <ul class="record_actions">
<li v-if="isCurrentUserPicker" class="btn btn-sm btn-misc">
<label class="flex items-center gap-2">
<input
ref="itsMeCheckbox"
:type="multiple ? 'checkbox' : 'radio'"
@change="selectItsMe"
/>
{{ trans(USER_CURRENT_USER) }}
</label>
</li>
<li class="add-persons"> <li class="add-persons">
<add-persons <add-persons
:options="addPersonsOptions" :options="addPersonsOptions"
@@ -24,119 +39,83 @@
</ul> </ul>
</template> </template>
<script> <script setup>
import AddPersons from "ChillPersonAssets/vuejs/_components/AddPersons.vue"; import { ref, computed } from "vue";
import AddPersons from "ChillPersonAssets/vuejs/_components/AddPersons.vue"; // eslint-disable-line
import { appMessages } from "./i18n"; import { appMessages } from "./i18n";
import { trans, USER_CURRENT_USER } from "translator";
export default { const props = defineProps({
name: "PickEntity", multiple: Boolean,
props: { types: Array,
multiple: { picked: Array,
type: Boolean, uniqid: String,
required: true, removableIfSet: { type: Boolean, default: true },
}, displayPicked: { type: Boolean, default: true },
types: { suggested: { type: Array, default: () => [] },
type: Array, label: String,
required: true, isCurrentUserPicker: { type: Boolean, default: false },
},
picked: {
required: true,
},
uniqid: {
type: String,
required: true,
},
removableIfSet: {
type: Boolean,
default: true,
},
displayPicked: {
// display picked entities.
type: Boolean,
default: true,
},
suggested: {
type: Array,
default: [],
},
label: {
type: String,
required: false,
},
},
emits: ["addNewEntity", "removeEntity", "addNewEntityProcessEnded"],
components: {
AddPersons,
},
data() {
return {
key: "",
};
},
computed: {
addPersonsOptions() {
return {
uniq: !this.multiple,
type: this.types,
priority: null,
button: {
size: "btn-sm",
class: "btn-submit",
},
};
},
translatedListOfTypes() {
if (this.label !== "") {
return this.label;
}
let trans = [];
this.types.forEach((t) => {
if (this.$props.multiple) {
trans.push(appMessages.fr.pick_entity[t].toLowerCase());
} else {
trans.push(
appMessages.fr.pick_entity[t + "_one"].toLowerCase(),
);
}
}); });
if (this.$props.multiple) { const emit = defineEmits([
return ( "addNewEntity",
appMessages.fr.pick_entity.modal_title + trans.join(", ") "removeEntity",
"addNewEntityProcessEnded",
]);
const itsMeCheckbox = ref(null);
const addPersons = ref(null);
const addPersonsOptions = computed(() => ({
uniq: !props.multiple,
type: props.types,
priority: null,
button: { size: "btn-sm", class: "btn-submit" },
}));
const translatedListOfTypes = computed(() => {
if (props.label) return props.label;
let trans = props.types.map((t) =>
props.multiple
? appMessages.fr.pick_entity[t].toLowerCase()
: appMessages.fr.pick_entity[t + "_one"].toLowerCase(),
); );
} else { return props.multiple
return ( ? appMessages.fr.pick_entity.modal_title + trans.join(", ")
appMessages.fr.pick_entity.modal_title_one + : appMessages.fr.pick_entity.modal_title_one + trans.join(", ");
trans.join(", ") });
);
} const listClasses = computed(() => ({
},
listClasses() {
return {
"list-suggest": true, "list-suggest": true,
"remove-items": this.$props.removableIfSet, "remove-items": props.removableIfSet,
}));
const selectItsMe = (event) =>
event.target.checked ? addNewSuggested("me") : removeEntity("me");
const addNewSuggested = (entity) => {
emit("addNewEntity", { entity });
}; };
},
}, const addNewEntity = ({ selected, modal }) => {
methods: { selected.forEach((item) => emit("addNewEntity", { entity: item.result }));
addNewSuggested(entity) { addPersons.value?.resetSearch();
this.$emit("addNewEntity", { entity: entity });
},
addNewEntity({ selected, modal }) {
selected.forEach((item) => {
this.$emit("addNewEntity", { entity: item.result });
}, this);
this.$refs.addPersons.resetSearch(); // to cast child method
modal.showModal = false; modal.showModal = false;
this.$emit("addNewEntityProcessEnded"); emit("addNewEntityProcessEnded");
}, };
removeEntity(entity) {
if (!this.$props.removableIfSet) { const removeEntity = (entity) => {
return; if (!props.removableIfSet) return;
if (entity === "me" && itsMeCheckbox.value) {
itsMeCheckbox.value.checked = false;
} }
this.$emit("removeEntity", { entity: entity }); emit("removeEntity", { entity });
},
},
}; };
</script> </script>
<style lang="scss" scoped>
.current-user {
color: var(--bs-body-color);
background-color: var(--bs-chill-l-gray) !important;
}
</style>

View File

@@ -49,6 +49,7 @@ Name: Nom
Label: Nom Label: Nom
user: user:
current_user: Utilisateur courant
profile: profile:
title: Mon profil title: Mon profil
Phonenumber successfully updated!: Numéro de téléphone mis à jour! Phonenumber successfully updated!: Numéro de téléphone mis à jour!

View File

@@ -13,7 +13,7 @@ namespace Chill\PersonBundle\Export\Filter\AccompanyingCourseFilters;
use Chill\MainBundle\Export\FilterInterface; use Chill\MainBundle\Export\FilterInterface;
use Chill\MainBundle\Form\Type\PickRollingDateType; use Chill\MainBundle\Form\Type\PickRollingDateType;
use Chill\MainBundle\Form\Type\PickUserDynamicType; use Chill\MainBundle\Form\Type\PickUserOrMeDynamicType;
use Chill\MainBundle\Service\RollingDate\RollingDate; use Chill\MainBundle\Service\RollingDate\RollingDate;
use Chill\MainBundle\Service\RollingDate\RollingDateConverterInterface; use Chill\MainBundle\Service\RollingDate\RollingDateConverterInterface;
use Chill\PersonBundle\Export\Declarations; use Chill\PersonBundle\Export\Declarations;
@@ -66,7 +66,7 @@ class ReferrerFilter implements FilterInterface
public function buildForm(FormBuilderInterface $builder) public function buildForm(FormBuilderInterface $builder)
{ {
$builder $builder
->add('accepted_referrers', PickUserDynamicType::class, [ ->add('accepted_referrers', PickUserOrMeDynamicType::class, [
'multiple' => true, 'multiple' => true,
]) ])
->add('date_calc', PickRollingDateType::class, [ ->add('date_calc', PickRollingDateType::class, [