diff --git a/admin/app/adapters/training.js b/admin/app/adapters/training.js
index b042f861223..0b8bb17534a 100644
--- a/admin/app/adapters/training.js
+++ b/admin/app/adapters/training.js
@@ -19,4 +19,9 @@ export default class TrainingAdapter extends ApplicationAdapter {
const url = `${this.host}/${this.namespace}/trainings/${trainingId}/target-profiles/${targetProfileId}`;
return this.ajax(url, 'DELETE');
}
+
+ async duplicate(trainingId) {
+ const url = `${this.host}/${this.namespace}/trainings/${trainingId}/duplicate`;
+ return this.ajax(url, 'POST');
+ }
}
diff --git a/admin/app/components/trainings/duplicate-training.gjs b/admin/app/components/trainings/duplicate-training.gjs
new file mode 100644
index 00000000000..70049deca6e
--- /dev/null
+++ b/admin/app/components/trainings/duplicate-training.gjs
@@ -0,0 +1,50 @@
+import PixButton from '@1024pix/pix-ui/components/pix-button';
+import PixModal from '@1024pix/pix-ui/components/pix-modal';
+import { action } from '@ember/object';
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { t } from 'ember-intl';
+
+export default class DuplicateTraining extends Component {
+ @tracked showModal = false;
+
+ @action
+ closeModal() {
+ this.showModal = false;
+ }
+
+ @action
+ openModal() {
+ this.showModal = true;
+ }
+
+ @action
+ validateDuplication() {
+ this.args.onSubmit();
+ this.showModal = false;
+ }
+
+
+ {{t
+ "pages.trainings.training.duplicate.button.label"
+ }}
+
+
+ <:content>
+
+ {{t "pages.trainings.training.duplicate.modal.instruction"}}
+
+
+ <:footer>
+
+ {{t "common.actions.cancel"}}
+
+ {{t "common.actions.validate"}}
+
+
+
+}
diff --git a/admin/app/controllers/authenticated/trainings/training.js b/admin/app/controllers/authenticated/trainings/training.js
index f004243e0ac..b4a44f7540b 100644
--- a/admin/app/controllers/authenticated/trainings/training.js
+++ b/admin/app/controllers/authenticated/trainings/training.js
@@ -4,8 +4,11 @@ import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
export default class Training extends Controller {
+ @service store;
@service pixToast;
@service accessControl;
+ @service router;
+ @service intl;
@tracked isEditMode = false;
@@ -31,4 +34,25 @@ export default class Training extends Controller {
this.pixToast.sendErrorNotification({ message: 'Une erreur est survenue.' });
}
}
+
+ @action
+ async duplicateTraining() {
+ try {
+ const adapter = this.store.adapterFor('training');
+ const { trainingId: newTrainingId } = await adapter.duplicate(this.model.id);
+ this.goToNewTrainingDetails(newTrainingId);
+ this.pixToast.sendSuccessNotification({
+ message: this.intl.t('pages.trainings.training.duplicate.notifications.success'),
+ });
+ } catch (error) {
+ error.errors.forEach((apiError) => {
+ this.pixToast.sendErrorNotification({ message: apiError.detail });
+ });
+ }
+ }
+
+ @action
+ goToNewTrainingDetails(newTrainingId) {
+ this.router.transitionTo('authenticated.trainings.training', newTrainingId);
+ }
}
diff --git a/admin/app/styles/components/trainings/training-details-card.scss b/admin/app/styles/components/trainings/training-details-card.scss
index ef97b45ece9..9bf1b290a5d 100644
--- a/admin/app/styles/components/trainings/training-details-card.scss
+++ b/admin/app/styles/components/trainings/training-details-card.scss
@@ -32,4 +32,10 @@
width: 100%;
height: 100%;
}
+
+ &__actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 16px;
+ }
}
diff --git a/admin/app/templates/authenticated/trainings/training.hbs b/admin/app/templates/authenticated/trainings/training.hbs
index 5d8f17154fe..2f216be935c 100644
--- a/admin/app/templates/authenticated/trainings/training.hbs
+++ b/admin/app/templates/authenticated/trainings/training.hbs
@@ -35,8 +35,11 @@
{{else}}
{{#if this.canEdit}}
- Modifier
-
+
+
{{t "common.actions.edit"}}
+
+
+
{{/if}}
{{/if}}
diff --git a/admin/mirage/config.js b/admin/mirage/config.js
index 34caf87eba2..d08e9ce3b17 100644
--- a/admin/mirage/config.js
+++ b/admin/mirage/config.js
@@ -49,6 +49,7 @@ import {
createOrUpdateTrainingTrigger,
createTraining,
detachTargetProfileFromTraining,
+ duplicateTraining,
findPaginatedTrainingSummaries,
getTargetProfileSummariesForTraining,
getTraining,
@@ -398,6 +399,7 @@ function routes() {
this.post('/admin/trainings/:id/attach-target-profiles', attachTargetProfilesToTraining);
this.delete('/admin/trainings/:trainingId/target-profiles/:targetProfileId', detachTargetProfileFromTraining);
this.put('/admin/trainings/:id/triggers', createOrUpdateTrainingTrigger);
+ this.post('/admin/trainings/:id/duplicate', duplicateTraining);
this.get('/admin/certifications/:id');
this.get('/admin/certifications/:id/certified-profile', (schema, request) => {
diff --git a/admin/mirage/handlers/trainings.js b/admin/mirage/handlers/trainings.js
index 2c112c4ac55..cd632ee82a1 100644
--- a/admin/mirage/handlers/trainings.js
+++ b/admin/mirage/handlers/trainings.js
@@ -134,11 +134,24 @@ function createOrUpdateTrainingTrigger(schema, request) {
return trainingTrigger;
}
+function duplicateTraining(schema, request) {
+ const trainingId = request.params.id;
+ const training = schema.trainings.find(trainingId);
+ delete training.attrs.id;
+
+ const { id } = schema.create('training', {
+ ...training.attrs,
+ internalTitle: '[Copie] ' + training.attrs.internalTitle,
+ });
+ return { trainingId: id };
+}
+
export {
attachTargetProfilesToTraining,
createOrUpdateTrainingTrigger,
createTraining,
detachTargetProfileFromTraining,
+ duplicateTraining,
findPaginatedTrainingSummaries,
getTargetProfileSummariesForTraining,
getTraining,
diff --git a/admin/tests/acceptance/authenticated/trainings/training-test.js b/admin/tests/acceptance/authenticated/trainings/training-test.js
index 30e110d3570..c5d42e133c8 100644
--- a/admin/tests/acceptance/authenticated/trainings/training-test.js
+++ b/admin/tests/acceptance/authenticated/trainings/training-test.js
@@ -173,6 +173,25 @@ module('Acceptance | Trainings | Training', function (hooks) {
// then
assert.dom(screen.getByRole('heading', { name: 'Mon titre interne' })).exists();
});
+
+ test('should be possible to duplicate displayed training', async function (assert) {
+ // given
+ await authenticateAdminMemberWithRole({ isSuperAdmin: true })(server);
+ await visit(`/trainings/${trainingId}`);
+
+ // when
+ await clickByName('Dupliquer ce contenu formatif');
+ await screen.findByRole('button', { name: 'Valider' });
+ await clickByName('Valider');
+
+ // then
+ const title = await screen.findByRole('heading', {
+ name: `[Copie] Apprendre à piloter des chauves-souris comme Batman`,
+ level: 1,
+ });
+ assert.dom(title).exists();
+ assert.strictEqual(currentURL(), '/trainings/3/triggers');
+ });
});
module('when admin role is "SUPPORT', function () {
diff --git a/admin/tests/integration/components/trainings/duplicate-training-test.gjs b/admin/tests/integration/components/trainings/duplicate-training-test.gjs
new file mode 100644
index 00000000000..9d5ea098fdf
--- /dev/null
+++ b/admin/tests/integration/components/trainings/duplicate-training-test.gjs
@@ -0,0 +1,38 @@
+import { clickByText, render } from '@1024pix/ember-testing-library';
+import { click } from '@ember/test-helpers';
+import DuplicateTraining from 'pix-admin/components/trainings/duplicate-training';
+import { module, test } from 'qunit';
+import sinon from 'sinon';
+
+import setupIntlRenderingTest from '../../../helpers/setup-intl-rendering';
+
+module('Integration | Component | Trainings | Duplicate training', function (hooks) {
+ setupIntlRenderingTest(hooks);
+
+ const duplicateTraining = sinon.stub().resolves(true);
+
+ test('it should render the duplicate training modal', async function (assert) {
+ // when
+ const screen = await render();
+ const duplicateButton = screen.getByRole('button', { name: 'Dupliquer ce contenu formatif' });
+ await click(duplicateButton);
+
+ // then
+ assert.dom(await screen.findByText('Dupliquer le contenu formatif ?')).exists();
+ assert.dom(screen.getByText('Cette action dupliquera le contenu formatif avec ses déclencheurs.')).exists();
+ assert.dom(await screen.findByRole('button', { name: 'Valider' })).exists();
+ assert.dom(screen.getByRole('button', { name: 'Annuler' })).exists();
+ assert.dom(screen.getByRole('button', { name: 'Fermer' })).exists();
+ });
+
+ test('it should call the duplicate method on click on submit', async function (assert) {
+ await render();
+
+ // when
+ await clickByText('Valider');
+
+ // then
+ sinon.assert.calledOnce(duplicateTraining);
+ assert.ok(true);
+ });
+});
diff --git a/admin/tests/unit/adapters/training-test.js b/admin/tests/unit/adapters/training-test.js
index e41d9fe5463..7986f9e0042 100644
--- a/admin/tests/unit/adapters/training-test.js
+++ b/admin/tests/unit/adapters/training-test.js
@@ -44,4 +44,19 @@ module('Unit | Adapter | Training ', function (hooks) {
assert.ok(true);
});
});
+ module('#duplicate', function () {
+ test('should trigger an ajax call with the right url and method', async function (assert) {
+ // given
+ const trainingId = 1;
+ sinon.stub(adapter, 'ajax').resolves();
+ const expectedUrl = `http://localhost:3000/api/admin/trainings/${trainingId}/duplicate`;
+
+ // when
+ await adapter.duplicate(trainingId);
+
+ // then
+ sinon.assert.calledWith(adapter.ajax, expectedUrl, 'POST');
+ assert.ok(true);
+ });
+ });
});
diff --git a/admin/translations/fr.json b/admin/translations/fr.json
index 6a3cc04e458..e7b23c15a37 100644
--- a/admin/translations/fr.json
+++ b/admin/translations/fr.json
@@ -848,6 +848,18 @@
"status": "Statut :",
"title": "Titre public :"
},
+ "duplicate": {
+ "button": {
+ "label": "Dupliquer ce contenu formatif"
+ },
+ "modal": {
+ "instruction": "Cette action dupliquera le contenu formatif avec ses déclencheurs.",
+ "title": "Dupliquer le contenu formatif ?"
+ },
+ "notifications": {
+ "success": "Le contenu formatif a bien été dupliqué !"
+ }
+ },
"list": {
"caption": "Liste des contenus formatifs",
"goalThreshold": "Objectif à ne pas dépasser",
diff --git a/api/src/devcomp/infrastructure/repositories/training-repository.js b/api/src/devcomp/infrastructure/repositories/training-repository.js
index 3799616f956..cf6c863916c 100644
--- a/api/src/devcomp/infrastructure/repositories/training-repository.js
+++ b/api/src/devcomp/infrastructure/repositories/training-repository.js
@@ -133,6 +133,9 @@ async function findWithTriggersByCampaignParticipationIdAndLocale({ campaignPart
async function create({ training }) {
const knexConn = DomainTransaction.getConnection();
+ if (typeof training.duration !== 'string') {
+ training.duration = _transformDurationFormat(training.duration);
+ }
const pickedAttributes = pick(training, [
'title',
'internalTitle',
@@ -186,6 +189,10 @@ async function findPaginatedByUserId({ userId, locale, page }) {
return { userRecommendedTrainings, pagination };
}
+function _transformDurationFormat(durationObject) {
+ return `${durationObject.days ?? 0}d${durationObject.hours ?? 0}h${durationObject.minutes ?? 0}m${durationObject.seconds ?? 0}s`;
+}
+
export {
create,
findPaginatedByUserId,
diff --git a/api/tests/devcomp/integration/infrastructure/repositories/training-repository_test.js b/api/tests/devcomp/integration/infrastructure/repositories/training-repository_test.js
index 5e58fa0086a..cd847d22b3e 100644
--- a/api/tests/devcomp/integration/infrastructure/repositories/training-repository_test.js
+++ b/api/tests/devcomp/integration/infrastructure/repositories/training-repository_test.js
@@ -616,6 +616,33 @@ describe('Integration | Repository | training-repository', function () {
expect(createdTraining.id).to.exist;
expect(createdTraining).to.deep.include({ ...training, duration: { hours: 6 } });
});
+
+ it('should handle other duration‘s format', async function () {
+ // given
+ const training = {
+ title: 'Titre du training',
+ internalTitle: 'Titre interne du training',
+ link: 'https://training-link.org',
+ type: 'webinaire',
+ duration: {
+ hours: 5,
+ minutes: 30,
+ },
+ locale: 'fr',
+ editorName: 'Un ministère',
+ editorLogoUrl: 'https://mon-logo.svg',
+ };
+
+ // when
+ const createdTraining = await trainingRepository.create({
+ training,
+ });
+
+ // then
+ expect(createdTraining).to.be.instanceOf(TrainingForAdmin);
+ expect(createdTraining.id).to.exist;
+ expect(createdTraining).to.deep.include({ ...training, duration: { hours: 5, minutes: 30 } });
+ });
});
describe('#update', function () {