Skip to content

Commit c0003e6

Browse files
[FEATURE] PixAdmin : pouvoir dupliquer les contenus formatifs (PIX-16670)(PIX-16075)
#11504
2 parents 1861e01 + 304099d commit c0003e6

File tree

13 files changed

+223
-2
lines changed

13 files changed

+223
-2
lines changed

admin/app/adapters/training.js

+5
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,9 @@ export default class TrainingAdapter extends ApplicationAdapter {
1919
const url = `${this.host}/${this.namespace}/trainings/${trainingId}/target-profiles/${targetProfileId}`;
2020
return this.ajax(url, 'DELETE');
2121
}
22+
23+
async duplicate(trainingId) {
24+
const url = `${this.host}/${this.namespace}/trainings/${trainingId}/duplicate`;
25+
return this.ajax(url, 'POST');
26+
}
2227
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import PixButton from '@1024pix/pix-ui/components/pix-button';
2+
import PixModal from '@1024pix/pix-ui/components/pix-modal';
3+
import { action } from '@ember/object';
4+
import Component from '@glimmer/component';
5+
import { tracked } from '@glimmer/tracking';
6+
import { t } from 'ember-intl';
7+
8+
export default class DuplicateTraining extends Component {
9+
@tracked showModal = false;
10+
11+
@action
12+
closeModal() {
13+
this.showModal = false;
14+
}
15+
16+
@action
17+
openModal() {
18+
this.showModal = true;
19+
}
20+
21+
@action
22+
validateDuplication() {
23+
this.args.onSubmit();
24+
this.showModal = false;
25+
}
26+
27+
<template>
28+
<PixButton @size="small" @variant="primary" @triggerAction={{this.openModal}}>{{t
29+
"pages.trainings.training.duplicate.button.label"
30+
}}
31+
</PixButton>
32+
<PixModal
33+
@title={{t "pages.trainings.training.duplicate.modal.title"}}
34+
@showModal={{this.showModal}}
35+
@onCloseButtonClick={{this.closeModal}}
36+
>
37+
<:content>
38+
<p>
39+
{{t "pages.trainings.training.duplicate.modal.instruction"}}
40+
</p>
41+
</:content>
42+
<:footer>
43+
<PixButton @variant="secondary" @isBorderVisible={{true}} @triggerAction={{this.closeModal}}>
44+
{{t "common.actions.cancel"}}
45+
</PixButton>
46+
<PixButton @triggerAction={{this.validateDuplication}}>{{t "common.actions.validate"}}</PixButton>
47+
</:footer>
48+
</PixModal>
49+
</template>
50+
}

admin/app/controllers/authenticated/trainings/training.js

+24
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@ import { service } from '@ember/service';
44
import { tracked } from '@glimmer/tracking';
55

66
export default class Training extends Controller {
7+
@service store;
78
@service pixToast;
89
@service accessControl;
10+
@service router;
11+
@service intl;
912

1013
@tracked isEditMode = false;
1114

@@ -31,4 +34,25 @@ export default class Training extends Controller {
3134
this.pixToast.sendErrorNotification({ message: 'Une erreur est survenue.' });
3235
}
3336
}
37+
38+
@action
39+
async duplicateTraining() {
40+
try {
41+
const adapter = this.store.adapterFor('training');
42+
const { trainingId: newTrainingId } = await adapter.duplicate(this.model.id);
43+
this.goToNewTrainingDetails(newTrainingId);
44+
this.pixToast.sendSuccessNotification({
45+
message: this.intl.t('pages.trainings.training.duplicate.notifications.success'),
46+
});
47+
} catch (error) {
48+
error.errors.forEach((apiError) => {
49+
this.pixToast.sendErrorNotification({ message: apiError.detail });
50+
});
51+
}
52+
}
53+
54+
@action
55+
goToNewTrainingDetails(newTrainingId) {
56+
this.router.transitionTo('authenticated.trainings.training', newTrainingId);
57+
}
3458
}

admin/app/styles/components/trainings/training-details-card.scss

+6
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,10 @@
3232
width: 100%;
3333
height: 100%;
3434
}
35+
36+
&__actions {
37+
display: flex;
38+
flex-wrap: wrap;
39+
gap: 16px;
40+
}
3541
}

admin/app/templates/authenticated/trainings/training.hbs

+5-2
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,11 @@
3535
{{else}}
3636
<Trainings::TrainingDetailsCard @training={{@model}} />
3737
{{#if this.canEdit}}
38-
<PixButton @size="small" @variant="secondary" @triggerAction={{this.toggleEditMode}}>Modifier
39-
</PixButton>
38+
<div class="training-details-card__actions">
39+
<PixButton @size="small" @variant="primary" @triggerAction={{this.toggleEditMode}}>{{t "common.actions.edit"}}
40+
</PixButton>
41+
<Trainings::DuplicateTraining @onSubmit={{this.duplicateTraining}} />
42+
</div>
4043
{{/if}}
4144
{{/if}}
4245
</section>

admin/mirage/config.js

+2
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
createOrUpdateTrainingTrigger,
5050
createTraining,
5151
detachTargetProfileFromTraining,
52+
duplicateTraining,
5253
findPaginatedTrainingSummaries,
5354
getTargetProfileSummariesForTraining,
5455
getTraining,
@@ -398,6 +399,7 @@ function routes() {
398399
this.post('/admin/trainings/:id/attach-target-profiles', attachTargetProfilesToTraining);
399400
this.delete('/admin/trainings/:trainingId/target-profiles/:targetProfileId', detachTargetProfileFromTraining);
400401
this.put('/admin/trainings/:id/triggers', createOrUpdateTrainingTrigger);
402+
this.post('/admin/trainings/:id/duplicate', duplicateTraining);
401403

402404
this.get('/admin/certifications/:id');
403405
this.get('/admin/certifications/:id/certified-profile', (schema, request) => {

admin/mirage/handlers/trainings.js

+13
Original file line numberDiff line numberDiff line change
@@ -134,11 +134,24 @@ function createOrUpdateTrainingTrigger(schema, request) {
134134
return trainingTrigger;
135135
}
136136

137+
function duplicateTraining(schema, request) {
138+
const trainingId = request.params.id;
139+
const training = schema.trainings.find(trainingId);
140+
delete training.attrs.id;
141+
142+
const { id } = schema.create('training', {
143+
...training.attrs,
144+
internalTitle: '[Copie] ' + training.attrs.internalTitle,
145+
});
146+
return { trainingId: id };
147+
}
148+
137149
export {
138150
attachTargetProfilesToTraining,
139151
createOrUpdateTrainingTrigger,
140152
createTraining,
141153
detachTargetProfileFromTraining,
154+
duplicateTraining,
142155
findPaginatedTrainingSummaries,
143156
getTargetProfileSummariesForTraining,
144157
getTraining,

admin/tests/acceptance/authenticated/trainings/training-test.js

+19
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,25 @@ module('Acceptance | Trainings | Training', function (hooks) {
173173
// then
174174
assert.dom(screen.getByRole('heading', { name: 'Mon titre interne' })).exists();
175175
});
176+
177+
test('should be possible to duplicate displayed training', async function (assert) {
178+
// given
179+
await authenticateAdminMemberWithRole({ isSuperAdmin: true })(server);
180+
await visit(`/trainings/${trainingId}`);
181+
182+
// when
183+
await clickByName('Dupliquer ce contenu formatif');
184+
await screen.findByRole('button', { name: 'Valider' });
185+
await clickByName('Valider');
186+
187+
// then
188+
const title = await screen.findByRole('heading', {
189+
name: `[Copie] Apprendre à piloter des chauves-souris comme Batman`,
190+
level: 1,
191+
});
192+
assert.dom(title).exists();
193+
assert.strictEqual(currentURL(), '/trainings/3/triggers');
194+
});
176195
});
177196

178197
module('when admin role is "SUPPORT', function () {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { clickByText, render } from '@1024pix/ember-testing-library';
2+
import { click } from '@ember/test-helpers';
3+
import DuplicateTraining from 'pix-admin/components/trainings/duplicate-training';
4+
import { module, test } from 'qunit';
5+
import sinon from 'sinon';
6+
7+
import setupIntlRenderingTest from '../../../helpers/setup-intl-rendering';
8+
9+
module('Integration | Component | Trainings | Duplicate training', function (hooks) {
10+
setupIntlRenderingTest(hooks);
11+
12+
const duplicateTraining = sinon.stub().resolves(true);
13+
14+
test('it should render the duplicate training modal', async function (assert) {
15+
// when
16+
const screen = await render(<template><DuplicateTraining @onSubmit={{duplicateTraining}} /></template>);
17+
const duplicateButton = screen.getByRole('button', { name: 'Dupliquer ce contenu formatif' });
18+
await click(duplicateButton);
19+
20+
// then
21+
assert.dom(await screen.findByText('Dupliquer le contenu formatif ?')).exists();
22+
assert.dom(screen.getByText('Cette action dupliquera le contenu formatif avec ses déclencheurs.')).exists();
23+
assert.dom(await screen.findByRole('button', { name: 'Valider' })).exists();
24+
assert.dom(screen.getByRole('button', { name: 'Annuler' })).exists();
25+
assert.dom(screen.getByRole('button', { name: 'Fermer' })).exists();
26+
});
27+
28+
test('it should call the duplicate method on click on submit', async function (assert) {
29+
await render(<template><DuplicateTraining @onSubmit={{duplicateTraining}} /></template>);
30+
31+
// when
32+
await clickByText('Valider');
33+
34+
// then
35+
sinon.assert.calledOnce(duplicateTraining);
36+
assert.ok(true);
37+
});
38+
});

admin/tests/unit/adapters/training-test.js

+15
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,19 @@ module('Unit | Adapter | Training ', function (hooks) {
4444
assert.ok(true);
4545
});
4646
});
47+
module('#duplicate', function () {
48+
test('should trigger an ajax call with the right url and method', async function (assert) {
49+
// given
50+
const trainingId = 1;
51+
sinon.stub(adapter, 'ajax').resolves();
52+
const expectedUrl = `http://localhost:3000/api/admin/trainings/${trainingId}/duplicate`;
53+
54+
// when
55+
await adapter.duplicate(trainingId);
56+
57+
// then
58+
sinon.assert.calledWith(adapter.ajax, expectedUrl, 'POST');
59+
assert.ok(true);
60+
});
61+
});
4762
});

admin/translations/fr.json

+12
Original file line numberDiff line numberDiff line change
@@ -848,6 +848,18 @@
848848
"status": "Statut :",
849849
"title": "Titre public :"
850850
},
851+
"duplicate": {
852+
"button": {
853+
"label": "Dupliquer ce contenu formatif"
854+
},
855+
"modal": {
856+
"instruction": "Cette action dupliquera le contenu formatif avec ses déclencheurs.",
857+
"title": "Dupliquer le contenu formatif ?"
858+
},
859+
"notifications": {
860+
"success": "Le contenu formatif a bien été dupliqué !"
861+
}
862+
},
851863
"list": {
852864
"caption": "Liste des contenus formatifs",
853865
"goalThreshold": "Objectif à ne pas dépasser",

api/src/devcomp/infrastructure/repositories/training-repository.js

+7
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,9 @@ async function findWithTriggersByCampaignParticipationIdAndLocale({ campaignPart
133133

134134
async function create({ training }) {
135135
const knexConn = DomainTransaction.getConnection();
136+
if (typeof training.duration !== 'string') {
137+
training.duration = _transformDurationFormat(training.duration);
138+
}
136139
const pickedAttributes = pick(training, [
137140
'title',
138141
'internalTitle',
@@ -186,6 +189,10 @@ async function findPaginatedByUserId({ userId, locale, page }) {
186189
return { userRecommendedTrainings, pagination };
187190
}
188191

192+
function _transformDurationFormat(durationObject) {
193+
return `${durationObject.days ?? 0}d${durationObject.hours ?? 0}h${durationObject.minutes ?? 0}m${durationObject.seconds ?? 0}s`;
194+
}
195+
189196
export {
190197
create,
191198
findPaginatedByUserId,

api/tests/devcomp/integration/infrastructure/repositories/training-repository_test.js

+27
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,33 @@ describe('Integration | Repository | training-repository', function () {
616616
expect(createdTraining.id).to.exist;
617617
expect(createdTraining).to.deep.include({ ...training, duration: { hours: 6 } });
618618
});
619+
620+
it('should handle other duration‘s format', async function () {
621+
// given
622+
const training = {
623+
title: 'Titre du training',
624+
internalTitle: 'Titre interne du training',
625+
link: 'https://training-link.org',
626+
type: 'webinaire',
627+
duration: {
628+
hours: 5,
629+
minutes: 30,
630+
},
631+
locale: 'fr',
632+
editorName: 'Un ministère',
633+
editorLogoUrl: 'https://mon-logo.svg',
634+
};
635+
636+
// when
637+
const createdTraining = await trainingRepository.create({
638+
training,
639+
});
640+
641+
// then
642+
expect(createdTraining).to.be.instanceOf(TrainingForAdmin);
643+
expect(createdTraining.id).to.exist;
644+
expect(createdTraining).to.deep.include({ ...training, duration: { hours: 5, minutes: 30 } });
645+
});
619646
});
620647

621648
describe('#update', function () {

0 commit comments

Comments
 (0)