diff --git a/app/frontend/src/javascript/api/training.ts b/app/frontend/src/javascript/api/training.ts index 8249be2ff..7f53a1497 100644 --- a/app/frontend/src/javascript/api/training.ts +++ b/app/frontend/src/javascript/api/training.ts @@ -8,4 +8,24 @@ export default class TrainingAPI { const res: AxiosResponse> = await apiClient.get(`/api/trainings${ApiLib.filtersToQuery(filters)}`); return res?.data; } + + static async create (training: Training): Promise { + const data = ApiLib.serializeAttachments(training, 'training', ['training_image_attributes']); + const res: AxiosResponse = await apiClient.post('/api/trainings', data, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + return res?.data; + } + + static async update (training: Training): Promise { + const data = ApiLib.serializeAttachments(training, 'training', ['training_image_attributes']); + const res: AxiosResponse = await apiClient.put(`/api/trainings/${training.id}`, data, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + return res?.data; + } } diff --git a/app/frontend/src/javascript/components/trainings/training-form.tsx b/app/frontend/src/javascript/components/trainings/training-form.tsx new file mode 100644 index 000000000..d3f9bba7d --- /dev/null +++ b/app/frontend/src/javascript/components/trainings/training-form.tsx @@ -0,0 +1,130 @@ +import React, { useEffect, useState } from 'react'; +import { SubmitHandler, useForm, useWatch } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { FormInput } from '../form/form-input'; +import { FormImageUpload } from '../form/form-image-upload'; +import { IApplication } from '../../models/application'; +import { Loader } from '../base/loader'; +import { react2angular } from 'react2angular'; +import { ErrorBoundary } from '../base/error-boundary'; +import { FormRichText } from '../form/form-rich-text'; +import { FormSwitch } from '../form/form-switch'; +import { FabButton } from '../base/fab-button'; +import { Training } from '../../models/training'; +import TrainingAPI from '../../api/training'; +import { FormMultiSelect } from '../form/form-multi-select'; +import MachineAPI from '../../api/machine'; +import { Machine } from '../../models/machine'; +import { SelectOption } from '../../models/select'; +import SettingAPI from '../../api/setting'; +import { Setting } from '../../models/setting'; + +declare const Application: IApplication; + +interface TrainingFormProps { + action: 'create' | 'update', + training?: Training, + onError: (message: string) => void, + onSuccess: (message: string) => void, +} + +/** + * Form to edit or create trainings + */ +export const TrainingForm: React.FC = ({ action, training, onError, onSuccess }) => { + const [machineModule, setMachineModule] = useState(null); + const { handleSubmit, register, control, setValue, formState } = useForm({ defaultValues: { ...training } }); + const output = useWatch({ control }); + const { t } = useTranslation('admin'); + + useEffect(() => { + SettingAPI.get('machines_module').then(setMachineModule).catch(onError); + }, []); + + /** + * Callback triggered when the user validates the machine form: handle create or update + */ + const onSubmit: SubmitHandler = (data: Training) => { + TrainingAPI[action](data).then((res) => { + onSuccess(t(`app.admin.training_form.${action}_success`)); + window.location.href = `/#!/trainings/${res.slug}`; + }).catch(error => { + onError(error); + }); + }; + + /** + * Convert a machine to an option usable by react-select + */ + const machineToOption = (machine: Machine): SelectOption => { + return { value: machine.id, label: machine.name }; + }; + + /** + * Asynchronously load the full list of enabled machines to display in the drop-down select field + */ + const loadMachines = (inputValue: string, callback: (options: Array>) => void): void => { + MachineAPI.index({ disabled: false }).then(data => { + callback(data.map(m => machineToOption(m))); + }).catch(error => onError(error)); + }; + + return ( +
+ + + + {machineModule?.value === 'true' && } + + + + + {t('app.admin.training_form.ACTION_training', { ACTION: action })} + + + ); +}; + +const TrainingFormWrapper: React.FC = (props) => { + return ( + + + + + + ); +}; + +Application.Components.component('trainingForm', react2angular(TrainingFormWrapper, ['action', 'training', 'onError', 'onSuccess'])); diff --git a/app/frontend/src/javascript/controllers/admin/trainings.js b/app/frontend/src/javascript/controllers/admin/trainings.js index 6342f26fd..40d51e693 100644 --- a/app/frontend/src/javascript/controllers/admin/trainings.js +++ b/app/frontend/src/javascript/controllers/admin/trainings.js @@ -81,20 +81,23 @@ class TrainingsController { /** * Controller used in the training creation page (admin) */ -Application.Controllers.controller('NewTrainingController', ['$scope', '$state', 'machinesPromise', 'settingsPromise', 'CSRF', - function ($scope, $state, machinesPromise, settingsPromise, CSRF) { - /* PUBLIC SCOPE */ +Application.Controllers.controller('NewTrainingController', ['$scope', '$state', 'CSRF', 'growl', + function ($scope, $state, CSRF, growl) { + /* PUBLIC SCOPE */ - // Form action on the following URL - $scope.method = 'post'; + /** + * Callback triggered by react components + */ + $scope.onSuccess = function (message) { + growl.success(message); + }; - // API URL where the form will be posted - $scope.actionUrl = '/api/trainings/'; - - // list of machines - $scope.machines = machinesPromise; - - $scope.enableMachinesModule = settingsPromise.machines_module === 'true'; + /** + * Callback triggered by react components + */ + $scope.onError = function (message) { + growl.error(message); + }; /* PRIVATE SCOPE */ @@ -116,23 +119,26 @@ Application.Controllers.controller('NewTrainingController', ['$scope', '$state', /** * Controller used in the training edition page (admin) */ -Application.Controllers.controller('EditTrainingController', ['$scope', '$state', '$transition$', 'trainingPromise', 'machinesPromise', 'settingsPromise', 'CSRF', - function ($scope, $state, $transition$, trainingPromise, machinesPromise, settingsPromise, CSRF) { - /* PUBLIC SCOPE */ - - // Form action on the following URL - $scope.method = 'patch'; - - // API URL where the form will be posted - $scope.actionUrl = `/api/trainings/${$transition$.params().id}`; +Application.Controllers.controller('EditTrainingController', ['$scope', '$state', '$transition$', 'trainingPromise', 'CSRF', 'growl', + function ($scope, $state, $transition$, trainingPromise, CSRF, growl) { + /* PUBLIC SCOPE */ // Details of the training to edit (id in URL) - $scope.training = trainingPromise; + $scope.training = cleanTraining(trainingPromise); - // list of machines - $scope.machines = machinesPromise; + /** + * Callback triggered by react components + */ + $scope.onSuccess = function (message) { + growl.success(message); + }; - $scope.enableMachinesModule = settingsPromise.machines_module === 'true'; + /** + * Callback triggered by react components + */ + $scope.onError = function (message) { + growl.error(message); + }; /* PRIVATE SCOPE */ @@ -146,6 +152,13 @@ Application.Controllers.controller('EditTrainingController', ['$scope', '$state' return new TrainingsController($scope, $state); }; + // prepare the training for the react-hook-form + function cleanTraining (training) { + delete training.$promise; + delete training.$resolved; + return training; + } + // !!! MUST BE CALLED AT THE END of the controller return initialize(); } diff --git a/app/frontend/src/javascript/controllers/machines.js.erb b/app/frontend/src/javascript/controllers/machines.js.erb index e74b8bbbf..92e293a45 100644 --- a/app/frontend/src/javascript/controllers/machines.js.erb +++ b/app/frontend/src/javascript/controllers/machines.js.erb @@ -234,16 +234,6 @@ Application.Controllers.controller('MachinesController', ['$scope', '$state', '_ Application.Controllers.controller('NewMachineController', ['$scope', '$state', 'CSRF', 'growl', function ($scope, $state, CSRF, growl) { CSRF.setMetaTags(); - // API URL where the form will be posted - $scope.actionUrl = '/api/machines/'; - - // Form action on the above URL - $scope.method = 'post'; - - // default machine parameters - $scope.machine = - { machine_files_attributes: [] }; - /** * Shows an error message forwarded from a child component */ @@ -268,13 +258,7 @@ Application.Controllers.controller('NewMachineController', ['$scope', '$state', */ Application.Controllers.controller('EditMachineController', ['$scope', '$state', '$transition$', 'machinePromise', 'CSRF', 'growl', function ($scope, $state, $transition$, machinePromise, CSRF, growl) { - /* PUBLIC SCOPE */ - - // API URL where the form will be posted - $scope.actionUrl = `/api/machines/${$transition$.params().id}`; - - // Form action on the above URL - $scope.method = 'put'; + /* PUBLIC SCOPE */ // Retrieve the details for the machine id in the URL, if an error occurs redirect the user to the machines list $scope.machine = cleanMachine(machinePromise); diff --git a/app/frontend/src/javascript/models/training.ts b/app/frontend/src/javascript/models/training.ts index 02956b11f..45f3fc916 100644 --- a/app/frontend/src/javascript/models/training.ts +++ b/app/frontend/src/javascript/models/training.ts @@ -1,4 +1,6 @@ import { ApiFilter } from './api'; +import { TDateISO } from '../typings/date-iso'; +import { FileType } from './file'; export interface Training { id?: number, @@ -6,11 +8,21 @@ export interface Training { description: string, machine_ids: number[], nb_total_places: number, - slug: string, + slug?: string, public_page?: boolean, disabled?: boolean, plan_ids?: number[], - training_image?: string, + training_image_attributes?: FileType, + availabilities?: Array<{ + id: number, + start_at: TDateISO, + end_at: TDateISO, + reservation_users: Array<{ + id: number, + full_name: string, + is_valid: boolean + }> + }> } export interface TrainingIndexFilter extends ApiFilter { diff --git a/app/frontend/src/javascript/router.js b/app/frontend/src/javascript/router.js index 70cd69b83..f51e14b4b 100644 --- a/app/frontend/src/javascript/router.js +++ b/app/frontend/src/javascript/router.js @@ -504,7 +504,7 @@ angular.module('application.router', ['ui.router']) } }, resolve: { - trainingsPromise: ['Training', function (Training) { return Training.query({ public_page: true }).$promise; }] + trainingsPromise: ['Training', function (Training) { return Training.query({ public_page: true, disabled: false }).$promise; }] } }) .state('app.public.training_show', { @@ -761,10 +761,6 @@ angular.module('application.router', ['ui.router']) templateUrl: '/admin/trainings/new.html', controller: 'NewTrainingController' } - }, - resolve: { - machinesPromise: ['Machine', function (Machine) { return Machine.query().$promise; }], - settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['machines_module']" }).$promise; }] } }) .state('app.admin.trainings_edit', { @@ -777,9 +773,7 @@ angular.module('application.router', ['ui.router']) } }, resolve: { - trainingPromise: ['Training', '$transition$', function (Training, $transition$) { return Training.get({ id: $transition$.params().id }).$promise; }], - machinesPromise: ['Machine', function (Machine) { return Machine.query().$promise; }], - settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['machines_module']" }).$promise; }] + trainingPromise: ['Training', '$transition$', function (Training, $transition$) { return Training.get({ id: $transition$.params().id }).$promise; }] } }) // events diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index c8bdcc5f8..4d18c777b 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -123,6 +123,7 @@ @import "modules/supporting-documents/supporting-documents-validation"; @import "modules/supporting-documents/supporting-documents-type-form"; @import "modules/supporting-documents/supporting-documents-types-list"; +@import "modules/trainings/training-form"; @import "modules/user/avatar"; @import "modules/user/avatar-input"; @import "modules/user/gender-input"; diff --git a/app/frontend/src/stylesheets/modules/trainings/training-form.scss b/app/frontend/src/stylesheets/modules/trainings/training-form.scss new file mode 100644 index 000000000..c30576bff --- /dev/null +++ b/app/frontend/src/stylesheets/modules/trainings/training-form.scss @@ -0,0 +1,5 @@ +.training-form { + .submit-btn { + float: right; + } +} diff --git a/app/frontend/templates/admin/trainings/_form.html b/app/frontend/templates/admin/trainings/_form.html deleted file mode 100644 index 17a9d4421..000000000 --- a/app/frontend/templates/admin/trainings/_form.html +++ /dev/null @@ -1,144 +0,0 @@ -
- - - -
-
- - {{alert.msg}} - -
- -
- - {{ 'app.shared.trainings.name_is_required' }} -
-
- -
- -
-
-
- -
-
- -
-
- - {{ 'app.shared.trainings.add_an_illustration' | translate }} - {{ 'app.shared.buttons.change' }} - - - {{ 'app.shared.buttons.delete' }} -
-
-
-
- - -
- -
- - - {{ 'app.shared.trainings.description_is_required' }} -
-
- -
- -
- - - - - - - - - - - - -
-
- -
- -
- -
-
- - -
- -
- - -
-
- -
- -
- - -
-
- -
- - -
-
diff --git a/app/frontend/templates/admin/trainings/edit.html b/app/frontend/templates/admin/trainings/edit.html index bb0b6c9aa..046cd439d 100644 --- a/app/frontend/templates/admin/trainings/edit.html +++ b/app/frontend/templates/admin/trainings/edit.html @@ -22,6 +22,10 @@
- +
+
+ +
+
diff --git a/app/frontend/templates/admin/trainings/new.html b/app/frontend/templates/admin/trainings/new.html index f7c84f8b4..1c691fe0a 100644 --- a/app/frontend/templates/admin/trainings/new.html +++ b/app/frontend/templates/admin/trainings/new.html @@ -26,7 +26,11 @@ {{ 'app.admin.trainings_new.dont_forget_to_change_them_before_creating_slots_for_this_training' | translate }} - +
+
+ +
+
diff --git a/app/frontend/templates/machines/_form.html b/app/frontend/templates/machines/_form.html deleted file mode 100644 index 76cf91140..000000000 --- a/app/frontend/templates/machines/_form.html +++ /dev/null @@ -1,139 +0,0 @@ -
- - - -
-
- - {{alert.msg}} - -
- -
- - {{ 'app.shared.machine.name_is_required' }} -
-
- -
- -
-
-
- -
-
- -
-
- - {{ 'app.shared.machine.add_an_illustration' | translate }} - {{ 'app.shared.buttons.change' }} - - - -
-
-
-
- -
- -
- - - - {{ 'app.shared.machine.description_is_required' }} -
-
- -
- -
- - - - {{ 'app.shared.machine.technical_specifications_are_required' }} -
-
- -
- -
-
- - - -
-
- {{file.attachment}} -
- {{ 'app.shared.machine.attach_a_file' }} - {{ 'app.shared.buttons.change' }} - -
- -
- {{ 'app.shared.machine.add_an_attachment' | translate }} -
-
- -
- -
- - -
-
- -
- - -
-
diff --git a/app/frontend/templates/trainings/index.html b/app/frontend/templates/trainings/index.html index ffed856cc..2b93bbeff 100644 --- a/app/frontend/templates/trainings/index.html +++ b/app/frontend/templates/trainings/index.html @@ -23,10 +23,10 @@
-
+
-
+

{{training.name}}

diff --git a/app/frontend/templates/trainings/show.html b/app/frontend/templates/trainings/show.html index ceef95693..fabb04499 100644 --- a/app/frontend/templates/trainings/show.html +++ b/app/frontend/templates/trainings/show.html @@ -29,8 +29,8 @@
-
- {{training.name}} +
+ {{training.name}}

diff --git a/app/views/api/trainings/_training.json.jbuilder b/app/views/api/trainings/_training.json.jbuilder new file mode 100644 index 000000000..4c55b6741 --- /dev/null +++ b/app/views/api/trainings/_training.json.jbuilder @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +json.extract! training, :id, :name, :description, :machine_ids, :nb_total_places, :public_page, :disabled, :slug +if training.training_image + json.training_image_attributes do + json.id training.training_image.id + json.attachment_name training.training_image.attachment_identifier + json.attachment_url training.training_image.attachment.url + end +end diff --git a/app/views/api/trainings/index.json.jbuilder b/app/views/api/trainings/index.json.jbuilder index 4746592db..9ebdab5a3 100644 --- a/app/views/api/trainings/index.json.jbuilder +++ b/app/views/api/trainings/index.json.jbuilder @@ -1,9 +1,6 @@ # frozen_string_literal: true -role = (current_user and current_user.admin?) ? 'admin' : 'user' - json.array!(@trainings) do |training| - json.extract! training, :id, :name, :description, :machine_ids, :nb_total_places, :slug, :disabled - json.training_image training.training_image.attachment.large.url if training.training_image - json.plan_ids training.plan_ids if role == 'admin' + json.partial! 'api/trainings/training', training: training + json.plan_ids training.plan_ids if current_user&.admin? end diff --git a/app/views/api/trainings/show.json.jbuilder b/app/views/api/trainings/show.json.jbuilder index 524b3c0e7..22cb70919 100644 --- a/app/views/api/trainings/show.json.jbuilder +++ b/app/views/api/trainings/show.json.jbuilder @@ -1,2 +1,3 @@ -json.extract! @training, :id, :name, :description, :machine_ids, :nb_total_places, :public_page, :disabled -json.training_image @training.training_image.attachment.large.url if @training.training_image +# frozen_string_literal: true + +json.partial! 'api/trainings/training', training: @training diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 2a18f064a..c273b0c0f 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -11,8 +11,26 @@ en: attach_a_file: "Attach a file" add_an_attachment: "Add an attachment" disable_machine: "Disable machine" - disabled_help: "When disabled, the machine won't be reservable and won't appear by default in the machine list." + disabled_help: "When disabled, the machine won't be reservable and won't appear by default in the machines list." ACTION_machine: "{ACTION, select, create{Create} other{Update}} the machine" + create_success: "The machine was created successfully" + update_success: "The machine was updated successfully" + training_form: + name: "Name" + illustration: "Illustration" + add_an_illustration: "Add an illustration" + description: "Description" + add_a_new_training: "Add a new training" + validate_your_training: "Validate your training" + associated_machines: "Associated machines" + default_seats: "Default number of seats" + public_page: "Show in training lists" + public_help: "When unchecked, this option will prevent the training from appearing in the trainings list." + disable_training: "Disable the training" + disabled_help: "When disabled, the training won't be reservable and won't appear by default in the trainings list." + ACTION_training: "{ACTION, select, create{Create} other{Update}} the training" + create_success: "The training was created successfully" + update_success: "The training was updated successfully" #add a new machine machines_new: declare_a_new_machine: "Declare a new machine"