add scope selection on accompanying course

This commit is contained in:
Julien Fastré 2021-09-22 12:23:26 +02:00
parent c382008b4d
commit c62254caec
13 changed files with 351 additions and 35 deletions

View File

@ -0,0 +1,17 @@
const fetchScopes = () => {
return window.fetch('/api/1.0/main/scope.json').then(response => {
if (response.ok) {
return response.json();
}
}).then(data => {
console.log(data);
return new Promise((resolve, reject) => {
console.log(data);
resolve(data.results);
});
});
};
export {
fetchScopes
};

View File

@ -2,16 +2,21 @@
namespace Chill\MainBundle\Security\Resolver; namespace Chill\MainBundle\Security\Resolver;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\Scope; use Chill\MainBundle\Entity\Scope;
/**
* Interface to implement to define a ScopeResolver.
*/
interface ScopeResolverInterface interface ScopeResolverInterface
{ {
/**
* Return true if this resolve is able to decide "something" on this entity.
*/
public function supports($entity, ?array $options = []): bool; public function supports($entity, ?array $options = []): bool;
/** /**
* @param $entity * Will return the scope for the entity
* @param array|null $options *
* @return Scope|array|Scope[] * @return Scope|array|Scope[]
*/ */
public function resolveScope($entity, ?array $options = []); public function resolveScope($entity, ?array $options = []);
@ -19,12 +24,12 @@ interface ScopeResolverInterface
/** /**
* Return true if the entity is concerned by scope, false otherwise. * Return true if the entity is concerned by scope, false otherwise.
*
* @param $entity
* @param array|null $options
* @return bool
*/ */
public function isConcerned($entity, ?array $options = []): bool; public function isConcerned($entity, ?array $options = []): bool;
/**
* get the default priority for this resolver. Resolver with an higher priority will be
* queried first.
*/
public static function getDefaultPriority(): int; public static function getDefaultPriority(): int;
} }

View File

@ -491,3 +491,23 @@ paths:
description: "ok" description: "ok"
401: 401:
description: "Unauthorized" description: "Unauthorized"
/1.0/main/scope/{id}.json:
get:
tags:
- scope
summary: return a list of scopes
parameters:
- name: id
in: path
required: true
description: The scope id
schema:
type: integer
format: integer
minimum: 1
responses:
200:
description: "ok"
401:
description: "Unauthorized"

View File

@ -24,8 +24,12 @@ namespace Chill\PersonBundle\DataFixtures\ORM;
use Chill\MainBundle\Entity\Center; use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\Country; use Chill\MainBundle\Entity\Country;
use Chill\MainBundle\Entity\PostalCode; use Chill\MainBundle\Entity\PostalCode;
use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Repository\CenterRepository; use Chill\MainBundle\Repository\CenterRepository;
use Chill\MainBundle\Repository\CountryRepository; use Chill\MainBundle\Repository\CountryRepository;
use Chill\MainBundle\Repository\ScopeRepository;
use Chill\MainBundle\Repository\UserRepository;
use Chill\PersonBundle\Entity\AccompanyingPeriod; use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\MaritalStatus; use Chill\PersonBundle\Entity\MaritalStatus;
use Chill\PersonBundle\Entity\SocialWork\SocialIssue; use Chill\PersonBundle\Entity\SocialWork\SocialIssue;
@ -90,12 +94,26 @@ class LoadPeople extends AbstractFixture implements OrderedFixtureInterface, Con
protected MaritalStatusRepository $maritalStatusRepository; protected MaritalStatusRepository $maritalStatusRepository;
/**
* @var array|Scope[]
*/
protected array $cacheScopes = [];
protected ScopeRepository $scopeRepository;
/** @var array|User[] */
protected array $cacheUsers = [];
protected UserRepository $userRepository;
public function __construct( public function __construct(
Registry $workflowRegistry, Registry $workflowRegistry,
SocialIssueRepository $socialIssueRepository, SocialIssueRepository $socialIssueRepository,
CenterRepository $centerRepository, CenterRepository $centerRepository,
CountryRepository $countryRepository, CountryRepository $countryRepository,
MaritalStatusRepository $maritalStatusRepository MaritalStatusRepository $maritalStatusRepository,
ScopeRepository $scopeRepository,
UserRepository $userRepository
) { ) {
$this->faker = Factory::create('fr_FR'); $this->faker = Factory::create('fr_FR');
$this->faker->addProvider($this); $this->faker->addProvider($this);
@ -105,7 +123,8 @@ class LoadPeople extends AbstractFixture implements OrderedFixtureInterface, Con
$this->countryRepository = $countryRepository; $this->countryRepository = $countryRepository;
$this->maritalStatusRepository = $maritalStatusRepository; $this->maritalStatusRepository = $maritalStatusRepository;
$this->loader = new NativeLoader($this->faker); $this->loader = new NativeLoader($this->faker);
$this->scopeRepository = $scopeRepository;
$this->userRepository = $userRepository;
} }
public function getOrder() public function getOrder()
@ -220,10 +239,16 @@ class LoadPeople extends AbstractFixture implements OrderedFixtureInterface, Con
new \DateInterval('P' . \random_int(0, 180) . 'D') new \DateInterval('P' . \random_int(0, 180) . 'D')
) )
); );
$accompanyingPeriod->setCreatedBy($this->getRandomUser())
->setCreatedAt(new \DateTimeImmutable('now'));
$person->addAccompanyingPeriod($accompanyingPeriod); $person->addAccompanyingPeriod($accompanyingPeriod);
$accompanyingPeriod->addSocialIssue($this->getRandomSocialIssue()); $accompanyingPeriod->addSocialIssue($this->getRandomSocialIssue());
if (\random_int(0, 10) > 3) { if (\random_int(0, 10) > 3) {
// always add social scope:
$accompanyingPeriod->addScope($this->getReference('scope_social'));
var_dump(count($accompanyingPeriod->getScopes()));
$accompanyingPeriod->setAddressLocation($this->createAddress()); $accompanyingPeriod->setAddressLocation($this->createAddress());
$manager->persist($accompanyingPeriod->getAddressLocation()); $manager->persist($accompanyingPeriod->getAddressLocation());
$workflow = $this->workflowRegistry->get($accompanyingPeriod); $workflow = $this->workflowRegistry->get($accompanyingPeriod);
@ -231,9 +256,19 @@ class LoadPeople extends AbstractFixture implements OrderedFixtureInterface, Con
} }
$manager->persist($person); $manager->persist($person);
$manager->persist($accompanyingPeriod);
echo "add person'".$person->__toString()."'\n"; echo "add person'".$person->__toString()."'\n";
} }
private function getRandomUser(): User
{
if (0 === count($this->cacheUsers)) {
$this->cacheUsers = $this->userRepository->findAll();
}
return $this->cacheUsers[\array_rand($this->cacheUsers)];
}
private function createAddress(): Address private function createAddress(): Address
{ {
$objectSet = $this->loader->loadData([ $objectSet = $this->loader->loadData([

View File

@ -55,7 +55,7 @@ class LoadPersonACL extends AbstractFixture implements OrderedFixtureInterface
$permissionsGroup->addRoleScope( $permissionsGroup->addRoleScope(
(new RoleScope()) (new RoleScope())
->setRole(AccompanyingPeriodVoter::SEE) ->setRole(AccompanyingPeriodVoter::FULL)
->setScope($scopeSocial) ->setScope($scopeSocial)
); );

View File

@ -824,7 +824,9 @@ class AccompanyingPeriod implements TrackCreationInterface, TrackUpdateInterface
public function addScope(Scope $scope): self public function addScope(Scope $scope): self
{ {
if (!$this->scopes->contains($scope)) {
$this->scopes[] = $scope; $this->scopes[] = $scope;
}
return $this; return $this;
} }

View File

@ -10,6 +10,7 @@
<origin-demand></origin-demand> <origin-demand></origin-demand>
<requestor></requestor> <requestor></requestor>
<social-issue></social-issue> <social-issue></social-issue>
<scopes></scopes>
<referrer></referrer> <referrer></referrer>
<resources></resources> <resources></resources>
<comment v-if="accompanyingCourse.step === 'DRAFT'"></comment> <comment v-if="accompanyingCourse.step === 'DRAFT'"></comment>
@ -32,6 +33,7 @@ import PersonsAssociated from './components/PersonsAssociated.vue';
import Requestor from './components/Requestor.vue'; import Requestor from './components/Requestor.vue';
import SocialIssue from './components/SocialIssue.vue'; import SocialIssue from './components/SocialIssue.vue';
import CourseLocation from './components/CourseLocation.vue'; import CourseLocation from './components/CourseLocation.vue';
import Scopes from './components/Scopes.vue';
import Referrer from './components/Referrer.vue'; import Referrer from './components/Referrer.vue';
import Resources from './components/Resources.vue'; import Resources from './components/Resources.vue';
import Comment from './components/Comment.vue'; import Comment from './components/Comment.vue';
@ -47,6 +49,7 @@ export default {
Requestor, Requestor,
SocialIssue, SocialIssue,
CourseLocation, CourseLocation,
Scopes,
Referrer, Referrer,
Resources, Resources,
Comment, Comment,

View File

@ -191,7 +191,49 @@ const getListOrigins = () => {
if (response.ok) { return response.json(); } if (response.ok) { return response.json(); }
throw { msg: 'Error while retriving origin\'s list.', sta: response.status, txt: response.statusText, err: new Error(), body: response.body }; throw { msg: 'Error while retriving origin\'s list.', sta: response.status, txt: response.statusText, err: new Error(), body: response.body };
}); });
} };
const addScope = (id, scope) => {
const url = `/api/1.0/person/accompanying-course/${id}/scope.json`;
console.log(url);
console.log(scope);
return fetch(url, {
method: 'POST',
body: JSON.stringify({
id: scope.id,
type: scope.type,
}),
headers: {
'Content-Type': 'application/json;charset=utf-8'
},
})
.then(response => {
if (response.ok) { return response.json(); }
throw { msg: 'Error while adding scope', sta: response.status, txt: response.statusText, err: new Error(), body: response.body };
});
};
const removeScope = (id, scope) => {
const url = `/api/1.0/person/accompanying-course/${id}/scope.json`;
console.log(url);
console.log(scope);
return fetch(url, {
method: 'DELETE',
body: JSON.stringify({
id: scope.id,
type: scope.type,
}),
headers: {
'Content-Type': 'application/json;charset=utf-8'
},
})
.then(response => {
if (response.ok) { return response.json(); }
throw { msg: 'Error while adding scope', sta: response.status, txt: response.statusText, err: new Error(), body: response.body };
});
};
export { export {
getAccompanyingCourse, getAccompanyingCourse,
@ -204,5 +246,7 @@ export {
getUsers, getUsers,
whoami, whoami,
getListOrigins, getListOrigins,
postSocialIssue postSocialIssue,
addScope,
removeScope,
}; };

View File

@ -88,6 +88,10 @@ export default {
socialIssue: { socialIssue: {
msg: 'confirm.socialIssue_not_valid', msg: 'confirm.socialIssue_not_valid',
anchor: '#section-50' anchor: '#section-50'
},
scopes: {
msg: 'confirm.set_a_scope',
anchor: '#section-65'
} }
} }
} }

View File

@ -0,0 +1,47 @@
<template>
<div class="vue-component">
<h2><a name="section-65"></a>{{ $t('scopes.title') }}</h2>
<ul>
<li v-for="s in scopes">
<input type="checkbox" v-model="checkedScopes" :value="s" />
{{ s.name.fr }}
</li>
</ul>
<div v-if="!isScopeValid" class="alert alert-warning separator">
{{ $t('scopes.add_at_least_one') }}
</div>
</div>
</template>
<script>
import { mapState, mapGetters } from 'vuex';
export default {
name: "Scopes",
computed: {
...mapState([
'scopes',
'scopesAtStart'
]),
...mapGetters([
'isScopeValid'
]),
checkedScopes: {
get: function() {
return this.$store.state.accompanyingCourse.scopes;
},
set: function(v) {
this.$store.dispatch('setScopes', v);
}
}
}
}
</script>
<style scoped>
</style>

View File

@ -86,6 +86,10 @@ const appMessages = {
person_locator: "Parcours localisé auprès de {0}", person_locator: "Parcours localisé auprès de {0}",
no_address: "Il n'y a pas d'adresse associée au parcours" no_address: "Il n'y a pas d'adresse associée au parcours"
}, },
scopes: {
title: "Services",
add_at_least_one: "Indiquez au moins un service",
},
referrer: { referrer: {
title: "Référent du parcours", title: "Référent du parcours",
label: "Vous pouvez choisir un TMS ou vous assigner directement comme référent", label: "Vous pouvez choisir un TMS ou vous assigner directement comme référent",
@ -113,6 +117,7 @@ const appMessages = {
participation_not_valid: "sélectionnez au minimum 1 usager", participation_not_valid: "sélectionnez au minimum 1 usager",
socialIssue_not_valid: "sélectionnez au minimum une problématique sociale", socialIssue_not_valid: "sélectionnez au minimum une problématique sociale",
location_not_valid: "indiquez au minimum une localisation temporaire du parcours", location_not_valid: "indiquez au minimum une localisation temporaire du parcours",
set_a_scope: "indiquez au moins un service",
sure: "Êtes-vous sûr ?", sure: "Êtes-vous sûr ?",
sure_description: "Une fois le changement confirmé, il ne sera plus possible de le remettre à l'état de brouillon !", sure_description: "Une fois le changement confirmé, il ne sera plus possible de le remettre à l'état de brouillon !",
ok: "Confirmer le parcours" ok: "Confirmer le parcours"

View File

@ -1,28 +1,41 @@
import 'es6-promise/auto'; import 'es6-promise/auto';
import { createStore } from 'vuex'; import { createStore } from 'vuex';
import { fetchScopes } from 'ChillMainAssets/lib/api/scope.js';
import { getAccompanyingCourse, import { getAccompanyingCourse,
patchAccompanyingCourse, patchAccompanyingCourse,
confirmAccompanyingCourse, confirmAccompanyingCourse,
postParticipation, postParticipation,
postRequestor, postRequestor,
postResource, postResource,
postSocialIssue } from '../api'; postSocialIssue,
addScope,
removeScope,
} from '../api';
const debug = process.env.NODE_ENV !== 'production'; const debug = process.env.NODE_ENV !== 'production';
const id = window.accompanyingCourseId; const id = window.accompanyingCourseId;
let initPromise = getAccompanyingCourse(id) let scopesPromise = fetchScopes();
.then(accompanying_course => new Promise((resolve, reject) => { let accompanyingCoursePromise = getAccompanyingCourse(id);
let initPromise = Promise.all([scopesPromise, accompanyingCoursePromise])
.then(([scopes, accompanyingCourse]) => new Promise((resolve, reject) => {
const store = createStore({ const store = createStore({
strict: debug, strict: debug,
modules: { modules: {
}, },
state: { state: {
accompanyingCourse: accompanying_course, accompanyingCourse: accompanyingCourse,
addressContext: {}, addressContext: {},
errorMsg: [] errorMsg: [],
// all the available scopes
scopes: scopes,
// the scopes at start. If the user remove all scopes, we re-add those scopes, by security
scopesAtStart: accompanyingCourse.scopes.map(scope => scope),
// the scope states at server side
scopesAtBackend: accompanyingCourse.scopes.map(scope => scope),
}, },
getters: { getters: {
isParticipationValid(state) { isParticipationValid(state) {
@ -34,11 +47,16 @@ let initPromise = getAccompanyingCourse(id)
isLocationValid(state) { isLocationValid(state) {
return state.accompanyingCourse.location !== null; return state.accompanyingCourse.location !== null;
}, },
isScopeValid(state) {
console.log('is scope valid', state.accompanyingCourse.scopes.length > 0);
return state.accompanyingCourse.scopes.length > 0;
},
validationKeys(state, getters) { validationKeys(state, getters) {
let keys = []; let keys = [];
if (!getters.isParticipationValid) { keys.push('participation'); } if (!getters.isParticipationValid) { keys.push('participation'); }
if (!getters.isLocationValid) { keys.push('location'); } if (!getters.isLocationValid) { keys.push('location'); }
if (!getters.isSocialIssueValid) { keys.push('socialIssue'); } if (!getters.isSocialIssueValid) { keys.push('socialIssue'); }
if (!getters.isScopeValid) { keys.push('scopes'); }
//console.log('getter keys', keys); //console.log('getter keys', keys);
return keys; return keys;
}, },
@ -137,6 +155,21 @@ let initPromise = getAccompanyingCourse(id)
setEditContextTrue(state) { setEditContextTrue(state) {
//console.log('### mutation: set edit context = true'); //console.log('### mutation: set edit context = true');
state.addressContext.edit = true; state.addressContext.edit = true;
},
setScopes(state, scopes) {
state.accompanyingCourse.scopes = scopes;
},
addScopeAtBackend(state, scope) {
let scopeIds = state.scopesAtBackend.map(s => s.id);
if (!scopeIds.includes(scope.id)) {
state.scopesAtBackend.push(scope);
}
},
removeScopeAtBackend(state, scope){
let scopeIds = state.scopesAtBackend.map(s => s.id);
if (scopeIds.includes(scope.id)) {
state.scopesAtBackend = state.scopesAtBackend.filter(s => s.id !== scope.id);
}
} }
}, },
actions: { actions: {
@ -223,6 +256,107 @@ let initPromise = getAccompanyingCourse(id)
resolve(); resolve();
})).catch((error) => { commit('catchError', error) }); })).catch((error) => { commit('catchError', error) });
}, },
/**
* Handle the checked/unchecked scopes
*
* When the user set the scopes in a invalid situation (when no scopes are cheched), this
* method will internally re-add the scopes as they were originally when the page was loaded, but
* this does not appears for the user (they remains unchecked). When the user re-add a scope, the
* scope is back in a valid state, and the store synchronize with the new state (all the original scopes
* are removed if necessary, and the new checked scopes is backed).
*
* So, for instance:
*
* at load:
*
* [x] scope A (at backend: [x])
* [x] scope B (at backend: [x])
* [ ] scope C (at backend: [ ])
*
* The user uncheck scope A:
*
* [ ] scope A (at backend: [ ] as soon as the operation finish)
* [x] scope B (at backend: [x])
* [ ] scope C (at backend: [ ])
*
* The user uncheck scope B. The state is invalid (no scope checked), so we go back to initial state when
* the page loaded):
*
* [ ] scope A (at backend: [x] as soon as the operation finish)
* [ ] scope B (at backend: [x] as soon as the operation finish)
* [ ] scope C (at backend: [ ])
*
* The user check scope C. The scopes are back to valid state. So we go back to synchronization with UI and
* backend):
*
* [ ] scope A (at backend: [ ] as soon as the operation finish)
* [ ] scope B (at backend: [ ] as soon as the operation finish)
* [x] scope C (at backend: [x] as soon as the operation finish)
*
* **Warning** There is a problem if the user check/uncheck faster than the backend is synchronized.
*
* @param commit
* @param state
* @param dispatch
* @param scopes
* @returns Promise
*/
setScopes({ commit, state, dispatch }, scopes) {
let currentServerScopesIds = state.scopesAtBackend.map(scope => scope.id);
let checkedScopesIds = scopes.map(scope => scope.id);
let removedScopesIds = currentServerScopesIds.filter(id => !checkedScopesIds.includes(id));
let addedScopesIds = checkedScopesIds.filter(id => !currentServerScopesIds.includes(id));
let lengthAfterOperation = currentServerScopesIds.length + addedScopesIds.length
- removedScopesIds.length;
if (lengthAfterOperation > 0 || (lengthAfterOperation === 0 && state.scopesAtStart.length === 0) ) {
return dispatch('updateScopes', {
addedScopesIds, removedScopesIds
}).then(() => {
// warning: when the operation of dispatch are too slow, the user may check / uncheck before
// the end of the synchronisation with the server (done by dispatch operation). Then, it leads to
// check/uncheck in the UI. I do not know of to avoid it.
commit('setScopes', scopes);
return Promise.resolve();
});
} else {
return dispatch('setScopes', state.scopesAtStart).then(() => {
commit('setScopes', scopes);
return Promise.resolve();
});
}
},
/**
* Internal function for the store to effectively update scopes.
*
* Return a promise which resolves when all update operation are
* successful and finished.
*
* @param state
* @param commit
* @param addedScopesIds
* @param removedScopesIds
* @return Promise
*/
updateScopes({ state, commit }, { addedScopesIds, removedScopesIds }) {
let promises = [];
state.scopes.forEach(scope => {
if (addedScopesIds.includes(scope.id)) {
promises.push(addScope(state.accompanyingCourse.id, scope).then(() => {
commit('addScopeAtBackend', scope);
return Promise.resolve();
}));
}
if (removedScopesIds.includes(scope.id)) {
promises.push(removeScope(state.accompanyingCourse.id, scope).then(() => {
commit('removeScopeAtBackend', scope);
return Promise.resolve();
}));
}
});
return Promise.all(promises);
},
postFirstComment({ commit }, payload) { postFirstComment({ commit }, payload) {
//console.log('## action: postFirstComment: payload', payload); //console.log('## action: postFirstComment: payload', payload);
patchAccompanyingCourse(id, { type: "accompanying_period", initialComment: payload }) patchAccompanyingCourse(id, { type: "accompanying_period", initialComment: payload })