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:
commit
576a2540bf
@ -32,7 +32,7 @@ imports
|
||||
accounting
|
||||
|
||||
# Proof of identity files
|
||||
proof_of_identity_files
|
||||
supporting_document_files
|
||||
|
||||
# Development files
|
||||
Vagrantfile
|
||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -47,7 +47,7 @@
|
||||
/accounting/*
|
||||
|
||||
# Proof of identity files
|
||||
/proof_of_identity_files/*
|
||||
/supporting_document_files/*
|
||||
|
||||
.DS_Store
|
||||
|
||||
|
@ -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
|
||||
|
34
CHANGELOG.md
34
CHANGELOG.md
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
2
Gemfile
2
Gemfile
@ -103,8 +103,6 @@ gem 'elasticsearch-persistence', '~> 5'
|
||||
gem 'elasticsearch-rails', '~> 5'
|
||||
gem 'faraday', '~> 0.17'
|
||||
|
||||
gem 'notify_with'
|
||||
|
||||
gem 'pundit'
|
||||
|
||||
gem 'oj'
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
50
app/controllers/api/notification_preferences_controller.rb
Normal file
50
app/controllers/api/notification_preferences_controller.rb
Normal 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
|
14
app/controllers/api/notification_types_controller.rb
Normal file
14
app/controllers/api/notification_types_controller.rb
Normal 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
|
@ -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|
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
||||
|
@ -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
|
||||
|
46
app/controllers/api/statuses_controller.rb
Normal file
46
app/controllers/api/statuses_controller.rb
Normal 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
|
@ -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
|
||||
|
53
app/controllers/api/supporting_document_files_controller.rb
Normal file
53
app/controllers/api/supporting_document_files_controller.rb
Normal 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
|
@ -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
|
50
app/controllers/api/supporting_document_types_controller.rb
Normal file
50
app/controllers/api/supporting_document_types_controller.rb
Normal 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
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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?
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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?
|
||||
|
||||
|
@ -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?
|
||||
|
||||
|
@ -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
|
||||
|
5
app/exceptions/cart/anonymous_error.rb
Normal file
5
app/exceptions/cart/anonymous_error.rb
Normal file
@ -0,0 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Raised when an anonymous cart it not allowed
|
||||
class Cart::AnonymousError < StandardError
|
||||
end
|
5
app/exceptions/cart/unknown_item_error.rb
Normal file
5
app/exceptions/cart/unknown_item_error.rb
Normal file
@ -0,0 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Raised when the added item is not a recognized class
|
||||
class Cart::UnknownItemError < StandardError
|
||||
end
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
20
app/frontend/src/javascript/api/notification.ts
Normal file
20
app/frontend/src/javascript/api/notification.ts
Normal 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');
|
||||
}
|
||||
}
|
20
app/frontend/src/javascript/api/notification_preference.ts
Normal file
20
app/frontend/src/javascript/api/notification_preference.ts
Normal 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;
|
||||
}
|
||||
}
|
11
app/frontend/src/javascript/api/notification_types.ts
Normal file
11
app/frontend/src/javascript/api/notification_types.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
25
app/frontend/src/javascript/api/status.ts
Normal file
25
app/frontend/src/javascript/api/status.ts
Normal 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;
|
||||
}
|
||||
}
|
31
app/frontend/src/javascript/api/supporting-document-file.ts
Normal file
31
app/frontend/src/javascript/api/supporting-document-file.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
31
app/frontend/src/javascript/api/supporting-document-type.ts
Normal file
31
app/frontend/src/javascript/api/supporting-document-type.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
</>}
|
||||
</>);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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">
|
||||
|
@ -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')} />
|
||||
|
@ -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')} />
|
||||
|
@ -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')} />
|
||||
|
@ -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>
|
||||
|
@ -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>} />}
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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 */
|
||||
};
|
||||
|
100
app/frontend/src/javascript/components/cart/abstract-item.tsx
Normal file
100
app/frontend/src/javascript/components/cart/abstract-item.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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">
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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 };
|
@ -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']));
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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']));
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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']));
|
@ -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']));
|
@ -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> }
|
||||
</>
|
||||
));
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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']));
|
@ -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>
|
||||
|
@ -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}
|
||||
|
@ -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']));
|
@ -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 };
|
@ -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
Loading…
x
Reference in New Issue
Block a user