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

[FEATURE] PixAdmin : pouvoir dupliquer les contenus formatifs (PIX-16670)(PIX-16075) #11504

Merged
merged 6 commits into from
Feb 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions admin/app/adapters/training.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
}
50 changes: 50 additions & 0 deletions admin/app/components/trainings/duplicate-training.gjs
Original file line number Diff line number Diff line change
@@ -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;
}

<template>
<PixButton @size="small" @variant="primary" @triggerAction={{this.openModal}}>{{t
"pages.trainings.training.duplicate.button.label"
}}
</PixButton>
<PixModal
@title={{t "pages.trainings.training.duplicate.modal.title"}}
@showModal={{this.showModal}}
@onCloseButtonClick={{this.closeModal}}
>
<:content>
<p>
{{t "pages.trainings.training.duplicate.modal.instruction"}}
</p>
</:content>
<:footer>
<PixButton @variant="secondary" @isBorderVisible={{true}} @triggerAction={{this.closeModal}}>
{{t "common.actions.cancel"}}
</PixButton>
<PixButton @triggerAction={{this.validateDuplication}}>{{t "common.actions.validate"}}</PixButton>
</:footer>
</PixModal>
</template>
}
24 changes: 24 additions & 0 deletions admin/app/controllers/authenticated/trainings/training.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,10 @@
width: 100%;
height: 100%;
}

&__actions {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
}
7 changes: 5 additions & 2 deletions admin/app/templates/authenticated/trainings/training.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,11 @@
{{else}}
<Trainings::TrainingDetailsCard @training={{@model}} />
{{#if this.canEdit}}
<PixButton @size="small" @variant="secondary" @triggerAction={{this.toggleEditMode}}>Modifier
</PixButton>
<div class="training-details-card__actions">
<PixButton @size="small" @variant="primary" @triggerAction={{this.toggleEditMode}}>{{t "common.actions.edit"}}
</PixButton>
<Trainings::DuplicateTraining @onSubmit={{this.duplicateTraining}} />
</div>
{{/if}}
{{/if}}
</section>
Expand Down
2 changes: 2 additions & 0 deletions admin/mirage/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
createOrUpdateTrainingTrigger,
createTraining,
detachTargetProfileFromTraining,
duplicateTraining,
findPaginatedTrainingSummaries,
getTargetProfileSummariesForTraining,
getTraining,
Expand Down Expand Up @@ -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) => {
Expand Down
13 changes: 13 additions & 0 deletions admin/mirage/handlers/trainings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions admin/tests/acceptance/authenticated/trainings/training-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<template><DuplicateTraining @onSubmit={{duplicateTraining}} /></template>);
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(<template><DuplicateTraining @onSubmit={{duplicateTraining}} /></template>);

// when
await clickByText('Valider');

// then
sinon.assert.calledOnce(duplicateTraining);
assert.ok(true);
});
});
15 changes: 15 additions & 0 deletions admin/tests/unit/adapters/training-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
12 changes: 12 additions & 0 deletions admin/translations/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down