1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-03-15 12:29:16 +01:00

Merge branch 'dev' for release 5.7.0

This commit is contained in:
Sylvain 2023-02-17 17:09:32 +01:00
commit 576a2540bf
685 changed files with 15176 additions and 4951 deletions

View File

@ -32,7 +32,7 @@ imports
accounting
# Proof of identity files
proof_of_identity_files
supporting_document_files
# Development files
Vagrantfile

2
.gitignore vendored
View File

@ -47,7 +47,7 @@
/accounting/*
# Proof of identity files
/proof_of_identity_files/*
/supporting_document_files/*
.DS_Store

View File

@ -36,5 +36,9 @@ Style/FormatString:
EnforcedStyle: sprintf
Rails/RedundantPresenceValidationOnBelongsTo:
Enabled: false
Style/DateTime:
Enabled: true
Rails/TimeZone:
Enabled: true
Rails/UnknownEnv:
Environments: development, test, staging, production

View File

@ -1,10 +1,38 @@
# Changelog Fab-manager
## v5.7.0 2023 February 17
- Report user's prepaid packs in the dashboard
- Export external ID and private notes in the members excel export
- Ability to buy a new prepaid pack from the user's dashboard
- Improved calendars loading time
- Admin notification when an order was placed
- Management of notifications preferences for admins
- Display custom banners in machines/trainings/events lists
- Filter projects by status
- Maximum validity period for trainings authorizations
- Automatically cancel trainings with insufficient attendees
- Check SCSS syntax before saving home page style
- Use Time instead of DateTime objects
- Fix a bug: missing statististics subtypes
- Fix a bug: wrong times in admin/event monitoring
- Fix a bug: daylight saving time is ignored and result in wrong dates and/or times when dealing around the DST day
- Fix a bug: date shift in event creation/update
- Fix a bug: unable to run `rails db:seed` when first setup Fab-manager
- Fix a bug: cannot cancel a subscription after offering free days
- Fix a bug: event image updates are not reflected unless the browser's cache is purged
- Fix a bug: schedules jobs are not launched at the right time
- Fix a bug: unable to update the title of a training
- Fix a bug: members cannot update their cards for payment schedules
- [TODO DEPLOY] `rails fablab:fix_availabilities` THEN `rails fablab:setup:build_places_cache`
- [TODO DEPLOY] `\curl -sSL https://raw.githubusercontent.com/sleede/fab-manager/master/scripts/rename-supporting-document.sh | bash`
- [TODO DEPLOY] `rails db:seed`
## v5.6.11 2023 February 07
- OpenAPI endpoint to fetch subscription data
- Fix a bug: invalid date display in negative timezones
- Fix a bug: unable to get latest payment_gateway_object for plan/machine/training/space
- Fix a bug: unable to get the latest payment_gateway_object for plan/machine/training/space
## v5.6.10 2023 February 02
@ -44,8 +72,7 @@
- Fix a bug: unable to run task fix_invoice_item when some invoice items are associated with errors
- Fix a bug: invalid event date reported when the timezone in before UTC
- Fix a bug: unable to run accounting export if a line label was not defined
- Fix a security issue: updated rack to 2.2.6.2 to fix [CVE-2022-44571](https
- cgi-bin/cvename.cgi?name=CVE-2022-44571)
- Fix a security issue: updated rack to 2.2.6.2 to fix [CVE-2022-44571](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-44571)
- Fix a security issue: updated globalid to 1.0.1 to fix [CVE-2023-22799](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2023-22799)
- [TODO DEPLOY] `rails fablab:fix:invoice_items_in_error` THEN `rails fablab:fix_invoice_items` THEN `rails db:migrate`
@ -132,6 +159,7 @@
- Updated tiptap editor and its dependencies to 2.0.0-beta.204
- [TODO DEPLOY] `rails db:seed`
- [TODO DEPLOY] `rails fablab:setup:build_accounting_lines`
- [TODO DEPLOY] `rails fablab:fix:cart_operator`
## v5.5.8 2022 December 16

View File

@ -87,7 +87,7 @@ VOLUME /usr/src/app/invoices \
/usr/src/app/public/uploads \
/usr/src/app/public/packs \
/usr/src/app/accounting \
/usr/src/app/proof_of_identity_files \
/usr/src/app/supporting_document_files \
/var/log/supervisor
# Expose port 3000 to the Docker host, so we can access it from the outside

View File

@ -103,8 +103,6 @@ gem 'elasticsearch-persistence', '~> 5'
gem 'elasticsearch-rails', '~> 5'
gem 'faraday', '~> 0.17'
gem 'notify_with'
gem 'pundit'
gem 'oj'

View File

@ -236,10 +236,6 @@ GEM
nokogiri (1.13.10)
mini_portile2 (~> 2.8.0)
racc (~> 1.4)
notify_with (0.0.2)
jbuilder (~> 2.0)
rails (>= 4.2.0)
responders (~> 2.0)
oauth2 (1.4.4)
faraday (>= 0.8, < 2.0)
jwt (>= 1.0, < 3.0)
@ -531,7 +527,6 @@ DEPENDENCIES
message_format
mini_magick
minitest-reporters
notify_with
oj
omniauth (~> 1.9.2)
omniauth-oauth2

View File

@ -13,7 +13,7 @@ class API::AccountingPeriodsController < API::ApiController
def create
authorize AccountingPeriod
@accounting_period = AccountingPeriod.new(period_params.merge(closed_at: DateTime.current, closed_by: current_user.id))
@accounting_period = AccountingPeriod.new(period_params.merge(closed_at: Time.current, closed_by: current_user.id))
if @accounting_period.save
render :show, status: :created, location: @accounting_period
else

View File

@ -29,6 +29,7 @@ class API::AvailabilitiesController < API::ApiController
{ machines: machine_ids, spaces: params[:s], trainings: params[:t] },
events: (params[:evt] && params[:evt] == 'true')
)
@user = current_user
@title_filter = { machine_ids: machine_ids.map(&:to_i) }
@availabilities = filter_availabilites(@availabilities)
@ -50,6 +51,7 @@ class API::AvailabilitiesController < API::ApiController
end
end
# This endpoint is used to remove a machine or a plan from the given availability
def update
authorize Availability
if @availability.update(availability_params)

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true
# API Controller for manage user's cart
# API Controller to manage user's cart
class API::CartController < API::ApiController
include API::OrderConcern
@ -13,6 +13,17 @@ class API::CartController < API::ApiController
render 'api/orders/show'
end
def create_item
authorize @current_order, policy_class: CartPolicy
service = Cart::CreateCartItemService.new(@current_order)
@item = service.create(params)
if @item.save({ context: @current_order.order_items })
render 'api/orders/item', status: :created
else
render json: @item.errors.full_messages, status: :unprocessable_entity
end
end
def add_item
authorize @current_order, policy_class: CartPolicy
@order = Cart::AddItemService.new.call(@current_order, orderable, cart_params[:quantity])
@ -49,9 +60,16 @@ class API::CartController < API::ApiController
render json: @order_errors
end
def set_customer
authorize @current_order, policy_class: CartPolicy
customer = User.find(params[:user_id])
@order = Cart::SetCustomerService.new(current_user).call(@current_order, customer)
render 'api/orders/show'
end
private
def orderable
Product.find(cart_params[:orderable_id])
params[:orderable_type].classify.constantize.find(cart_params[:orderable_id])
end
end

View File

@ -24,6 +24,8 @@ class API::CheckoutController < API::ApiController
rescue PayzenError => e
render json: PayZen::Helper.human_error(e), status: :unprocessable_entity
rescue StandardError => e
Rails.logger.error e
Rails.logger.debug e.backtrace
render json: e, status: :unprocessable_entity
end

View File

@ -17,11 +17,11 @@ class API::EventsController < API::ApiController
if current_user&.admin? || current_user&.manager?
@events = case params[:scope]
when 'future'
@events.where('availabilities.start_at >= ?', DateTime.current).order('availabilities.start_at DESC')
@events.where('availabilities.start_at >= ?', Time.current).order('availabilities.start_at DESC')
when 'future_asc'
@events.where('availabilities.start_at >= ?', DateTime.current).order('availabilities.start_at ASC')
@events.where('availabilities.start_at >= ?', Time.current).order('availabilities.start_at ASC')
when 'passed'
@events.where('availabilities.start_at < ?', DateTime.current).order('availabilities.start_at DESC')
@events.where('availabilities.start_at < ?', Time.current).order('availabilities.start_at DESC')
else
@events.order('availabilities.start_at DESC')
end
@ -42,11 +42,11 @@ class API::EventsController < API::ApiController
@events = case Setting.get('upcoming_events_shown')
when 'until_start'
@events.where('availabilities.start_at >= ?', DateTime.current)
@events.where('availabilities.start_at >= ?', Time.current)
when '2h_before_end'
@events.where('availabilities.end_at >= ?', DateTime.current + 2.hours)
@events.where('availabilities.end_at >= ?', 2.hours.from_now)
else
@events.where('availabilities.end_at >= ?', DateTime.current)
@events.where('availabilities.end_at >= ?', Time.current)
end
end

View File

@ -4,7 +4,7 @@
class API::MembersController < API::ApiController
before_action :authenticate_user!, except: [:last_subscribed]
before_action :set_member, only: %i[update destroy merge complete_tour update_role validate]
before_action :set_operator, only: %i[show update create]
before_action :set_operator, only: %i[show update create merge validate]
respond_to :json
def index

View File

@ -0,0 +1,50 @@
# frozen_string_literal: true
# API Controller for resources of type Notification Preferences
class API::NotificationPreferencesController < API::ApiController
before_action :authenticate_user!
def index
@notification_preferences = current_user.notification_preferences
end
# Currently only available for Admin in NotificationPreferencePolicy
def update
authorize NotificationPreference
notification_type = NotificationType.find_by(name: params[:notification_preference][:notification_type])
@notification_preference = NotificationPreference.find_or_create_by(notification_type: notification_type, user: current_user)
@notification_preference.update(notification_preference_params)
if @notification_preference.save
render :show, status: :ok
else
render json: @notification_preference.errors, status: :unprocessable_entity
end
end
# Currently only available for Admin in NotificationPreferencePolicy
def bulk_update
authorize NotificationPreference
errors = []
params[:notification_preferences].each do |notification_preference|
notification_type = NotificationType.find_by(name: notification_preference[:notification_type])
db_notification_preference = NotificationPreference.find_or_create_by(notification_type_id: notification_type.id, user: current_user)
next if db_notification_preference.update(email: notification_preference[:email], in_system: notification_preference[:in_system])
errors.push(db_notification_preference.errors)
end
if errors.any?
render json: errors, status: :unprocessable_entity
else
head :no_content, status: :ok
end
end
private
def notification_preference_params
params.require(:notification_preference).permit(:notification_type_id, :in_system, :email)
end
end

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
# API Controller for resources of type Notification Types
class API::NotificationTypesController < API::ApiController
before_action :authenticate_user!
def index
@notification_types = if params[:is_configurable] == 'true'
NotificationType.where(is_configurable: true)
else
NotificationType.all
end
end
end

View File

@ -3,8 +3,8 @@
# API Controller for resources of type Notification
# Notifications are scoped by user
class API::NotificationsController < API::ApiController
include NotifyWith::NotificationsApi
before_action :authenticate_user!
before_action :set_notification, only: :update
# notifications can have anything attached, so we won't eager load the whole database
around_action :skip_bullet, if: -> { defined?(Bullet) }
@ -14,26 +14,36 @@ class API::NotificationsController < API::ApiController
def index
loop do
@notifications = current_user.notifications.includes(:attached_object).page(params[:page]).per(NOTIFICATIONS_PER_PAGE).order('created_at DESC')
@notifications = current_user.notifications
.delivered_in_system(current_user)
.includes(:attached_object)
.page(params[:page])
.per(NOTIFICATIONS_PER_PAGE)
.order('created_at DESC')
# we delete obsolete notifications on first access
break unless delete_obsoletes(@notifications)
end
@totals = {
total: current_user.notifications.count,
unread: current_user.notifications.where(is_read: false).count
total: current_user.notifications.delivered_in_system(current_user).count,
unread: current_user.notifications.delivered_in_system(current_user).where(is_read: false).count
}
render :index
end
def last_unread
loop do
@notifications = current_user.notifications.includes(:attached_object).where(is_read: false).limit(3).order('created_at DESC')
@notifications = current_user.notifications
.delivered_in_system(current_user)
.includes(:attached_object)
.where(is_read: false)
.limit(3)
.order('created_at DESC')
# we delete obsolete notifications on first access
break unless delete_obsoletes(@notifications)
end
@totals = {
total: current_user.notifications.count,
unread: current_user.notifications.where(is_read: false).count
total: current_user.notifications.delivered_in_system(current_user).count,
unread: current_user.notifications.delivered_in_system(current_user).where(is_read: false).count
}
render :index
end
@ -43,14 +53,28 @@ class API::NotificationsController < API::ApiController
.where('is_read = false AND created_at >= :date', date: params[:last_poll])
.order('created_at DESC')
@totals = {
total: current_user.notifications.count,
unread: current_user.notifications.where(is_read: false).count
total: current_user.notifications.delivered_in_system(current_user).count,
unread: current_user.notifications.delivered_in_system(current_user).where(is_read: false).count
}
render :index
end
def update
@notification.mark_as_read
render :show
end
def update_all
current_user.notifications.where(is_read: false).find_each(&:mark_as_read)
head :no_content
end
private
def set_notification
@notification = current_user.notifications.find(params[:id])
end
def delete_obsoletes(notifications)
cleaned = false
notifications.each do |n|

View File

@ -41,6 +41,7 @@ class API::PaymentsController < API::ApiController
{ json: res[:errors].drop_while(&:empty?), status: :unprocessable_entity }
end
rescue StandardError => e
Rails.logger.debug e.backtrace
{ json: e, status: :unprocessable_entity }
end
end

View File

@ -14,7 +14,7 @@ class API::ProfileCustomFieldsController < API::ApiController
def show; end
def create
authorize ProofOfIdentityType
authorize ProfileCustomField
@profile_custom_field = ProfileCustomField.new(profile_custom_field_params)
if @profile_custom_field.save
render status: :created

View File

@ -68,13 +68,13 @@ class API::ProjectsController < API::ApiController
end
def project_params
params.require(:project).permit(:name, :description, :tags, :machine_ids, :component_ids, :theme_ids, :licence_id, :licence_id, :state,
params.require(:project).permit(:name, :description, :tags, :machine_ids, :component_ids, :theme_ids, :licence_id, :status_id, :state,
user_ids: [], machine_ids: [], component_ids: [], theme_ids: [],
project_image_attributes: [:attachment],
project_caos_attributes: %i[id attachment _destroy],
project_steps_attributes: [
:id, :description, :title, :_destroy, :step_nb,
project_step_images_attributes: %i[id attachment _destroy]
{ project_step_images_attributes: %i[id attachment _destroy] }
])
end
end

View File

@ -1,54 +0,0 @@
# frozen_string_literal: true
# API Controller for resources of type ProofOfIdentityFile
# ProofOfIdentityFiles are used in settings
class API::ProofOfIdentityFilesController < API::ApiController
before_action :authenticate_user!
before_action :set_proof_of_identity_file, only: %i[show update download]
def index
@proof_of_identity_files = ProofOfIdentityFileService.list(current_user, params)
end
# PUT /api/proof_of_identity_files/1/
def update
authorize @proof_of_identity_file
if ProofOfIdentityFileService.update(@proof_of_identity_file, proof_of_identity_file_params)
render :show, status: :ok, location: @proof_of_identity_file
else
render json: @proof_of_identity_file.errors, status: :unprocessable_entity
end
end
# POST /api/proof_of_identity_files/
def create
@proof_of_identity_file = ProofOfIdentityFile.new(proof_of_identity_file_params)
authorize @proof_of_identity_file
if ProofOfIdentityFileService.create(@proof_of_identity_file)
render :show, status: :created, location: @proof_of_identity_file
else
render json: @proof_of_identity_file.errors, status: :unprocessable_entity
end
end
# GET /api/proof_of_identity_files/1/download
def download
authorize @proof_of_identity_file
send_file @proof_of_identity_file.attachment.url, type: @proof_of_identity_file.attachment.content_type, disposition: 'attachment'
end
# GET /api/proof_of_identity_files/1/
def show; end
private
def set_proof_of_identity_file
@proof_of_identity_file = ProofOfIdentityFile.find(params[:id])
end
# Never trust parameters from the scary internet, only allow the white list through.
def proof_of_identity_file_params
params.required(:proof_of_identity_file).permit(:proof_of_identity_type_id, :attachment, :user_id)
end
end

View File

@ -1,32 +0,0 @@
# frozen_string_literal: true
# API Controller for resources of type ProofOfIdentityRefusal
# ProofOfIdentityRefusal are used by admin refuse user's proof of identity file
class API::ProofOfIdentityRefusalsController < API::ApiController
before_action :authenticate_user!
def index
authorize ProofOfIdentityRefusal
@proof_of_identity_files = ProofOfIdentityRefusalService.list(params)
end
def show; end
# POST /api/proof_of_identity_refusals/
def create
authorize ProofOfIdentityRefusal
@proof_of_identity_refusal = ProofOfIdentityRefusal.new(proof_of_identity_refusal_params)
if ProofOfIdentityRefusalService.create(@proof_of_identity_refusal)
render :show, status: :created, location: @proof_of_identity_refusal
else
render json: @proof_of_identity_refusal.errors, status: :unprocessable_entity
end
end
private
# Never trust parameters from the scary internet, only allow the white list through.
def proof_of_identity_refusal_params
params.required(:proof_of_identity_refusal).permit(:message, :operator_id, :user_id, proof_of_identity_type_ids: [])
end
end

View File

@ -1,50 +0,0 @@
# frozen_string_literal: true
# API Controller for resources of type ProofOfIdentityType
# ProofOfIdentityTypes are used to provide admin config proof of identity type by group
class API::ProofOfIdentityTypesController < API::ApiController
before_action :authenticate_user!, except: :index
before_action :set_proof_of_identity_type, only: %i[show update destroy]
def index
@proof_of_identity_types = ProofOfIdentityTypeService.list(params)
end
def show; end
def create
authorize ProofOfIdentityType
@proof_of_identity_type = ProofOfIdentityType.new(proof_of_identity_type_params)
if @proof_of_identity_type.save
render status: :created
else
render json: @proof_of_identity_type.errors.full_messages, status: :unprocessable_entity
end
end
def update
authorize @proof_of_identity_type
if @proof_of_identity_type.update(proof_of_identity_type_params)
render status: :ok
else
render json: @proof_of_identity_type.errors.full_messages, status: :unprocessable_entity
end
end
def destroy
authorize @proof_of_identity_type
@proof_of_identity_type.destroy
head :no_content
end
private
def set_proof_of_identity_type
@proof_of_identity_type = ProofOfIdentityType.find(params[:id])
end
def proof_of_identity_type_params
params.require(:proof_of_identity_type).permit(:name, group_ids: [])
end
end

View File

@ -14,6 +14,9 @@ class API::SettingsController < API::ApiController
render status: :not_modified and return if setting_params[:value] == @setting.value
render status: :locked, json: { error: I18n.t('settings.locked_setting') } and return unless SettingService.update_allowed?(@setting)
error = SettingService.check_before_update({ name: params[:name], value: setting_params[:value] })
render status: :unprocessable_entity, json: { error: error } and return if error
if @setting.save && @setting.history_values.create(value: setting_params[:value], invoicing_profile: current_user.invoicing_profile)
SettingService.run_after_update([@setting])
render status: :ok
@ -26,24 +29,31 @@ class API::SettingsController < API::ApiController
authorize Setting
@settings = []
updated_settings = []
may_transaction params[:transactional] do
params[:settings].each do |setting|
next if !setting[:name] || !setting[:value]
next if !setting[:name] || !setting[:value] || setting[:value].blank?
db_setting = Setting.find_or_initialize_by(name: setting[:name])
if !SettingService.update_allowed?(db_setting)
db_setting.errors.add(:-, "#{I18n.t("settings.#{setting[:name]}")}: #{I18n.t('settings.locked_setting')}")
elsif db_setting.save
unless db_setting.value == setting[:value]
db_setting.history_values.create(value: setting[:value], invoicing_profile: current_user.invoicing_profile)
if SettingService.update_allowed?(db_setting)
error = SettingService.check_before_update(setting)
if error
db_setting.errors.add(:-, "#{I18n.t("settings.#{setting[:name]}")}: #{error}")
elsif db_setting.save
if db_setting.value != setting[:value] &&
db_setting.history_values.create(value: setting[:value], invoicing_profile: current_user.invoicing_profile)
updated_settings.push(db_setting)
end
end
else
db_setting.errors.add(:-, "#{I18n.t("settings.#{setting[:name]}")}: #{I18n.t('settings.locked_setting')}")
end
@settings.push db_setting
may_rollback(params[:transactional]) if db_setting.errors.keys.count.positive?
end
end
SettingService.run_after_update(@settings)
SettingService.run_after_update(updated_settings)
end
def show

View File

@ -11,8 +11,8 @@ class API::SlotsReservationsController < API::ApiController
def update
authorize @slot_reservation
if @slot_reservation.update(slot_params)
SubscriptionExtensionAfterReservation.new(@slot_reservation.reservation).extend_subscription_if_eligible
render :show, status: :created, location: @slot_reservation
Subscriptions::ExtensionAfterReservation.new(@slot_reservation.reservation).extend_subscription_if_eligible
render :show, status: :ok, location: @slot_reservation
else
render json: @slot_reservation.errors, status: :unprocessable_entity
end

View File

@ -0,0 +1,46 @@
# frozen_string_literal: true
# API Controller for resources of type Status
# Status are used to check Projects state
class API::StatusesController < ApplicationController
before_action :set_status, only: %i[update destroy]
before_action :authenticate_user!, only: %i[create update destroy]
def index
@statuses = Status.all
end
def create
authorize Status
@status = Status.new(status_params)
if @status.save
render json: @status, status: :created
else
render json: @status.errors, status: :unprocessable_entity
end
end
def update
authorize Status
if @status.update(status_params)
render json: @status, status: :ok
else
render json: @status.errors, status: :unprocessable_entity
end
end
def destroy
authorize Status
@status.destroy
head :no_content
end
private
def set_status
@status = Status.find(params[:id])
end
def status_params
params.require(:status).permit(:name)
end
end

View File

@ -15,7 +15,7 @@ class API::SubscriptionsController < API::ApiController
def cancel
authorize @subscription
if @subscription.expire(DateTime.current)
if @subscription.expire
render :show, status: :ok, location: @subscription
else
render json: { error: 'already expired' }, status: :unprocessable_entity

View File

@ -0,0 +1,53 @@
# frozen_string_literal: true
# API Controller for resources of type SupportingDocumentFile
# SupportingDocumentFiles are used in settings
class API::SupportingDocumentFilesController < API::ApiController
before_action :authenticate_user!
before_action :set_supporting_document_file, only: %i[show update download]
def index
@supporting_document_files = SupportingDocumentFileService.list(current_user, params)
end
# PUT /api/supporting_document_files/1/
def update
authorize @supporting_document_file
if SupportingDocumentFileService.update(@supporting_document_file, supporting_document_file_params)
render :show, status: :ok, location: @supporting_document_file
else
render json: @supporting_document_file.errors, status: :unprocessable_entity
end
end
# POST /api/supporting_document_files/
def create
@supporting_document_file = SupportingDocumentFile.new(supporting_document_file_params)
authorize @supporting_document_file
if SupportingDocumentFileService.create(@supporting_document_file)
render :show, status: :created, location: @supporting_document_file
else
render json: @supporting_document_file.errors, status: :unprocessable_entity
end
end
# GET /api/supporting_document_files/1/download
def download
authorize @supporting_document_file
send_file @supporting_document_file.attachment.url, type: @supporting_document_file.attachment.content_type, disposition: 'attachment'
end
# GET /api/supporting_document_files/1/
def show; end
private
def set_supporting_document_file
@supporting_document_file = SupportingDocumentFile.find(params[:id])
end
# Never trust parameters from the scary internet, only allow the white list through.
def supporting_document_file_params
params.required(:supporting_document_file).permit(:supporting_document_type_id, :attachment, :user_id)
end
end

View File

@ -0,0 +1,32 @@
# frozen_string_literal: true
# API Controller for resources of type SupportingDocumentRefusal
# SupportingDocumentRefusal are used by admin refuse user's proof of identity file
class API::SupportingDocumentRefusalsController < API::ApiController
before_action :authenticate_user!
def index
authorize SupportingDocumentRefusal
@supporting_document_refusals = SupportingDocumentRefusalService.list(params)
end
def show; end
# POST /api/supporting_document_refusals/
def create
authorize SupportingDocumentRefusal
@supporting_document_refusal = SupportingDocumentRefusal.new(supporting_document_refusal_params)
if SupportingDocumentRefusalService.create(@supporting_document_refusal)
render :show, status: :created, location: @supporting_document_refusal
else
render json: @supporting_document_refusal.errors, status: :unprocessable_entity
end
end
private
# Never trust parameters from the scary internet, only allow the white list through.
def supporting_document_refusal_params
params.required(:supporting_document_refusal).permit(:message, :operator_id, :user_id, supporting_document_type_ids: [])
end
end

View File

@ -0,0 +1,50 @@
# frozen_string_literal: true
# API Controller for resources of type SupportingDocumentType
# ProofOfIdentityTypes are used to provide admin config proof of identity type by group
class API::SupportingDocumentTypesController < API::ApiController
before_action :authenticate_user!, except: :index
before_action :set_supporting_document_type, only: %i[show update destroy]
def index
@supporting_document_types = SupportingDocumentTypeService.list(params)
end
def show; end
def create
authorize SupportingDocumentType
@supporting_document_type = SupportingDocumentType.new(supporting_document_type_params)
if @supporting_document_type.save
render status: :created
else
render json: @supporting_document_type.errors.full_messages, status: :unprocessable_entity
end
end
def update
authorize @supporting_document_type
if @supporting_document_type.update(supporting_document_type_params)
render status: :ok
else
render json: @supporting_document_type.errors.full_messages, status: :unprocessable_entity
end
end
def destroy
authorize @supporting_document_type
@supporting_document_type.destroy
head :no_content
end
private
def set_supporting_document_type
@supporting_document_type = SupportingDocumentType.find(params[:id])
end
def supporting_document_type_params
params.require(:supporting_document_type).permit(:name, group_ids: [])
end
end

View File

@ -8,6 +8,7 @@ class API::TrainingsController < API::ApiController
before_action :set_training, only: %i[update destroy]
def index
@requested_attributes = params[:requested_attributes]
@trainings = TrainingService.list(params)
end
@ -77,6 +78,8 @@ class API::TrainingsController < API::ApiController
def training_params
params.require(:training)
.permit(:id, :name, :description, :machine_ids, :plan_ids, :nb_total_places, :public_page, :disabled,
:auto_cancel, :auto_cancel_threshold, :auto_cancel_deadline, :authorization, :authorization_period,
:invalidation, :invalidation_period,
training_image_attributes: %i[id attachment], machine_ids: [], plan_ids: [],
advanced_accounting_attributes: %i[code analytical_section])
end

View File

@ -17,6 +17,8 @@ class API::UserPacksController < API::ApiController
end
def item
return nil if params[:priceable_type].nil?
params[:priceable_type].classify.constantize.find(params[:priceable_id])
end
end

View File

@ -16,8 +16,8 @@ class OpenAPI::V1::AccountingController < OpenAPI::V1::BaseController
@lines = AccountingLine.order(date: :desc)
.includes(:invoicing_profile, invoice: :payment_gateway_object)
@lines = @lines.where('date >= ?', DateTime.parse(params[:after])) if params[:after].present?
@lines = @lines.where('date <= ?', DateTime.parse(params[:before])) if params[:before].present?
@lines = @lines.where('date >= ?', Time.zone.parse(params[:after])) if params[:after].present?
@lines = @lines.where('date <= ?', Time.zone.parse(params[:before])) if params[:before].present?
@lines = @lines.where(invoice_id: may_array(params[:invoice_id])) if params[:invoice_id].present?
@lines = @lines.where(line_type: may_array(params[:type])) if params[:type].present?

View File

@ -11,7 +11,7 @@ class OpenAPI::V1::EventsController < OpenAPI::V1::BaseController
.where(deleted_at: nil)
@events = if upcoming
@events.references(:availabilities)
.where('availabilities.end_at >= ?', DateTime.current)
.where('availabilities.end_at >= ?', Time.current)
.order('availabilities.start_at ASC')
else
@events.order(created_at: :desc)

View File

@ -11,8 +11,8 @@ class OpenAPI::V1::SubscriptionsController < OpenAPI::V1::BaseController
.includes(:plan, statistic_profile: :user)
.references(:statistic_profile, :plan)
@subscriptions = @subscriptions.where('created_at >= ?', DateTime.parse(params[:after])) if params[:after].present?
@subscriptions = @subscriptions.where('created_at <= ?', DateTime.parse(params[:before])) if params[:before].present?
@subscriptions = @subscriptions.where('created_at >= ?', Time.zone.parse(params[:after])) if params[:after].present?
@subscriptions = @subscriptions.where('created_at <= ?', Time.zone.parse(params[:before])) if params[:before].present?
@subscriptions = @subscriptions.where(plan_id: may_array(params[:plan_id])) if params[:plan_id].present?
@subscriptions = @subscriptions.where(statistic_profiles: { user_id: may_array(params[:user_id]) }) if params[:user_id].present?

View File

@ -14,7 +14,7 @@ class OpenAPI::V1::UsersController < OpenAPI::V1::BaseController
@users = @users.where(email: email_param)
end
@users = @users.where(id: may_array(params[:user_id])) if params[:user_id].present?
@users = @users.where('created_at >= ?', DateTime.parse(params[:created_after])) if params[:created_after].present?
@users = @users.where('created_at >= ?', Time.zone.parse(params[:created_after])) if params[:created_after].present?
return if params[:page].blank?

View File

@ -2,11 +2,10 @@
# RSS feed about 10 last events
class Rss::EventsController < Rss::RssController
def index
@events = Event.includes(:event_image, :event_files, :availability, :category)
.where('availabilities.start_at >= ?', DateTime.current)
.order('availabilities.start_at ASC').references(:availabilities).limit(10)
.where('availabilities.start_at >= ?', Time.current)
.order('availabilities.start_at').references(:availabilities).limit(10)
@fab_name = Setting.get('fablab_name')
end
end

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
# Raised when an anonymous cart it not allowed
class Cart::AnonymousError < StandardError
end

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
# Raised when the added item is not a recognized class
class Cart::UnknownItemError < StandardError
end

View File

@ -1,6 +1,7 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { Order, OrderErrors } from '../models/order';
import { Order, OrderableType, OrderErrors } from '../models/order';
import { CartItem, CartItemResponse } from '../models/cart_item';
export default class CartAPI {
static async create (token?: string): Promise<Order> {
@ -8,28 +9,33 @@ export default class CartAPI {
return res?.data;
}
static async addItem (order: Order, orderableId: number, quantity: number): Promise<Order> {
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/add_item', { order_token: order.token, orderable_id: orderableId, quantity });
static async createItem (order: Order, item: CartItem): Promise<CartItemResponse> {
const res: AxiosResponse<CartItemResponse> = await apiClient.post('/api/cart/create_item', { order_token: order.token, ...item });
return res?.data;
}
static async removeItem (order: Order, orderableId: number): Promise<Order> {
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/remove_item', { order_token: order.token, orderable_id: orderableId });
static async addItem (order: Order, orderableId: number, orderableType: OrderableType, quantity: number): Promise<Order> {
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/add_item', { order_token: order.token, orderable_id: orderableId, orderable_type: orderableType, quantity });
return res?.data;
}
static async setQuantity (order: Order, orderableId: number, quantity: number): Promise<Order> {
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/set_quantity', { order_token: order.token, orderable_id: orderableId, quantity });
static async removeItem (order: Order, orderableId: number, orderableType: OrderableType): Promise<Order> {
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/remove_item', { order_token: order.token, orderable_id: orderableId, orderable_type: orderableType });
return res?.data;
}
static async setOffer (order: Order, orderableId: number, isOffered: boolean): Promise<Order> {
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/set_offer', { order_token: order.token, orderable_id: orderableId, is_offered: isOffered, customer_id: order.user?.id });
static async setQuantity (order: Order, orderableId: number, orderableType: OrderableType, quantity: number): Promise<Order> {
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/set_quantity', { order_token: order.token, orderable_id: orderableId, orderable_type: orderableType, quantity });
return res?.data;
}
static async refreshItem (order: Order, orderableId: number): Promise<Order> {
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/refresh_item', { order_token: order.token, orderable_id: orderableId });
static async setOffer (order: Order, orderableId: number, orderableType: OrderableType, isOffered: boolean): Promise<Order> {
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/set_offer', { order_token: order.token, orderable_id: orderableId, orderable_type: orderableType, is_offered: isOffered, customer_id: order.user?.id });
return res?.data;
}
static async refreshItem (order: Order, orderableId: number, orderableType: OrderableType): Promise<Order> {
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/refresh_item', { order_token: order.token, orderable_id: orderableId, orderable_type: orderableType });
return res?.data;
}
@ -37,4 +43,9 @@ export default class CartAPI {
const res: AxiosResponse<OrderErrors> = await apiClient.post('/api/cart/validate', { order_token: order.token });
return res?.data;
}
static async setCustomer (order: Order, customerId: number): Promise<Order> {
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/set_customer', { order_token: order.token, user_id: customerId });
return res?.data;
}
}

View File

@ -5,7 +5,7 @@ import ApiLib from '../lib/api';
export default class MachineAPI {
static async index (filters?: MachineIndexFilter): Promise<Array<Machine>> {
const res: AxiosResponse<Array<Machine>> = await apiClient.get(`/api/machines${ApiLib.filtersToQuery(filters)}`);
const res: AxiosResponse<Array<Machine>> = await apiClient.get(`/api/machines${ApiLib.filtersToQuery(filters, false)}`);
return res?.data;
}

View File

@ -0,0 +1,20 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { NotificationsIndex, Notification } from '../models/notification';
export default class NotificationAPI {
static async index (page?: number): Promise<NotificationsIndex> {
const withPage = page ? `?page=${page}` : '';
const res: AxiosResponse<NotificationsIndex> = await apiClient.get(`/api/notifications${withPage}`);
return res?.data;
}
static async update (updatedNotification: Notification): Promise<Notification> {
const res: AxiosResponse<Notification> = await apiClient.patch(`/api/notifications/${updatedNotification.id}`, { notification: updatedNotification });
return res?.data;
}
static async update_all (): Promise<void> {
await apiClient.patch('/api/notifications');
}
}

View File

@ -0,0 +1,20 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { NotificationPreference } from '../models/notification-preference';
export default class NotificationPreferencesAPI {
static async index (): Promise<Array<NotificationPreference>> {
const res: AxiosResponse<Array<NotificationPreference>> = await apiClient.get('/api/notification_preferences');
return res?.data;
}
static async update (updatedPreference: NotificationPreference): Promise<NotificationPreference> {
const res: AxiosResponse<NotificationPreference> = await apiClient.patch(`/api/notification_preferences/${updatedPreference.notification_type}`, { notification_preference: updatedPreference });
return res?.data;
}
static async bulk_update (updatedPreferences: Array<NotificationPreference>): Promise<NotificationPreference> {
const res: AxiosResponse<NotificationPreference> = await apiClient.patch('/api/notification_preferences/bulk_update', { notification_preferences: updatedPreferences });
return res?.data;
}
}

View File

@ -0,0 +1,11 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { NotificationTypeIndexFilter, NotificationType } from '../models/notification-type';
import ApiLib from '../lib/api';
export default class NotificationTypesAPI {
static async index (filters?:NotificationTypeIndexFilter): Promise<Array<NotificationType>> {
const res: AxiosResponse<Array<NotificationType>> = await apiClient.get(`/api/notification_types${ApiLib.filtersToQuery(filters)}`);
return res?.data;
}
}

View File

@ -1,31 +0,0 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { ProofOfIdentityFile, ProofOfIdentityFileIndexFilter } from '../models/proof-of-identity-file';
import ApiLib from '../lib/api';
export default class ProofOfIdentityFileAPI {
static async index (filters?: ProofOfIdentityFileIndexFilter): Promise<Array<ProofOfIdentityFile>> {
const res: AxiosResponse<Array<ProofOfIdentityFile>> = await apiClient.get(`/api/proof_of_identity_files${ApiLib.filtersToQuery(filters)}`);
return res?.data;
}
static async get (id: number): Promise<ProofOfIdentityFile> {
const res: AxiosResponse<ProofOfIdentityFile> = await apiClient.get(`/api/proof_of_identity_files/${id}`);
return res?.data;
}
static async create (proofOfIdentityFile: FormData): Promise<ProofOfIdentityFile> {
const res: AxiosResponse<ProofOfIdentityFile> = await apiClient.post('/api/proof_of_identity_files', proofOfIdentityFile);
return res?.data;
}
static async update (id: number, proofOfIdentityFile: FormData): Promise<ProofOfIdentityFile> {
const res: AxiosResponse<ProofOfIdentityFile> = await apiClient.patch(`/api/proof_of_identity_files/${id}`, proofOfIdentityFile);
return res?.data;
}
static async destroy (proofOfIdentityFileId: number): Promise<void> {
const res: AxiosResponse<void> = await apiClient.delete(`/api/proof_of_identity_files/${proofOfIdentityFileId}`);
return res?.data;
}
}

View File

@ -1,16 +0,0 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { ProofOfIdentityRefusal, ProofOfIdentityRefusalIndexFilter } from '../models/proof-of-identity-refusal';
import ApiLib from '../lib/api';
export default class ProofOfIdentityRefusalAPI {
static async index (filters?: ProofOfIdentityRefusalIndexFilter): Promise<Array<ProofOfIdentityRefusal>> {
const res: AxiosResponse<Array<ProofOfIdentityRefusal>> = await apiClient.get(`/api/proof_of_identity_refusals${ApiLib.filtersToQuery(filters)}`);
return res?.data;
}
static async create (proofOfIdentityRefusal: ProofOfIdentityRefusal): Promise<ProofOfIdentityRefusal> {
const res: AxiosResponse<ProofOfIdentityRefusal> = await apiClient.post('/api/proof_of_identity_refusals', { proof_of_identity_refusal: proofOfIdentityRefusal });
return res?.data;
}
}

View File

@ -1,31 +0,0 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { ProofOfIdentityType, ProofOfIdentityTypeIndexfilter } from '../models/proof-of-identity-type';
import ApiLib from '../lib/api';
export default class ProofOfIdentityTypeAPI {
static async index (filters?: ProofOfIdentityTypeIndexfilter): Promise<Array<ProofOfIdentityType>> {
const res: AxiosResponse<Array<ProofOfIdentityType>> = await apiClient.get(`/api/proof_of_identity_types${ApiLib.filtersToQuery(filters)}`);
return res?.data;
}
static async get (id: number): Promise<ProofOfIdentityType> {
const res: AxiosResponse<ProofOfIdentityType> = await apiClient.get(`/api/proof_of_identity_types/${id}`);
return res?.data;
}
static async create (proofOfIdentityType: ProofOfIdentityType): Promise<ProofOfIdentityType> {
const res: AxiosResponse<ProofOfIdentityType> = await apiClient.post('/api/proof_of_identity_types', { proof_of_identity_type: proofOfIdentityType });
return res?.data;
}
static async update (proofOfIdentityType: ProofOfIdentityType): Promise<ProofOfIdentityType> {
const res: AxiosResponse<ProofOfIdentityType> = await apiClient.patch(`/api/proof_of_identity_types/${proofOfIdentityType.id}`, { proof_of_identity_type: proofOfIdentityType });
return res?.data;
}
static async destroy (proofOfIdentityTypeId: number): Promise<void> {
const res: AxiosResponse<void> = await apiClient.delete(`/api/proof_of_identity_types/${proofOfIdentityTypeId}`);
return res?.data;
}
}

View File

@ -0,0 +1,25 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { Status } from '../models/status';
export default class StatusAPI {
static async index (): Promise<Array<Status>> {
const res: AxiosResponse<Array<Status>> = await apiClient.get('/api/statuses');
return res?.data;
}
static async create (newStatus: Status): Promise<Status> {
const res: AxiosResponse<Status> = await apiClient.post('/api/statuses', { status: newStatus });
return res?.data;
}
static async update (updatedStatus: Status): Promise<Status> {
const res: AxiosResponse<Status> = await apiClient.patch(`/api/statuses/${updatedStatus.id}`, { status: updatedStatus });
return res?.data;
}
static async destroy (statusId: number): Promise<void> {
const res: AxiosResponse<void> = await apiClient.delete(`/api/statuses/${statusId}`);
return res?.data;
}
}

View File

@ -0,0 +1,31 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { SupportingDocumentFile, SupportingDocumentFileIndexFilter } from '../models/supporting-document-file';
import ApiLib from '../lib/api';
export default class SupportingDocumentFileAPI {
static async index (filters?: SupportingDocumentFileIndexFilter): Promise<Array<SupportingDocumentFile>> {
const res: AxiosResponse<Array<SupportingDocumentFile>> = await apiClient.get(`/api/supporting_document_files${ApiLib.filtersToQuery(filters)}`);
return res?.data;
}
static async get (id: number): Promise<SupportingDocumentFile> {
const res: AxiosResponse<SupportingDocumentFile> = await apiClient.get(`/api/supporting_document_files/${id}`);
return res?.data;
}
static async create (proofOfIdentityFile: FormData): Promise<SupportingDocumentFile> {
const res: AxiosResponse<SupportingDocumentFile> = await apiClient.post('/api/supporting_document_files', proofOfIdentityFile);
return res?.data;
}
static async update (id: number, proofOfIdentityFile: FormData): Promise<SupportingDocumentFile> {
const res: AxiosResponse<SupportingDocumentFile> = await apiClient.patch(`/api/supporting_document_files/${id}`, proofOfIdentityFile);
return res?.data;
}
static async destroy (proofOfIdentityFileId: number): Promise<void> {
const res: AxiosResponse<void> = await apiClient.delete(`/api/supporting_document_files/${proofOfIdentityFileId}`);
return res?.data;
}
}

View File

@ -0,0 +1,16 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { SupportingDocumentRefusal, SupportingDocumentRefusalIndexFilter } from '../models/supporting-document-refusal';
import ApiLib from '../lib/api';
export default class SupportingDocumentRefusalAPI {
static async index (filters?: SupportingDocumentRefusalIndexFilter): Promise<Array<SupportingDocumentRefusal>> {
const res: AxiosResponse<Array<SupportingDocumentRefusal>> = await apiClient.get(`/api/supporting_document_refusals${ApiLib.filtersToQuery(filters)}`);
return res?.data;
}
static async create (supportingDocumentRefusal: SupportingDocumentRefusal): Promise<SupportingDocumentRefusal> {
const res: AxiosResponse<SupportingDocumentRefusal> = await apiClient.post('/api/supporting_document_refusals', { supporting_document_refusal: supportingDocumentRefusal });
return res?.data;
}
}

View File

@ -0,0 +1,31 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { SupportingDocumentType, SupportingDocumentTypeIndexfilter } from '../models/supporting-document-type';
import ApiLib from '../lib/api';
export default class SupportingDocumentTypeAPI {
static async index (filters?: SupportingDocumentTypeIndexfilter): Promise<Array<SupportingDocumentType>> {
const res: AxiosResponse<Array<SupportingDocumentType>> = await apiClient.get(`/api/supporting_document_types${ApiLib.filtersToQuery(filters)}`);
return res?.data;
}
static async get (id: number): Promise<SupportingDocumentType> {
const res: AxiosResponse<SupportingDocumentType> = await apiClient.get(`/api/supporting_document_types/${id}`);
return res?.data;
}
static async create (proofOfIdentityType: SupportingDocumentType): Promise<SupportingDocumentType> {
const res: AxiosResponse<SupportingDocumentType> = await apiClient.post('/api/supporting_document_types', { supporting_document_type: proofOfIdentityType });
return res?.data;
}
static async update (proofOfIdentityType: SupportingDocumentType): Promise<SupportingDocumentType> {
const res: AxiosResponse<SupportingDocumentType> = await apiClient.patch(`/api/supporting_document_types/${proofOfIdentityType.id}`, { supporting_document_type: proofOfIdentityType });
return res?.data;
}
static async destroy (proofOfIdentityTypeId: number): Promise<void> {
const res: AxiosResponse<void> = await apiClient.delete(`/api/supporting_document_types/${proofOfIdentityTypeId}`);
return res?.data;
}
}

View File

@ -28,4 +28,9 @@ export default class TrainingAPI {
});
return res?.data;
}
static async destroy (trainingId: number): Promise<void> {
const res: AxiosResponse<void> = await apiClient.delete(`/api/trainings/${trainingId}`);
return res?.data;
}
}

View File

@ -23,17 +23,19 @@ export const AdvancedAccountingForm = <TFieldValues extends FieldValues>({ regis
SettingAPI.get('advanced_accounting').then(res => setIsEnabled(res.value === 'true')).catch(onError);
}, []);
return (
<div className="advanced-accounting-form">
{isEnabled && <div>
<h4>{t('app.admin.advanced_accounting_form.title')}</h4>
return (<>
{isEnabled && <>
<header>
<p className="title" role="heading">{t('app.admin.advanced_accounting_form.title')}</p>
</header>
<div className="content">
<FormInput register={register}
id="advanced_accounting_attributes.code"
label={t('app.admin.advanced_accounting_form.code')} />
id="advanced_accounting_attributes.code"
label={t('app.admin.advanced_accounting_form.code')} />
<FormInput register={register}
id="advanced_accounting_attributes.analytical_section"
label={t('app.admin.advanced_accounting_form.analytical_section')} />
</div>}
</div>
);
id="advanced_accounting_attributes.analytical_section"
label={t('app.admin.advanced_accounting_form.analytical_section')} />
</div>
</>}
</>);
};

View File

@ -1,4 +1,4 @@
import { UseFormRegister } from 'react-hook-form';
import { UseFormRegister, FormState } from 'react-hook-form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { useTranslation } from 'react-i18next';
import { FormInput } from '../form/form-input';
@ -6,12 +6,13 @@ import { FormInput } from '../form/form-input';
export interface BooleanMappingFormProps<TFieldValues> {
register: UseFormRegister<TFieldValues>,
fieldMappingId: number,
formState: FormState<TFieldValues>
}
/**
* Partial form to map an internal boolean field to an external API providing a string value.
*/
export const BooleanMappingForm = <TFieldValues extends FieldValues>({ register, fieldMappingId }: BooleanMappingFormProps<TFieldValues>) => {
export const BooleanMappingForm = <TFieldValues extends FieldValues>({ register, fieldMappingId, formState }: BooleanMappingFormProps<TFieldValues>) => {
const { t } = useTranslation('admin');
return (
@ -20,10 +21,12 @@ export const BooleanMappingForm = <TFieldValues extends FieldValues>({ register,
<FormInput id={`auth_provider_mappings_attributes.${fieldMappingId}.transformation.true_value`}
register={register}
rules={{ required: true }}
formState={formState}
label={t('app.admin.authentication.boolean_mapping_form.true_value')} />
<FormInput id={`auth_provider_mappings_attributes.${fieldMappingId}.transformation.false_value`}
register={register}
rules={{ required: true }}
formState={formState}
label={t('app.admin.authentication.boolean_mapping_form.false_value')} />
</div>
);

View File

@ -3,7 +3,7 @@ import { UseFormRegister, useFieldArray, ArrayPath, useWatch, Path, FieldPathVal
import { FieldValues } from 'react-hook-form/dist/types/fields';
import AuthProviderAPI from '../../api/auth-provider';
import { AuthenticationProviderMapping, MappingFields, mappingType, ProvidableType } from '../../models/authentication-provider';
import { Control, UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form';
import { Control, UnpackNestedValue, UseFormSetValue, FormState } from 'react-hook-form/dist/types/form';
import { FormSelect } from '../form/form-select';
import { FormInput } from '../form/form-input';
import { useTranslation } from 'react-i18next';
@ -19,6 +19,7 @@ export interface DataMappingFormProps<TFieldValues, TContext extends object> {
providerType: ProvidableType,
setValue: UseFormSetValue<TFieldValues>,
currentFormValues: Array<AuthenticationProviderMapping>,
formState: FormState<TFieldValues>
}
type selectModelFieldOption = { value: string, label: string };
@ -26,7 +27,7 @@ type selectModelFieldOption = { value: string, label: string };
/**
* Partial form to define the mapping of the data between the API of the authentication provider and the application internals.
*/
export const DataMappingForm = <TFieldValues extends FieldValues, TContext extends object>({ register, control, providerType, setValue, currentFormValues }: DataMappingFormProps<TFieldValues, TContext>) => {
export const DataMappingForm = <TFieldValues extends FieldValues, TContext extends object>({ register, control, providerType, setValue, currentFormValues, formState }: DataMappingFormProps<TFieldValues, TContext>) => {
const { t } = useTranslation('admin');
const [dataMapping, setDataMapping] = useState<MappingFields>(null);
const [isOpenTypeMappingModal, updateIsOpenTypeMappingModal] = useImmer<Map<number, boolean>>(new Map());
@ -144,20 +145,24 @@ export const DataMappingForm = <TFieldValues extends FieldValues, TContext exten
<FormInput id={`auth_provider_mappings_attributes.${index}.id`} register={register} type="hidden" />
<div className="local-data">
<FormSelect id={`auth_provider_mappings_attributes.${index}.local_model`}
control={control} rules={{ required: true }}
control={control}
rules={{ required: true }}
formState={formState}
options={buildModelOptions()}
label={t('app.admin.authentication.data_mapping_form.model')}/>
<FormSelect id={`auth_provider_mappings_attributes.${index}.local_field`}
options={buildFieldOptions(output, index)}
control={control}
rules={{ required: true }}
formState={formState}
label={t('app.admin.authentication.data_mapping_form.field')} />
</div>
<div className="remote-data">
{providerType === 'OAuth2Provider' && <Oauth2DataMappingForm register={register} control={control} index={index} />}
{providerType === 'OAuth2Provider' && <Oauth2DataMappingForm register={register} control={control} index={index} formState={formState} />}
{providerType === 'OpenIdConnectProvider' && <OpenidConnectDataMappingForm register={register}
index={index}
setValue={setValue}
formState={formState}
currentFormValues={currentFormValues} />}
</div>
</div>
@ -172,7 +177,9 @@ export const DataMappingForm = <TFieldValues extends FieldValues, TContext exten
type={getDataType(output, index)}
isOpen={isOpenTypeMappingModal.get(index)}
toggleModal={toggleTypeMappingModal(index)}
control={control} register={register}
control={control}
register={register}
formState={formState}
fieldMappingId={index} />
</div>
</div>

View File

@ -1,17 +1,18 @@
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { useTranslation } from 'react-i18next';
import { FormSelect } from '../form/form-select';
import { Control } from 'react-hook-form/dist/types/form';
import { Control, FormState } from 'react-hook-form/dist/types/form';
export interface DateMappingFormProps<TFieldValues, TContext extends object> {
control: Control<TFieldValues, TContext>,
fieldMappingId: number,
formState: FormState<TFieldValues>
}
/**
* Partial form for mapping an internal date field to an external API.
*/
export const DateMappingForm = <TFieldValues extends FieldValues, TContext extends object>({ control, fieldMappingId }: DateMappingFormProps<TFieldValues, TContext>) => {
export const DateMappingForm = <TFieldValues extends FieldValues, TContext extends object>({ control, fieldMappingId, formState }: DateMappingFormProps<TFieldValues, TContext>) => {
const { t } = useTranslation('admin');
// available date formats
@ -44,6 +45,7 @@ export const DateMappingForm = <TFieldValues extends FieldValues, TContext exten
<FormSelect id={`auth_provider_mappings_attributes.${fieldMappingId}.transformation.format`}
control={control}
rules={{ required: true }}
formState={formState}
options={dateFormats}
label={t('app.admin.authentication.date_mapping_form.date_format')} />
</div>

View File

@ -1,5 +1,5 @@
import { ArrayPath, useFieldArray, UseFormRegister } from 'react-hook-form';
import { Control } from 'react-hook-form/dist/types/form';
import { Control, FormState } from 'react-hook-form/dist/types/form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { useTranslation } from 'react-i18next';
import { FabButton } from '../base/fab-button';
@ -9,12 +9,13 @@ export interface IntegerMappingFormProps<TFieldValues, TContext extends object>
register: UseFormRegister<TFieldValues>,
control: Control<TFieldValues, TContext>,
fieldMappingId: number,
formState: FormState<TFieldValues>
}
/**
* Partial for to map an internal integer field to an external API providing a string value.
*/
export const IntegerMappingForm = <TFieldValues extends FieldValues, TContext extends object>({ register, control, fieldMappingId }: IntegerMappingFormProps<TFieldValues, TContext>) => {
export const IntegerMappingForm = <TFieldValues extends FieldValues, TContext extends object>({ register, control, fieldMappingId, formState }: IntegerMappingFormProps<TFieldValues, TContext>) => {
const { t } = useTranslation('admin');
const { fields, append, remove } = useFieldArray({ control, name: 'auth_provider_mappings_attributes_transformation_mapping' as ArrayPath<TFieldValues> });
@ -33,11 +34,13 @@ export const IntegerMappingForm = <TFieldValues extends FieldValues, TContext ex
<FormInput id={`auth_provider_mappings_attributes.${fieldMappingId}.transformation.mapping.${index}.from`}
register={register}
rules={{ required: true }}
formState={formState}
label={t('app.admin.authentication.integer_mapping_form.mapping_from')} />
<FormInput id={`auth_provider_mappings_attributes.${fieldMappingId}.transformation.mapping.${index}.to`}
register={register}
type="number"
rules={{ required: true }}
formState={formState}
label={t('app.admin.authentication.integer_mapping_form.mapping_to')} />
</div>
<div className="actions">

View File

@ -1,5 +1,5 @@
import { UseFormRegister } from 'react-hook-form';
import { Control } from 'react-hook-form/dist/types/form';
import { Control, FormState } from 'react-hook-form/dist/types/form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { FormInput } from '../form/form-input';
import { FormSelect } from '../form/form-select';
@ -10,13 +10,14 @@ interface Oauth2DataMappingFormProps<TFieldValues, TContext extends object> {
register: UseFormRegister<TFieldValues>,
control: Control<TFieldValues, TContext>,
index: number,
formState: FormState<TFieldValues>
}
/**
* Partial form to set the data mapping for an OAuth 2.0 provider.
* The data mapping is the way to bind data from the authentication provider API to the Fab-manager's database
*/
export const Oauth2DataMappingForm = <TFieldValues extends FieldValues, TContext extends object>({ register, control, index }: Oauth2DataMappingFormProps<TFieldValues, TContext>) => {
export const Oauth2DataMappingForm = <TFieldValues extends FieldValues, TContext extends object>({ register, control, index, formState }: Oauth2DataMappingFormProps<TFieldValues, TContext>) => {
const { t } = useTranslation('admin');
return (
@ -24,15 +25,19 @@ export const Oauth2DataMappingForm = <TFieldValues extends FieldValues, TContext
<FormInput id={`auth_provider_mappings_attributes.${index}.api_endpoint`}
register={register}
rules={{ required: true }}
formState={formState}
placeholder="/api/resource..."
label={t('app.admin.authentication.oauth2_data_mapping_form.api_endpoint_url')} />
<FormSelect id={`auth_provider_mappings_attributes.${index}.api_data_type`}
options={[{ label: 'JSON', value: 'json' }]}
control={control} rules={{ required: true }}
control={control}
rules={{ required: true }}
formState={formState}
label={t('app.admin.authentication.oauth2_data_mapping_form.api_type')} />
<FormInput id={`auth_provider_mappings_attributes.${index}.api_field`}
register={register}
rules={{ required: true }}
formState={formState}
placeholder="field_name..."
tooltip={<HtmlTranslate trKey="app.admin.authentication.oauth2_data_mapping_form.api_field_help_html" />}
label={t('app.admin.authentication.oauth2_data_mapping_form.api_field')} />

View File

@ -1,18 +1,19 @@
import { FormInput } from '../form/form-input';
import { UseFormRegister } from 'react-hook-form';
import { UseFormRegister, FormState } from 'react-hook-form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { useTranslation } from 'react-i18next';
import { FabOutputCopy } from '../base/fab-output-copy';
interface Oauth2FormProps<TFieldValues> {
register: UseFormRegister<TFieldValues>,
formState: FormState<TFieldValues>,
strategyName?: string,
}
/**
* Partial form to fill the OAuth2 settings for a new/existing authentication provider.
*/
export const Oauth2Form = <TFieldValues extends FieldValues>({ register, strategyName }: Oauth2FormProps<TFieldValues>) => {
export const Oauth2Form = <TFieldValues extends FieldValues>({ register, strategyName, formState }: Oauth2FormProps<TFieldValues>) => {
const { t } = useTranslation('admin');
// regular expression to validate the input fields
@ -34,31 +35,37 @@ export const Oauth2Form = <TFieldValues extends FieldValues>({ register, strateg
register={register}
placeholder="https://sso.example.net..."
label={t('app.admin.authentication.oauth2_form.common_url')}
rules={{ required: true, pattern: urlRegex }} />
rules={{ required: true, pattern: urlRegex }}
formState={formState} />
<FormInput id="providable_attributes.authorization_endpoint"
register={register}
placeholder="/oauth2/auth..."
label={t('app.admin.authentication.oauth2_form.authorization_endpoint')}
rules={{ required: true, pattern: endpointRegex }} />
rules={{ required: true, pattern: endpointRegex }}
formState={formState} />
<FormInput id="providable_attributes.token_endpoint"
register={register}
placeholder="/oauth2/token..."
label={t('app.admin.authentication.oauth2_form.token_acquisition_endpoint')}
rules={{ required: true, pattern: endpointRegex }} />
rules={{ required: true, pattern: endpointRegex }}
formState={formState} />
<FormInput id="providable_attributes.profile_url"
register={register}
placeholder="https://exemple.net/user..."
label={t('app.admin.authentication.oauth2_form.profile_edition_url')}
tooltip={t('app.admin.authentication.oauth2_form.profile_edition_url_help')}
rules={{ required: true, pattern: urlRegex }} />
rules={{ required: true, pattern: urlRegex }}
formState={formState} />
<FormInput id="providable_attributes.client_id"
register={register}
label={t('app.admin.authentication.oauth2_form.client_identifier')}
rules={{ required: true }} />
rules={{ required: true }}
formState={formState} />
<FormInput id="providable_attributes.client_secret"
register={register}
label={t('app.admin.authentication.oauth2_form.client_secret')}
rules={{ required: true }} />
rules={{ required: true }}
formState={formState} />
<FormInput id="providable_attributes.scopes" register={register}
placeholder="profile,email..."
label={t('app.admin.authentication.oauth2_form.scopes')} />

View File

@ -3,7 +3,7 @@ import { FieldValues } from 'react-hook-form/dist/types/fields';
import { FormInput } from '../form/form-input';
import { HtmlTranslate } from '../base/html-translate';
import { useTranslation } from 'react-i18next';
import { UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form';
import { UnpackNestedValue, UseFormSetValue, FormState } from 'react-hook-form/dist/types/form';
import { FabButton } from '../base/fab-button';
import { FieldPathValue } from 'react-hook-form/dist/types/path';
import { AuthenticationProviderMapping } from '../../models/authentication-provider';
@ -13,13 +13,14 @@ interface OpenidConnectDataMappingFormProps<TFieldValues> {
setValue: UseFormSetValue<TFieldValues>,
currentFormValues: Array<AuthenticationProviderMapping>,
index: number,
formState: FormState<TFieldValues>
}
/**
* Partial form to set the data mapping for an OpenID Connect provider.
* The data mapping is the way to bind data from the OIDC claims to the Fab-manager's database
*/
export const OpenidConnectDataMappingForm = <TFieldValues extends FieldValues>({ register, setValue, currentFormValues, index }: OpenidConnectDataMappingFormProps<TFieldValues>) => {
export const OpenidConnectDataMappingForm = <TFieldValues extends FieldValues>({ register, setValue, currentFormValues, index, formState }: OpenidConnectDataMappingFormProps<TFieldValues>) => {
const { t } = useTranslation('admin');
const standardConfiguration = {
@ -65,15 +66,18 @@ export const OpenidConnectDataMappingForm = <TFieldValues extends FieldValues>({
type="hidden"
register={register}
rules={{ required: true }}
formState={formState}
defaultValue="user_info" />
<FormInput id={`auth_provider_mappings_attributes.${index}.api_data_type`}
type="hidden"
register={register}
rules={{ required: true }}
formState={formState}
defaultValue="json" />
<FormInput id={`auth_provider_mappings_attributes.${index}.api_field`}
register={register}
rules={{ required: true }}
formState={formState}
placeholder="claim..."
tooltip={<HtmlTranslate trKey="app.admin.authentication.openid_connect_data_mapping_form.api_field_help_html" />}
label={t('app.admin.authentication.openid_connect_data_mapping_form.api_field')} />

View File

@ -161,41 +161,49 @@ export const OpenidConnectForm = <TFieldValues extends FieldValues, TContext ext
placeholder="https://sso.exemple.com/my-account"
label={t('app.admin.authentication.openid_connect_form.profile_edition_url')}
tooltip={t('app.admin.authentication.openid_connect_form.profile_edition_url_help')}
rules={{ required: false, pattern: urlRegex }} />
rules={{ required: false, pattern: urlRegex }}
formState={formState} />
<h4>{t('app.admin.authentication.openid_connect_form.client_options')}</h4>
<FormInput id="providable_attributes.client__identifier"
label={t('app.admin.authentication.openid_connect_form.client__identifier')}
rules={{ required: true }}
formState={formState}
register={register} />
<FormInput id="providable_attributes.client__secret"
label={t('app.admin.authentication.openid_connect_form.client__secret')}
rules={{ required: true }}
formState={formState}
register={register} />
{!currentFormValues?.discovery && <div className="client-options-without-discovery">
<FormInput id="providable_attributes.client__authorization_endpoint"
label={t('app.admin.authentication.openid_connect_form.client__authorization_endpoint')}
placeholder="/authorize"
rules={{ required: !currentFormValues?.discovery, pattern: endpointRegex }}
formState={formState}
register={register} />
<FormInput id="providable_attributes.client__token_endpoint"
label={t('app.admin.authentication.openid_connect_form.client__token_endpoint')}
placeholder="/token"
rules={{ required: !currentFormValues?.discovery, pattern: endpointRegex }}
formState={formState}
register={register} />
<FormInput id="providable_attributes.client__userinfo_endpoint"
label={t('app.admin.authentication.openid_connect_form.client__userinfo_endpoint')}
placeholder="/userinfo"
rules={{ required: !currentFormValues?.discovery, pattern: endpointRegex }}
formState={formState}
register={register} />
{currentFormValues?.client_auth_method === 'jwks' && <FormInput id="providable_attributes.client__jwks_uri"
label={t('app.admin.authentication.openid_connect_form.client__jwks_uri')}
rules={{ required: currentFormValues.client_auth_method === 'jwks', pattern: endpointRegex }}
formState={formState}
placeholder="/jwk"
register={register} />}
<FormInput id="providable_attributes.client__end_session_endpoint"
label={t('app.admin.authentication.openid_connect_form.client__end_session_endpoint')}
tooltip={t('app.admin.authentication.openid_connect_form.client__end_session_endpoint_help')}
rules={{ pattern: endpointRegex }}
formState={formState}
register={register} />
</div>}
</div>

View File

@ -99,6 +99,7 @@ export const ProviderForm: React.FC<ProviderFormProps> = ({ action, provider, on
register={register}
disabled={action === 'update'}
rules={{ required: true }}
formState={formState}
label={t('app.admin.authentication.provider_form.name')} />
<FormSelect id="providable_type"
control={control}
@ -106,9 +107,10 @@ export const ProviderForm: React.FC<ProviderFormProps> = ({ action, provider, on
label={t('app.admin.authentication.provider_form.authentication_type')}
onChange={onProvidableTypeChange}
disabled={action === 'update'}
rules={{ required: true }} />
rules={{ required: true }}
formState={formState} />
{providableType === 'DatabaseProvider' && <DatabaseForm register={register} />}
{providableType === 'OAuth2Provider' && <Oauth2Form register={register} strategyName={strategyName} />}
{providableType === 'OAuth2Provider' && <Oauth2Form register={register} strategyName={strategyName} formState={formState} />}
{providableType === 'OpenIdConnectProvider' && <OpenidConnectForm register={register}
control={control}
currentFormValues={output.providable_attributes as OpenIdConnectProvider}
@ -116,6 +118,7 @@ export const ProviderForm: React.FC<ProviderFormProps> = ({ action, provider, on
setValue={setValue} />}
{providableType && providableType !== 'DatabaseProvider' && <DataMappingForm register={register}
control={control}
formState={formState}
providerType={providableType}
setValue={setValue}
currentFormValues={output.auth_provider_mappings_attributes as Array<AuthenticationProviderMapping>} />}

View File

@ -1,5 +1,5 @@
import { ArrayPath, useFieldArray, UseFormRegister } from 'react-hook-form';
import { Control } from 'react-hook-form/dist/types/form';
import { Control, FormState } from 'react-hook-form/dist/types/form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { useTranslation } from 'react-i18next';
import { FabButton } from '../base/fab-button';
@ -9,12 +9,13 @@ export interface StringMappingFormProps<TFieldValues, TContext extends object> {
register: UseFormRegister<TFieldValues>,
control: Control<TFieldValues, TContext>,
fieldMappingId: number,
formState: FormState<TFieldValues>
}
/**
* Partial form to map an internal string field to an external API.
*/
export const StringMappingForm = <TFieldValues extends FieldValues, TContext extends object>({ register, control, fieldMappingId }: StringMappingFormProps<TFieldValues, TContext>) => {
export const StringMappingForm = <TFieldValues extends FieldValues, TContext extends object>({ register, control, fieldMappingId, formState }: StringMappingFormProps<TFieldValues, TContext>) => {
const { t } = useTranslation('admin');
const { fields, append, remove } = useFieldArray({ control, name: 'auth_provider_mappings_attributes_transformation_mapping' as ArrayPath<TFieldValues> });
@ -33,10 +34,12 @@ export const StringMappingForm = <TFieldValues extends FieldValues, TContext ext
<FormInput id={`auth_provider_mappings_attributes.${fieldMappingId}.transformation.mapping.${index}.from`}
register={register}
rules={{ required: true }}
formState={formState}
label={t('app.admin.authentication.string_mapping_form.mapping_from')} />
<FormInput id={`auth_provider_mappings_attributes.${fieldMappingId}.transformation.mapping.${index}.to`}
register={register}
rules={{ required: true }}
formState={formState}
label={t('app.admin.authentication.string_mapping_form.mapping_to')} />
</div>
<div className="actions">

View File

@ -2,7 +2,7 @@ import { FabModal } from '../base/fab-modal';
import { useTranslation } from 'react-i18next';
import { IntegerMappingForm } from './integer-mapping-form';
import { UseFormRegister } from 'react-hook-form';
import { Control } from 'react-hook-form/dist/types/form';
import { Control, FormState } from 'react-hook-form/dist/types/form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { mappingType } from '../../models/authentication-provider';
import { BooleanMappingForm } from './boolean-mapping-form';
@ -19,6 +19,7 @@ interface TypeMappingModalProps<TFieldValues, TContext extends object> {
register: UseFormRegister<TFieldValues>,
control: Control<TFieldValues, TContext>,
fieldMappingId: number,
formState: FormState<TFieldValues>
}
/**
@ -27,7 +28,7 @@ interface TypeMappingModalProps<TFieldValues, TContext extends object> {
*
* This component is intended to be used in a react-hook-form context.
*/
export const TypeMappingModal = <TFieldValues extends FieldValues, TContext extends object>({ model, field, type, isOpen, toggleModal, register, control, fieldMappingId }:TypeMappingModalProps<TFieldValues, TContext>) => {
export const TypeMappingModal = <TFieldValues extends FieldValues, TContext extends object>({ model, field, type, isOpen, toggleModal, register, control, fieldMappingId, formState }:TypeMappingModalProps<TFieldValues, TContext>) => {
const { t } = useTranslation('admin');
return (
@ -42,10 +43,10 @@ export const TypeMappingModal = <TFieldValues extends FieldValues, TContext exte
id={`auth_provider_mappings_attributes.${fieldMappingId}.transformation.type`}
type="hidden"
defaultValue={type} />
{type === 'integer' && <IntegerMappingForm register={register} control={control} fieldMappingId={fieldMappingId} />}
{type === 'boolean' && <BooleanMappingForm register={register} fieldMappingId={fieldMappingId} />}
{type === 'date' && <DateMappingForm control={control} fieldMappingId={fieldMappingId} />}
{type === 'string' && <StringMappingForm register={register} control={control} fieldMappingId={fieldMappingId} />}
{type === 'integer' && <IntegerMappingForm register={register} control={control} fieldMappingId={fieldMappingId} formState={formState} />}
{type === 'boolean' && <BooleanMappingForm register={register} fieldMappingId={fieldMappingId} formState={formState} />}
{type === 'date' && <DateMappingForm control={control} fieldMappingId={fieldMappingId} formState={formState} />}
{type === 'string' && <StringMappingForm register={register} control={control} fieldMappingId={fieldMappingId} formState={formState} />}
</FabModal>
);
};

View File

@ -0,0 +1,63 @@
import { ReactNode, useState } from 'react';
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { FabButton } from './fab-button';
import { FabModal } from './fab-modal';
import { Trash } from 'phosphor-react';
interface DestroyButtonProps {
onSuccess: (message: string) => void,
onError: (message: string) => void,
itemId: number,
itemType: string,
apiDestroy: (itemId: number) => Promise<void>,
confirmationMessage?: string|ReactNode,
className?: string,
iconSize?: number
}
/**
* This component shows a button.
* When clicked, we show a modal dialog to ask the user for confirmation about the deletion of the provided item.
*/
export const DestroyButton: React.FC<DestroyButtonProps> = ({ onSuccess, onError, itemId, itemType, apiDestroy, confirmationMessage, className, iconSize = 24 }) => {
const { t } = useTranslation('admin');
const [deletionModal, setDeletionModal] = useState<boolean>(false);
/**
* Opens/closes the deletion modal
*/
const toggleDeletionModal = (): void => {
setDeletionModal(!deletionModal);
};
/**
* The deletion has been confirmed by the user.
* Call the API to trigger the deletion of the given item
*/
const onDeleteConfirmed = (): void => {
apiDestroy(itemId).then(() => {
onSuccess(t('app.admin.destroy_button.deleted', { TYPE: itemType }));
}).catch((error) => {
onError(t('app.admin.destroy_button.unable_to_delete', { TYPE: itemType }) + error);
});
toggleDeletionModal();
};
return (
<div className='destroy-button'>
<FabButton type='button' className={className} onClick={toggleDeletionModal}>
<Trash size={iconSize} weight="fill" />
</FabButton>
<FabModal title={t('app.admin.destroy_button.delete_item', { TYPE: itemType })}
isOpen={deletionModal}
toggleModal={toggleDeletionModal}
closeButton={true}
confirmButton={t('app.admin.destroy_button.confirm_delete')}
onConfirm={onDeleteConfirmed}>
<span>{confirmationMessage || t('app.admin.destroy_button.delete_confirmation', { TYPE: itemType })}</span>
</FabModal>
</div>
);
};

View File

@ -0,0 +1,70 @@
import { PencilSimple, Trash } from 'phosphor-react';
import * as React from 'react';
import { ReactNode, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FabButton } from './fab-button';
import { FabModal } from './fab-modal';
interface EditDestroyButtonsProps {
onDeleteSuccess: (message: string) => void,
onError: (message: string) => void,
onEdit: () => void,
itemId: number,
itemType: string,
apiDestroy: (itemId: number) => Promise<void>,
confirmationMessage?: string|ReactNode,
className?: string,
iconSize?: number
}
/**
* This component shows a group of two buttons.
* Destroy : shows a modal dialog to ask the user for confirmation about the deletion of the provided item.
* Edit : triggers the provided function.
*/
export const EditDestroyButtons: React.FC<EditDestroyButtonsProps> = ({ onDeleteSuccess, onError, onEdit, itemId, itemType, apiDestroy, confirmationMessage, className, iconSize = 20 }) => {
const { t } = useTranslation('admin');
const [deletionModal, setDeletionModal] = useState<boolean>(false);
/**
* Opens/closes the deletion modal
*/
const toggleDeletionModal = (): void => {
setDeletionModal(!deletionModal);
};
/**
* The deletion has been confirmed by the user.
* Call the API to trigger the deletion of the given item
*/
const onDeleteConfirmed = (): void => {
apiDestroy(itemId).then(() => {
onDeleteSuccess(t('app.admin.edit_destroy_buttons.deleted', { TYPE: itemType }));
}).catch((error) => {
onError(t('app.admin.edit_destroy_buttons.unable_to_delete', { TYPE: itemType }) + error);
});
toggleDeletionModal();
};
return (
<>
<div className={`edit-destroy-buttons ${className || ''}`}>
<FabButton className='edit-btn' onClick={onEdit}>
<PencilSimple size={iconSize} weight="fill" />
</FabButton>
<FabButton type='button' className='delete-btn' onClick={toggleDeletionModal}>
<Trash size={iconSize} weight="fill" />
</FabButton>
</div>
<FabModal title={t('app.admin.edit_destroy_buttons.delete_item', { TYPE: itemType })}
isOpen={deletionModal}
toggleModal={toggleDeletionModal}
closeButton={true}
confirmButton={t('app.admin.edit_destroy_buttons.confirm_delete')}
onConfirm={onDeleteConfirmed}>
<span>{confirmationMessage || t('app.admin.edit_destroy_buttons.delete_confirmation', { TYPE: itemType })}</span>
</FabModal>
</>
);
};

View File

@ -12,18 +12,16 @@ interface FabPanelProps {
*/
export const FabPanel: React.FC<FabPanelProps> = ({ className, header, size, children }) => {
return (
<div className={`fab-panel ${className || ''}`}>
{header && <div>
<div className={`fab-panel ${className || ''} ${!header ? 'no-header' : ''}`}>
{header && <>
<div className={`panel-header ${size}`}>
{header}
</div>
<div className="panel-content">
{children}
</div>
</div>}
{!header && <div className="no-header">
{children}
</div>}
</>}
{!header && <>{ children }</>}
</div>
);
};

View File

@ -3,16 +3,19 @@ import { useTranslation } from 'react-i18next';
interface HtmlTranslateProps {
trKey: string,
className?: string,
options?: Record<string, string|number>
}
/**
* This component renders a translation with some HTML content.
*/
export const HtmlTranslate: React.FC<HtmlTranslateProps> = ({ trKey, options }) => {
export const HtmlTranslate: React.FC<HtmlTranslateProps> = ({ trKey, className, options }) => {
const { t } = useTranslation(trKey?.split('.')[1]);
/* eslint-disable fabmanager/component-class-named-as-component */
return (
<span dangerouslySetInnerHTML={{ __html: t(trKey, options) }} />
<span className={className || ''} dangerouslySetInnerHTML={{ __html: t(trKey, options) }} />
);
/* eslint-enable fabmanager/component-class-named-as-component */
};

View File

@ -0,0 +1,100 @@
import * as React from 'react';
import noImage from '../../../../images/no_image.png';
import FormatLib from '../../lib/format';
import OrderLib from '../../lib/order';
import { FabButton } from '../base/fab-button';
import Switch from 'react-switch';
import type { ItemError, OrderItem } from '../../models/order';
import { useTranslation } from 'react-i18next';
import { ReactNode } from 'react';
import { Order } from '../../models/order';
import CartAPI from '../../api/cart';
interface AbstractItemProps {
item: OrderItem,
errors: Array<ItemError>,
cart: Order,
setCart: (cart: Order) => void,
reloadCart: () => Promise<void>,
onError: (message: string) => void,
className?: string,
offerItemLabel?: string,
privilegedOperator: boolean,
actions?: ReactNode
}
/**
* This component shares the common code for items in the cart (product, cart-item, etc)
*/
export const AbstractItem: React.FC<AbstractItemProps> = ({ item, errors, cart, setCart, reloadCart, onError, className, offerItemLabel, privilegedOperator, actions, children }) => {
const { t } = useTranslation('public');
/**
* Return the callback triggered when then user remove the given item from the cart
*/
const handleRemoveItem = (item: OrderItem) => {
return (e: React.BaseSyntheticEvent) => {
e.preventDefault();
e.stopPropagation();
if (errors.length === 1 && errors[0].error === 'not_found') {
reloadCart().catch(onError);
} else {
CartAPI.removeItem(cart, item.orderable_id, item.orderable_type).then(data => {
setCart(data);
}).catch(onError);
}
};
};
/**
* Return the callback triggered when the privileged user enable/disable the offered attribute for the given item
*/
const handleToggleOffer = (item: OrderItem) => {
return (checked: boolean) => {
CartAPI.setOffer(cart, item.orderable_id, item.orderable_type, checked).then(data => {
setCart(data);
}).catch(e => {
if (e.match(/code 403/)) {
onError(t('app.public.abstract_item.errors.unauthorized_offering_product'));
} else {
onError(e);
}
});
};
};
return (
<article className={`item ${className || ''} ${errors.length > 0 ? 'error' : ''}`}>
<div className='picture'>
<img alt='' src={item.orderable_main_image_url || noImage} />
</div>
{children}
<div className="actions">
{actions}
<div className='total'>
<span>{t('app.public.abstract_item.total')}</span>
<p>{FormatLib.price(OrderLib.itemAmount(item))}</p>
</div>
<FabButton className="main-action-btn" onClick={handleRemoveItem(item)}>
<i className="fa fa-trash" />
</FabButton>
</div>
{privilegedOperator &&
<div className='offer'>
<label>
<span>{offerItemLabel || t('app.public.abstract_item.offer_product')}</span>
<Switch
checked={item.is_offered || false}
onChange={handleToggleOffer(item)}
width={40}
height={19}
uncheckedIcon={false}
checkedIcon={false}
handleDiameter={15} />
</label>
</div>
}
</article>
);
};

View File

@ -0,0 +1,142 @@
import * as React from 'react';
import FormatLib from '../../lib/format';
import { CaretDown, CaretUp } from 'phosphor-react';
import type { OrderProduct, OrderErrors, Order, ItemError } from '../../models/order';
import { useTranslation } from 'react-i18next';
import _ from 'lodash';
import CartAPI from '../../api/cart';
import { AbstractItem } from './abstract-item';
import { ReactNode } from 'react';
interface CartOrderProductProps {
item: OrderProduct,
cartErrors: OrderErrors,
className?: string,
cart: Order,
setCart: (cart: Order) => void,
reloadCart: () => Promise<void>,
onError: (message: string) => void,
privilegedOperator: boolean,
}
/**
* This component shows a product in the cart
*/
export const CartOrderProduct: React.FC<CartOrderProductProps> = ({ item, cartErrors, className, cart, setCart, reloadCart, onError, privilegedOperator }) => {
const { t } = useTranslation('public');
/**
* Get the given item's errors
*/
const getItemErrors = (item: OrderProduct): Array<ItemError> => {
if (!cartErrors) return [];
const errors = _.find(cartErrors.details, (e) => e.item_id === item.id);
return errors?.errors || [{ error: 'not_found' }];
};
/**
* Show an human-readable styled error for the given item's error
*/
const itemError = (item: OrderProduct, error) => {
if (error.error === 'is_active' || error.error === 'not_found') {
return <div className='error'><p>{t('app.public.cart_order_product.errors.product_not_found')}</p></div>;
}
if (error.error === 'stock' && error.value === 0) {
return <div className='error'><p>{t('app.public.cart_order_product.errors.out_of_stock')}</p></div>;
}
if (error.error === 'stock' && error.value > 0) {
return <div className='error'><p>{t('app.public.cart_order_product.errors.stock_limit_QUANTITY', { QUANTITY: error.value })}</p></div>;
}
if (error.error === 'quantity_min') {
return <div className='error'><p>{t('app.public.cart_order_product.errors.quantity_min_QUANTITY', { QUANTITY: error.value })}</p></div>;
}
if (error.error === 'amount') {
return <div className='error'>
<p>{t('app.public.cart_order_product.errors.price_changed_PRICE', { PRICE: `${FormatLib.price(error.value)} / ${t('app.public.cart_order_product.unit')}` })}</p>
<span className='refresh-btn' onClick={refreshItem(item)}>{t('app.public.cart_order_product.update_item')}</span>
</div>;
}
};
/**
* Refresh product amount
*/
const refreshItem = (item: OrderProduct) => {
return (e: React.BaseSyntheticEvent) => {
e.preventDefault();
e.stopPropagation();
CartAPI.refreshItem(cart, item.orderable_id, item.orderable_type).then(data => {
setCart(data);
}).catch(onError);
};
};
/**
* Change product quantity
*/
const changeProductQuantity = (e: React.BaseSyntheticEvent, item: OrderProduct) => {
CartAPI.setQuantity(cart, item.orderable_id, item.orderable_type, e.target.value)
.then(data => {
setCart(data);
})
.catch(() => onError(t('app.public.cart_order_product.stock_limit')));
};
/**
* Increment/decrement product quantity
*/
const increaseOrDecreaseProductQuantity = (item: OrderProduct, direction: 'up' | 'down') => {
CartAPI.setQuantity(cart, item.orderable_id, item.orderable_type, direction === 'up' ? item.quantity + 1 : item.quantity - 1)
.then(data => {
setCart(data);
})
.catch(() => onError(t('app.public.cart_order_product.stock_limit')));
};
/**
* Return the components in the "actions" section of the item
*/
const buildActions = (): ReactNode => {
return (
<>
<div className='price'>
<p>{FormatLib.price(item.amount)}</p>
<span>/ {t('app.public.cart_order_product.unit')}</span>
</div>
<div className='quantity'>
<input type='number'
onChange={e => changeProductQuantity(e, item)}
min={item.quantity_min}
max={item.orderable_external_stock}
value={item.quantity}
/>
<button onClick={() => increaseOrDecreaseProductQuantity(item, 'up')}><CaretUp size={12} weight="fill" /></button>
<button onClick={() => increaseOrDecreaseProductQuantity(item, 'down')}><CaretDown size={12} weight="fill" /></button>
</div>
</>
);
};
return (
<AbstractItem className={`cart-order-product ${className || ''}`}
errors={getItemErrors(item)}
setCart={setCart}
cart={cart}
onError={onError}
reloadCart={reloadCart}
item={item}
privilegedOperator={privilegedOperator}
actions={buildActions()}>
<div className="ref">
<span>{t('app.public.cart_order_product.reference_short')} {item.orderable_ref || ''}</span>
<p><a className="text-black" href={`/#!/store/p/${item.orderable_slug}`}>{item.orderable_name}</a></p>
{item.quantity_min > 1 &&
<span className='min'>{t('app.public.cart_order_product.minimum_purchase')}{item.quantity_min}</span>
}
{getItemErrors(item).map(e => {
return itemError(item, e);
})}
</div>
</AbstractItem>
);
};

View File

@ -0,0 +1,61 @@
import * as React from 'react';
import type { OrderErrors, Order } from '../../models/order';
import { useTranslation } from 'react-i18next';
import _ from 'lodash';
import { AbstractItem } from './abstract-item';
import { OrderCartItemReservation } from '../../models/order';
import FormatLib from '../../lib/format';
interface CartOrderReservationProps {
item: OrderCartItemReservation,
cartErrors: OrderErrors,
className?: string,
cart: Order,
setCart: (cart: Order) => void,
reloadCart: () => Promise<void>,
onError: (message: string) => void,
privilegedOperator: boolean,
}
/**
* This component shows a product in the cart
*/
export const CartOrderReservation: React.FC<CartOrderReservationProps> = ({ item, cartErrors, className, cart, setCart, reloadCart, onError, privilegedOperator }) => {
const { t } = useTranslation('public');
/**
* Get the given item's errors
*/
const getItemErrors = (item: OrderCartItemReservation) => {
if (!cartErrors) return [];
const errors = _.find(cartErrors.details, (e) => e.item_id === item.id);
return errors?.errors || [{ error: 'not_found' }];
};
return (
<AbstractItem className={`cart-order-reservation ${className || ''}`}
errors={getItemErrors(item)}
item={item}
cart={cart}
setCart={setCart}
onError={onError}
reloadCart={reloadCart}
actions={<div/>}
offerItemLabel={t('app.public.cart_order_reservation.offer_reservation')}
privilegedOperator={privilegedOperator}>
<div className="ref">
<p>{t('app.public.cart_order_reservation.reservation')} {item.orderable_name}</p>
<ul>{item.slots_reservations.map(sr => (
<li key={sr.id}>
{
t('app.public.cart_order_reservation.slot',
{ DATE: FormatLib.date(sr.slot.start_at), START: FormatLib.time(sr.slot.start_at), END: FormatLib.time(sr.slot.end_at) })
}
<span>{sr.offered ? t('app.public.cart_order_reservation.offered') : ''}</span>
</li>
))}</ul>
{getItemErrors(item)}
</div>
</AbstractItem>
);
};

View File

@ -3,24 +3,23 @@ import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';
import { IApplication } from '../../models/application';
import type { IApplication } from '../../models/application';
import { FabButton } from '../base/fab-button';
import useCart from '../../hooks/use-cart';
import FormatLib from '../../lib/format';
import CartAPI from '../../api/cart';
import { User } from '../../models/user';
import type { User } from '../../models/user';
import { PaymentModal } from '../payment/stripe/payment-modal';
import { PaymentMethod } from '../../models/payment';
import { Order, OrderErrors } from '../../models/order';
import type { Order, OrderCartItemReservation, OrderErrors, OrderProduct } from '../../models/order';
import { MemberSelect } from '../user/member-select';
import { CouponInput } from '../coupon/coupon-input';
import { Coupon } from '../../models/coupon';
import noImage from '../../../../images/no_image.png';
import Switch from 'react-switch';
import type { Coupon } from '../../models/coupon';
import OrderLib from '../../lib/order';
import { CaretDown, CaretUp } from 'phosphor-react';
import _ from 'lodash';
import OrderAPI from '../../api/order';
import { CartOrderProduct } from './cart-order-product';
import { CartOrderReservation } from './cart-order-reservation';
declare const Application: IApplication;
@ -54,59 +53,6 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
}
}, [cart]);
/**
* Remove the product from cart
*/
const removeProductFromCart = (item) => {
return (e: React.BaseSyntheticEvent) => {
e.preventDefault();
e.stopPropagation();
const errors = getItemErrors(item);
if (errors.length === 1 && errors[0].error === 'not_found') {
reloadCart().catch(onError);
} else {
CartAPI.removeItem(cart, item.orderable_id).then(data => {
setCart(data);
}).catch(onError);
}
};
};
/**
* Change product quantity
*/
const changeProductQuantity = (e: React.BaseSyntheticEvent, item) => {
CartAPI.setQuantity(cart, item.orderable_id, e.target.value)
.then(data => {
setCart(data);
})
.catch(() => onError(t('app.public.store_cart.stock_limit')));
};
/**
* Increment/decrement product quantity
*/
const increaseOrDecreaseProductQuantity = (item, direction: 'up' | 'down') => {
CartAPI.setQuantity(cart, item.orderable_id, direction === 'up' ? item.quantity + 1 : item.quantity - 1)
.then(data => {
setCart(data);
})
.catch(() => onError(t('app.public.store_cart.stock_limit')));
};
/**
* Refresh product amount
*/
const refreshItem = (item) => {
return (e: React.BaseSyntheticEvent) => {
e.preventDefault();
e.stopPropagation();
CartAPI.refreshItem(cart, item.orderable_id).then(data => {
setCart(data);
}).catch(onError);
};
};
/**
* Check the current cart's items (available, price, stock, quantity_min)
*/
@ -149,15 +95,6 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
return false;
};
/**
* get givean item's error
*/
const getItemErrors = (item) => {
if (!cartErrors) return [];
const errors = _.find(cartErrors.details, (e) => e.item_id === item.id);
return errors?.errors || [{ error: 'not_found' }];
};
/**
* Open/closes the payment modal
*/
@ -179,17 +116,10 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
};
/**
* Change cart's customer by admin/manger
* Change cart's customer by admin/manager
*/
const handleChangeMember = (user: User): void => {
// if the selected user is the operator, he cannot offer products to himself
if (user.id === currentUser.id && cart.order_items_attributes.filter(item => item.is_offered).length > 0) {
Promise.all(cart.order_items_attributes.filter(item => item.is_offered).map(item => {
return CartAPI.setOffer(cart, item.orderable_id, false);
})).then((data) => setCart({ ...data[data.length - 1], user: { id: user.id, role: user.role } }));
} else {
setCart({ ...cart, user: { id: user.id, role: user.role } });
}
CartAPI.setCustomer(cart, user.id).then(setCart).catch(onError);
};
/**
@ -206,23 +136,6 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
return cart && cart.order_items_attributes.length === 0;
};
/**
* Toggle product offer
*/
const toggleProductOffer = (item) => {
return (checked: boolean) => {
CartAPI.setOffer(cart, item.orderable_id, checked).then(data => {
setCart(data);
}).catch(e => {
if (e.match(/code 403/)) {
onError(t('app.public.store_cart.errors.unauthorized_offering_product'));
} else {
onError(e);
}
});
};
};
/**
* Apply coupon to current cart
*/
@ -232,89 +145,36 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
}
};
/**
* Show item error
*/
const itemError = (item, error) => {
if (error.error === 'is_active' || error.error === 'not_found') {
return <div className='error'><p>{t('app.public.store_cart.errors.product_not_found')}</p></div>;
}
if (error.error === 'stock' && error.value === 0) {
return <div className='error'><p>{t('app.public.store_cart.errors.out_of_stock')}</p></div>;
}
if (error.error === 'stock' && error.value > 0) {
return <div className='error'><p>{t('app.public.store_cart.errors.stock_limit_QUANTITY', { QUANTITY: error.value })}</p></div>;
}
if (error.error === 'quantity_min') {
return <div className='error'><p>{t('app.public.store_cart.errors.quantity_min_QUANTITY', { QUANTITY: error.value })}</p></div>;
}
if (error.error === 'amount') {
return <div className='error'>
<p>{t('app.public.store_cart.errors.price_changed_PRICE', { PRICE: `${FormatLib.price(error.value)} / ${t('app.public.store_cart.unit')}` })}</p>
<span className='refresh-btn' onClick={refreshItem(item)}>{t('app.public.store_cart.update_item')}</span>
</div>;
}
};
return (
<div className='store-cart'>
<div className="store-cart-list">
{cart && cartIsEmpty() && <p>{t('app.public.store_cart.cart_is_empty')}</p>}
{cart && cart.order_items_attributes.map(item => (
<article key={item.id} className={`store-cart-list-item ${getItemErrors(item).length > 0 ? 'error' : ''}`}>
<div className='picture'>
<img alt='' src={item.orderable_main_image_url || noImage} />
</div>
<div className="ref">
<span>{t('app.public.store_cart.reference_short')} {item.orderable_ref || ''}</span>
<p><a className="text-black" href={`/#!/store/p/${item.orderable_slug}`}>{item.orderable_name}</a></p>
{item.quantity_min > 1 &&
<span className='min'>{t('app.public.store_cart.minimum_purchase')}{item.quantity_min}</span>
}
{getItemErrors(item).map(e => {
return itemError(item, e);
})}
</div>
<div className="actions">
<div className='price'>
<p>{FormatLib.price(item.amount)}</p>
<span>/ {t('app.public.store_cart.unit')}</span>
</div>
<div className='quantity'>
<input type='number'
onChange={e => changeProductQuantity(e, item)}
min={item.quantity_min}
max={item.orderable_external_stock}
value={item.quantity}
/>
<button onClick={() => increaseOrDecreaseProductQuantity(item, 'up')}><CaretUp size={12} weight="fill" /></button>
<button onClick={() => increaseOrDecreaseProductQuantity(item, 'down')}><CaretDown size={12} weight="fill" /></button>
</div>
<div className='total'>
<span>{t('app.public.store_cart.total')}</span>
<p>{FormatLib.price(OrderLib.itemAmount(item))}</p>
</div>
<FabButton className="main-action-btn" onClick={removeProductFromCart(item)}>
<i className="fa fa-trash" />
</FabButton>
</div>
{isPrivileged() &&
<div className='offer'>
<label>
<span>{t('app.public.store_cart.offer_product')}</span>
<Switch
checked={item.is_offered || false}
onChange={toggleProductOffer(item)}
width={40}
height={19}
uncheckedIcon={false}
checkedIcon={false}
handleDiameter={15} />
</label>
</div>
}
</article>
))}
{cart && cart.order_items_attributes.map(item => {
if (item.orderable_type === 'Product') {
return (
<CartOrderProduct item={item as OrderProduct}
key={item.id}
className="store-cart-list-item"
cartErrors={cartErrors}
cart={cart}
setCart={setCart}
reloadCart={reloadCart}
onError={onError}
privilegedOperator={isPrivileged()} />
);
}
return (
<CartOrderReservation item={item as OrderCartItemReservation}
key={item.id}
className="store-cart-list-item"
cartErrors={cartErrors}
cart={cart}
reloadCart={reloadCart}
setCart={setCart}
onError={onError}
privilegedOperator={isPrivileged()} />
);
})}
</div>
<div className="group">

View File

@ -28,7 +28,7 @@ const CreditsPanel: React.FC<CreditsPanelProps> = ({ userId, onError, reservable
}, []);
/**
* Compute the remainings hours for the given credit
* Compute the remaining hours for the given credit
*/
const remainingHours = (credit: Credit): number => {
return credit.hours - credit.hours_used;
@ -39,29 +39,31 @@ const CreditsPanel: React.FC<CreditsPanelProps> = ({ userId, onError, reservable
*/
const noCredits = (): ReactNode => {
return (
<li className="no-credits">{t('app.logged.dashboard.reservations.credits_panel.no_credits')}</li>
);
};
/**
* Panel title
*/
const header = (): ReactNode => {
return (
<div>
{t(`app.logged.dashboard.reservations.credits_panel.title_${reservableType}`)}
</div>
<div className="fab-alert fab-alert--warning">{t('app.logged.dashboard.reservations_dashboard.credits_panel.no_credits')}</div>
);
};
return (
<FabPanel className="credits-panel" header={header()}>
<ul>
{credits.map(c => <li key={c.id}>
<HtmlTranslate trKey="app.logged.dashboard.reservations.credits_panel.reamaining_credits_html" options={{ NAME: c.creditable.name, REMAINING: remainingHours(c), USED: c.hours_used }} />
</li>)}
<FabPanel className="credits-panel">
<p className="title">{t('app.logged.dashboard.reservations_dashboard.credits_panel.title')}</p>
{credits.length !== 0 &&
<div className="fab-alert fab-alert--warning">
{t('app.logged.dashboard.reservations_dashboard.credits_panel.info')}
</div>
}
<div className="credits-list">
{credits.map(c => <div key={c.id} className="credits-list-item">
<p className="title">{c.creditable.name}</p>
<p>
<HtmlTranslate trKey="app.logged.dashboard.reservations_dashboard.credits_panel.remaining_credits_html" options={{ REMAINING: remainingHours(c) }} /><br />
{(c.hours_used && c.hours_used > 0) &&
<HtmlTranslate trKey="app.logged.dashboard.reservations_dashboard.credits_panel.used_credits_html" options={{ USED: c.hours_used }} />
}
</p>
</div>)}
</div>
{credits.length === 0 && noCredits()}
</ul>
</FabPanel>
);
};

View File

@ -0,0 +1,175 @@
import { FabPanel } from '../../base/fab-panel';
import { Loader } from '../../base/loader';
import { useTranslation } from 'react-i18next';
import { useEffect, useState } from 'react';
import { UserPack } from '../../../models/user-pack';
import UserPackAPI from '../../../api/user-pack';
import FormatLib from '../../../lib/format';
import SettingAPI from '../../../api/setting';
import { Machine } from '../../../models/machine';
import MachineAPI from '../../../api/machine';
import { SubmitHandler, useForm } from 'react-hook-form';
import { FabButton } from '../../base/fab-button';
import { FormSelect } from '../../form/form-select';
import { SelectOption } from '../../../models/select';
import { ProposePacksModal } from '../../prepaid-packs/propose-packs-modal';
import * as React from 'react';
import { User } from '../../../models/user';
import PrepaidPackAPI from '../../../api/prepaid-pack';
import { PrepaidPack } from '../../../models/prepaid-pack';
interface PrepaidPacksPanelProps {
user: User,
onError: (message: string) => void
}
/**
* List all available prepaid packs for the given user
*/
const PrepaidPacksPanel: React.FC<PrepaidPacksPanelProps> = ({ user, onError }) => {
const { t } = useTranslation('logged');
const [machines, setMachines] = useState<Array<Machine>>([]);
const [packs, setPacks] = useState<Array<PrepaidPack>>([]);
const [userPacks, setUserPacks] = useState<Array<UserPack>>([]);
const [threshold, setThreshold] = useState<number>(null);
const [selectedMachine, setSelectedMachine] = useState<Machine>(null);
const [packsModal, setPacksModal] = useState<boolean>(false);
const [packsForSubscribers, setPacksForSubscribers] = useState<boolean>(false);
const { handleSubmit, control, formState } = useForm<{ machine_id: number }>();
useEffect(() => {
UserPackAPI.index({ user_id: user.id })
.then(setUserPacks)
.catch(onError);
SettingAPI.get('renew_pack_threshold')
.then(data => setThreshold(parseFloat(data.value)))
.catch(onError);
MachineAPI.index({ disabled: false })
.then(setMachines)
.catch(onError);
PrepaidPackAPI.index({ disabled: false, group_id: user.group_id, priceable_type: 'Machine' })
.then(setPacks)
.catch(onError);
SettingAPI.get('pack_only_for_subscription')
.then(data => setPacksForSubscribers(data.value === 'true'))
.catch(onError);
}, []);
/**
* Check if the provided pack has a remaining amount of hours under the defined threshold
*/
const isLow = (pack: UserPack): boolean => {
if (threshold < 1) {
return pack.prepaid_pack.minutes - pack.minutes_used <= pack.prepaid_pack.minutes * threshold;
}
return pack.prepaid_pack.minutes - pack.minutes_used <= threshold * 60;
};
/**
* Callback triggered when the user clicks on "buy a pack"
*/
const onBuyPack: SubmitHandler<{ machine_id: number }> = (data) => {
const machine = machines.find(m => m.id === data.machine_id);
setSelectedMachine(machine);
togglePacksModal();
};
/**
* Open/closes the buy pack modal
*/
const togglePacksModal = () => {
setPacksModal(!packsModal);
};
/**
* Build the options for the select dropdown, for the given list of machines
*/
const buildMachinesOptions = (machines: Array<Machine>): Array<SelectOption<number>> => {
const packMachinesId = packs.map(p => p.priceable_id);
return machines.filter(m => packMachinesId.includes(m.id)).map(m => {
return { label: m.name, value: m.id };
});
};
/**
* Check if the user can buy a pack
*/
const canBuyPacks = (): boolean => {
return (packs.length > 0 && (!packsForSubscribers || (packsForSubscribers && user.subscribed_plan != null)));
};
/**
* Callback triggered when a prepaid pack was successfully bought: refresh the list of packs for the user
*/
const onPackBoughtSuccess = () => {
togglePacksModal();
UserPackAPI.index({ user_id: user.id })
.then(setUserPacks)
.catch(onError);
};
return (
<FabPanel className='prepaid-packs-panel'>
<p className="title">{t('app.logged.dashboard.reservations_dashboard.prepaid_packs_panel.title')}</p>
{userPacks.map(pack => (
<div className={`prepaid-packs ${isLow(pack) ? 'is-low' : ''}`} key={pack.id}>
<div className='prepaid-packs-list'>
<span className="prepaid-packs-list-label name">{t('app.logged.dashboard.reservations_dashboard.prepaid_packs_panel.name')}</span>
<span className="prepaid-packs-list-label end">{t('app.logged.dashboard.reservations_dashboard.prepaid_packs_panel.end')}</span>
<span className="prepaid-packs-list-label countdown">{t('app.logged.dashboard.reservations_dashboard.prepaid_packs_panel.countdown')}</span>
<div className='prepaid-packs-list-item'>
<p className='name'>{pack.prepaid_pack.priceable.name}</p>
{FormatLib.date(pack.expires_at) && <p className="end">{FormatLib.date(pack.expires_at)}</p>}
<p className="countdown"><span>{pack.minutes_used / 60}H</span> / {pack.prepaid_pack.minutes / 60}H</p>
</div>
</div>
{ /* usage history is not saved for now
<div className="prepaid-packs-list is-history">
<span className='prepaid-packs-list-label'>{t('app.logged.dashboard.reservations_dashboard.prepaid_packs_panel.history')}</span>
<div className='prepaid-packs-list-item'>
<p className='name'>00{t('app.logged.dashboard.reservations_dashboard.prepaid_packs_panel.consumed_hours')}</p>
<p className="date">00/00/00</p>
</div>
</div>
*/ }
</div>
))}
{canBuyPacks() && <div className='prepaid-packs-cta'>
<p>{t('app.logged.dashboard.reservations_dashboard.prepaid_packs_panel.cta_info')}</p>
<form onSubmit={handleSubmit(onBuyPack)}>
<FormSelect options={buildMachinesOptions(machines)} control={control} id="machine_id" rules={{ required: true }} formState={formState} label={t('app.logged.dashboard.reservations_dashboard.prepaid_packs_panel.select_machine')} />
<FabButton className='is-black' type="submit">
{t('app.logged.dashboard.reservations_dashboard.prepaid_packs_panel.cta_button')}
</FabButton>
</form>
{selectedMachine && packsModal &&
<ProposePacksModal isOpen={packsModal}
toggleModal={togglePacksModal}
item={selectedMachine}
itemType='Machine'
customer={user}
operator={user}
onError={onError}
onDecline={togglePacksModal}
onSuccess={onPackBoughtSuccess} />}
</div>}
</FabPanel>
);
};
const PrepaidPacksPanelWrapper: React.FC<PrepaidPacksPanelProps> = (props) => {
return (
<Loader>
<PrepaidPacksPanel {...props} />
</Loader>
);
};
export { PrepaidPacksPanelWrapper as PrepaidPacksPanel };

View File

@ -6,18 +6,22 @@ import { ReservationsPanel } from './reservations-panel';
import SettingAPI from '../../../api/setting';
import { SettingName } from '../../../models/setting';
import { CreditsPanel } from './credits-panel';
import { useTranslation } from 'react-i18next';
import { PrepaidPacksPanel } from './prepaid-packs-panel';
import { User } from '../../../models/user';
declare const Application: IApplication;
interface ReservationsDashboardProps {
onError: (message: string) => void,
userId: number
user: User
}
/**
* User dashboard showing everything about his spaces/machine reservations and also remaining credits
*/
const ReservationsDashboard: React.FC<ReservationsDashboardProps> = ({ onError, userId }) => {
const ReservationsDashboard: React.FC<ReservationsDashboardProps> = ({ onError, user }) => {
const { t } = useTranslation('logged');
const [modules, setModules] = useState<Map<SettingName, string>>();
useEffect(() => {
@ -28,12 +32,19 @@ const ReservationsDashboard: React.FC<ReservationsDashboardProps> = ({ onError,
return (
<div className="reservations-dashboard">
{modules?.get('machines_module') !== 'false' && <CreditsPanel userId={userId} onError={onError} reservableType="Machine" />}
{modules?.get('spaces_module') !== 'false' && <CreditsPanel userId={userId} onError={onError} reservableType="Space" />}
{modules?.get('machines_module') !== 'false' && <ReservationsPanel userId={userId} onError={onError} reservableType="Machine" />}
{modules?.get('spaces_module') !== 'false' && <ReservationsPanel userId={userId} onError={onError} reservableType="Space" />}
{modules?.get('machines_module') !== 'false' && <div className="section">
<p className="section-title">{t('app.logged.dashboard.reservations_dashboard.machine_section_title')}</p>
<CreditsPanel userId={user.id} onError={onError} reservableType="Machine" />
<PrepaidPacksPanel user={user} onError={onError} />
<ReservationsPanel userId={user.id} onError={onError} reservableType="Machine" />
</div>}
{modules?.get('spaces_module') !== 'false' && <div className="section">
<p className="section-title">{t('app.logged.dashboard.reservations_dashboard.space_section_title')}</p>
<CreditsPanel userId={user.id} onError={onError} reservableType="Space" />
<ReservationsPanel userId={user.id} onError={onError} reservableType="Space" />
</div>}
</div>
);
};
Application.Components.component('reservationsDashboard', react2angular(ReservationsDashboard, ['onError', 'userId']));
Application.Components.component('reservationsDashboard', react2angular(ReservationsDashboard, ['onError', 'user']));

View File

@ -7,8 +7,6 @@ import { useTranslation } from 'react-i18next';
import moment from 'moment';
import { Loader } from '../../base/loader';
import FormatLib from '../../../lib/format';
import { FabPopover } from '../../base/fab-popover';
import { useImmer } from 'use-immer';
import _ from 'lodash';
import { FabButton } from '../../base/fab-button';
@ -25,7 +23,6 @@ const ReservationsPanel: React.FC<SpaceReservationsProps> = ({ userId, onError,
const { t } = useTranslation('logged');
const [reservations, setReservations] = useState<Array<Reservation>>([]);
const [details, updateDetails] = useImmer<Record<number, boolean>>({});
const [showMore, setShowMore] = useState<boolean>(false);
useEffect(() => {
@ -51,28 +48,6 @@ const ReservationsPanel: React.FC<SpaceReservationsProps> = ({ userId, onError,
(state === 'futur' && moment(sr.slot_attributes.start_at).isAfter());
};
/**
* Panel title
*/
const header = (): ReactNode => {
return (
<div>
{t(`app.logged.dashboard.reservations.reservations_panel.title_${reservableType}`)}
</div>
);
};
/**
* Show/hide the slots details for the given reservation
*/
const toggleDetails = (reservationId: number): () => void => {
return () => {
updateDetails(draft => {
draft[reservationId] = !draft[reservationId];
});
};
};
/**
* Shows/hide the very old reservations list
*/
@ -85,7 +60,7 @@ const ReservationsPanel: React.FC<SpaceReservationsProps> = ({ userId, onError,
*/
const noReservations = (): ReactNode => {
return (
<li className="no-reservations">{t('app.logged.dashboard.reservations.reservations_panel.no_reservations')}</li>
<span className="no-reservations">{t('app.logged.dashboard.reservations_dashboard.reservations_panel.no_reservation')}</span>
);
};
@ -101,18 +76,17 @@ const ReservationsPanel: React.FC<SpaceReservationsProps> = ({ userId, onError,
*/
const renderReservation = (reservation: Reservation, state: 'past' | 'futur'): ReactNode => {
return (
<li key={reservation.id} className="reservation">
<a className={`reservation-title ${details[reservation.id] ? 'clicked' : ''} ${isCancelled(reservation) ? 'canceled' : ''}`} onClick={toggleDetails(reservation.id)}>
{reservation.reservable.name} - {FormatLib.date(reservation.slots_reservations_attributes[0].slot_attributes.start_at)}
</a>
{details[reservation.id] && <FabPopover title={t('app.logged.dashboard.reservations.reservations_panel.slots_details')}>
<div key={reservation.id} className={`reservations-list-item ${isCancelled(reservation) ? 'cancelled' : ''}`}>
<p className='name'>{reservation.reservable.name}</p>
<div className="date">
{reservation.slots_reservations_attributes.filter(s => filterSlot(s, state)).map(
slotReservation => <span key={slotReservation.id} className={`slot-details ${slotReservation.canceled_at ? 'canceled' : ''}`}>
{FormatLib.date(slotReservation.slot_attributes.start_at)}, {FormatLib.time(slotReservation.slot_attributes.start_at)} - {FormatLib.time(slotReservation.slot_attributes.end_at)}
</span>
slotReservation => <p key={slotReservation.id} className={slotReservation.canceled_at ? 'cancelled' : ''}>
{slotReservation.canceled_at ? t('app.logged.dashboard.reservations_dashboard.reservations_panel.cancelled_slot') : ''} {FormatLib.date(slotReservation.slot_attributes.start_at)} - {FormatLib.time(slotReservation.slot_attributes.start_at)} - {FormatLib.time(slotReservation.slot_attributes.end_at)}
</p>
)}
</FabPopover>}
</li>
</div>
</div>
);
};
@ -120,21 +94,31 @@ const ReservationsPanel: React.FC<SpaceReservationsProps> = ({ userId, onError,
const past = _.orderBy(reservationsByDate('past'), r => r.slots_reservations_attributes[0].slot_attributes.start_at, 'desc');
return (
<FabPanel className="reservations-panel" header={header()}>
<h4>{t('app.logged.dashboard.reservations.reservations_panel.upcoming')}</h4>
<ul>
{futur.length === 0 && noReservations()}
{futur.map(r => renderReservation(r, 'futur'))}
</ul>
<h4>{t('app.logged.dashboard.reservations.reservations_panel.past')}</h4>
<ul>
{past.length === 0 && noReservations()}
{past.slice(0, 10).map(r => renderReservation(r, 'past'))}
{past.length > 10 && !showMore && <li className="show-more"><FabButton onClick={toggleShowMore}>
{t('app.logged.dashboard.reservations.reservations_panel.show_more')}
</FabButton></li>}
{past.length > 10 && showMore && past.slice(10).map(r => renderReservation(r, 'past'))}
</ul>
<FabPanel className="reservations-panel">
<p className="title">{t('app.logged.dashboard.reservations_dashboard.reservations_panel.title')}</p>
<div className="reservations">
{futur.length === 0
? noReservations()
: <div className="reservations-list">
<span className="reservations-list-label name">{t('app.logged.dashboard.reservations_dashboard.reservations_panel.upcoming')}</span>
<span className="reservations-list-label date">{t('app.logged.dashboard.reservations_dashboard.reservations_panel.date')}</span>
{futur.map(r => renderReservation(r, 'futur'))}
</div>
}
{past.length > 0 &&
<div className="reservations-list is-history">
<span className="reservations-list-label">{t('app.logged.dashboard.reservations_dashboard.reservations_panel.history')}</span>
{past.slice(0, 5).map(r => renderReservation(r, 'past'))}
{past.length > 5 && !showMore && <FabButton onClick={toggleShowMore} className="show-more is-black">
{t('app.logged.dashboard.reservations_dashboard.reservations_panel.show_more')}
</FabButton>}
{past.length > 5 && showMore && past.slice(5).map(r => renderReservation(r, 'past'))}
</div>
}
</div>
</FabPanel>
);
};

View File

@ -0,0 +1,89 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Control, FormState, UseFormRegister } from 'react-hook-form';
import { FormSwitch } from '../form/form-switch';
import { FormRichText } from '../form/form-rich-text';
import { FormInput } from '../form/form-input';
import { SettingName, SettingValue } from '../../models/setting';
export type EditorialKeys = 'active_text_block' | 'text_block' | 'active_cta' | 'cta_label' | 'cta_url';
interface EditorialBlockFormProps {
register: UseFormRegister<Record<SettingName, SettingValue>>,
control: Control<Record<SettingName, SettingValue>>,
formState: FormState<Record<SettingName, SettingValue>>,
info?: string
keys: Record<EditorialKeys, SettingName>
}
// regular expression to validate the input fields
const urlRegex = /^(https?:\/\/)([^.]+)\.(.{2,30})(\/.*)*\/?$/;
/**
* Allows to create a formatted text and optional cta button in a form block, to be included in a resource form managed by react-hook-form.
*/
export const EditorialBlockForm: React.FC<EditorialBlockFormProps> = ({ register, control, formState, info, keys }) => {
const { t } = useTranslation('admin');
const [isActiveTextBlock, setIsActiveTextBlock] = useState<boolean>(false);
const [isActiveCta, setIsActiveCta] = useState<boolean>(false);
/** Set correct values for switches when formState changes */
useEffect(() => {
setIsActiveTextBlock(control._formValues[keys.active_text_block]);
setIsActiveCta(control._formValues[keys.active_cta]);
}, [control._formValues]);
/** Callback triggered when the text block switch has changed. */
const toggleTextBlockSwitch = (value: boolean) => setIsActiveTextBlock(value);
/** Callback triggered when the CTA switch has changed. */
const toggleTextBlockCta = (value: boolean) => setIsActiveCta(value);
return (
<>
<header>
<p className="title">{t('app.admin.editorial_block_form.title')}</p>
{info && <p className="description">{info}</p>}
</header>
<div className="content" data-testid="editorial-block-form">
<FormSwitch id={keys.active_text_block} control={control}
onChange={toggleTextBlockSwitch} formState={formState}
defaultValue={isActiveTextBlock}
label={t('app.admin.editorial_block_form.switch')} />
<FormRichText id={keys.text_block}
label={t('app.admin.editorial_block_form.content')}
control={control}
formState={formState}
heading
limit={280}
rules={{ required: { value: isActiveTextBlock, message: t('app.admin.editorial_block_form.content_is_required') } }}
disabled={!isActiveTextBlock} />
{isActiveTextBlock && <>
<FormSwitch id={keys.active_cta} control={control}
onChange={toggleTextBlockCta} formState={formState}
label={t('app.admin.editorial_block_form.cta_switch')} />
{isActiveCta && <>
<FormInput id={keys.cta_label}
register={register}
formState={formState}
rules={{ required: { value: isActiveCta, message: t('app.admin.editorial_block_form.label_is_required') } }}
maxLength={25}
label={t('app.admin.editorial_block_form.cta_label')} />
<FormInput id={keys.cta_url}
register={register}
formState={formState}
rules={{
required: { value: isActiveCta, message: t('app.admin.editorial_block_form.url_is_required') },
pattern: { value: urlRegex, message: t('app.admin.editorial_block_form.url_must_be_safe') }
}}
label={t('app.admin.editorial_block_form.cta_url')} />
</>}
</>}
</div>
</>
);
};

View File

@ -0,0 +1,41 @@
import React from 'react';
import { IApplication } from '../../models/application';
import { Loader } from '../base/loader';
import { react2angular } from 'react2angular';
import { FabButton } from '../base/fab-button';
import { SettingValue } from '../../models/setting';
declare const Application: IApplication;
interface EditorialBlockProps {
text: SettingValue,
cta?: SettingValue,
url?: SettingValue
}
/**
* Display a editorial text block with an optional cta button
*/
export const EditorialBlock: React.FC<EditorialBlockProps> = ({ text, cta, url }) => {
/** Link to url from props */
const linkTo = (): void => {
window.location.href = url as string;
};
return (
<div className={`editorial-block ${(cta as string)?.length > 25 ? 'long-cta' : ''}`}>
<div dangerouslySetInnerHTML={{ __html: text as string }}></div>
{cta && <FabButton className='is-main' onClick={linkTo}>{cta}</FabButton>}
</div>
);
};
const EditorialBlockWrapper: React.FC<EditorialBlockProps> = ({ ...props }) => {
return (
<Loader>
<EditorialBlock {...props} />
</Loader>
);
};
Application.Components.component('editorialBlock', react2angular(EditorialBlockWrapper, ['text', 'cta', 'url']));

View File

@ -23,6 +23,7 @@ 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 SettingAPI from '../../api/setting';
import { UpdateRecurrentModal } from './update-recurrent-modal';
import { AdvancedAccountingForm } from '../accounting/advanced-accounting-form';
@ -52,6 +53,7 @@ export const EventForm: React.FC<EventFormProps> = ({ action, event, onError, on
const [priceCategoriesOptions, setPriceCategoriesOptions] = useState<Array<SelectOption<number>>>(null);
const [isOpenRecurrentModal, setIsOpenRecurrentModal] = useState<boolean>(false);
const [updatingEvent, setUpdatingEvent] = useState<Event>(null);
const [isActiveAccounting, setIsActiveAccounting] = useState<boolean>(false);
useEffect(() => {
EventCategoryAPI.index()
@ -66,6 +68,7 @@ export const EventForm: React.FC<EventFormProps> = ({ action, event, onError, on
EventPriceCategoryAPI.index()
.then(data => setPriceCategoriesOptions(data.map(c => decorationToOption(c))))
.catch(onError);
SettingAPI.get('advanced_accounting').then(res => setIsActiveAccounting(res.value === 'true')).catch(onError);
}, []);
/**
@ -153,155 +156,193 @@ export const EventForm: React.FC<EventFormProps> = ({ action, event, onError, on
};
return (
<form className="event-form" onSubmit={handleSubmit(onSubmit)}>
<FormInput register={register}
id="title"
formState={formState}
rules={{ required: true }}
label={t('app.admin.event_form.title')} />
<FormImageUpload setValue={setValue}
register={register}
control={control}
<div className="event-form">
<header>
<h2>{t('app.admin.event_form.ACTION_title', { ACTION: action })}</h2>
<FabButton onClick={handleSubmit(onSubmit)} className="fab-button save-btn is-main">
{t('app.admin.event_form.save')}
</FabButton>
</header>
<form className="event-form-content" onSubmit={handleSubmit(onSubmit)}>
<section>
<header>
<p className="title">{t('app.admin.event_form.description')}</p>
</header>
<div className="content">
<FormInput register={register}
id="title"
formState={formState}
rules={{ required: true }}
id="event_image_attributes"
accept="image/*"
defaultImage={output.event_image_attributes}
label={t('app.admin.event_form.matching_visual')} />
<FormRichText control={control}
id="description"
rules={{ required: true }}
label={t('app.admin.event_form.description')}
limit={null}
heading bulletList blockquote link video image />
<FormSelect id="category_id"
control={control}
formState={formState}
label={t('app.admin.event_form.event_category')}
options={categoriesOptions}
rules={{ required: true }} />
{themesOptions?.length > 0 && <FormMultiSelect control={control}
id="event_theme_ids"
formState={formState}
options={themesOptions}
label={t('app.admin.event_form.event_themes')} />}
{ageRangeOptions?.length > 0 && <FormSelect control={control}
id="age_range_id"
formState={formState}
options={ageRangeOptions}
label={t('app.admin.event_form.age_range')} />}
<div className="dates-times">
<h4>{t('app.admin.event_form.dates_and_opening_hours')}</h4>
<div className="dates">
<FormInput id="start_date"
type="date"
register={register}
formState={formState}
label={t('app.admin.event_form.start_date')}
rules={{ required: true }} />
<FormInput id="end_date"
type="date"
register={register}
formState={formState}
label={t('app.admin.event_form.end_date')}
rules={{ required: true }} />
</div>
<FormSwitch control={control}
id="all_day"
label={t('app.admin.event_form.all_day')}
formState={formState}
tooltip={t('app.admin.event_form.all_day_help')}
onChange={setIsAllDay} />
{!isAllDay && <div className="times">
<FormInput id="start_time"
type="time"
register={register}
formState={formState}
label={t('app.admin.event_form.start_time')}
rules={{ required: !isAllDay }} />
<FormInput id="end_time"
type="time"
register={register}
formState={formState}
label={t('app.admin.event_form.end_time')}
rules={{ required: !isAllDay }} />
</div> }
{action === 'create' && <div className="recurring">
<FormSelect options={buildRecurrenceOptions()}
control={control}
formState={formState}
id="recurrence"
valueDefault="none"
label={t('app.admin.event_form.recurrence')} />
<FormInput register={register}
id="recurrence_end_at"
type="date"
formState={formState}
nullable
defaultValue={null}
label={t('app.admin.event_form._and_ends_on')}
rules={{ required: !['none', undefined].includes(output.recurrence) }} />
</div>}
</div>
<div className="seats-prices">
<h4>{t('app.admin.event_form.prices_and_availabilities')}</h4>
<FormInput register={register}
id="nb_total_places"
label={t('app.admin.event_form.seats_available')}
type="number"
tooltip={t('app.admin.event_form.seats_help')} />
<FormInput register={register}
id="amount"
formState={formState}
rules={{ required: true }}
label={t('app.admin.event_form.standard_rate')}
tooltip={t('app.admin.event_form.0_equal_free')}
addOn={FormatLib.currencySymbol()} />
{priceCategoriesOptions && <div className="additional-prices">
{fields.map((price, index) => (
<div key={index} className={`price-item ${output.event_price_categories_attributes && output.event_price_categories_attributes[index]?._destroy ? 'destroyed-item' : ''}`}>
<FormSelect options={priceCategoriesOptions}
control={control}
id={`event_price_categories_attributes.${index}.price_category_id`}
rules={{ required: true }}
label={t('app.admin.event_form.fare_class')} />
<FormInput id={`event_price_categories_attributes.${index}.amount`}
register={register}
type="number"
rules={{ required: true }}
label={t('app.admin.event_form.price')}
addOn={FormatLib.currencySymbol()} />
<FabButton className="remove-price is-main" onClick={() => handlePriceRemove(price, index)} icon={<Trash size={20} />} />
</div>
))}
<FabButton className="add-price is-secondary" onClick={() => append({})}>
<Plus size={20} />
{t('app.admin.event_form.add_price')}
</FabButton>
</div>}
</div>
<div className="attachments">
<div className='form-item-header event-files-header'>
<h4>{t('app.admin.event_form.attachments')}</h4>
</div>
<FormMultiFileUpload setValue={setValue}
addButtonLabel={t('app.admin.event_form.add_a_new_file')}
control={control}
accept="application/pdf"
label={t('app.admin.event_form.title')} />
<FormImageUpload setValue={setValue}
register={register}
id="event_files_attributes"
className="event-files" />
</div>
<AdvancedAccountingForm register={register} onError={onError} />
<FabButton type="submit" className="is-info submit-btn">
{t('app.admin.event_form.ACTION_event', { ACTION: action })}
</FabButton>
<UpdateRecurrentModal isOpen={isOpenRecurrentModal}
toggleModal={toggleRecurrentModal}
event={updatingEvent}
onConfirmed={handleUpdateRecurrentConfirmed}
datesChanged={datesHaveChanged()} />
</form>
control={control}
formState={formState}
rules={{ required: true }}
id="event_image_attributes"
accept="image/*"
defaultImage={output.event_image_attributes}
label={t('app.admin.event_form.matching_visual')} />
<FormRichText control={control}
id="description"
rules={{ required: true }}
formState={formState}
label={t('app.admin.event_form.description')}
limit={null}
heading bulletList blockquote link video image />
<FormSelect id="category_id"
control={control}
formState={formState}
label={t('app.admin.event_form.event_category')}
options={categoriesOptions}
rules={{ required: true }} />
{themesOptions?.length > 0 && <FormMultiSelect control={control}
id="event_theme_ids"
formState={formState}
options={themesOptions}
label={t('app.admin.event_form.event_themes')} />}
{ageRangeOptions?.length > 0 && <FormSelect control={control}
id="age_range_id"
formState={formState}
options={ageRangeOptions}
label={t('app.admin.event_form.age_range')} />}
</div>
</section>
<section>
<header>
<p className='title'>{t('app.admin.event_form.dates_and_opening_hours')}</p>
</header>
<div className="content">
<div className="grp">
<FormInput id="start_date"
type="date"
register={register}
formState={formState}
label={t('app.admin.event_form.start_date')}
rules={{ required: true }} />
<FormInput id="end_date"
type="date"
register={register}
formState={formState}
label={t('app.admin.event_form.end_date')}
rules={{ required: true }} />
</div>
<FormSwitch control={control}
id="all_day"
label={t('app.admin.event_form.all_day')}
formState={formState}
tooltip={t('app.admin.event_form.all_day_help')}
onChange={setIsAllDay} />
{!isAllDay && <div className="grp">
<FormInput id="start_time"
type="time"
register={register}
formState={formState}
label={t('app.admin.event_form.start_time')}
rules={{ required: !isAllDay }} />
<FormInput id="end_time"
type="time"
register={register}
formState={formState}
label={t('app.admin.event_form.end_time')}
rules={{ required: !isAllDay }} />
</div>}
{action === 'create' && <div className="grp">
<FormSelect options={buildRecurrenceOptions()}
control={control}
formState={formState}
id="recurrence"
valueDefault="none"
label={t('app.admin.event_form.recurrence')} />
<FormInput register={register}
id="recurrence_end_at"
type="date"
formState={formState}
nullable
defaultValue={null}
label={t('app.admin.event_form._and_ends_on')}
rules={{ required: !['none', undefined].includes(output.recurrence) }} />
</div>}
</div>
</section>
<section>
<header>
<p className="title">{t('app.admin.event_form.prices_and_availabilities')}</p>
</header>
<div className="content">
<FormInput register={register}
id="nb_total_places"
label={t('app.admin.event_form.seats_available')}
type="number"
tooltip={t('app.admin.event_form.seats_help')} />
<FormInput register={register}
id="amount"
formState={formState}
rules={{ required: true }}
label={t('app.admin.event_form.standard_rate')}
tooltip={t('app.admin.event_form.0_equal_free')}
addOn={FormatLib.currencySymbol()} />
{priceCategoriesOptions && <div className="additional-prices">
{fields.map((price, index) => (
<div key={index} className={`price-item ${output.event_price_categories_attributes && output.event_price_categories_attributes[index]?._destroy ? 'destroyed-item' : ''}`}>
<FormSelect options={priceCategoriesOptions}
control={control}
id={`event_price_categories_attributes.${index}.price_category_id`}
rules={{ required: true }}
formState={formState}
label={t('app.admin.event_form.fare_class')} />
<FormInput id={`event_price_categories_attributes.${index}.amount`}
register={register}
type="number"
rules={{ required: true }}
formState={formState}
label={t('app.admin.event_form.price')}
addOn={FormatLib.currencySymbol()} />
<FabButton className="remove-price is-main" onClick={() => handlePriceRemove(price, index)} icon={<Trash size={20} />} />
</div>
))}
<FabButton className="add-price is-secondary" onClick={() => append({})}>
<Plus size={20} />
{t('app.admin.event_form.add_price')}
</FabButton>
</div>}
</div>
</section>
<section>
<header>
<p className="title">{t('app.admin.event_form.attachments')}</p>
</header>
<div className="content">
<div className='form-item-header machine-files-header'>
<p>{t('app.admin.event_form.attached_files_pdf')}</p>
</div>
<FormMultiFileUpload setValue={setValue}
addButtonLabel={t('app.admin.event_form.add_a_new_file')}
control={control}
accept="application/pdf"
register={register}
id="event_files_attributes"
className="event-files" />
</div>
</section>
{isActiveAccounting &&
<section>
<AdvancedAccountingForm register={register} onError={onError} />
</section>
}
<UpdateRecurrentModal isOpen={isOpenRecurrentModal}
toggleModal={toggleRecurrentModal}
event={updatingEvent}
onConfirmed={handleUpdateRecurrentConfirmed}
datesChanged={datesHaveChanged()} />
</form>
</div>
);
};

View File

@ -0,0 +1,52 @@
import { useEffect, useState } from 'react';
import { IApplication } from '../../models/application';
import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';
import { EditorialBlock } from '../editorial-block/editorial-block';
import SettingAPI from '../../api/setting';
import SettingLib from '../../lib/setting';
import { SettingValue, eventsSettings } from '../../models/setting';
declare const Application: IApplication;
interface EventsEditorialBlockProps {
onError: (message: string) => void
}
/**
* This component displays to Users (public view) the editorial block (= banner) associated to events.
*/
export const EventsEditorialBlock: React.FC<EventsEditorialBlockProps> = ({ onError }) => {
// Stores banner retrieved from API
const [banner, setBanner] = useState<Record<string, SettingValue>>({});
// Retrieve the settings related to the Events Banner from the API
useEffect(() => {
SettingAPI.query(eventsSettings)
.then(settings => {
setBanner({ ...SettingLib.bulkMapToObject(settings) });
})
.catch(onError);
}, []);
return (
<>
{banner.events_banner_active &&
<EditorialBlock
text={banner.events_banner_text}
cta={banner.events_banner_cta_active && banner.events_banner_cta_label}
url={banner.events_banner_cta_active && banner.events_banner_cta_url} />
}
</>
);
};
const EventsEditorialBlockWrapper: React.FC<EventsEditorialBlockProps> = (props) => {
return (
<Loader>
<EventsEditorialBlock {...props} />
</Loader>
);
};
Application.Components.component('eventsEditorialBlock', react2angular(EventsEditorialBlockWrapper, ['onError']));

View File

@ -0,0 +1,86 @@
import React, { useEffect } from 'react';
import { IApplication } from '../../models/application';
import { Loader } from '../base/loader';
import { react2angular } from 'react2angular';
import { ErrorBoundary } from '../base/error-boundary';
import { useTranslation } from 'react-i18next';
import { useForm, SubmitHandler } from 'react-hook-form';
import { FabButton } from '../base/fab-button';
import { EditorialKeys, EditorialBlockForm } from '../editorial-block/editorial-block-form';
import SettingAPI from '../../api/setting';
import SettingLib from '../../lib/setting';
import { SettingName, SettingValue, eventsSettings } from '../../models/setting';
import { UnsavedFormAlert } from '../form/unsaved-form-alert';
import { UIRouter } from '@uirouter/angularjs';
declare const Application: IApplication;
interface EventsSettingsProps {
onError: (message: string) => void,
onSuccess: (message: string) => void
uiRouter?: UIRouter
}
/**
* Events settings
*/
export const EventsSettings: React.FC<EventsSettingsProps> = ({ onError, onSuccess, uiRouter }) => {
const { t } = useTranslation('admin');
const { register, control, formState, handleSubmit, reset } = useForm<Record<SettingName, SettingValue>>();
/** Link Events Banner Setting Names to generic keys expected by the Editorial Form */
const bannerKeys: Record<EditorialKeys, SettingName> = {
active_text_block: 'events_banner_active',
text_block: 'events_banner_text',
active_cta: 'events_banner_cta_active',
cta_label: 'events_banner_cta_label',
cta_url: 'events_banner_cta_url'
};
/** Callback triggered when the form is submitted: save the settings */
const onSubmit: SubmitHandler<Record<SettingName, SettingValue>> = (data) => {
SettingAPI.bulkUpdate(SettingLib.objectToBulkMap(data)).then(() => {
onSuccess(t('app.admin.events_settings.update_success'));
}, reason => {
onError(reason);
});
};
/** On component mount, fetch existing Events Banner Settings from API, and populate form with these values. */
useEffect(() => {
SettingAPI.query(eventsSettings)
.then(settings => reset(SettingLib.bulkMapToObject(settings)))
.catch(onError);
}, []);
return (
<div className="events-settings">
<header>
<h2>{t('app.admin.events_settings.title')}</h2>
<FabButton onClick={handleSubmit(onSubmit)} className='save-btn is-main'>{t('app.admin.events_settings.save')}</FabButton>
</header>
<form className="events-settings-content">
{uiRouter && <UnsavedFormAlert uiRouter={uiRouter} formState={formState} />}
<div className="settings-section">
<EditorialBlockForm register={register}
control={control}
formState={formState}
keys={bannerKeys}
info={t('app.admin.events_settings.generic_text_block_info')} />
</div>
</form>
</div>
);
};
const EventsSettingsWrapper: React.FC<EventsSettingsProps> = (props) => {
return (
<Loader>
<ErrorBoundary>
<EventsSettings {...props} />
</ErrorBoundary>
</Loader>
);
};
Application.Components.component('eventsSettings', react2angular(EventsSettingsWrapper, ['onError', 'onSuccess', 'uiRouter']));

View File

@ -4,7 +4,7 @@ import { AbstractFormComponent } from '../../models/form-component';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { get as _get } from 'lodash';
export interface AbstractFormItemProps<TFieldValues> extends PropsWithChildren<AbstractFormComponent<TFieldValues>> {
export type AbstractFormItemProps<TFieldValues> = PropsWithChildren<AbstractFormComponent<TFieldValues>> & {
id: string,
label?: string|ReactNode,
tooltip?: ReactNode,
@ -21,7 +21,7 @@ export interface AbstractFormItemProps<TFieldValues> extends PropsWithChildren<A
*/
export const AbstractFormItem = <TFieldValues extends FieldValues>({ id, label, tooltip, className, disabled, error, warning, rules, formState, onLabelClick, inLine, containerType, children }: AbstractFormItemProps<TFieldValues>) => {
const [isDirty, setIsDirty] = useState<boolean>(false);
const [fieldError, setFieldError] = useState<{ message: string }>(error);
const [fieldError, setFieldError] = useState<{ message: string }>(null);
const [isDisabled, setIsDisabled] = useState<boolean>(false);
useEffect(() => {
@ -29,10 +29,6 @@ export const AbstractFormItem = <TFieldValues extends FieldValues>({ id, label,
setFieldError(_get(formState?.errors, id));
}, [formState]);
useEffect(() => {
setFieldError(error);
}, [error]);
useEffect(() => {
if (typeof disabled === 'function') {
setIsDisabled(disabled(id));
@ -44,7 +40,7 @@ export const AbstractFormItem = <TFieldValues extends FieldValues>({ id, label,
// Compose classnames from props
const classNames = [
`${className || ''}`,
`${isDirty && fieldError ? 'is-incorrect' : ''}`,
`${(isDirty && error) || fieldError ? 'is-incorrect' : ''}`,
`${isDirty && warning ? 'is-warned' : ''}`,
`${rules && rules.required ? 'is-required' : ''}`,
`${isDisabled ? 'is-disabled' : ''}`
@ -79,7 +75,8 @@ export const AbstractFormItem = <TFieldValues extends FieldValues>({ id, label,
</div>}
{children}
</div>
{(isDirty && fieldError) && <div className="form-item-error">{fieldError.message}</div> }
{ fieldError && <div className="form-item-error">{fieldError.message}</div> }
{(isDirty && error) && <div className="form-item-error">{error.message}</div> }
{(isDirty && warning) && <div className="form-item-warning">{warning.message}</div> }
</>
));

View File

@ -9,7 +9,7 @@ import { AbstractFormItem, AbstractFormItemProps } from './abstract-form-item';
import { FabButton } from '../base/fab-button';
import { ChecklistOption } from '../../models/select';
interface FormChecklistProps<TFieldValues, TOptionValue, TContext extends object> extends FormControlledComponent<TFieldValues, TContext>, AbstractFormItemProps<TFieldValues> {
type FormChecklistProps<TFieldValues, TOptionValue, TContext extends object> = FormControlledComponent<TFieldValues, TContext> & AbstractFormItemProps<TFieldValues> & {
defaultValue?: Array<TOptionValue>,
options: Array<ChecklistOption<TOptionValue>>,
onChange?: (values: Array<TOptionValue>) => void,

View File

@ -13,7 +13,7 @@ import { FilePdf, Trash } from 'phosphor-react';
import { FileType } from '../../models/file';
import FileUploadLib from '../../lib/file-upload';
interface FormFileUploadProps<TFieldValues> extends FormComponent<TFieldValues>, AbstractFormItemProps<TFieldValues> {
type FormFileUploadProps<TFieldValues> = FormComponent<TFieldValues> & AbstractFormItemProps<TFieldValues> & {
setValue: UseFormSetValue<TFieldValues>,
defaultFile?: FileType,
accept?: string,

View File

@ -14,7 +14,7 @@ import { Trash } from 'phosphor-react';
import { ImageType } from '../../models/file';
import FileUploadLib from '../../lib/file-upload';
interface FormImageUploadProps<TFieldValues, TContext extends object> extends FormComponent<TFieldValues>, FormControlledComponent<TFieldValues, TContext>, AbstractFormItemProps<TFieldValues> {
type FormImageUploadProps<TFieldValues, TContext extends object> = FormComponent<TFieldValues> & FormControlledComponent<TFieldValues, TContext> & AbstractFormItemProps<TFieldValues> & {
setValue: UseFormSetValue<TFieldValues>,
defaultImage?: ImageType,
accept?: string,

View File

@ -1,4 +1,4 @@
import { ReactNode, useCallback } from 'react';
import { ReactNode, useCallback, useState } from 'react';
import * as React from 'react';
import { FieldPathValue } from 'react-hook-form';
import { debounce as _debounce } from 'lodash';
@ -7,7 +7,7 @@ import { FieldPath } from 'react-hook-form/dist/types/path';
import { FormComponent } from '../../models/form-component';
import { AbstractFormItem, AbstractFormItemProps } from './abstract-form-item';
interface FormInputProps<TFieldValues, TInputType> extends FormComponent<TFieldValues>, AbstractFormItemProps<TFieldValues> {
type FormInputProps<TFieldValues, TInputType> = FormComponent<TFieldValues> & AbstractFormItemProps<TFieldValues> & {
icon?: ReactNode,
addOn?: ReactNode,
addOnAction?: (event: React.MouseEvent<HTMLButtonElement>) => void,
@ -22,12 +22,15 @@ interface FormInputProps<TFieldValues, TInputType> extends FormComponent<TFieldV
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void,
nullable?: boolean,
ariaLabel?: string,
maxLength?: number
}
/**
* This component is a template for an input component to use within React Hook Form
*/
export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, register, label, tooltip, defaultValue, icon, className, rules, disabled, type, addOn, addOnAction, addOnClassName, addOnAriaLabel, placeholder, error, warning, formState, step, onChange, debounce, accept, nullable = false, ariaLabel }: FormInputProps<TFieldValues, TInputType>) => {
export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, register, label, tooltip, defaultValue, icon, className, rules, disabled, type, addOn, addOnAction, addOnClassName, addOnAriaLabel, placeholder, error, warning, formState, step, onChange, debounce, accept, nullable = false, ariaLabel, maxLength }: FormInputProps<TFieldValues, TInputType>) => {
const [characterCount, setCharacterCount] = useState<number>(0);
/**
* Debounced (ie. temporised) version of the 'on change' callback.
*/
@ -37,6 +40,7 @@ export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, re
* Handle the change of content in the input field, and trigger the parent callback, if any
*/
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setCharacterCount(e.currentTarget.value.length);
if (typeof onChange === 'function') {
if (debouncedOnChange) {
debouncedOnChange(e);
@ -46,6 +50,32 @@ export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, re
}
};
/**
* Parse the inputted value before saving it in the RHF state
*/
const parseValue = (value: string) => {
if ([null, ''].includes(value) && nullable) {
return null;
} else {
if (type === 'number') {
const num: number = parseFloat(value);
if (Number.isNaN(num) && nullable) {
return null;
}
return num;
}
if (type === 'date') {
const date: Date = new Date(value);
if (Number.isNaN(date) && nullable) {
return null;
}
return date;
}
setCharacterCount(value?.length || 0);
return value;
}
};
// Compose classnames from props
const classNames = [
`${className || ''}`,
@ -61,8 +91,7 @@ export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, re
<input id={id} aria-label={ariaLabel}
{...register(id as FieldPath<TFieldValues>, {
...rules,
valueAsDate: type === 'date',
setValueAs: v => ([null, ''].includes(v) && nullable) ? null : (type === 'number' ? parseFloat(v) : v),
setValueAs: parseValue,
value: defaultValue as FieldPathValue<TFieldValues, FieldPath<TFieldValues>>,
onChange: (e) => { handleChange(e); }
})}
@ -70,8 +99,10 @@ export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, re
step={step}
disabled={typeof disabled === 'function' ? disabled(id) : disabled}
placeholder={placeholder}
accept={accept} />
accept={accept}
maxLength={maxLength} />
{(type === 'file' && placeholder) && <span className='fab-button is-black file-placeholder'>{placeholder}</span>}
{maxLength && <span className='countdown'>{characterCount} / {maxLength}</span>}
{addOn && addOnAction && <button aria-label={addOnAriaLabel} type="button" onClick={addOnAction} className={`addon ${addOnClassName || ''} is-btn`}>{addOn}</button>}
{addOn && !addOnAction && <span aria-label={addOnAriaLabel} className={`addon ${addOnClassName || ''}`}>{addOn}</span>}
</AbstractFormItem>

View File

@ -11,7 +11,7 @@ import { FileType } from '../../models/file';
import { UnpackNestedValue } from 'react-hook-form/dist/types';
import { FieldPathValue } from 'react-hook-form/dist/types/path';
interface FormMultiFileUploadProps<TFieldValues, TContext extends object> extends FormComponent<TFieldValues>, FormControlledComponent<TFieldValues, TContext>, AbstractFormItemProps<TFieldValues> {
type FormMultiFileUploadProps<TFieldValues, TContext extends object> = FormComponent<TFieldValues> & FormControlledComponent<TFieldValues, TContext> & AbstractFormItemProps<TFieldValues> & {
setValue: UseFormSetValue<TFieldValues>,
addButtonLabel: ReactNode,
accept: string

View File

@ -11,7 +11,7 @@ import { ImageType } from '../../models/file';
import { UnpackNestedValue } from 'react-hook-form/dist/types';
import { FieldPathValue } from 'react-hook-form/dist/types/path';
interface FormMultiImageUploadProps<TFieldValues, TContext extends object> extends FormComponent<TFieldValues>, FormControlledComponent<TFieldValues, TContext>, AbstractFormItemProps<TFieldValues> {
type FormMultiImageUploadProps<TFieldValues, TContext extends object> = FormComponent<TFieldValues> & FormControlledComponent<TFieldValues, TContext> & AbstractFormItemProps<TFieldValues> & {
setValue: UseFormSetValue<TFieldValues>,
addButtonLabel: ReactNode
}

View File

@ -11,7 +11,7 @@ import { useTranslation } from 'react-i18next';
import { Controller, FieldPathValue, Path } from 'react-hook-form';
import { UnpackNestedValue } from 'react-hook-form/dist/types/form';
interface CommonProps<TFieldValues, TContext extends object, TOptionValue> extends FormControlledComponent<TFieldValues, TContext>, AbstractFormItemProps<TFieldValues> {
type CommonProps<TFieldValues, TContext extends object, TOptionValue> = FormControlledComponent<TFieldValues, TContext> & AbstractFormItemProps<TFieldValues> & {
valuesDefault?: Array<TOptionValue>,
onChange?: (values: Array<TOptionValue>) => void,
placeholder?: string,

View File

@ -8,7 +8,7 @@ import { Controller, Path } from 'react-hook-form';
import { FieldPath } from 'react-hook-form/dist/types/path';
import { FieldPathValue, UnpackNestedValue } from 'react-hook-form/dist/types';
interface FormRichTextProps<TFieldValues, TContext extends object> extends FormControlledComponent<TFieldValues, TContext>, AbstractFormItemProps<TFieldValues> {
type FormRichTextProps<TFieldValues, TContext extends object> = FormControlledComponent<TFieldValues, TContext> & AbstractFormItemProps<TFieldValues> & {
valueDefault?: string,
limit?: number,
heading?: boolean,

View File

@ -9,7 +9,7 @@ import { FormControlledComponent } from '../../models/form-component';
import { AbstractFormItem, AbstractFormItemProps } from './abstract-form-item';
import { SelectOption } from '../../models/select';
interface FormSelectProps<TFieldValues, TContext extends object, TOptionValue, TOptionLabel> extends FormControlledComponent<TFieldValues, TContext>, AbstractFormItemProps<TFieldValues> {
type FormSelectProps<TFieldValues, TContext extends object, TOptionValue, TOptionLabel> = FormControlledComponent<TFieldValues, TContext> & AbstractFormItemProps<TFieldValues> & {
options: Array<SelectOption<TOptionValue, TOptionLabel>>,
valueDefault?: TOptionValue,
onChange?: (value: TOptionValue) => void,

View File

@ -5,7 +5,7 @@ import { Controller, Path } from 'react-hook-form';
import Switch from 'react-switch';
import { AbstractFormItem, AbstractFormItemProps } from './abstract-form-item';
interface FormSwitchProps<TFieldValues, TContext extends object> extends FormControlledComponent<TFieldValues, TContext>, AbstractFormItemProps<TFieldValues> {
type FormSwitchProps<TFieldValues, TContext extends object> = FormControlledComponent<TFieldValues, TContext> & AbstractFormItemProps<TFieldValues> & {
defaultValue?: boolean,
onChange?: (value: boolean) => void,
}

View File

@ -38,7 +38,7 @@ enableMapSet();
export const VatSettingsModal: React.FC<VatSettingsModalProps> = ({ isOpen, toggleModal, onError, onSuccess }) => {
const { t } = useTranslation('admin');
const { handleSubmit, reset, control, register } = useForm<Record<SettingName, SettingValue>>();
const { handleSubmit, reset, control, register, formState } = useForm<Record<SettingName, SettingValue>>();
const isActive = useWatch({ control, name: 'invoice_VAT-active' });
const generalRate = useWatch({ control, name: 'invoice_VAT-rate' });
@ -108,11 +108,13 @@ export const VatSettingsModal: React.FC<VatSettingsModalProps> = ({ isOpen, togg
<FormInput register={register}
id="invoice_VAT-name"
rules={{ required: true }}
formState={formState}
tooltip={t('app.admin.vat_settings_modal.VAT_name_help')}
label={t('app.admin.vat_settings_modal.VAT_name')} />
<FormInput register={register}
id="invoice_VAT-rate"
rules={{ required: true }}
formState={formState}
tooltip={t('app.admin.vat_settings_modal.VAT_rate_help')}
type='number'
step={0.001}

View File

@ -40,11 +40,11 @@ export const MachineCategoriesList: React.FC<MachineCategoriesListProps> = ({ on
// retrieve the full list of machine categories on component mount
useEffect(() => {
MachineCategoryAPI.index()
.then(data => setMachineCategories(data))
.catch(e => onError(e));
MachineAPI.index()
.then(data => setMachines(data))
.catch(e => onError(e));
.then(setMachineCategories)
.catch(onError);
MachineAPI.index({ category: 'none' })
.then(setMachines)
.catch(onError);
}, []);
/**
@ -59,6 +59,7 @@ export const MachineCategoriesList: React.FC<MachineCategoriesListProps> = ({ on
*/
const onSaveTypeSuccess = (message: string): void => {
setModalIsOpen(false);
MachineAPI.index({ category: 'none' }).then(setMachines).catch(onError);
MachineCategoryAPI.index().then(data => {
setMachineCategories(data);
onSuccess(message);
@ -117,8 +118,12 @@ export const MachineCategoriesList: React.FC<MachineCategoriesListProps> = ({ on
return (
<div className="machine-categories-list">
<h3 className="machines-categories">{t('app.admin.machine_categories_list.machine_categories')}</h3>
<FabButton onClick={addMachineCategory} className="is-secondary" >{t('app.admin.machine_categories_list.add_a_machine_category')}</FabButton>
<header>
<h2>{t('app.admin.machine_categories_list.machine_categories')}</h2>
<div className='grpBtn'>
<FabButton className="main-action-btn" onClick={addMachineCategory}>{t('app.admin.machine_categories_list.add_a_machine_category')}</FabButton>
</div>
</header>
<MachineCategoryModal isOpen={modalIsOpen}
machines={machines}
machineCategory={machineCategory}

View File

@ -18,7 +18,9 @@ import { AdvancedAccountingForm } from '../accounting/advanced-accounting-form';
import { FormSelect } from '../form/form-select';
import { SelectOption } from '../../models/select';
import MachineCategoryAPI from '../../api/machine-category';
import SettingAPI from '../../api/setting';
import { MachineCategory } from '../../models/machine-category';
import { FabAlert } from '../base/fab-alert';
declare const Application: IApplication;
@ -38,12 +40,15 @@ export const MachineForm: React.FC<MachineFormProps> = ({ action, machine, onErr
const { t } = useTranslation('admin');
const [machineCategories, setMachineCategories] = useState<Array<MachineCategory>>([]);
const [isActiveAccounting, setIsActiveAccounting] = useState<boolean>(false);
// retrieve the full list of machine categories on component mount
// check advanced accounting activation
useEffect(() => {
MachineCategoryAPI.index()
.then(data => setMachineCategories(data))
.catch(e => onError(e));
SettingAPI.get('advanced_accounting').then(res => setIsActiveAccounting(res.value === 'true')).catch(onError);
}, []);
/**
@ -58,6 +63,26 @@ export const MachineForm: React.FC<MachineFormProps> = ({ action, machine, onErr
});
};
/**
* Callack triggered when the user changes the 'reservable' status of the machine:
* A reservable machine cannot be disabled
*/
const onReservableToggled = (reservable: boolean) => {
if (reservable) {
setValue('disabled', false);
}
};
/**
* Callack triggered when the user changes the 'disabled' status of the machine:
* A disabled machine cannot be reservable
*/
const onDisabledToggled = (disabled: boolean) => {
if (disabled) {
setValue('reservable', false);
}
};
/**
* Convert all machine categories to the select format
*/
@ -68,62 +93,103 @@ export const MachineForm: React.FC<MachineFormProps> = ({ action, machine, onErr
};
return (
<form className="machine-form" onSubmit={handleSubmit(onSubmit)}>
<FormInput register={register} id="name"
formState={formState}
rules={{ required: true }}
label={t('app.admin.machine_form.name')} />
<FormImageUpload setValue={setValue}
register={register}
control={control}
<div className="machine-form">
<header>
<h2>{t('app.admin.machine_form.ACTION_title', { ACTION: action })}</h2>
<FabButton onClick={handleSubmit(onSubmit)} className="fab-button save-btn is-main">
{t('app.admin.machine_form.save')}
</FabButton>
</header>
<form className="machine-form-content" onSubmit={handleSubmit(onSubmit)}>
{action === 'create' &&
<FabAlert level='warning'>
{t('app.admin.machine_form.watch_out_when_creating_a_new_machine_its_prices_are_initialized_at_0_for_all_subscriptions')} {t('app.admin.machine_form.consider_changing_them_before_creating_any_reservation_slot')}
</FabAlert>
}
<section>
<header>
<p className="title">{t('app.admin.machine_form.description')}</p>
</header>
<div className="content">
<FormInput register={register} id="name"
formState={formState}
rules={{ required: true }}
id="machine_image_attributes"
accept="image/*"
defaultImage={output.machine_image_attributes}
label={t('app.admin.machine_form.illustration')} />
<FormRichText control={control}
id="description"
rules={{ required: true }}
label={t('app.admin.machine_form.description')}
limit={null}
heading bulletList blockquote link video image />
<FormRichText control={control}
id="spec"
rules={{ required: true }}
label={t('app.admin.machine_form.technical_specifications')}
limit={null}
heading bulletList blockquote link video image />
<FormSelect options={buildOptions()}
control={control}
id="machine_category_id"
formState={formState}
label={t('app.admin.machine_form.category')} />
<div className='form-item-header machine-files-header'>
<p>{t('app.admin.machine_form.attached_files_pdf')}</p>
</div>
<FormMultiFileUpload setValue={setValue}
addButtonLabel={t('app.admin.machine_form.add_an_attachment')}
control={control}
accept="application/pdf"
register={register}
id="machine_files_attributes"
className="machine-files" />
label={t('app.admin.machine_form.name')} />
<FormImageUpload setValue={setValue}
register={register}
control={control}
formState={formState}
rules={{ required: true }}
id="machine_image_attributes"
accept="image/*"
defaultImage={output.machine_image_attributes}
label={t('app.admin.machine_form.illustration')} />
<FormRichText control={control}
id="description"
rules={{ required: true }}
formState={formState}
label={t('app.admin.machine_form.description')}
limit={null}
heading bulletList blockquote link image video />
<FormRichText control={control}
id="spec"
rules={{ required: true }}
formState={formState}
label={t('app.admin.machine_form.technical_specifications')}
limit={null}
heading bulletList link />
<FormSelect options={buildOptions()}
control={control}
id="machine_category_id"
formState={formState}
label={t('app.admin.machine_form.category')} />
</div>
</section>
<FormSwitch control={control}
id="reservable"
label={t('app.admin.machine_form.reservable')}
tooltip={t('app.admin.machine_form.reservable_help')}
defaultValue={true} />
<FormSwitch control={control}
id="disabled"
label={t('app.admin.machine_form.disable_machine')}
tooltip={t('app.admin.machine_form.disabled_help')} />
<AdvancedAccountingForm register={register} onError={onError} />
<FabButton type="submit" className="is-info submit-btn">
{t('app.admin.machine_form.ACTION_machine', { ACTION: action })}
</FabButton>
</form>
<section>
<header>
<p className="title">{t('app.admin.machine_form.attachments')}</p>
</header>
<div className="content">
<div className='form-item-header machine-files-header'>
<p>{t('app.admin.machine_form.attached_files_pdf')}</p>
</div>
<FormMultiFileUpload setValue={setValue}
addButtonLabel={t('app.admin.machine_form.add_an_attachment')}
control={control}
accept="application/pdf"
register={register}
id="machine_files_attributes"
className="machine-files" />
</div>
</section>
<section>
<header>
<p className="title">{t('app.admin.machine_form.settings')}</p>
</header>
<div className="content">
<FormSwitch control={control}
id="reservable"
label={t('app.admin.machine_form.reservable')}
onChange={onReservableToggled}
tooltip={t('app.admin.machine_form.reservable_help')}
defaultValue={true} />
<FormSwitch control={control}
id="disabled"
onChange={onDisabledToggled}
label={t('app.admin.machine_form.disable_machine')}
tooltip={t('app.admin.machine_form.disabled_help')} />
</div>
</section>
{isActiveAccounting &&
<section>
<AdvancedAccountingForm register={register} onError={onError} />
</section>
}
</form>
</div>
);
};

View File

@ -0,0 +1,52 @@
import { useEffect, useState } from 'react';
import { IApplication } from '../../models/application';
import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';
import { EditorialBlock } from '../editorial-block/editorial-block';
import SettingAPI from '../../api/setting';
import SettingLib from '../../lib/setting';
import { SettingValue, machinesSettings } from '../../models/setting';
declare const Application: IApplication;
interface MachinesEditorialBlockProps {
onError: (message: string) => void
}
/**
* This component displays to Users the editorial block (banner) associated to machines.
*/
export const MachinesEditorialBlock: React.FC<MachinesEditorialBlockProps> = ({ onError }) => {
// Store Banner retrieved from API
const [banner, setBanner] = useState<Record<string, SettingValue>>({});
// Retrieve the settings related to the Machines Banner from the API
useEffect(() => {
SettingAPI.query(machinesSettings)
.then(settings => {
setBanner({ ...SettingLib.bulkMapToObject(settings) });
})
.catch(onError);
}, []);
return (
<>
{banner.machines_banner_active &&
<EditorialBlock
text={banner.machines_banner_text}
cta={banner.machines_banner_cta_active && banner.machines_banner_cta_label}
url={banner.machines_banner_cta_active && banner.machines_banner_cta_url} />
}
</>
);
};
const MachinesEditorialBlockWrapper: React.FC<MachinesEditorialBlockProps> = (props) => {
return (
<Loader>
<MachinesEditorialBlock {...props} />
</Loader>
);
};
Application.Components.component('machinesEditorialBlock', react2angular(MachinesEditorialBlockWrapper, ['onError']));

View File

@ -15,16 +15,29 @@ interface MachinesFiltersProps {
export const MachinesFilters: React.FC<MachinesFiltersProps> = ({ onFilterChangedBy, machineCategories }) => {
const { t } = useTranslation('public');
const defaultValue = { value: true, label: t('app.public.machines_filters.status_enabled') };
const defaultValue = { value: false, label: t('app.public.machines_filters.status_enabled') };
const categoryDefaultValue = { value: null, label: t('app.public.machines_filters.all_machines') };
// Styles the React-select component
const customStyles = {
control: base => ({
...base,
width: '20ch',
border: 'none',
backgroundColor: 'transparent'
}),
indicatorSeparator: () => ({
display: 'none'
})
};
/**
* Provides boolean options in the react-select format (yes/no/all)
*/
const buildBooleanOptions = (): Array<SelectOption<boolean>> => {
return [
defaultValue,
{ value: false, label: t('app.public.machines_filters.status_disabled') },
{ value: true, label: t('app.public.machines_filters.status_disabled') },
{ value: null, label: t('app.public.machines_filters.status_all') }
];
};
@ -43,7 +56,7 @@ export const MachinesFilters: React.FC<MachinesFiltersProps> = ({ onFilterChange
* Callback triggered when the user selects a machine status in the dropdown list
*/
const handleStatusSelected = (option: SelectOption<boolean>): void => {
onFilterChangedBy('status', option.value);
onFilterChangedBy('disabled', option.value);
};
/**
@ -56,21 +69,23 @@ export const MachinesFilters: React.FC<MachinesFiltersProps> = ({ onFilterChange
return (
<div className="machines-filters">
<div className="filter-item">
<label htmlFor="status">{t('app.public.machines_filters.show_machines')}</label>
<p>{t('app.public.machines_filters.show_machines')}</p>
<Select defaultValue={defaultValue}
id="status"
className="status-select"
onChange={handleStatusSelected}
options={buildBooleanOptions()}/>
options={buildBooleanOptions()}
styles={customStyles}/>
</div>
{machineCategories.length > 0 &&
<div className="filter-item">
<label htmlFor="category">{t('app.public.machines_filters.filter_by_machine_category')}</label>
<p>{t('app.public.machines_filters.filter_by_machine_category')}</p>
<Select defaultValue={categoryDefaultValue}
id="machine_category"
className="category-select"
onChange={handleCategorySelected}
options={buildCategoriesOptions()}/>
options={buildCategoriesOptions()}
styles={customStyles}/>
</div>
}
</div>

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import * as React from 'react';
import { Machine, MachineListFilter } from '../../models/machine';
import { Machine, MachineIndexFilter } from '../../models/machine';
import { IApplication } from '../../models/application';
import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';
@ -10,8 +10,6 @@ import { MachineCategory } from '../../models/machine-category';
import { MachineCard } from './machine-card';
import { MachinesFilters } from './machines-filters';
import { User } from '../../models/user';
import { useTranslation } from 'react-i18next';
import { FabButton } from '../base/fab-button';
declare const Application: IApplication;
@ -30,59 +28,32 @@ interface MachinesListProps {
* This component shows a list of all machines and allows filtering on that list.
*/
export const MachinesList: React.FC<MachinesListProps> = ({ onError, onSuccess, onShowMachine, onReserveMachine, onLoginRequested, onEnrollRequested, user, canProposePacks }) => {
const { t } = useTranslation('public');
// shown machines
const [machines, setMachines] = useState<Array<Machine>>(null);
// we keep the full list of machines, for filtering
const [allMachines, setAllMachines] = useState<Array<Machine>>(null);
// shown machine categories
const [machineCategories, setMachineCategories] = useState<Array<MachineCategory>>([]);
// machine list filter
const [filter, setFilter] = useState<MachineListFilter>({
status: true,
const [filters, setFilters] = useState<MachineIndexFilter>({
disabled: false,
category: null
});
// retrieve the full list of machines on component mount
useEffect(() => {
MachineAPI.index()
.then(data => setAllMachines(data))
MachineAPI.index(filters)
.then(data => setMachines(data))
.catch(e => onError(e));
MachineCategoryAPI.index()
.then(data => setMachineCategories(data))
.catch(e => onError(e));
}, []);
// filter the machines shown when the full list was retrieved
// refetch the machines when the filters change
useEffect(() => {
handleFilter();
}, [allMachines]);
// filter the machines shown when the filter was changed
useEffect(() => {
handleFilter();
}, [filter]);
/**
* Callback triggered when the user changes the filter.
* filter the machines shown when the filter was changed.
*/
const handleFilter = (): void => {
let machinesFiltered = [];
if (allMachines) {
if (filter.status === null) {
machinesFiltered = allMachines;
} else {
// enabled machines may have the m.disabled property null (for never disabled machines)
// or false (for re-enabled machines)
machinesFiltered = allMachines.filter(m => !!m.disabled === !filter.status);
}
if (filter.category !== null) {
machinesFiltered = machinesFiltered.filter(m => m.machine_category_id === filter.category);
}
}
setMachines(machinesFiltered);
};
MachineAPI.index(filters)
.then(data => setMachines(data))
.catch(e => onError(e));
}, [filters]);
/**
* Callback triggered when the user changes the filter.
@ -90,36 +61,16 @@ export const MachinesList: React.FC<MachinesListProps> = ({ onError, onSuccess,
* @param value, status and category value
*/
const handleFilterChangedBy = (type: string, value: number | boolean | void) => {
setFilter({
...filter,
setFilters({
...filters,
[type]: value
});
};
/**
* Go to store
*/
const linkToStore = (): void => {
window.location.href = '/#!/store';
};
// TODO: Conditionally display the store ad
return (
<div className="machines-list">
<MachinesFilters onFilterChangedBy={handleFilterChangedBy} machineCategories={machineCategories}/>
<div className="all-machines">
{false &&
<div className='store-ad' onClick={() => linkToStore}>
<div className='content'>
<h3>{t('app.public.machines_list.store_ad.title')}</h3>
<p>{t('app.public.machines_list.store_ad.buy')}</p>
<p className='sell'>{t('app.public.machines_list.store_ad.sell')}</p>
</div>
<FabButton icon={<i className="fa fa-cart-plus fa-lg" />} className="cta" onClick={linkToStore}>
{t('app.public.machines_list.store_ad.link')}
</FabButton>
</div>
}
{machines && machines.map(machine => {
return <MachineCard key={machine.id}
user={user}

View File

@ -0,0 +1,88 @@
import React, { useEffect } from 'react';
import { IApplication } from '../../models/application';
import { Loader } from '../base/loader';
import { react2angular } from 'react2angular';
import { ErrorBoundary } from '../base/error-boundary';
import { useTranslation } from 'react-i18next';
import { useForm, SubmitHandler } from 'react-hook-form';
import { FabButton } from '../base/fab-button';
import { EditorialKeys, EditorialBlockForm } from '../editorial-block/editorial-block-form';
import SettingAPI from '../../api/setting';
import SettingLib from '../../lib/setting';
import { SettingName, SettingValue, machinesSettings } from '../../models/setting';
import { UnsavedFormAlert } from '../form/unsaved-form-alert';
import { UIRouter } from '@uirouter/angularjs';
declare const Application: IApplication;
interface MachinesSettingsProps {
onError: (message: string) => void,
onSuccess: (message: string) => void,
beforeSubmit?: (data: Record<SettingName, SettingValue>) => void,
uiRouter?: UIRouter
}
/**
* Machines settings
*/
export const MachinesSettings: React.FC<MachinesSettingsProps> = ({ onError, onSuccess, beforeSubmit, uiRouter }) => {
const { t } = useTranslation('admin');
const { register, control, formState, handleSubmit, reset } = useForm<Record<SettingName, SettingValue>>();
/** Link Machines Banner Setting Names to generic keys expected by the Editorial Form */
const bannerKeys: Record<EditorialKeys, SettingName> = {
active_text_block: 'machines_banner_active',
text_block: 'machines_banner_text',
active_cta: 'machines_banner_cta_active',
cta_label: 'machines_banner_cta_label',
cta_url: 'machines_banner_cta_url'
};
/** Callback triggered when the form is submitted: save the settings */
const onSubmit: SubmitHandler<Record<SettingName, SettingValue>> = (data) => {
if (typeof beforeSubmit === 'function') beforeSubmit(data);
SettingAPI.bulkUpdate(SettingLib.objectToBulkMap(data)).then(() => {
onSuccess(t('app.admin.machines_settings.successfully_saved'));
}, reason => {
onError(reason);
});
};
/** On component mount, fetch existing Machines Banner Settings from API, and populate form with these values. */
useEffect(() => {
SettingAPI.query(machinesSettings)
.then(settings => reset(SettingLib.bulkMapToObject(settings)))
.catch(onError);
}, []);
return (
<div className="machines-settings">
<header>
<h2>{t('app.admin.machines_settings.title')}</h2>
<FabButton onClick={handleSubmit(onSubmit)} className='save-btn is-main'>{t('app.admin.machines_settings.save')}</FabButton>
</header>
<form className="machines-settings-content">
{uiRouter && <UnsavedFormAlert uiRouter={uiRouter} formState={formState} />}
<div className="settings-section">
<EditorialBlockForm register={register}
control={control}
formState={formState}
keys={bannerKeys}
info={t('app.admin.machines_settings.generic_text_block_info')} />
</div>
</form>
</div>
);
};
const MachinesSettingsWrapper: React.FC<MachinesSettingsProps> = (props) => {
return (
<Loader>
<ErrorBoundary>
<MachinesSettings {...props} />
</ErrorBoundary>
</Loader>
);
};
Application.Components.component('machinesSettings', react2angular(MachinesSettingsWrapper, ['onError', 'onSuccess', 'beforeSubmit', 'uiRouter']));

View File

@ -0,0 +1,65 @@
import { useEffect } from 'react';
import { Loader } from '../base/loader';
import { useTranslation } from 'react-i18next';
import { NotificationPreference } from '../../models/notification-preference';
import { useForm } from 'react-hook-form';
import { FormSwitch } from '../form/form-switch';
import NotificationPreferencesAPI from '../../api/notification_preference';
interface NotificationFormProps {
onError: (message: string) => void,
preference: NotificationPreference
}
/**
* Displays the list of notifications
*/
const NotificationForm: React.FC<NotificationFormProps> = ({ preference, onError }) => {
const { t } = useTranslation('logged');
const { handleSubmit, formState, control, reset } = useForm<NotificationPreference>({ defaultValues: { ...preference } });
// Create or Update (if id exists) a Notification Preference
const onSubmit = (updatedPreference: NotificationPreference) => NotificationPreferencesAPI.update(updatedPreference).catch(onError);
// Calls submit handler on every change of a Form Switch
const handleChange = () => handleSubmit(onSubmit)();
// Resets form on component mount, and if preference changes (happens when bulk updating a category)
useEffect(() => {
reset(preference);
}, [preference]);
return (
<form onSubmit={handleSubmit(onSubmit)} className="notification-form">
<p className="notification-type">{t(`app.logged.notification_form.${preference.notification_type}`)}</p>
<div className='form-actions'>
<FormSwitch
className="form-action"
control={control}
formState={formState}
defaultValue={preference.email}
id="email"
label='email'
onChange={handleChange}/>
<FormSwitch
className="form-action"
control={control}
formState={formState}
defaultValue={preference.in_system}
id="in_system"
label='push'
onChange={handleChange}/>
</div>
</form>
);
};
const NotificationFormWrapper: React.FC<NotificationFormProps> = (props) => {
return (
<Loader>
<NotificationForm {...props} />
</Loader>
);
};
export { NotificationFormWrapper as NotificationForm };

View File

@ -0,0 +1,39 @@
import { Loader } from '../base/loader';
import { Notification } from '../../models/notification';
import FormatLib from '../../lib/format';
import { FabButton } from '../base/fab-button';
import { useTranslation } from 'react-i18next';
interface NotificationInlineProps {
notification: Notification,
onUpdate?: (Notification) => void
}
/**
* Displays one notification
*/
const NotificationInline: React.FC<NotificationInlineProps> = ({ notification, onUpdate }) => {
const { t } = useTranslation('logged');
const createdAt = new Date(notification.created_at);
// Call a parent component method to update the notification
const update = () => onUpdate(notification);
return (
<div className="notification-inline">
<div className="date">{ FormatLib.date(createdAt) } { FormatLib.time(createdAt) }</div>
<div className="message" dangerouslySetInnerHTML={{ __html: notification.message.description }}/>
{onUpdate && <FabButton onClick={update} className="is-secondary">{ t('app.logged.notification_inline.mark_as_read') }</FabButton>}
</div>
);
};
const NotificationInlineWrapper: React.FC<NotificationInlineProps> = (props) => {
return (
<Loader>
<NotificationInline {...props} />
</Loader>
);
};
export { NotificationInlineWrapper as NotificationInline };

Some files were not shown because too many files have changed in this diff Show More