From 3d796549f29305ad92bb3a138ee2c80a59872d08 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Tue, 8 Nov 2022 17:41:18 +0100 Subject: [PATCH] (ui) refactor event form --- app/frontend/src/javascript/api/age-range.ts | 10 + .../src/javascript/api/event-category.ts | 10 + .../javascript/api/event-price-category.ts | 10 + .../src/javascript/api/event-theme.ts | 2 +- app/frontend/src/javascript/api/event.ts | 27 ++ .../components/events/event-card.tsx | 6 +- .../components/events/event-form.tsx | 315 ++++++++++++++++++ .../components/events/event-themes.tsx | 96 ------ .../events/update-recurrent-modal.tsx | 66 ++++ .../javascript/controllers/admin/events.js | 304 ++--------------- .../src/javascript/controllers/events.js.erb | 4 +- app/frontend/src/javascript/lib/format.ts | 19 +- .../src/javascript/models/event-theme.ts | 5 - app/frontend/src/javascript/models/event.ts | 118 +++++-- app/frontend/src/javascript/router.js | 12 +- app/frontend/src/stylesheets/application.scss | 2 + .../modules/events/event-form.scss | 37 ++ .../events/update-recurrent-modal.scss | 9 + app/frontend/templates/events/_form.html | 280 ---------------- app/frontend/templates/events/edit.html | 14 +- .../templates/events/editRecurrent.html | 26 -- app/frontend/templates/events/new.html | 12 +- app/frontend/templates/events/show.html | 12 +- app/views/api/categories/index.json.jbuilder | 4 +- app/views/api/events/_event.json.jbuilder | 25 +- app/views/api/events/index.json.jbuilder | 4 +- app/views/api/events/show.json.jbuilder | 10 +- app/views/api/events/upcoming.json.jbuilder | 3 +- config/locales/app.admin.en.yml | 49 ++- 29 files changed, 724 insertions(+), 767 deletions(-) create mode 100644 app/frontend/src/javascript/api/age-range.ts create mode 100644 app/frontend/src/javascript/api/event-category.ts create mode 100644 app/frontend/src/javascript/api/event-price-category.ts create mode 100644 app/frontend/src/javascript/api/event.ts create mode 100644 app/frontend/src/javascript/components/events/event-form.tsx delete mode 100644 app/frontend/src/javascript/components/events/event-themes.tsx create mode 100644 app/frontend/src/javascript/components/events/update-recurrent-modal.tsx delete mode 100644 app/frontend/src/javascript/models/event-theme.ts create mode 100644 app/frontend/src/stylesheets/modules/events/event-form.scss create mode 100644 app/frontend/src/stylesheets/modules/events/update-recurrent-modal.scss delete mode 100644 app/frontend/templates/events/_form.html delete mode 100644 app/frontend/templates/events/editRecurrent.html diff --git a/app/frontend/src/javascript/api/age-range.ts b/app/frontend/src/javascript/api/age-range.ts new file mode 100644 index 000000000..8b9b1c10f --- /dev/null +++ b/app/frontend/src/javascript/api/age-range.ts @@ -0,0 +1,10 @@ +import apiClient from './clients/api-client'; +import { AxiosResponse } from 'axios'; +import { AgeRange } from '../models/event'; + +export default class AgeRangeAPI { + static async index (): Promise> { + const res: AxiosResponse> = await apiClient.get('/api/age_ranges'); + return res?.data; + } +} diff --git a/app/frontend/src/javascript/api/event-category.ts b/app/frontend/src/javascript/api/event-category.ts new file mode 100644 index 000000000..301454f47 --- /dev/null +++ b/app/frontend/src/javascript/api/event-category.ts @@ -0,0 +1,10 @@ +import apiClient from './clients/api-client'; +import { AxiosResponse } from 'axios'; +import { EventCategory } from '../models/event'; + +export default class EventCategoryAPI { + static async index (): Promise> { + const res: AxiosResponse> = await apiClient.get('/api/categories'); + return res?.data; + } +} diff --git a/app/frontend/src/javascript/api/event-price-category.ts b/app/frontend/src/javascript/api/event-price-category.ts new file mode 100644 index 000000000..59ca69f44 --- /dev/null +++ b/app/frontend/src/javascript/api/event-price-category.ts @@ -0,0 +1,10 @@ +import apiClient from './clients/api-client'; +import { AxiosResponse } from 'axios'; +import { EventPriceCategory } from '../models/event'; + +export default class EventPriceCategoryAPI { + static async index (): Promise> { + const res: AxiosResponse> = await apiClient.get('/api/price_categories'); + return res?.data; + } +} diff --git a/app/frontend/src/javascript/api/event-theme.ts b/app/frontend/src/javascript/api/event-theme.ts index 102600c16..60b6d3924 100644 --- a/app/frontend/src/javascript/api/event-theme.ts +++ b/app/frontend/src/javascript/api/event-theme.ts @@ -1,6 +1,6 @@ import apiClient from './clients/api-client'; import { AxiosResponse } from 'axios'; -import { EventTheme } from '../models/event-theme'; +import { EventTheme } from '../models/event'; export default class EventThemeAPI { static async index (): Promise> { diff --git a/app/frontend/src/javascript/api/event.ts b/app/frontend/src/javascript/api/event.ts new file mode 100644 index 000000000..9419b4691 --- /dev/null +++ b/app/frontend/src/javascript/api/event.ts @@ -0,0 +1,27 @@ +import apiClient from './clients/api-client'; +import { AxiosResponse } from 'axios'; +import { Event, EventUpdateResult } from '../models/event'; +import ApiLib from '../lib/api'; + +export default class EventAPI { + static async create (event: Event): Promise { + const data = ApiLib.serializeAttachments(event, 'event', ['event_files_attributes', 'event_image_attributes']); + const res: AxiosResponse = await apiClient.post('/api/events', data, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + return res?.data; + } + + static async update (event: Event, mode: 'single' | 'next' | 'all'): Promise { + const data = ApiLib.serializeAttachments(event, 'event', ['event_files_attributes', 'event_image_attributes']); + data.set('edit_mode', mode); + const res: AxiosResponse = await apiClient.put(`/api/events/${event.id}`, data, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + return res?.data; + } +} diff --git a/app/frontend/src/javascript/components/events/event-card.tsx b/app/frontend/src/javascript/components/events/event-card.tsx index af58fd386..6d6ca6c78 100644 --- a/app/frontend/src/javascript/components/events/event-card.tsx +++ b/app/frontend/src/javascript/components/events/event-card.tsx @@ -53,14 +53,14 @@ export const EventCard: React.FC = ({ event, cardType }) => { const formatTime = (): string => { return event.all_day ? t('app.public.event_card.all_day') - : t('app.public.event_card.from_time_to_time', { START: FormatLib.time(event.start_date), END: FormatLib.time(event.end_date) }); + : t('app.public.event_card.from_time_to_time', { START: FormatLib.time(event.start_time), END: FormatLib.time(event.end_time) }); }; return (
- {event.event_image + {event.event_image_attributes ?
- + {event.event_image_attributes.attachment_name}
: cardType !== 'sm' &&
diff --git a/app/frontend/src/javascript/components/events/event-form.tsx b/app/frontend/src/javascript/components/events/event-form.tsx new file mode 100644 index 000000000..46c60176c --- /dev/null +++ b/app/frontend/src/javascript/components/events/event-form.tsx @@ -0,0 +1,315 @@ +import React, { useEffect, useState } from 'react'; +import { SubmitHandler, useFieldArray, useForm, useWatch } from 'react-hook-form'; +import { Event, EventDecoration, EventPriceCategoryAttributes, RecurrenceOption } from '../../models/event'; +import EventAPI from '../../api/event'; +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 { FormMultiFileUpload } from '../form/form-multi-file-upload'; +import { FabButton } from '../base/fab-button'; +import { FormSwitch } from '../form/form-switch'; +import { SelectOption } from '../../models/select'; +import EventCategoryAPI from '../../api/event-category'; +import { FormSelect } from '../form/form-select'; +import EventThemeAPI from '../../api/event-theme'; +import { FormMultiSelect } from '../form/form-multi-select'; +import AgeRangeAPI from '../../api/age-range'; +import { Plus, Trash } from 'phosphor-react'; +import FormatLib from '../../lib/format'; +import EventPriceCategoryAPI from '../../api/event-price-category'; +import { UpdateRecurrentModal } from './update-recurrent-modal'; + +declare const Application: IApplication; + +interface EventFormProps { + action: 'create' | 'update', + event?: Event, + onError: (message: string) => void, + onSuccess: (message: string) => void, +} + +/** + * Form to edit or create events + */ +export const EventForm: React.FC = ({ action, event, onError, onSuccess }) => { + const { handleSubmit, register, control, setValue, formState } = useForm({ defaultValues: { ...event } }); + const output = useWatch({ control }); + const { fields, append, remove } = useFieldArray({ control, name: 'event_price_categories_attributes' }); + + const { t } = useTranslation('admin'); + + const [isAllDay, setIsAllDay] = useState(event?.all_day); + const [categoriesOptions, setCategoriesOptions] = useState>>([]); + const [themesOptions, setThemesOptions] = useState>>(null); + const [ageRangeOptions, setAgeRangeOptions] = useState>>(null); + const [priceCategoriesOptions, setPriceCategoriesOptions] = useState>>(null); + const [isOpenRecurrentModal, setIsOpenRecurrentModal] = useState(false); + const [updatingEvent, setUpdatingEvent] = useState(null); + + useEffect(() => { + EventCategoryAPI.index() + .then(data => setCategoriesOptions(data.map(m => decorationToOption(m)))) + .catch(onError); + EventThemeAPI.index() + .then(data => setThemesOptions(data.map(t => decorationToOption(t)))) + .catch(onError); + AgeRangeAPI.index() + .then(data => setAgeRangeOptions(data.map(r => decorationToOption(r)))) + .catch(onError); + EventPriceCategoryAPI.index() + .then(data => setPriceCategoriesOptions(data.map(c => decorationToOption(c)))) + .catch(onError); + }, []); + + /** + * Callback triggered when the user clicks on the 'remove' button, in the additional prices area + */ + const handlePriceRemove = (price: EventPriceCategoryAttributes, index: number) => { + if (!price.id) return remove(index); + + setValue(`event_price_categories_attributes.${index}._destroy`, true); + }; + + /** + * Callback triggered when the user validates the machine form: handle create or update + */ + const onSubmit: SubmitHandler = (data: Event) => { + if (action === 'update') { + if (event?.recurrence_events?.length > 0) { + setUpdatingEvent(data); + toggleRecurrentModal(); + } else { + handleUpdateRecurrentConfirmed(data, 'single'); + } + } else { + EventAPI.create(data).then(res => { + onSuccess(t(`app.admin.event_form.${action}_success`)); + window.location.href = `/#!/events/${res.id}`; + }).catch(onError); + } + }; + + /** + * Open/closes the confirmation modal for updating recurring events + */ + const toggleRecurrentModal = () => { + setIsOpenRecurrentModal(!isOpenRecurrentModal); + }; + + /** + * Check if any dates have changed + */ + const datesHaveChanged = (): boolean => { + return ((event?.start_date !== (updatingEvent?.start_date as Date)?.toISOString()?.substring(0, 10)) || + (event?.end_date !== (updatingEvent?.end_date as Date)?.toISOString()?.substring(0, 10))); + }; + + /** + * When the user has confirmed the update of the other occurences (or not), proceed with the API update + * and handle the result + */ + const handleUpdateRecurrentConfirmed = (data: Event, mode: 'single' | 'next' | 'all') => { + EventAPI.update(data, mode).then(res => { + if (res.total === res.updated) { + onSuccess(t('app.admin.event_form.events_updated', { COUNT: res.updated })); + } else { + onError(t('app.admin.event_form.events_not_updated', { TOTAL: res.total, COUNT: res.total - res.updated })); + if (res.details.events.find(d => d.error === 'EventPriceCategory')) { + onError(t('app.admin.event_form.error_deleting_reserved_price')); + } else { + onError(t('app.admin.event_form.other_error')); + } + } + window.location.href = '/#!/events'; + }).catch(onError); + }; + + /** + * Convert an event-decoration (category/theme/etc.) to an option usable by react-select + */ + const decorationToOption = (item: EventDecoration): SelectOption => { + return { value: item.id, label: item.name }; + }; + + /** + * In 'create' mode, the user can choose if the new event will be recurrent. + * This method provides teh various options for recurrence + */ + const buildRecurrenceOptions = (): Array> => { + return [ + { label: t('app.admin.event_form.recurring.none'), value: 'none' }, + { label: t('app.admin.event_form.recurring.every_days'), value: 'day' }, + { label: t('app.admin.event_form.recurring.every_week'), value: 'week' }, + { label: t('app.admin.event_form.recurring.every_month'), value: 'month' }, + { label: t('app.admin.event_form.recurring.every_year'), value: 'year' } + ]; + }; + + return ( +
+ + + + + {themesOptions && } + {ageRangeOptions && } +
+

{t('app.admin.event_form.dates_and_opening_hours')}

+
+ + +
+ + {!isAllDay &&
+ + +
} + {action === 'create' &&
+ + +
} +
+
+

{t('app.admin.event_form.prices_and_availabilities')}

+ + + {priceCategoriesOptions &&
+ {fields.map((price, index) => ( +
+ + + handlePriceRemove(price, index)} icon={} /> +
+ ))} + append({})}> + + {t('app.admin.event_form.add_price')} + +
} +
+
+
+

{t('app.admin.event_form.attachments')}

+
+ +
+ + {t('app.admin.event_form.ACTION_event', { ACTION: action })} + + + + ); +}; + +const EventFormWrapper: React.FC = (props) => { + return ( + + + + + + ); +}; + +Application.Components.component('eventForm', react2angular(EventFormWrapper, ['action', 'event', 'onError', 'onSuccess'])); diff --git a/app/frontend/src/javascript/components/events/event-themes.tsx b/app/frontend/src/javascript/components/events/event-themes.tsx deleted file mode 100644 index 473c6e887..000000000 --- a/app/frontend/src/javascript/components/events/event-themes.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import Select from 'react-select'; -import { react2angular } from 'react2angular'; -import { Loader } from '../base/loader'; -import { Event } from '../../models/event'; -import { EventTheme } from '../../models/event-theme'; -import { IApplication } from '../../models/application'; -import EventThemeAPI from '../../api/event-theme'; -import { SelectOption } from '../../models/select'; - -declare const Application: IApplication; - -interface EventThemesProps { - event: Event, - onChange: (themes: Array) => void -} - -/** - * This component shows a select input to edit the themes associated with the event - */ -export const EventThemes: React.FC = ({ event, onChange }) => { - const { t } = useTranslation('shared'); - - const [themes, setThemes] = useState>([]); - - useEffect(() => { - EventThemeAPI.index().then(data => setThemes(data)); - }, []); - - /** - * Check if there's any EventTheme in DB, otherwise we won't display the selector - */ - const hasThemes = (): boolean => { - return themes.length > 0; - }; - - /** - * Return the current theme(s) for the given event, formatted to match the react-select format - */ - const defaultValues = (): Array> => { - const res = []; - themes.forEach(t => { - if (event.event_theme_ids && event.event_theme_ids.indexOf(t.id) > -1) { - res.push({ value: t.id, label: t.name }); - } - }); - return res; - }; - - /** - * Callback triggered when the selection has changed. - * Convert the react-select specific format to an array of EventTheme, and call the provided callback. - */ - const handleChange = (selectedOptions: Array>): void => { - const res = []; - selectedOptions.forEach(opt => { - res.push(themes.find(t => t.id === opt.value)); - }); - onChange(res); - }; - - /** - * Convert all themes to the react-select format - */ - const buildOptions = (): Array> => { - return themes.map(t => { - return { value: t.id, label: t.name }; - }); - }; - - return ( -
- {hasThemes() &&
-

{ t('app.shared.event_themes.title') }

-
- setEditMode('single')} /> + {t('app.admin.update_recurrent_modal.edit_this_event')} + + + + {datesChanged && editMode !== 'single' && + {t('app.admin.update_recurrent_modal.date_wont_change')} + } + + ); +}; diff --git a/app/frontend/src/javascript/controllers/admin/events.js b/app/frontend/src/javascript/controllers/admin/events.js index a26668edb..f04ba3a61 100644 --- a/app/frontend/src/javascript/controllers/admin/events.js +++ b/app/frontend/src/javascript/controllers/admin/events.js @@ -41,126 +41,10 @@ */ class EventsController { constructor ($scope, $state) { - // default parameters for AngularUI-Bootstrap datepicker - $scope.datePicker = { - format: Fablab.uibDateFormat, - startOpened: false, // default: datePicker is not shown - endOpened: false, - recurrenceEndOpened: false, - options: { - startingDay: Fablab.weekStartingDay - } - }; - - // themes of the current event - $scope.event_themes = $scope.event.event_theme_ids; - - /** - * For use with ngUpload (https://github.com/twilson63/ngUpload). - * Intended to be the callback when an upload is done: any raised error will be stacked in the - * $scope.alerts array. If everything goes fine, the user is redirected to the project page. - * @param content {Object} JSON - The upload's result - */ - $scope.submited = function (content) { - $scope.onSubmited(content); - }; - /** * Changes the user's view to the events list page */ $scope.cancel = function () { $state.go('app.public.events_list'); }; - - /** - * For use with 'ng-class', returns the CSS class name for the uploads previews. - * The preview may show a placeholder or the content of the file depending on the upload state. - * @param v {*} any attribute, will be tested for truthiness (see JS evaluation rules) - */ - $scope.fileinputClass = function (v) { - if (v) { - return 'fileinput-exists'; - } else { - return 'fileinput-new'; - } - }; - - /** - * This will create a single new empty entry into the event's attachements list. - */ - $scope.addFile = function () { $scope.event.event_files_attributes.push({}); }; - - /** - * This will remove the given file from the event's attachements list. If the file was previously uploaded - * to the server, it will be marked for deletion on the server. Otherwise, it will be simply truncated from - * the attachements array. - * @param file {Object} the file to delete - */ - $scope.deleteFile = function (file) { - const index = $scope.event.event_files_attributes.indexOf(file); - if (file.id != null) { - return file._destroy = true; - } else { - return $scope.event.event_files_attributes.splice(index, 1); - } - }; - - /** - * Show/Hide the "start" datepicker (open the drop down/close it) - */ - $scope.toggleStartDatePicker = function ($event) { - $event.preventDefault(); - $event.stopPropagation(); - return $scope.datePicker.startOpened = !$scope.datePicker.startOpened; - }; - - /** - * Show/Hide the "end" datepicker (open the drop down/close it) - */ - $scope.toggleEndDatePicker = function ($event) { - $event.preventDefault(); - $event.stopPropagation(); - return $scope.datePicker.endOpened = !$scope.datePicker.endOpened; - }; - - /** - * Masks/displays the recurrence pane allowing the admin to set the current event as recursive - */ - $scope.toggleRecurrenceEnd = function (e) { - e.preventDefault(); - e.stopPropagation(); - return $scope.datePicker.recurrenceEndOpened = !$scope.datePicker.recurrenceEndOpened; - }; - - /** - * Initialize a new price item in the additional prices list - */ - $scope.addPrice = function () { - $scope.event.prices.push({ - category: null, - amount: null - }); - }; - - /** - * Remove the price or mark it as 'to delete' - */ - $scope.removePrice = function (price, event) { - event.preventDefault(); - event.stopPropagation(); - if (price.id) { - price._destroy = true; - } else { - const index = $scope.event.prices.indexOf(price); - $scope.event.prices.splice(index, 1); - } - }; - - /** - * When the theme selection has changes, extract the IDs to populate the form - * @param themes {Array} - */ - $scope.handleEventChange = function (themes) { - $scope.event_themes = themes.map(t => t.id); - }; } } @@ -555,60 +439,22 @@ Application.Controllers.controller('ShowEventReservationsController', ['$scope', /** * Controller used in the event creation page */ -Application.Controllers.controller('NewEventController', ['$scope', '$state', 'CSRF', 'categoriesPromise', 'themesPromise', 'ageRangesPromise', 'priceCategoriesPromise', '_t', - function ($scope, $state, CSRF, categoriesPromise, themesPromise, ageRangesPromise, priceCategoriesPromise, _t) { +Application.Controllers.controller('NewEventController', ['$scope', '$state', 'CSRF', 'growl', + function ($scope, $state, CSRF, growl) { CSRF.setMetaTags(); - // API URL where the form will be posted - $scope.actionUrl = '/api/events/'; - - // Form action on the above URL - $scope.method = 'post'; - - // List of categories for the events - $scope.categories = categoriesPromise; - - // List of events themes - $scope.themes = themesPromise; - - // List of age ranges - $scope.ageRanges = ageRangesPromise; - - // List of availables price's categories - $scope.priceCategories = priceCategoriesPromise; - - // Default event parameters - $scope.event = { - event_files_attributes: [], - start_date: new Date(), - end_date: new Date(), - start_time: new Date(), - end_time: new Date(), - all_day: true, - recurrence: 'none', - category_id: null, - prices: [] + /** + * Callback triggered by react components + */ + $scope.onSuccess = function (message) { + growl.success(message); }; - // Possible types of recurrences for an event - $scope.recurrenceTypes = [ - { label: _t('app.admin.events_new.none'), value: 'none' }, - { label: _t('app.admin.events_new.every_days'), value: 'day' }, - { label: _t('app.admin.events_new.every_week'), value: 'week' }, - { label: _t('app.admin.events_new.every_month'), value: 'month' }, - { label: _t('app.admin.events_new.every_year'), value: 'year' } - ]; - - // triggered when the new event form was submitted to the API and have received an answer - $scope.onSubmited = function (content) { - if ((content.id == null)) { - $scope.alerts = []; - angular.forEach(content, function (v, k) { - angular.forEach(v, function (err) { $scope.alerts.push({ msg: k + ': ' + err, type: 'danger' }); }); - }); - } else { - $state.go('app.public.events_list'); - } + /** + * Callback triggered by react components + */ + $scope.onError = function (message) { + growl.error(message); }; // Using the EventsController @@ -619,109 +465,25 @@ Application.Controllers.controller('NewEventController', ['$scope', '$state', 'C /** * Controller used in the events edition page */ -Application.Controllers.controller('EditEventController', ['$scope', '$state', '$transition$', 'CSRF', 'eventPromise', 'categoriesPromise', 'themesPromise', 'ageRangesPromise', 'priceCategoriesPromise', '$uibModal', 'growl', '_t', - function ($scope, $state, $transition$, CSRF, eventPromise, categoriesPromise, themesPromise, ageRangesPromise, priceCategoriesPromise, $uibModal, growl, _t) { +Application.Controllers.controller('EditEventController', ['$scope', '$state', 'CSRF', 'eventPromise', 'growl', + function ($scope, $state, CSRF, eventPromise, growl) { /* PUBLIC SCOPE */ - // API URL where the form will be posted - $scope.actionUrl = `/api/events/${$transition$.params().id}`; - - // Form action on the above URL - $scope.method = 'put'; - // Retrieve the event details, in case of error the user is redirected to the events listing - $scope.event = eventPromise; + $scope.event = cleanEvent(eventPromise); - // We'll keep track of the initial dates here, for later comparison - $scope.initialDates = {}; - - // List of categories for the events - $scope.categories = categoriesPromise; - - // List of available price's categories - $scope.priceCategories = priceCategoriesPromise; - - // List of events themes - $scope.themes = themesPromise; - - // List of age ranges - $scope.ageRanges = ageRangesPromise; - - // Default edit-mode for periodic event - $scope.editMode = 'single'; - - // show edit-mode modal if event is recurrent - $scope.isShowEditModeModal = $scope.event.recurrence_events.length > 0; - - $scope.editRecurrent = function (e) { - if ($scope.isShowEditModeModal && $scope.event.recurrence_events.length > 0) { - e.preventDefault(); - - // open a choice edit-mode dialog - const modalInstance = $uibModal.open({ - animation: true, - templateUrl: '/events/editRecurrent.html', - size: 'md', - controller: 'EditRecurrentEventController', - resolve: { - editMode: function () { return $scope.editMode; }, - initialDates: function () { return $scope.initialDates; }, - currentEvent: function () { return $scope.event; } - } - }); - // submit form event by edit-mode - modalInstance.result.then(function (res) { - $scope.isShowEditModeModal = false; - $scope.editMode = res.editMode; - e.target.click(); - }); - } + /** + * Callback triggered by react components + */ + $scope.onSuccess = function (message) { + growl.success(message); }; - // triggered when the edit event form was submitted to the API and have received an answer - $scope.onSubmited = function (data) { - if (data.total === data.updated) { - if (data.updated > 1) { - growl.success(_t( - 'app.admin.events_edit.events_updated', - { COUNT: data.updated - 1 } - )); - } else { - growl.success(_t( - 'app.admin.events_edit.event_successfully_updated' - )); - } - } else { - if (data.total > 1) { - growl.warning(_t( - 'app.admin.events_edit.events_not_updated', - { TOTAL: data.total, COUNT: data.total - data.updated } - )); - if (_.find(data.details, { error: 'EventPriceCategory' })) { - growl.error(_t( - 'app.admin.events_edit.error_deleting_reserved_price' - )); - } else { - growl.error(_t( - 'app.admin.events_edit.other_error' - )); - } - } else { - growl.error(_t( - 'app.admin.events_edit.unable_to_update_the_event' - )); - if (data.details[0].error === 'EventPriceCategory') { - growl.error(_t( - 'app.admin.events_edit.error_deleting_reserved_price' - )); - } else { - growl.error(_t( - 'app.admin.events_edit.other_error' - )); - } - } - } - $state.go('app.public.events_list'); + /** + * Callback triggered by react components + */ + $scope.onError = function (message) { + growl.error(message); }; /* PRIVATE SCOPE */ @@ -732,19 +494,17 @@ Application.Controllers.controller('EditEventController', ['$scope', '$state', ' const initialize = function () { CSRF.setMetaTags(); - // init the dates to JS objects - $scope.event.start_date = moment($scope.event.start_date).toDate(); - $scope.event.end_date = moment($scope.event.end_date).toDate(); - - $scope.initialDates = { - start: new Date($scope.event.start_date.valueOf()), - end: new Date($scope.event.end_date.valueOf()) - }; - // Using the EventsController return new EventsController($scope, $state); }; + // prepare the event for the react-hook-form + function cleanEvent (event) { + delete event.$promise; + delete event.$resolved; + return event; + } + // !!! MUST BE CALLED AT THE END of the controller return initialize(); } diff --git a/app/frontend/src/javascript/controllers/events.js.erb b/app/frontend/src/javascript/controllers/events.js.erb index d933bc438..6969cb45a 100644 --- a/app/frontend/src/javascript/controllers/events.js.erb +++ b/app/frontend/src/javascript/controllers/events.js.erb @@ -639,7 +639,7 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', ' slot_id: event.availability.slot_id }); - for (let evt_px_cat of Array.from(event.prices)) { + for (let evt_px_cat of Array.from(event.event_price_categories_attributes)) { const booked = reserve.tickets[evt_px_cat.id]; if (booked > 0) { reservation.tickets_attributes.push({ @@ -684,7 +684,7 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', ' totalSeats: 0 }; - for (let evt_px_cat of Array.from($scope.event.prices)) { + for (let evt_px_cat of Array.from($scope.event.event_price_categories_attributes)) { $scope.reserve.nbPlaces[evt_px_cat.id] = __range__(0, $scope.event.nb_free_places, true); $scope.reserve.tickets[evt_px_cat.id] = 0; } diff --git a/app/frontend/src/javascript/lib/format.ts b/app/frontend/src/javascript/lib/format.ts index 3b6b608e6..1e6299ab7 100644 --- a/app/frontend/src/javascript/lib/format.ts +++ b/app/frontend/src/javascript/lib/format.ts @@ -1,6 +1,6 @@ import moment, { unitOfTime } from 'moment'; import { IFablab } from '../models/fablab'; -import { TDateISO } from '../typings/date-iso'; +import { TDateISO, TDateISODate, THours, TMinutes } from '../typings/date-iso'; declare let Fablab: IFablab; @@ -8,15 +8,24 @@ export default class FormatLib { /** * Return the formatted localized date for the given date */ - static date = (date: Date|TDateISO): string => { + static date = (date: Date|TDateISO|TDateISODate): string => { return Intl.DateTimeFormat().format(moment(date).toDate()); }; /** * Return the formatted localized time for the given date */ - static time = (date: Date|TDateISO): string => { - return Intl.DateTimeFormat(Fablab.intl_locale, { hour: 'numeric', minute: 'numeric' }).format(moment(date).toDate()); + static time = (date: Date|TDateISO|`${THours}:${TMinutes}`): string => { + let tempDate: Date; + const isoTimeMatch = (date as string).match(/^(\d\d):(\d\d)$/); + if (isoTimeMatch) { + tempDate = new Date(); + tempDate.setHours(parseInt(isoTimeMatch[1], 10)); + tempDate.setMinutes(parseInt(isoTimeMatch[2], 10)); + } else { + tempDate = moment(date).toDate(); + } + return Intl.DateTimeFormat(Fablab.intl_locale, { hour: 'numeric', minute: 'numeric' }).format(tempDate); }; /** @@ -37,6 +46,6 @@ export default class FormatLib { * Return currency symbol for currency setting */ static currencySymbol = (): string => { - return new Intl.NumberFormat('fr', { style: 'currency', currency: Fablab.intl_currency }).formatToParts()[2].value; + return new Intl.NumberFormat(Fablab.intl_locale, { style: 'currency', currency: Fablab.intl_currency }).formatToParts().filter(p => p.type === 'currency')[0].value; }; } diff --git a/app/frontend/src/javascript/models/event-theme.ts b/app/frontend/src/javascript/models/event-theme.ts deleted file mode 100644 index 48e20bf16..000000000 --- a/app/frontend/src/javascript/models/event-theme.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface EventTheme { - id: number, - name: string, - related_to: number -} diff --git a/app/frontend/src/javascript/models/event.ts b/app/frontend/src/javascript/models/event.ts index 52ef2cbf2..bf34dadfd 100644 --- a/app/frontend/src/javascript/models/event.ts +++ b/app/frontend/src/javascript/models/event.ts @@ -1,52 +1,110 @@ -import { TDateISO } from '../typings/date-iso'; +import { TDateISO, TDateISODate, THours, TMinutes } from '../typings/date-iso'; +import { FileType } from './file'; + +export interface EventPriceCategoryAttributes { + id?: number, + price_category_id: number, + amount: number, + _destroy?: boolean, + category: EventPriceCategory +} + +export type RecurrenceOption = 'none' | 'day' | 'week' | 'month' | 'year'; export interface Event { - id: number, + id?: number, title: string, description: string, - event_image: string, - event_files_attributes: Array<{ - id: number, - attachment: string, - attachment_url: string - }>, + event_image_attributes: FileType, + event_files_attributes: Array, category_id: number, category: { id: number, name: string, slug: string }, - event_theme_ids: Array, - event_themes: Array<{ + event_theme_ids?: Array, + event_themes?: Array<{ name: string }>, - age_range_id: number, - age_range: { + age_range_id?: number, + age_range?: { name: string }, - start_date: TDateISO, - start_time: TDateISO, - end_date: TDateISO, - end_time: TDateISO, - month: string; - month_id: number, - year: number, - all_day: boolean, - availability: { + start_date: TDateISODate | Date, + start_time: `${THours}:${TMinutes}`, + end_date: TDateISODate | Date, + end_time: `${THours}:${TMinutes}`, + month?: string; + month_id?: number, + year?: number, + all_day?: boolean, + availability?: { id: number, start_at: TDateISO, end_at: TDateISO }, availability_id: number, amount: number, - prices: Array<{ - id: number, - amount: number, - category: { - id: number, - name: string - } - }>, + event_price_categories_attributes?: Array, nb_total_places: number, - nb_free_places: number + nb_free_places: number, + recurrence_id?: number, + updated_at?: TDateISO, + recurrence_events?: Array<{ + id: number, + start_date: TDateISODate, + start_time: `${THours}:${TMinutes}` + end_date: TDateISODate + end_time: `${THours}:${TMinutes}` + nb_free_places: number, + availability_id: number + }>, + recurrence: RecurrenceOption, + recurrence_end_at: Date +} + +export interface EventDecoration { + id?: number, + name: string, + related_to?: number +} + +export type EventTheme = EventDecoration; +export type EventCategory = EventDecoration; +export type AgeRange = EventDecoration; + +export interface EventPriceCategory { + id?: number, + name: string, + conditions?: string, + events?: number, + created_at?: TDateISO +} + +export interface EventUpdateResult { + action: 'update', + total: number, + updated: number, + details: { + events: Array<{ + event: Event, + status: boolean, + error?: string, + message?: string + }>, + slots: Array<{ + slot: { + id: number, + availability_id: number, + created_at: TDateISO, + end_at: TDateISO, + start_at: TDateISO, + updated_at: TDateISO, + }, + status: boolean, + error?: string, + message?: string + }> + } } diff --git a/app/frontend/src/javascript/router.js b/app/frontend/src/javascript/router.js index f51e14b4b..20a8fd7bd 100644 --- a/app/frontend/src/javascript/router.js +++ b/app/frontend/src/javascript/router.js @@ -801,12 +801,6 @@ angular.module('application.router', ['ui.router']) templateUrl: '/events/new.html', controller: 'NewEventController' } - }, - resolve: { - categoriesPromise: ['Category', function (Category) { return Category.query().$promise; }], - themesPromise: ['EventTheme', function (EventTheme) { return EventTheme.query().$promise; }], - ageRangesPromise: ['AgeRange', function (AgeRange) { return AgeRange.query().$promise; }], - priceCategoriesPromise: ['PriceCategory', function (PriceCategory) { return PriceCategory.query().$promise; }] } }) .state('app.admin.events_edit', { @@ -818,11 +812,7 @@ angular.module('application.router', ['ui.router']) } }, resolve: { - eventPromise: ['Event', '$transition$', function (Event, $transition$) { return Event.get({ id: $transition$.params().id }).$promise; }], - categoriesPromise: ['Category', function (Category) { return Category.query().$promise; }], - themesPromise: ['EventTheme', function (EventTheme) { return EventTheme.query().$promise; }], - ageRangesPromise: ['AgeRange', function (AgeRange) { return AgeRange.query().$promise; }], - priceCategoriesPromise: ['PriceCategory', function (PriceCategory) { return PriceCategory.query().$promise; }] + eventPromise: ['Event', '$transition$', function (Event, $transition$) { return Event.get({ id: $transition$.params().id }).$promise; }] } }) .state('app.admin.event_reservations', { diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index a472696f3..d8d24dff1 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -39,6 +39,8 @@ @import "modules/dashboard/reservations/reservations-dashboard"; @import "modules/dashboard/reservations/reservations-panel"; @import "modules/events/event"; +@import "modules/events/event-form"; +@import "modules/events/update-recurrent-modal"; @import "modules/form/abstract-form-item"; @import "modules/form/form-input"; @import "modules/form/form-multi-file-upload"; diff --git a/app/frontend/src/stylesheets/modules/events/event-form.scss b/app/frontend/src/stylesheets/modules/events/event-form.scss new file mode 100644 index 000000000..465768efc --- /dev/null +++ b/app/frontend/src/stylesheets/modules/events/event-form.scss @@ -0,0 +1,37 @@ +.event-form { + .additional-prices { + display: flex; + flex-direction: column; + align-items: flex-end; + + .add-price { + max-width: fit-content; + margin-bottom: 1.6rem; + } + .price-item { + width: 100%; + .remove-price { + align-items: center; + display: flex; + margin: 2.6rem 2rem 0; + cursor: pointer; + } + + &.destroyed-item { + display: none; + } + } + } + .dates, .times, .price-item, .recurring { + display: flex; + flex-direction: row; + + .form-item:first-child { + margin-right: 32px; + } + } + .submit-btn { + margin-top: 2rem; + float: right; + } +} diff --git a/app/frontend/src/stylesheets/modules/events/update-recurrent-modal.scss b/app/frontend/src/stylesheets/modules/events/update-recurrent-modal.scss new file mode 100644 index 000000000..b4d658b36 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/events/update-recurrent-modal.scss @@ -0,0 +1,9 @@ +.update-recurrent-modal { + label { + margin-left: 2rem; + + span { + margin-left: 1rem; + } + } +} diff --git a/app/frontend/templates/events/_form.html b/app/frontend/templates/events/_form.html deleted file mode 100644 index bbbc8b682..000000000 --- a/app/frontend/templates/events/_form.html +++ /dev/null @@ -1,280 +0,0 @@ -
- -
- -
-
- - {{alert.msg}} - - - -
- -
- - {{ 'app.shared.event.title_is_required' }} -
-
- - -
- -
-
-
- -
-
- -
-
- {{ 'app.shared.event.choose_a_picture' | translate }} {{ 'app.shared.buttons.change' }} - - -
-
-
-
- -
- -
- - - - {{ 'app.shared.event.description_is_required' }} -
-
- -
- -
-
- - - -
-
- {{file.attachment}} -
- {{ 'app.shared.buttons.browse' }} - {{ 'app.shared.buttons.change' }} - -
- -
- {{ 'app.shared.event.add_a_new_file' | translate }} -
-
- - -
- - -
- -
- -
- - -
-
-

{{ 'app.shared.event.event_type' }} *

-
-
- - - - - - - - - -
-
- - - - - -
-
-

{{ 'app.shared.event.age_range' }}

-
-
- - - - - - - - - -
-
-
-
-

{{ 'app.shared.event.dates_and_opening_hours' }}

-
-
-
- -
- - -
-
- -
- -
- - - - - -
-
-
- -
- - - - - -
-
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
- {{ 'app.shared.event._and_ends_on' | translate }} -
- - - - - -
-
-
-
-
- -
-
-

{{ 'app.shared.event.prices_and_availabilities' }}

-
-
-
- -
-
- -
{{currencySymbol}}
-
- {{ 'app.shared.event.0_equal_free' }} -
-
-
-
- - -
-
-
- -
{{currencySymbol}}
-
-
-
- - -
-
- -
- -
-
- -
-
-
-
-
-
-
-
diff --git a/app/frontend/templates/events/edit.html b/app/frontend/templates/events/edit.html index 4e9d9742f..20919db28 100644 --- a/app/frontend/templates/events/edit.html +++ b/app/frontend/templates/events/edit.html @@ -16,10 +16,14 @@ -
- - - -
+
+
+
+
+ +
+
+
+
diff --git a/app/frontend/templates/events/editRecurrent.html b/app/frontend/templates/events/editRecurrent.html deleted file mode 100644 index 12a67707d..000000000 --- a/app/frontend/templates/events/editRecurrent.html +++ /dev/null @@ -1,26 +0,0 @@ - - - diff --git a/app/frontend/templates/events/new.html b/app/frontend/templates/events/new.html index a1b7a5c9d..deb18d891 100644 --- a/app/frontend/templates/events/new.html +++ b/app/frontend/templates/events/new.html @@ -16,10 +16,12 @@ -
- - - -
+
+
+
+ +
+
+
diff --git a/app/frontend/templates/events/show.html b/app/frontend/templates/events/show.html index eb0201b3d..dbfee4a87 100644 --- a/app/frontend/templates/events/show.html +++ b/app/frontend/templates/events/show.html @@ -31,8 +31,8 @@
-
- {{event.title}} +
+ {{event.title}}

{{ 'app.public.events_show.event_description' }}

@@ -57,7 +57,7 @@ @@ -85,12 +85,12 @@
{{ 'app.public.events_show.beginning' | translate }} {{event.start_date | amDateFormat:'L'}}
{{ 'app.public.events_show.ending' | translate }} {{event.end_date | amDateFormat:'L'}}
{{ 'app.public.events_show.opening_hours' | translate }}
{{ 'app.public.events_show.all_day' }}
-
{{ 'app.public.events_show.from_time' | translate }} {{event.start_date | amDateFormat:'LT'}} {{ 'app.public.events_show.to_time' | translate }} {{event.end_date | amDateFormat:'LT'}}
+
{{ 'app.public.events_show.from_time' | translate }} {{event.start_time}} {{ 'app.public.events_show.to_time' | translate }} {{event.end_time}}
{{ 'app.public.events_show.full_price_' | translate }} {{ event.amount | currency}}
-
+
{{price.category.name}} : @@ -120,7 +120,7 @@ {{ 'app.public.events_show.ticket' | translate:{NUMBER:reserve.nbReservePlaces} }}
-
+