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

Merge branch 'dev' for release 5.6.0

This commit is contained in:
Sylvain 2023-01-05 12:32:17 +01:00
commit 19cde5f9c4
594 changed files with 18224 additions and 7258 deletions

View File

@ -37,14 +37,11 @@
"sourceType": "module",
"project": "./tsconfig.json"
},
"settings": {
"react": {
"version": "detect"
}
},
"plugins": ["@typescript-eslint", "react"],
"rules": {
"react/prop-types": "off"
"react/prop-types": "off",
"react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off"
}
},
{
@ -66,7 +63,26 @@
"rules": {
"camelcase": "off"
}
},
{
"files": ["test/**/*"],
"plugins": ["jest"],
"env": {
"jest/globals": true
},
"globals": {
"Range": true,
"Document": true
},
"parserOptions": {
"project": "./test/frontend/tsconfig.json"
}
}
]
],
"settings": {
"react": {
"version": "detect"
}
}
}

View File

@ -12,7 +12,7 @@ CommitMsg:
MessageFormat:
enabled: true
pattern: ^(\((doc|bug|feat|security|dev|i18n|api|test|quality|ui|merge)\) [\w ]++(\n\n.+)?)|(Version (\d+\.?)+)|(Merge branch .*)
expected_pattern_message: (doc|bug|feat|security|dev|i18n|api|test|quality|ui|merge) title\n\ndescription
pattern: ^(\((doc|bug|feat|security|dev|i18n|api|test|quality|ui|merge|wip)\) [\w ]++(\n\n.+)?)|(Version (\d+\.?)+)|(Merge branch .*)
expected_pattern_message: (doc|bug|feat|security|dev|i18n|api|test|quality|ui|merge|wip) title\n\ndescription
sample_message: (bug) no validation on date\n\nThe birthdate was not validated...

View File

@ -3,7 +3,7 @@ require: rubocop-rails
AllCops:
NewCops: enable
Layout/LineLength:
Max: 140
Max: 145
Metrics/MethodLength:
Max: 35
Metrics/CyclomaticComplexity:

View File

@ -1,5 +1,49 @@
# Changelog Fab-manager
## v5.6.0 2023 January 5
- Ability to group machines by categories
- Ability to mark a machine as reservable or not
- Ability to filter the admin's calendar
- Private note on member's profile
- Optional external identifier for users
- Ability to disable generation of invoices at zero
- Accounting data is now built each night and saved in database
- Ability to define multiple accounting journal codes
- Ability to change the name of the VAT
- Ability to cancel a running subscription from the member edition view for admin/managers
- OpenAPI endpoint to fetch accounting data
- Add reservation deadline parameter (#414)
- Verify current password at server side when changing password
- Password strengh indicator
- Updated OpenAPI documentation
- Updated OpenID Connect documentation
- OpenAPI users endpoint offer ability to filter by created_after
- OpenAPI users endpoint return first name, last name, gender, organization and address
- Default accounting codes and labels if not set
- Active serving static files from the `/public` folder by default from rails
- Display custom error message if the PDF invoice is not found
- Report subsription mismatch with user's group
- Added sentry for error reporting
- Report details of the due for invoices related to a payment schedule
- Fix a bug: unable to run test in negative timezones (#425)
- Fix a bug: providing an array of attributes to filter OpenApi data, results in error
- Fix a bug: unable to manage stocks on new products
- Fix a bug: unsupported param[] syntax in OpenAPI
- Fix a bug: unable to access in-system notifications if a slot was cancelled
- Fix a bug: feature tour in admin/settings is broken
- Fix a bug: clearing the new expiration date field in the offer days modal result in errors
- Fix a bug: low stock notification is always sent if one of the stocks has reached the threshold
- Fix a bug: unable to update title of availability after admin remove a machine/plan in calendar
- Fix a bug: unable to access files from the public folder (like example.csv)
- Fix a bug: unable to return to the statistics tab
- Fix a bug: payment schedule by check result in error if payment mean was not changed
- Fix a security issue: updated httparty to 0.21.0 to fix [GHSA-5pq7-52mg-hr42](https://github.com/advisories/GHSA-5pq7-52mg-hr42)
- Updated react-modal to 3.16.1
- 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`
## v5.5.8 2022 December 16
- Fix a bug: wrong reservations count for spaces in availabilities export (#415)

View File

@ -143,3 +143,7 @@ gem 'sassc', '= 2.1.0'
gem 'redis-session-store'
gem 'acts_as_list'
# Error reporting
gem 'sentry-rails'
gem 'sentry-ruby'

View File

@ -166,8 +166,8 @@ GEM
hashery (2.1.2)
hashie (5.0.0)
htmlentities (4.3.4)
httparty (0.20.0)
mime-types (~> 3.0)
httparty (0.21.0)
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
httpclient (2.8.3)
i18n (1.12.0)
@ -216,9 +216,6 @@ GEM
message_format (0.0.6)
twitter_cldr (~> 5.0)
method_source (1.0.0)
mime-types (3.4.1)
mime-types-data (~> 3.2015)
mime-types-data (3.2022.0105)
mimemagic (0.4.3)
nokogiri (~> 1)
rake
@ -397,6 +394,11 @@ GEM
activerecord (>= 4)
activesupport (>= 4)
semantic_range (3.0.0)
sentry-rails (5.7.0)
railties (>= 5.0)
sentry-ruby (~> 5.7.0)
sentry-ruby (5.7.0)
concurrent-ruby (~> 1.0, >= 1.0.2)
sha3 (1.0.1)
shakapacker (6.2.0)
activesupport (>= 5.2)
@ -559,6 +561,8 @@ DEPENDENCIES
rubyzip (>= 1.3.0)
sassc (= 2.1.0)
seed_dump
sentry-rails
sentry-ruby
sha3
shakapacker (= 6.2.0)
sidekiq (>= 6.0.7)
@ -574,4 +578,4 @@ DEPENDENCIES
webmock
BUNDLED WITH
2.3.25
2.3.26

View File

@ -2,12 +2,11 @@
# API Controller for resources of AccountingPeriod
class API::AccountingPeriodsController < API::ApiController
before_action :authenticate_user!
before_action :set_period, only: %i[show download_archive]
def index
@accounting_periods = AccountingPeriodService.all_periods_with_users
@accounting_periods = Accounting::AccountingPeriodService.all_periods_with_users
end
def show; end
@ -24,7 +23,7 @@ class API::AccountingPeriodsController < API::ApiController
def last_closing_end
authorize AccountingPeriod
last_period = AccountingPeriodService.find_last_period
last_period = Accounting::AccountingPeriodService.find_last_period
if last_period.nil?
invoice = Invoice.order(:created_at).first
@last_end = invoice.created_at if invoice
@ -35,7 +34,7 @@ class API::AccountingPeriodsController < API::ApiController
def download_archive
authorize AccountingPeriod
send_file File.join(Rails.root, @accounting_period.archive_file), type: 'application/json', disposition: 'attachment'
send_file Rails.root.join(@accounting_period.archive_file), type: 'application/json', disposition: 'attachment'
end
private

View File

@ -11,12 +11,12 @@ class API::AvailabilitiesController < API::ApiController
def index
authorize Availability
display_window = window
@availabilities = Availability.includes(:machines, :tags, :trainings, :spaces)
.where('start_at >= ? AND end_at <= ?', display_window[:start], display_window[:end])
@availabilities = @availabilities.where.not(available_type: 'event') unless Setting.get('events_in_calendar')
@availabilities = @availabilities.where.not(available_type: 'space') unless Setting.get('spaces_module')
service = Availabilities::AvailabilitiesService.new(@current_user, 'availability')
machine_ids = params[:m] || []
@availabilities = service.index(display_window,
{ machines: machine_ids, spaces: params[:s], trainings: params[:t] },
events: (params[:evt] && params[:evt] == 'true'))
@availabilities = filter_availabilites(@availabilities)
end
def public
@ -27,7 +27,7 @@ class API::AvailabilitiesController < API::ApiController
@availabilities = service.public_availabilities(
display_window,
{ machines: machine_ids, spaces: params[:s], trainings: params[:t] },
(params[:evt] && params[:evt] == 'true')
events: (params[:evt] && params[:evt] == 'true')
)
@title_filter = { machine_ids: machine_ids.map(&:to_i) }
@ -112,7 +112,7 @@ class API::AvailabilitiesController < API::ApiController
render json: @export.errors, status: :unprocessable_entity
end
else
send_file File.join(Rails.root, export.file),
send_file Rails.root.join(export.file),
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
disposition: 'attachment'
end
@ -120,7 +120,7 @@ class API::AvailabilitiesController < API::ApiController
def lock
authorize @availability
if @availability.update_attributes(lock: lock_params)
if @availability.update(lock: lock_params)
render :show, status: :ok, location: @availability
else
render json: @availability.errors, status: :unprocessable_entity
@ -163,10 +163,19 @@ class API::AvailabilitiesController < API::ApiController
end
def filter_availabilites(availabilities)
availabilities.delete_if(&method(:remove_full?))
availabilities_filterd = availabilities
availabilities_filterd = availabilities.delete_if(&method(:remove_full?)) if params[:dispo] == 'false'
availabilities_filterd = availabilities.delete_if(&method(:remove_empty?)) if params[:reserved] == 'true'
availabilities_filterd
end
def remove_full?(availability)
params[:dispo] == 'false' && (availability.is_reserved || (availability.try(:full?) && availability.full?))
availability.try(:full?) && availability.full?
end
def remove_empty?(availability)
availability.try(:empty?) && availability.empty?
end
end

View File

@ -10,9 +10,9 @@ class API::EventsController < API::ApiController
@scope = params[:scope]
# filters
@events = @events.joins(:category).where('categories.id = :category', category: params[:category_id]) if params[:category_id]
@events = @events.joins(:event_themes).where('event_themes.id = :theme', theme: params[:theme_id]) if params[:theme_id]
@events = @events.where('age_range_id = :age_range', age_range: params[:age_range_id]) if params[:age_range_id]
@events = @events.joins(:category).where(categories: { id: params[:category_id] }) if params[:category_id]
@events = @events.joins(:event_themes).where(event_themes: { id: params[:theme_id] }) if params[:theme_id]
@events = @events.where(age_range_id: params[:age_range_id]) if params[:age_range_id]
if current_user&.admin? || current_user&.manager?
@events = case params[:scope]
@ -65,7 +65,7 @@ class API::EventsController < API::ApiController
def update
authorize Event
res = EventService.update(@event, event_params.permit!, params[:edit_mode])
res = Event::UpdateEventService.update(@event, event_params.permit!, params[:edit_mode])
render json: { action: 'update', total: res[:events].length, updated: res[:events].select { |r| r[:status] }.length, details: res },
status: :ok,
location: @event
@ -97,7 +97,8 @@ class API::EventsController < API::ApiController
event_theme_ids: [],
event_image_attributes: [:attachment],
event_files_attributes: %i[id attachment _destroy],
event_price_categories_attributes: %i[id price_category_id amount _destroy])
event_price_categories_attributes: %i[id price_category_id amount _destroy],
advanced_accounting_attributes: %i[code analytical_section])
EventService.process_params(event_preparams)
end
end

View File

@ -12,9 +12,13 @@ class API::InvoicesController < API::ApiController
).all.order('reference DESC')
end
def show; end
def download
authorize @invoice
send_file File.join(Rails.root, @invoice.file), type: 'application/pdf', disposition: 'attachment'
send_file Rails.root.join(@invoice.file), type: 'application/pdf', disposition: 'attachment'
rescue ActionController::MissingFile
render html: I18n.t('invoices.unable_to_find_pdf'), status: :not_found
end
def list

View File

@ -0,0 +1,52 @@
# frozen_string_literal: true
# API Controller for resources of type Machine Category
# Categories are used to classify Machine
class API::MachineCategoriesController < API::ApiController
before_action :authenticate_user!, except: [:index]
before_action :set_machine_category, only: %i[show update destroy]
def index
@machine_categories = MachineCategory.all.order(name: :asc)
end
def show; end
def create
authorize MachineCategory
@machine_category = MachineCategory.new(machine_category_params)
if @machine_category.save
render :show, status: :created, location: @category
else
render json: @machine_category.errors, status: :unprocessable_entity
end
end
def update
authorize MachineCategory
if @machine_category.update(machine_category_params)
render :show, status: :ok, location: @category
else
render json: @machine_category.errors, status: :unprocessable_entity
end
end
def destroy
authorize MachineCategory
if @machine_category.destroy
head :no_content
else
render json: @machine_category.errors, status: :unprocessable_entity
end
end
private
def set_machine_category
@machine_category = MachineCategory.find(params[:id])
end
def machine_category_params
params.require(:machine_category).permit(:name, machine_ids: [])
end
end

View File

@ -49,8 +49,9 @@ class API::MachinesController < API::ApiController
end
def machine_params
params.require(:machine).permit(:name, :description, :spec, :disabled, :plan_ids,
params.require(:machine).permit(:name, :description, :spec, :disabled, :machine_category_id, :plan_ids, :reservable,
plan_ids: [], machine_image_attributes: [:attachment],
machine_files_attributes: %i[id attachment _destroy])
machine_files_attributes: %i[id attachment _destroy],
advanced_accounting_attributes: %i[code analytical_section])
end
end

View File

@ -4,6 +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]
respond_to :json
def index
@ -46,7 +47,7 @@ class API::MembersController < API::ApiController
authorize @member
members_service = Members::MembersService.new(@member)
if members_service.update(user_params)
if members_service.update(user_params, current_user, params[:user][:current_password])
# Update password without logging out
bypass_sign_in(@member) unless current_user.id != params[:id].to_i
render :show, status: :ok, location: member_path(@member)
@ -213,6 +214,10 @@ class API::MembersController < API::ApiController
@member = User.find(params[:id])
end
def set_operator
@operator = current_user
end
def user_params
if current_user.id == params[:id].to_i
params.require(:user).permit(:username, :email, :password, :password_confirmation, :group_id, :is_allow_contact, :is_allow_newsletter,
@ -230,15 +235,15 @@ class API::MembersController < API::ApiController
],
statistic_profile_attributes: %i[id gender birthday])
elsif current_user.admin? || current_user.manager?
elsif current_user.privileged?
params.require(:user).permit(:username, :email, :password, :password_confirmation, :is_allow_contact, :is_allow_newsletter, :group_id,
tag_ids: [],
profile_attributes: [:id, :first_name, :last_name, :phone, :interest, :software_mastered, :website, :job,
:facebook, :twitter, :google_plus, :viadeo, :linkedin, :instagram, :youtube, :vimeo,
:dailymotion, :github, :echosciences, :pinterest, :lastfm, :flickr,
:dailymotion, :github, :echosciences, :pinterest, :lastfm, :flickr, :note,
{ user_avatar_attributes: %i[id attachment destroy] }],
invoicing_profile_attributes: [
:id, :organization,
:id, :organization, :external_id,
{
address_attributes: %i[id address],
organization_attributes: [:id, :name, { address_attributes: %i[id address] }],

View File

@ -4,7 +4,6 @@
class API::PaymentsController < API::ApiController
before_action :authenticate_user!
# This method must be overridden by the the gateways controllers that inherits API::PaymentsControllers
def confirm_payment
raise NoMethodError
@ -41,5 +40,7 @@ class API::PaymentsController < API::ApiController
else
{ json: res[:errors].drop_while(&:empty?), status: :unprocessable_entity }
end
rescue StandardError => e
{ json: e, status: :unprocessable_entity }
end
end

View File

@ -26,7 +26,7 @@ class API::PlansController < API::ApiController
end
type = plan_params[:type]
partner = params[:plan][:partner_id].empty? ? nil : User.find(params[:plan][:partner_id])
partner = params[:plan][:partner_id].blank? ? nil : User.find(params[:plan][:partner_id])
plan = PlansService.create(type, partner, plan_params)
if plan.key?(:errors)
@ -83,7 +83,8 @@ class API::PlansController < API::ApiController
.permit(:base_name, :type, :group_id, :amount, :interval, :interval_count, :is_rolling,
:training_credit_nb, :ui_weight, :disabled, :monthly_payment, :description, :plan_category_id,
plan_file_attributes: %i[id attachment _destroy],
prices_attributes: %i[id amount])
prices_attributes: %i[id amount],
advanced_accounting_attributes: %i[code analytical_section])
end
end
end

View File

@ -73,6 +73,7 @@ class API::ProductsController < API::ApiController
:low_stock_alert, :low_stock_threshold,
machine_ids: [],
product_files_attributes: %i[id attachment _destroy],
product_images_attributes: %i[id attachment is_main _destroy])
product_images_attributes: %i[id attachment is_main _destroy],
advanced_accounting_attributes: %i[code analytical_section])
end
end

View File

@ -1,13 +1,14 @@
# frozen_string_literal: true
# API Controller for resources of type ProfileCustomField
# ProfileCustomFields are used to provide admin config user profile custom fields
# ProfileCustomFields are fields configured by an admin, added to the user's profile
class API::ProfileCustomFieldsController < API::ApiController
before_action :authenticate_user!, except: :index
before_action :set_profile_custom_field, only: %i[show update destroy]
def index
@profile_custom_fields = ProfileCustomField.all.order('id ASC')
@profile_custom_fields = @profile_custom_fields.where(actived: params[:actived]) if params[:actived].present?
end
def show; end

View File

@ -12,10 +12,10 @@ class API::SettingsController < API::ApiController
authorize Setting
@setting = Setting.find_or_initialize_by(name: params[:name])
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.before_update(@setting)
render status: :locked, json: { error: I18n.t('settings.locked_setting') } and return unless SettingService.update_allowed?(@setting)
if @setting.save && @setting.history_values.create(value: setting_params[:value], invoicing_profile: current_user.invoicing_profile)
SettingService.after_update(@setting)
SettingService.run_after_update([@setting])
render status: :ok
else
render json: @setting.errors.full_messages, status: :unprocessable_entity
@ -31,17 +31,19 @@ class API::SettingsController < API::ApiController
next if !setting[:name] || !setting[:value]
db_setting = Setting.find_or_initialize_by(name: setting[:name])
if !SettingService.before_update(db_setting)
if !SettingService.update_allowed?(db_setting)
db_setting.errors.add(:-, "#{I18n.t("settings.#{setting[:name]}")}: #{I18n.t('settings.locked_setting')}")
elsif db_setting.save
db_setting.history_values.create(value: setting[:value], invoicing_profile: current_user.invoicing_profile)
SettingService.after_update(db_setting)
unless db_setting.value == setting[:value]
db_setting.history_values.create(value: setting[:value], invoicing_profile: current_user.invoicing_profile)
end
end
@settings.push db_setting
may_rollback(params[:transactional]) if db_setting.errors.keys.count.positive?
end
end
SettingService.run_after_update(@settings)
end
def show
@ -61,7 +63,7 @@ class API::SettingsController < API::ApiController
authorize Setting
setting = Setting.find_or_create_by(name: params[:name])
render status: :locked, json: { error: 'locked setting' } and return unless SettingService.before_update(setting)
render status: :locked, json: { error: 'locked setting' } and return unless SettingService.update_allowed?(setting)
first_val = setting.history_values.order(created_at: :asc).limit(1).first
new_val = HistoryValue.create!(
@ -69,7 +71,7 @@ class API::SettingsController < API::ApiController
value: first_val&.value,
invoicing_profile_id: current_user.invoicing_profile.id
)
SettingService.after_update(setting)
SettingService.run_after_update([setting])
render json: new_val, status: :ok
end

View File

@ -51,6 +51,7 @@ class API::SpacesController < API::ApiController
def space_params
params.require(:space).permit(:name, :description, :characteristics, :default_places, :disabled,
space_image_attributes: [:attachment],
space_files_attributes: %i[id attachment _destroy])
space_files_attributes: %i[id attachment _destroy],
advanced_accounting_attributes: %i[code analytical_section])
end
end

View File

@ -2,7 +2,7 @@
# API Controller for resources of type Subscription
class API::SubscriptionsController < API::ApiController
before_action :set_subscription, only: %i[show payment_details]
before_action :set_subscription, only: %i[show payment_details cancel]
before_action :authenticate_user!
def show
@ -13,6 +13,15 @@ class API::SubscriptionsController < API::ApiController
authorize @subscription
end
def cancel
authorize @subscription
if @subscription.expire(DateTime.current)
render :show, status: :ok, location: @subscription
else
render json: { error: 'already expired' }, status: :unprocessable_entity
end
end
private
# Use callbacks to share common setup or constraints between actions.

View File

@ -76,6 +76,7 @@ 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,
training_image_attributes: [:attachment], machine_ids: [], plan_ids: [])
training_image_attributes: [:attachment], machine_ids: [], plan_ids: [],
advanced_accounting_attributes: %i[code analytical_section])
end
end

View File

@ -11,7 +11,7 @@ class API::UsersController < API::ApiController
if %w[partner manager].include?(params[:role])
@users = User.with_role(params[:role].to_sym).includes(:profile)
else
head 403
head :forbidden
end
end

View File

@ -0,0 +1,37 @@
# frozen_string_literal: true
# authorized 3rd party softwares can fetch the accounting lines through the OpenAPI
class OpenAPI::V1::AccountingController < OpenAPI::V1::BaseController
extend OpenAPI::ApiDoc
include Rails::Pagination
expose_doc
def index
@codes = {
card: Setting.get('accounting_payment_card_code'),
wallet: Setting.get('accounting_payment_wallet_code'),
other: Setting.get('accounting_payment_other_code')
}
@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(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?
@lines = @lines.page(page).per(per_page)
paginate @lines, per_page: per_page
end
private
def page
params[:page] || 1
end
def per_page
params[:per_page] || 20
end
end

View File

@ -4,7 +4,9 @@
module OpenAPI::V1; end
# Parameters for OpenAPI endpoints
class OpenAPI::V1::BaseController < ActionController::Base
class OpenAPI::V1::BaseController < ActionController::Base # rubocop:disable Rails/ApplicationController
include ApplicationHelper
protect_from_forgery with: :null_session
skip_before_action :verify_authenticity_token
before_action :authenticate
@ -49,7 +51,7 @@ class OpenAPI::V1::BaseController < ActionController::Base
end
def render_unauthorized
render json: { errors: ['Bad credentials'] }, status: 401
render json: { errors: ['Bad credentials'] }, status: :unauthorized
end
private

View File

@ -9,9 +9,7 @@ class OpenAPI::V1::BookableMachinesController < OpenAPI::V1::BaseController
raise ActionController::ParameterMissing if params[:user_id].blank?
@machines = Machine.all
@machines = @machines.where(id: params[:machine_id]) if params[:machine_id].present?
@machines = @machines.to_a
user = User.find(params[:user_id])
@ -20,15 +18,11 @@ class OpenAPI::V1::BookableMachinesController < OpenAPI::V1::BaseController
(machine.trainings.count != 0) and !user.training_machine?(machine)
end
@hours_remaining = Hash[@machines.map { |m| [m.id, 0] }]
@hours_remaining = @machines.to_h { |m| [m.id, 0] }
return unless user.subscription
plan_id = user.subscription.plan_id
@machines.each do |machine|
credit = Credit.find_by(plan_id: plan_id, creditable: machine)
users_credit = user.users_credits.find_by(credit: credit) if credit

View File

@ -16,10 +16,9 @@ class OpenAPI::V1::EventsController < OpenAPI::V1::BaseController
@events.order(created_at: :desc)
end
@events = @events.where(id: may_array(params[:id])) if params[:id].present?
@events = @events.where(id: params[:id]) if params[:id].present?
return unless params[:page].present?
return if params[:page].blank?
@events = @events.page(params[:page]).per(per_page)
paginate @events, per_page: per_page

View File

@ -11,9 +11,9 @@ class OpenAPI::V1::InvoicesController < OpenAPI::V1::BaseController
.includes(invoicing_profile: :user)
.references(:invoicing_profiles)
@invoices = @invoices.where(invoicing_profiles: { user_id: params[:user_id] }) if params[:user_id].present?
@invoices = @invoices.where(invoicing_profiles: { user_id: may_array(params[:user_id]) }) if params[:user_id].present?
return unless params[:page].present?
return if params[:page].blank?
@invoices = @invoices.page(params[:page]).per(per_page)
paginate @invoices, per_page: per_page
@ -21,7 +21,7 @@ class OpenAPI::V1::InvoicesController < OpenAPI::V1::BaseController
def download
@invoice = Invoice.find(params[:id])
send_file File.join(Rails.root, @invoice.file), type: 'application/pdf', disposition: 'inline', filename: @invoice.filename
send_file Rails.root.join(@invoice.file), type: 'application/pdf', disposition: 'inline', filename: @invoice.filename
end
private

View File

@ -11,11 +11,11 @@ class OpenAPI::V1::ReservationsController < OpenAPI::V1::BaseController
.includes(statistic_profile: :user)
.references(:statistic_profiles)
@reservations = @reservations.where(statistic_profiles: { user_id: params[:user_id] }) if params[:user_id].present?
@reservations = @reservations.where(statistic_profiles: { user_id: may_array(params[:user_id]) }) if params[:user_id].present?
@reservations = @reservations.where(reservable_type: format_type(params[:reservable_type])) if params[:reservable_type].present?
@reservations = @reservations.where(reservable_id: params[:reservable_id]) if params[:reservable_id].present?
@reservations = @reservations.where(reservable_id: may_array(params[:reservable_id])) if params[:reservable_id].present?
return unless params[:page].present?
return if params[:page].blank?
@reservations = @reservations.page(params[:page]).per(per_page)
paginate @reservations, per_page: per_page

View File

@ -12,11 +12,10 @@ class OpenAPI::V1::UserTrainingsController < OpenAPI::V1::BaseController
.references(:statistic_profiles)
.order(created_at: :desc)
@user_trainings = @user_trainings.where(statistic_profiles: { user_id: may_array(params[:user_id]) }) if params[:user_id].present?
@user_trainings = @user_trainings.where(training_id: may_array(params[:training_id])) if params[:training_id].present?
@user_trainings = @user_trainings.where(statistic_profiles: { user_id: params[:user_id] }) if params[:user_id].present?
@user_trainings = @user_trainings.where(training_id: params[:training_id]) if params[:training_id].present?
return unless params[:page].present?
return if params[:page].blank?
@user_trainings = @user_trainings.page(params[:page]).per(per_page)
paginate @user_trainings, per_page: per_page

View File

@ -7,15 +7,16 @@ class OpenAPI::V1::UsersController < OpenAPI::V1::BaseController
expose_doc
def index
@users = User.order(created_at: :desc).includes(:group, :profile)
@users = User.order(created_at: :desc).includes(:group, :profile, :invoicing_profile)
if params[:email].present?
email_param = params[:email].is_a?(String) ? params[:email].downcase : params[:email].map(&:downcase)
@users = @users.where(email: email_param)
end
@users = @users.where(id: params[:user_id]) if params[:user_id].present?
@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?
return unless params[:page].present?
return if params[:page].blank?
@users = @users.page(params[:page]).per(per_page)
paginate @users, per_page: per_page

View File

@ -1,19 +1,17 @@
# frozen_string_literal: true
# Devise controller used for the "forgotten password" feature
# Devise controller used for the "forgotten password" feature and to check the current's user password
class PasswordsController < Devise::PasswordsController
# POST /users/password.json
def create
self.resource = resource_class.send_reset_password_instructions(resource_params)
yield resource if block_given?
if successfully_sent?(resource)
respond_with({}, location: after_sending_reset_password_instructions_path_for(resource_name))
end
respond_with({}, location: after_sending_reset_password_instructions_path_for(resource_name)) if successfully_sent?(resource)
end
# POST /password/verify
def verify
current_user.valid_password?(params[:password]) ? head(200) : head(404)
current_user.valid_password?(params[:password]) ? head(:ok) : head(:not_found)
end
end

View File

@ -0,0 +1,109 @@
# frozen_string_literal: true
# openAPI documentation for accounting endpoints
class OpenAPI::V1::AccountingDoc < OpenAPI::V1::BaseDoc
resource_description do
short 'Accounting lines'
desc 'Accounting lines according to the French General Accounting Plan (PCG)'
formats FORMATS
api_version API_VERSION
end
include OpenAPI::V1::Concerns::ParamGroups
doc_for :index do
api :GET, "/#{API_VERSION}/accounting", 'Accounting lines'
description 'All accounting lines, paginated (necessarily becauce there is a lot of data) with optional dates filtering. ' \
'Ordered by *date* descendant.<br>' \
'The field *status* indicates if the accounting data is being built or if the build is over. ' \
'Possible status are: <i>building</i> or <i>built</i>.<br>' \
'The field *invoice.payment_details* is available if line_type=payment. It will contain the following data:<br>' \
'· *payment_mean*, possible status are: <i>card</i>, <i>wallet</i> or <i>other</i>. *WARNING*: If an invoice was settled ' \
'using multiple payment means, this will only report the payment mean applicable to current line.<br>' \
'· *gateway_object_id*, if payment_mean=card, report the ID of the payment gateway related object<br>' \
'· *gateway_object_type*, if payment_mean=card, report the type of the payment gateway related object<br>' \
'· *wallet_transaction_id*, if payment_mean=wallet, report the ID of the wallet transaction<br>'
param_group :pagination
param :after, DateTime, optional: true, desc: 'Filter accounting lines to lines after the given date.'
param :before, DateTime, optional: true, desc: 'Filter accounting lines to lines before the given date.'
param :invoice_id, [Integer, Array], optional: true, desc: 'Scope the request to one or various invoices.'
param :type, %w[client vat item], optional: true, desc: 'Filter accounting lines by line type.'
example <<-LINES
# /open_api/v1/accounting?after=2022-01-01T00:00:00+02:00&page=1&per_page=3
{
"lines": [
{
"id": 1,
"line_type": "payment",
"journal_code": "VT01",
"date": "2022-01-02T18:14:21+01:00",
"account_code": "5802",
"account_label": "Card customers",
"analytical_code": "",
"invoice": {
"reference": "22010009/VL",
"id": 274,
"label": "Subscription of Dupont Marcel for 1 month starting from 2022, january 2nd",
"url": "/open_api/v1/invoices/247/download",
"payment_details": {
"payment_mean": "card",
"gateway_object_id": "pi_3MA2PPW4kx8QemzC02ABBEbo",
"gateway_object_type": "Stripe::PaymentIntent"
}
},
"user": {
"invoicing_profile_id": 6512,
"external_id": "U52-ALC4"
},
"debit": 14.0,
"credit": 0
"currency": "EUR",
"summary": "Dupont Marcel, 22010009/VL, subscr."
},
{
"id": 2,
"line_type": "item",
"journal_code": "VT01",
"date": "2022-01-02T18:14:21+01:00",
"account_code": "7071",
"account_label": "Subscriptions",
"analytical_code": "P3D71",
"invoice": {
"reference": "22010009/VL",
"id": 274,
"label": "Subscription of Dupont Marcel for 1 month starting from 2022, january 2nd",
"url": "/open_api/v1/invoices/247/download"
},
"user_invoicing_profile_id": 6512,
"debit": 0,
"credit": 11.67
"currency": "EUR",
"summary": "Dupont Marcel, 22010009/VL, subscr."
},
{
"id": 3,
"line_type": "vat",
"journal_code": "VT01",
"date": "2022-01-02T18:14:21+01:00",
"account_code": "4457",
"account_label": "Collected VAT",
"analytical_code": "P3D71",
"invoice": {
"reference": "22010009/VL",
"id": 274,
"label": "Subscription of Dupont Marcel for 1 month starting from 2022, january 2nd",
"url": "/open_api/v1/invoices/247/download"
},
"user_invoicing_profile_id": 6512,
"debit": 0,
"credit": 2.33
"currency": "EUR",
"summary": "Dupont Marcel, 22010009/VL, subscr."
}
],
"status": "built"
}
LINES
end
end

View File

@ -11,8 +11,10 @@ class OpenAPI::V1::BookableMachinesDoc < OpenAPI::V1::BaseDoc
doc_for :index do
api :GET, "/#{API_VERSION}/bookable_machines", 'Bookable machines index'
description 'Machines that a given user is allowed to book.'
description 'Machines that a given user is allowed to book. If the given user has machine credits due to his current subscription, ' \
'it will be reported in *hours_remaining*.'
param :user_id, Integer, required: true, desc: 'Id of the given user.'
param :machine_id, Integer, optional: true, desc: 'Id of a machine to filter by'
example <<-MACHINES
# /open_api/v1/bookable_machines?user_id=522
{
@ -44,7 +46,7 @@ class OpenAPI::V1::BookableMachinesDoc < OpenAPI::V1::BaseDoc
"updated_at": "2014-06-30T15:10:14.272+02:00",
"created_at": "2014-06-30T03:32:31.977+02:00",
"description": "La découpeuse Vinyle, Roland CAMM ...",
"spec": "Largeurs de support acceptées: de 50 mm à 70 ... 50 cm/sec ... mécanique: 0,0125 mm/pas\r\n",
"spec": "Largeurs de support acceptées: de 50 mm à 70 ... 50 cm/sec ... mécanique: 0,0125 mm/pas",
"hours_remaining": 0
},
{

View File

@ -17,6 +17,7 @@ class OpenAPI::V1::UsersDoc < OpenAPI::V1::BaseDoc
param_group :pagination
param :email, [String, Array], optional: true, desc: 'Filter users by *email* using strict matching.'
param :user_id, [Integer, Array], optional: true, desc: 'Filter users by *id* using strict matching.'
param :created_after, DateTime, optional: true, desc: 'Filter users to accounts created after the given date.'
example <<-USERS
# /open_api/v1/users?page=1&per_page=4
{
@ -25,7 +26,13 @@ class OpenAPI::V1::UsersDoc < OpenAPI::V1::BaseDoc
"id": 1746,
"email": "xxxxxxx@xxxx.com",
"created_at": "2016-05-04T17:21:48.403+02:00",
"external_id": "J5821-4"
"full_name": "xxxx xxxx",
"first_name": "xxxx",
"last_name": "xxxx",
"gender": "man",
"organization": true,
"address": "2 impasse xxxxxx, BRUXELLES",
"group": {
"id": 1,
"name": "standard, association",
@ -36,7 +43,13 @@ class OpenAPI::V1::UsersDoc < OpenAPI::V1::BaseDoc
"id": 1745,
"email": "xxxxxxx@gmail.com",
"created_at": "2016-05-03T15:21:13.125+02:00",
"external_id": "J5846-4"
"full_name": "xxxxx xxxxx",
"first_name": "xxxxx",
"last_name": "xxxxx",
"gender": "woman",
"organization": true,
"address": "Grenoble",
"group": {
"id": 2,
"name": "étudiant, - de 25 ans, enseignant, demandeur d'emploi",
@ -47,7 +60,13 @@ class OpenAPI::V1::UsersDoc < OpenAPI::V1::BaseDoc
"id": 1744,
"email": "xxxxxxx@gmail.com",
"created_at": "2016-05-03T13:51:03.223+02:00",
"external_id": "J5900-1"
"full_name": "xxxxxxx xxxx",
"first_name": "xxxxxxx",
"last_name": "xxxx",
"gender": "man",
"organization": false,
"address": "21 rue des xxxxxx",
"group": {
"id": 1,
"name": "standard, association",
@ -58,7 +77,13 @@ class OpenAPI::V1::UsersDoc < OpenAPI::V1::BaseDoc
"id": 1743,
"email": "xxxxxxxx@setecastronomy.eu",
"created_at": "2016-05-03T12:24:38.724+02:00",
"external_id": "P4172-4"
"full_name": "xxx xxxxxxx",
"first_name": "xxx",
"last_name": "xxxxxxx",
"gender": "woman",
"organization": false,
"address": "147 rue xxxxxx, 75000 PARIS, France",
"group": {
"id": 1,
"name": "standard, association",
@ -75,7 +100,13 @@ class OpenAPI::V1::UsersDoc < OpenAPI::V1::BaseDoc
"id": 1746,
"email": "xxxxxxxxxxxx",
"created_at": "2016-05-04T17:21:48.403+02:00",
"external_id": "J5500-4"
"full_name": "xxxx xxxxxx",
"first_name": "xxxx",
"last_name": "xxxxxx",
"gender": "man",
"organization": true,
"address": "38100",
"group": {
"id": 1,
"name": "standard, association",
@ -86,7 +117,13 @@ class OpenAPI::V1::UsersDoc < OpenAPI::V1::BaseDoc
"id": 1745,
"email": "xxxxxxxxx@gmail.com",
"created_at": "2016-05-03T15:21:13.125+02:00",
"external_id": null,
"full_name": "xxxxx xxxxxx",
"first_name": "xxxx",
"last_name": "xxxxxx",
"gender": "woman",
"organization": true,
"address": "",
"group": {
"id": 2,
"name": "étudiant, - de 25 ans, enseignant, demandeur d'emploi",

View File

@ -0,0 +1,10 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { AgeRange } from '../models/event';
export default class AgeRangeAPI {
static async index (): Promise<Array<AgeRange>> {
const res: AxiosResponse<Array<AgeRange>> = await apiClient.get('/api/age_ranges');
return res?.data;
}
}

View File

@ -0,0 +1,10 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { EventCategory } from '../models/event';
export default class EventCategoryAPI {
static async index (): Promise<Array<EventCategory>> {
const res: AxiosResponse<Array<EventCategory>> = await apiClient.get('/api/categories');
return res?.data;
}
}

View File

@ -0,0 +1,10 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { EventPriceCategory } from '../models/event';
export default class EventPriceCategoryAPI {
static async index (): Promise<Array<EventPriceCategory>> {
const res: AxiosResponse<Array<EventPriceCategory>> = await apiClient.get('/api/price_categories');
return res?.data;
}
}

View File

@ -1,6 +1,6 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { EventTheme } from '../models/event-theme';
import { EventTheme } from '../models/event';
export default class EventThemeAPI {
static async index (): Promise<Array<EventTheme>> {

View File

@ -0,0 +1,27 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { Event, EventUpdateResult } from '../models/event';
import ApiLib from '../lib/api';
export default class EventAPI {
static async create (event: Event): Promise<Event> {
const data = ApiLib.serializeAttachments(event, 'event', ['event_files_attributes', 'event_image_attributes']);
const res: AxiosResponse<Event> = await apiClient.post('/api/events', data, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return res?.data;
}
static async update (event: Event, mode: 'single' | 'next' | 'all'): Promise<EventUpdateResult> {
const data = ApiLib.serializeAttachments(event, 'event', ['event_files_attributes', 'event_image_attributes']);
data.set('edit_mode', mode);
const res: AxiosResponse<EventUpdateResult> = await apiClient.put(`/api/events/${event.id}`, data, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return res?.data;
}
}

View File

@ -0,0 +1,25 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { MachineCategory } from '../models/machine-category';
export default class MachineCategoryAPI {
static async index (): Promise<Array<MachineCategory>> {
const res: AxiosResponse<Array<MachineCategory>> = await apiClient.get('/api/machine_categories');
return res?.data;
}
static async create (category: MachineCategory): Promise<MachineCategory> {
const res: AxiosResponse<MachineCategory> = await apiClient.post('/api/machine_categories', { machine_category: category });
return res?.data;
}
static async update (category: MachineCategory): Promise<MachineCategory> {
const res: AxiosResponse<MachineCategory> = await apiClient.patch(`/api/machine_categories/${category.id}`, { machine_category: category });
return res?.data;
}
static async destroy (categoryId: number): Promise<void> {
const res: AxiosResponse<void> = await apiClient.delete(`/api/machine_categories/${categoryId}`);
return res?.data;
}
}

View File

@ -13,4 +13,24 @@ export default class MachineAPI {
const res: AxiosResponse<Machine> = await apiClient.get(`/api/machines/${id}`);
return res?.data;
}
static async create (machine: Machine): Promise<Machine> {
const data = ApiLib.serializeAttachments(machine, 'machine', ['machine_files_attributes', 'machine_image_attributes']);
const res: AxiosResponse<Machine> = await apiClient.post('/api/machines', data, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return res?.data;
}
static async update (machine: Machine): Promise<Machine> {
const data = ApiLib.serializeAttachments(machine, 'machine', ['machine_files_attributes', 'machine_image_attributes']);
const res: AxiosResponse<Machine> = await apiClient.put(`/api/machines/${machine.id}`, data, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return res?.data;
}
}

View File

@ -1,10 +1,10 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { serialize } from 'object-to-formdata';
import { User, UserIndexFilter, UserRole } from '../models/user';
import { User, MemberIndexFilter, UserRole } from '../models/user';
export default class MemberAPI {
static async list (filters: UserIndexFilter): Promise<Array<User>> {
static async list (filters: MemberIndexFilter): Promise<Array<User>> {
const res: AxiosResponse<Array<User>> = await apiClient.post('/api/members/list', filters);
return res?.data;
}

View File

@ -1,6 +1,7 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { Plan, PlansDuration } from '../models/plan';
import ApiLib from '../lib/api';
export default class PlanAPI {
static async index (): Promise<Array<Plan>> {
@ -12,4 +13,29 @@ export default class PlanAPI {
const res: AxiosResponse<Array<PlansDuration>> = await apiClient.get('/api/plans/durations');
return res?.data;
}
static async create (plan: Plan): Promise<Plan> {
const data = ApiLib.serializeAttachments(plan, 'plan', ['plan_file_attributes']);
const res: AxiosResponse<Plan> = await apiClient.post('/api/plans', data, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return res?.data;
}
static async update (plan: Plan): Promise<Plan> {
const data = ApiLib.serializeAttachments(plan, 'plan', ['plan_file_attributes']);
const res: AxiosResponse<Plan> = await apiClient.put(`/api/plans/${plan.id}`, data, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return res?.data;
}
static async get (id: number): Promise<Plan> {
const res: AxiosResponse<Plan> = await apiClient.get(`/api/plans/${id}`);
return res?.data;
}
}

View File

@ -1,6 +1,5 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { serialize } from 'object-to-formdata';
import {
Product,
ProductIndexFilter,
@ -22,26 +21,7 @@ export default class ProductAPI {
}
static async create (product: Product): Promise<Product> {
const data = serialize({
product: {
...product,
product_files_attributes: null,
product_images_attributes: null
}
});
data.delete('product[product_files_attributes]');
data.delete('product[product_images_attributes]');
product.product_files_attributes?.forEach((file, i) => {
if (file?.attachment_files && file?.attachment_files[0]) {
data.set(`product[product_files_attributes][${i}][attachment]`, file.attachment_files[0]);
}
});
product.product_images_attributes?.forEach((image, i) => {
if (image?.attachment_files && image?.attachment_files[0]) {
data.set(`product[product_images_attributes][${i}][attachment]`, image.attachment_files[0]);
data.set(`product[product_images_attributes][${i}][is_main]`, (!!image.is_main).toString());
}
});
const data = ApiLib.serializeAttachments(product, 'product', ['product_files_attributes', 'product_images_attributes']);
const res: AxiosResponse<Product> = await apiClient.post('/api/products', data, {
headers: {
'Content-Type': 'multipart/form-data'
@ -51,38 +31,7 @@ export default class ProductAPI {
}
static async update (product: Product): Promise<Product> {
const data = serialize({
product: {
...product,
product_files_attributes: null,
product_images_attributes: null
}
});
data.delete('product[product_files_attributes]');
data.delete('product[product_images_attributes]');
product.product_files_attributes?.forEach((file, i) => {
if (file?.attachment_files && file?.attachment_files[0]) {
data.set(`product[product_files_attributes][${i}][attachment]`, file.attachment_files[0]);
}
if (file?.id) {
data.set(`product[product_files_attributes][${i}][id]`, file.id.toString());
}
if (file?._destroy) {
data.set(`product[product_files_attributes][${i}][_destroy]`, file._destroy.toString());
}
});
product.product_images_attributes?.forEach((image, i) => {
if (image?.attachment_files && image?.attachment_files[0]) {
data.set(`product[product_images_attributes][${i}][attachment]`, image.attachment_files[0]);
}
if (image?.id) {
data.set(`product[product_images_attributes][${i}][id]`, image.id.toString());
}
if (image?._destroy) {
data.set(`product[product_images_attributes][${i}][_destroy]`, image._destroy.toString());
}
data.set(`product[product_images_attributes][${i}][is_main]`, (!!image.is_main).toString());
});
const data = ApiLib.serializeAttachments(product, 'product', ['product_files_attributes', 'product_images_attributes']);
const res: AxiosResponse<Product> = await apiClient.patch(`/api/products/${product.id}`, data, {
headers: {
'Content-Type': 'multipart/form-data'

View File

@ -1,10 +1,11 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { ProfileCustomField } from '../models/profile-custom-field';
import { ProfileCustomField, ProfileCustomFieldIndexFilters } from '../models/profile-custom-field';
import ApiLib from '../lib/api';
export default class ProfileCustomFieldAPI {
static async index (): Promise<Array<ProfileCustomField>> {
const res: AxiosResponse<Array<ProfileCustomField>> = await apiClient.get('/api/profile_custom_fields');
static async index (filters?: ProfileCustomFieldIndexFilters): Promise<Array<ProfileCustomField>> {
const res: AxiosResponse<Array<ProfileCustomField>> = await apiClient.get(`/api/profile_custom_fields${ApiLib.filtersToQuery(filters)}`);
return res?.data;
}

View File

@ -4,14 +4,15 @@ import {
Setting,
SettingBulkArray,
SettingBulkResult,
SettingError,
SettingError, SettingGetOptions,
SettingName,
SettingValue
} from '../models/setting';
import ApiLib from '../lib/api';
export default class SettingAPI {
static async get (name: SettingName): Promise<Setting> {
const res: AxiosResponse<{setting: Setting}> = await apiClient.get(`/api/settings/${name}`);
static async get (name: SettingName, options?: SettingGetOptions): Promise<Setting> {
const res: AxiosResponse<{setting: Setting}> = await apiClient.get(`/api/settings/${name}${ApiLib.filtersToQuery(options)}`);
return res?.data?.setting;
}

View File

@ -1,6 +1,7 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { Space } from '../models/space';
import ApiLib from '../lib/api';
export default class SpaceAPI {
static async index (): Promise<Array<Space>> {
@ -12,4 +13,24 @@ export default class SpaceAPI {
const res: AxiosResponse<Space> = await apiClient.get(`/api/spaces/${id}`);
return res?.data;
}
static async create (space: Space): Promise<Space> {
const data = ApiLib.serializeAttachments(space, 'space', ['space_files_attributes', 'space_image_attributes']);
const res: AxiosResponse<Space> = await apiClient.post('/api/spaces', data, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return res?.data;
}
static async update (space: Space): Promise<Space> {
const data = ApiLib.serializeAttachments(space, 'space', ['space_files_attributes', 'space_image_attributes']);
const res: AxiosResponse<Space> = await apiClient.put(`/api/spaces/${space.id}`, data, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return res?.data;
}
}

View File

@ -1,10 +1,10 @@
import apiClient from './clients/api-client';
import { Subscription, SubscriptionPaymentDetails, UpdateSubscriptionRequest } from '../models/subscription';
import { Subscription, SubscriptionPaymentDetails } from '../models/subscription';
import { AxiosResponse } from 'axios';
export default class SubscriptionAPI {
static async update (request: UpdateSubscriptionRequest): Promise<Subscription> {
const res: AxiosResponse<Subscription> = await apiClient.patch(`/api/subscriptions/${request.id}`, { subscription: request });
static async cancel (id: number): Promise<Subscription> {
const res: AxiosResponse<Subscription> = await apiClient.patch(`/api/subscriptions/${id}/cancel`);
return res?.data;
}

View File

@ -8,4 +8,24 @@ export default class TrainingAPI {
const res: AxiosResponse<Array<Training>> = await apiClient.get(`/api/trainings${ApiLib.filtersToQuery(filters)}`);
return res?.data;
}
static async create (training: Training): Promise<Training> {
const data = ApiLib.serializeAttachments(training, 'training', ['training_image_attributes']);
const res: AxiosResponse<Training> = await apiClient.post('/api/trainings', data, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return res?.data;
}
static async update (training: Training): Promise<Training> {
const data = ApiLib.serializeAttachments(training, 'training', ['training_image_attributes']);
const res: AxiosResponse<Training> = await apiClient.put(`/api/trainings/${training.id}`, data, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return res?.data;
}
}

View File

@ -0,0 +1,19 @@
import apiClient from './clients/api-client';
import ApiLib from '../lib/api';
import { UserIndexFilter, User } from '../models/user';
import { AxiosResponse } from 'axios';
import { Partner } from '../models/plan';
export default class UserAPI {
static async index (filters: UserIndexFilter): Promise<Array<User>> {
const res: AxiosResponse<Array<User>> = await apiClient.get(`/api/users${ApiLib.filtersToQuery(filters)}`);
return res?.data;
}
static async create (user: Partner|User, role: 'partner'|'manager'): Promise<User> {
const data = {};
data[role === 'partner' ? 'user' : 'manager'] = user;
const res: AxiosResponse<User> = await apiClient.post('/api/users', data);
return res?.data;
}
}

View File

@ -104,7 +104,7 @@ angular.module('application', ['ngCookies', 'ngResource', 'ngSanitize', 'ui.rout
['style', ['style']],
['font', ['bold', 'italic', 'underline', 'clear']],
['color', ['color']],
['para', ['ul', 'ol', 'paragraph']],
['para', ['ul', 'ol']],
['table', ['table']],
['insert', ['link', 'picture', 'hr']],
['view', ['fullscreen', 'codeview']],

View File

@ -0,0 +1,145 @@
import { useEffect } from 'react';
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { SubmitHandler, useForm } from 'react-hook-form';
import { FabButton } from '../base/fab-button';
import { FormInput } from '../form/form-input';
import { IApplication } from '../../models/application';
import { react2angular } from 'react2angular';
import { SettingName, SettingValue, accountingSettings } from '../../models/setting';
import SettingAPI from '../../api/setting';
import SettingLib from '../../lib/setting';
import { FormSwitch } from '../form/form-switch';
import { FabPanel } from '../base/fab-panel';
declare const Application: IApplication;
interface AccountingCodesSettingsProps {
onError: (message: string) => void,
onSuccess: (message: string) => void
}
/**
* This component allows customization of accounting codes and other related settings
*/
export const AccountingCodesSettings: React.FC<AccountingCodesSettingsProps> = ({ onError, onSuccess }) => {
const { t } = useTranslation('admin');
const { handleSubmit, register, control, reset } = useForm<Record<SettingName, SettingValue>>();
useEffect(() => {
SettingAPI.query(accountingSettings)
.then(settings => {
const data = SettingLib.bulkMapToObject(settings);
reset(data);
})
.catch(onError);
}, []);
/**
* Callback triggered when the user clicks on 'save':
* Update the settings on the API
*/
const onSubmit: SubmitHandler<Record<SettingName, SettingValue>> = (data) => {
SettingAPI.bulkUpdate(SettingLib.objectToBulkMap(data)).then(() => {
onSuccess(t('app.admin.accounting_codes_settings.update_success'));
}, reason => {
onError(reason);
});
};
return (
<form className="accounting-codes-settings" onSubmit={handleSubmit(onSubmit)}>
<FabPanel>
<h4>{t('app.admin.accounting_codes_settings.advanced_accounting')}</h4>
<FormSwitch control={control} id="advanced_accounting"
label={t('app.admin.accounting_codes_settings.enable_advanced')}
tooltip={t('app.admin.accounting_codes_settings.enable_advanced_help')} />
</FabPanel>
<FabPanel>
<h4>{t('app.admin.accounting_codes_settings.financial')}</h4>
<h5>{t('app.admin.accounting_codes_settings.card')}</h5>
<div className="cards">
<FormInput register={register} id="accounting_payment_card_journal_code" label={t('app.admin.accounting_codes_settings.journal_code')} />
<FormInput register={register} id="accounting_payment_card_code" label={t('app.admin.accounting_codes_settings.code')} />
<FormInput register={register} id="accounting_payment_card_label" label={t('app.admin.accounting_codes_settings.label')} />
</div>
<h5>{t('app.admin.accounting_codes_settings.wallet_debit')}</h5>
<div className="wallets">
<FormInput register={register} id="accounting_payment_wallet_journal_code" label={t('app.admin.accounting_codes_settings.journal_code')} />
<FormInput register={register} id="accounting_payment_wallet_code" label={t('app.admin.accounting_codes_settings.code')} />
<FormInput register={register} id="accounting_payment_wallet_label" label={t('app.admin.accounting_codes_settings.label')} />
</div>
<h5>{t('app.admin.accounting_codes_settings.other')}</h5>
<div className="others">
<FormInput register={register} id="accounting_payment_other_journal_code" label={t('app.admin.accounting_codes_settings.journal_code')} />
<FormInput register={register} id="accounting_payment_other_code" label={t('app.admin.accounting_codes_settings.code')} />
<FormInput register={register} id="accounting_payment_other_label" label={t('app.admin.accounting_codes_settings.label')} />
</div>
<h4>{t('app.admin.accounting_codes_settings.sales')}</h4>
<h5>{t('app.admin.accounting_codes_settings.sales_journal')}</h5>
<FormInput register={register} id="accounting_sales_journal_code" label={t('app.admin.accounting_codes_settings.journal_code')} />
<h5>{t('app.admin.accounting_codes_settings.subscriptions')}</h5>
<div className="subscriptions">
<FormInput register={register} id="accounting_subscription_code" label={t('app.admin.accounting_codes_settings.code')} />
<FormInput register={register} id="accounting_subscription_label" label={t('app.admin.accounting_codes_settings.label')} />
</div>
<h5>{t('app.admin.accounting_codes_settings.machine')}</h5>
<div className="machine">
<FormInput register={register} id="accounting_Machine_code" label={t('app.admin.accounting_codes_settings.code')} />
<FormInput register={register} id="accounting_Machine_label" label={t('app.admin.accounting_codes_settings.label')} />
</div>
<h5>{t('app.admin.accounting_codes_settings.training')}</h5>
<div className="training">
<FormInput register={register} id="accounting_Training_code" label={t('app.admin.accounting_codes_settings.code')} />
<FormInput register={register} id="accounting_Training_label" label={t('app.admin.accounting_codes_settings.label')} />
</div>
<h5>{t('app.admin.accounting_codes_settings.event')}</h5>
<div className="events">
<FormInput register={register} id="accounting_Event_code" label={t('app.admin.accounting_codes_settings.code')} />
<FormInput register={register} id="accounting_Event_label" label={t('app.admin.accounting_codes_settings.label')} />
</div>
<h5>{t('app.admin.accounting_codes_settings.space')}</h5>
<div className="space">
<FormInput register={register} id="accounting_Space_code" label={t('app.admin.accounting_codes_settings.code')} />
<FormInput register={register} id="accounting_Space_label" label={t('app.admin.accounting_codes_settings.label')} />
</div>
<h5>{t('app.admin.accounting_codes_settings.prepaid_pack')}</h5>
<div className="prepaid_pack">
<FormInput register={register} id="accounting_Pack_code" label={t('app.admin.accounting_codes_settings.code')} />
<FormInput register={register} id="accounting_Pack_label" label={t('app.admin.accounting_codes_settings.label')} />
</div>
<h5>{t('app.admin.accounting_codes_settings.product')}</h5>
<div className="product">
<FormInput register={register} id="accounting_Product_code" label={t('app.admin.accounting_codes_settings.code')} />
<FormInput register={register} id="accounting_Product_label" label={t('app.admin.accounting_codes_settings.label')} />
</div>
<h5>{t('app.admin.accounting_codes_settings.error')}</h5>
<div className="error">
<FormInput register={register} id="accounting_Error_code" label={t('app.admin.accounting_codes_settings.code')} />
<FormInput register={register} id="accounting_Error_label"
label={t('app.admin.accounting_codes_settings.label')}
tooltip={t('app.admin.accounting_codes_settings.error_help')} />
</div>
<h4>{t('app.admin.accounting_codes_settings.wallet_credit')}</h4>
<div className="wallets">
<FormInput register={register} id="accounting_wallet_journal_code" label={t('app.admin.accounting_codes_settings.journal_code')} />
<FormInput register={register} id="accounting_wallet_code" label={t('app.admin.accounting_codes_settings.code')} />
<FormInput register={register} id="accounting_wallet_label" label={t('app.admin.accounting_codes_settings.label')} />
</div>
<h4>{t('app.admin.accounting_codes_settings.VAT')}</h4>
<div className="vat">
<FormInput register={register} id="accounting_VAT_journal_code" label={t('app.admin.accounting_codes_settings.journal_code')} />
<FormInput register={register} id="accounting_VAT_code" label={t('app.admin.accounting_codes_settings.code')} />
<FormInput register={register} id="accounting_VAT_label" label={t('app.admin.accounting_codes_settings.label')} />
</div>
</FabPanel>
<FabPanel className="actions">
<FabButton type="submit" className="is-info submit-btn">
{t('app.admin.accounting_codes_settings.save')}
</FabButton>
</FabPanel>
</form>
);
};
Application.Components.component('accountingCodesSettings', react2angular(AccountingCodesSettings, ['onSuccess', 'onError']));

View File

@ -0,0 +1,39 @@
import { useEffect, useState } from 'react';
import SettingAPI from '../../api/setting';
import { UseFormRegister } from 'react-hook-form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { FormInput } from '../form/form-input';
import { useTranslation } from 'react-i18next';
interface AdvancedAccountingFormProps<TFieldValues> {
register: UseFormRegister<TFieldValues>,
onError: (message: string) => void
}
/**
* This component is a partial form, to be included in a resource form managed by react-hook-form.
* It will add advanced accounting attributes to the parent form, if they are enabled
*/
export const AdvancedAccountingForm = <TFieldValues extends FieldValues>({ register, onError }: AdvancedAccountingFormProps<TFieldValues>) => {
const [isEnabled, setIsEnabled] = useState<boolean>(false);
const { t } = useTranslation('admin');
useEffect(() => {
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>
<FormInput register={register}
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>
);
};

View File

@ -1,10 +1,10 @@
/**
* This is a compatibility wrapper to allow usage of react-switch inside the angular.js app
*/
import Switch from 'react-switch';
import { react2angular } from 'react2angular';
import { IApplication } from '../../models/application';
declare const Application: IApplication;
/**
* This is a compatibility wrapper to allow usage of react-switch inside the angular.js app
*/
Application.Components.component('switch', react2angular(Switch, ['checked', 'onChange', 'id', 'className', 'disabled']));

View File

@ -1,4 +1,3 @@
import React from 'react';
import { UseFormRegister } from 'react-hook-form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { useTranslation } from 'react-i18next';

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { UseFormRegister, useFieldArray, ArrayPath, useWatch, Path, FieldPathValue } from 'react-hook-form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import AuthProviderAPI from '../../api/auth-provider';
@ -114,7 +114,7 @@ export const DataMappingForm = <TFieldValues extends FieldValues, TContext exten
* Return a className based on the current mapping-item status
*/
const itemStatus = (index: number): string => {
if (currentFormValues[index]?.id) {
if (currentFormValues && currentFormValues[index]?.id) {
if (currentFormValues[index]._destroy) return 'destroyed-item';
return 'saved-item';
}

View File

@ -1,4 +1,3 @@
import React from 'react';
import { FormInput } from '../form/form-input';
import { UseFormRegister } from 'react-hook-form';
import { FieldValues } from 'react-hook-form/dist/types/fields';

View File

@ -1,4 +1,3 @@
import React from 'react';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { useTranslation } from 'react-i18next';
import { FormSelect } from '../form/form-select';

View File

@ -1,4 +1,3 @@
import React from 'react';
import { ArrayPath, useFieldArray, UseFormRegister } from 'react-hook-form';
import { Control } from 'react-hook-form/dist/types/form';
import { FieldValues } from 'react-hook-form/dist/types/fields';

View File

@ -1,4 +1,3 @@
import React from 'react';
import { UseFormRegister } from 'react-hook-form';
import { Control } from 'react-hook-form/dist/types/form';
import { FieldValues } from 'react-hook-form/dist/types/fields';

View File

@ -1,4 +1,3 @@
import React from 'react';
import { FormInput } from '../form/form-input';
import { UseFormRegister } from 'react-hook-form';
import { FieldValues } from 'react-hook-form/dist/types/fields';

View File

@ -1,4 +1,3 @@
import React from 'react';
import { Path, UseFormRegister } from 'react-hook-form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { FormInput } from '../form/form-input';

View File

@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import * as React from 'react';
import { Path, UseFormRegister } from 'react-hook-form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { useTranslation } from 'react-i18next';

View File

@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import * as React from 'react';
import { useForm, SubmitHandler, useWatch } from 'react-hook-form';
import { react2angular } from 'react2angular';
import { debounce as _debounce } from 'lodash';

View File

@ -1,4 +1,3 @@
import React from 'react';
import { ArrayPath, useFieldArray, UseFormRegister } from 'react-hook-form';
import { Control } from 'react-hook-form/dist/types/form';
import { FieldValues } from 'react-hook-form/dist/types/fields';

View File

@ -1,4 +1,3 @@
import React from 'react';
import { FabModal } from '../base/fab-modal';
import { useTranslation } from 'react-i18next';
import { IntegerMappingForm } from './integer-mapping-form';

View File

@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import * as React from 'react';
import { CaretDown } from 'phosphor-react';
interface AccordionItemProps {

View File

@ -1,4 +1,4 @@
import React from 'react';
import { Component } from 'react';
interface ErrorBoundaryState {
hasError: boolean;
@ -8,7 +8,7 @@ interface ErrorBoundaryState {
* This component will catch javascript errors anywhere in their child component tree and display a message to the user.
* @see https://reactjs.org/docs/error-boundaries.html
*/
export class ErrorBoundary extends React.Component<unknown, ErrorBoundaryState> {
export class ErrorBoundary extends Component<unknown, ErrorBoundaryState> {
constructor (props) {
super(props);
this.state = { hasError: false };

View File

@ -1,4 +1,4 @@
import React from 'react';
import * as React from 'react';
interface FabAlertProps {
level: 'info' | 'warning' | 'danger',
@ -10,7 +10,7 @@ interface FabAlertProps {
*/
export const FabAlert: React.FC<FabAlertProps> = ({ level, className, children }) => {
return (
<div className={`fab-alert fab-alert--${level} ${className || ''}`}>
<div className={`fab-alert fab-alert--${level} ${className || ''}`} role="alert">
{children}
</div>
);

View File

@ -1,4 +1,5 @@
import React, { ReactNode, BaseSyntheticEvent } from 'react';
import { ReactNode, BaseSyntheticEvent } from 'react';
import * as React from 'react';
interface FabButtonProps {
onClick?: (event: BaseSyntheticEvent) => void,

View File

@ -1,4 +1,5 @@
import React, { BaseSyntheticEvent, ReactNode, useCallback, useEffect, useState } from 'react';
import { BaseSyntheticEvent, ReactNode, useCallback, useEffect, useState } from 'react';
import * as React from 'react';
import { debounce as _debounce } from 'lodash';
type inputType = string|number|readonly string [];

View File

@ -1,4 +1,5 @@
import React, { ReactNode, BaseSyntheticEvent, useEffect } from 'react';
import { ReactNode, BaseSyntheticEvent, useEffect } from 'react';
import * as React from 'react';
import Modal from 'react-modal';
import { useTranslation } from 'react-i18next';
import { Loader } from './loader';

View File

@ -1,4 +1,4 @@
import React from 'react';
import * as React from 'react';
interface FabOutputCopyProps {
text: string,

View File

@ -1,4 +1,4 @@
import React from 'react';
import * as React from 'react';
import { CaretDoubleLeft, CaretLeft, CaretRight, CaretDoubleRight } from 'phosphor-react';
interface FabPaginationProps {

View File

@ -1,4 +1,5 @@
import React, { ReactNode } from 'react';
import { ReactNode } from 'react';
import * as React from 'react';
interface FabPanelProps {
className?: string,

View File

@ -1,4 +1,5 @@
import React, { ReactNode } from 'react';
import { ReactNode } from 'react';
import * as React from 'react';
interface FabPopoverProps {
title: string,

View File

@ -1,4 +1,4 @@
import React from 'react';
import * as React from 'react';
interface FabStateLabelProps {
status?: string,

View File

@ -0,0 +1,60 @@
import { ReactNode, useEffect, useState } from 'react';
import { Tab, Tabs, TabList, TabPanel } from 'react-tabs';
import * as React from 'react';
import _ from 'lodash';
import { usePrevious } from '../../lib/use-previous';
type tabId = string|number;
interface Tab {
id: tabId,
title: ReactNode,
content: ReactNode,
onSelected?: () => void,
}
interface FabTabsProps {
tabs: Array<Tab>,
defaultTab?: tabId,
className?: string
}
/**
* A wrapper around https://github.com/reactjs/react-tabs that provides the Fab-manager's theme for tabs
*/
export const FabTabs: React.FC<FabTabsProps> = ({ tabs, defaultTab, className }) => {
const [active, setActive] = useState<Tab>(tabs.filter(Boolean).find(t => t.id === defaultTab) || tabs.filter(Boolean)[0]);
const previousTabs = usePrevious<Tab[]>(tabs);
useEffect(() => {
if (!_.isEqual(previousTabs?.filter(Boolean).map(t => t.id), tabs?.filter(Boolean).map(t => t?.id))) {
setActive(tabs.filter(Boolean).find(t => t.id === defaultTab) || tabs.filter(Boolean)[0]);
}
}, [tabs]);
/**
* Return the index of the currently selected tabs (i.e. the "active" tab)
*/
const selectedIndex = (): number => {
return tabs.findIndex(t => t?.id === active?.id) || 0;
};
/**
* Callback triggered when the active tab is changed by the user
*/
const onIndexSelected = (index: number) => {
setActive(tabs[index]);
if (typeof tabs[index].onSelected === 'function') {
tabs[index].onSelected();
}
};
return (
<Tabs className={`fab-tabs ${className || ''}`} selectedIndex={selectedIndex()} onSelect={onIndexSelected}>
<TabList className="tabs">
{tabs.filter(Boolean).map((tab, index) => <Tab key={index}>{tab.title}</Tab>)}
</TabList>
{tabs.filter(Boolean).map((tab, index) => <TabPanel key={index}>{tab.content}</TabPanel>)}
</Tabs>
);
};

View File

@ -1,4 +1,4 @@
import React from 'react';
import * as React from 'react';
import { useTranslation } from 'react-i18next';
interface HtmlTranslateProps {

View File

@ -1,4 +1,5 @@
import React, { BaseSyntheticEvent, ReactNode } from 'react';
import { BaseSyntheticEvent, ReactNode } from 'react';
import * as React from 'react';
type inputType = string|number|readonly string [];

View File

@ -1,4 +1,5 @@
import React, { Suspense } from 'react';
import { Suspense } from 'react';
import * as React from 'react';
/**
* This component is a wrapper that display a loader while the children components have their rendering suspended.

View File

@ -1,4 +1,5 @@
import React, { forwardRef, RefObject, useEffect, useImperativeHandle, useRef } from 'react';
import { forwardRef, RefObject, useEffect, useImperativeHandle, useRef } from 'react';
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { useEditor, EditorContent, Editor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
@ -24,6 +25,7 @@ interface FabTextEditorProps {
placeholder?: string,
error?: string,
disabled?: boolean
ariaLabel?: string,
}
export interface FabTextEditorRef {
@ -33,7 +35,7 @@ export interface FabTextEditorRef {
/**
* This component is a WYSIWYG text editor
*/
const FabTextEditor: React.ForwardRefRenderFunction<FabTextEditorRef, FabTextEditorProps> = ({ heading, bulletList, blockquote, content, limit = 400, video, image, link, onChange, placeholder, error, disabled = false }, ref: RefObject<FabTextEditorRef>) => {
const FabTextEditor: React.ForwardRefRenderFunction<FabTextEditorRef, FabTextEditorProps> = ({ heading, bulletList, blockquote, content, limit = 400, video, image, link, onChange, placeholder, error, disabled = false, ariaLabel }, ref: RefObject<FabTextEditorRef>) => {
const { t } = useTranslation('shared');
const placeholderText = placeholder || t('app.shared.text_editor.fab_text_editor.text_placeholder');
// TODO: Add ctrl+click on link to visit
@ -73,6 +75,12 @@ const FabTextEditor: React.ForwardRefRenderFunction<FabTextEditorRef, FabTextEdi
}
})
],
editorProps: {
attributes: {
'aria-label': ariaLabel,
role: 'textbox'
}
},
content,
onUpdate: ({ editor }) => {
if (editor.isEmpty) {

View File

@ -1,4 +1,5 @@
import React, { useCallback, useState, useEffect } from 'react';
import { useCallback, useState, useEffect } from 'react';
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import useOnclickOutside from 'react-cool-onclickoutside';
import { Editor } from '@tiptap/react';

View File

@ -1,4 +1,5 @@
import React, { useState } from 'react';
import { useState } from 'react';
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';

View File

@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';

View File

@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { FabInput } from '../base/fab-input';
import { FabAlert } from '../base/fab-alert';

View File

@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import { Loader } from '../../base/loader';

View File

@ -1,4 +1,5 @@
import React, { ReactNode, useEffect, useState } from 'react';
import { ReactNode, useEffect, useState } from 'react';
import * as React from 'react';
import { FabPanel } from '../../base/fab-panel';
import { Loader } from '../../base/loader';
import { useTranslation } from 'react-i18next';

View File

@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import * as React from 'react';
import { IApplication } from '../../../models/application';
import { react2angular } from 'react2angular';
import { ReservationsPanel } from './reservations-panel';

View File

@ -1,4 +1,5 @@
import React, { ReactNode, useEffect, useState } from 'react';
import { ReactNode, useEffect, useState } from 'react';
import * as React from 'react';
import { FabPanel } from '../../base/fab-panel';
import { Reservation, SlotsReservation } from '../../../models/reservation';
import ReservationAPI from '../../../api/reservation';

View File

@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import * as React from 'react';
import { LabelledInput } from './base/labelled-input';
import { useTranslation } from 'react-i18next';
import { TDateISODate } from '../typings/date-iso';

View File

@ -1,4 +1,4 @@
import React from 'react';
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import { IApplication } from '../../models/application';
@ -6,6 +6,8 @@ import { Loader } from '../base/loader';
import { Event } from '../../models/event';
import FormatLib from '../../lib/format';
import defaultImage from '../../../../images/default-image.png';
declare const Application: IApplication;
interface EventCardProps {
@ -53,14 +55,17 @@ export const EventCard: React.FC<EventCardProps> = ({ event, cardType }) => {
const formatTime = (): string => {
return event.all_day
? t('app.public.event_card.all_day')
: t('app.public.event_card.from_time_to_time', { START: FormatLib.time(event.start_date), END: FormatLib.time(event.end_date) });
: t('app.public.event_card.from_time_to_time', { START: FormatLib.time(event.start_time), END: FormatLib.time(event.end_time) });
};
return (
<div className={`event-card event-card--${cardType}`}>
{event.event_image
{event.event_image_attributes
? <div className="event-card-picture">
<img src={event.event_image} alt="" />
<img src={event.event_image_attributes.attachment_url} alt={event.event_image_attributes.attachment_name} onError={({ currentTarget }) => {
currentTarget.onerror = null;
currentTarget.src = defaultImage;
}} />
</div>
: cardType !== 'sm' &&
<div className="event-card-picture">

View File

@ -0,0 +1,318 @@
import { useEffect, useState } from 'react';
import * as React from 'react';
import { SubmitHandler, useFieldArray, useForm, useWatch } from 'react-hook-form';
import { Event, EventDecoration, EventPriceCategoryAttributes, RecurrenceOption } from '../../models/event';
import EventAPI from '../../api/event';
import { useTranslation } from 'react-i18next';
import { FormInput } from '../form/form-input';
import { FormImageUpload } from '../form/form-image-upload';
import { IApplication } from '../../models/application';
import { Loader } from '../base/loader';
import { react2angular } from 'react2angular';
import { ErrorBoundary } from '../base/error-boundary';
import { FormRichText } from '../form/form-rich-text';
import { FormMultiFileUpload } from '../form/form-multi-file-upload';
import { FabButton } from '../base/fab-button';
import { FormSwitch } from '../form/form-switch';
import { SelectOption } from '../../models/select';
import EventCategoryAPI from '../../api/event-category';
import { FormSelect } from '../form/form-select';
import EventThemeAPI from '../../api/event-theme';
import { FormMultiSelect } from '../form/form-multi-select';
import AgeRangeAPI from '../../api/age-range';
import { Plus, Trash } from 'phosphor-react';
import FormatLib from '../../lib/format';
import EventPriceCategoryAPI from '../../api/event-price-category';
import { UpdateRecurrentModal } from './update-recurrent-modal';
import { AdvancedAccountingForm } from '../accounting/advanced-accounting-form';
declare const Application: IApplication;
interface EventFormProps {
action: 'create' | 'update',
event?: Event,
onError: (message: string) => void,
onSuccess: (message: string) => void,
}
/**
* Form to edit or create events
*/
export const EventForm: React.FC<EventFormProps> = ({ action, event, onError, onSuccess }) => {
const { handleSubmit, register, control, setValue, formState } = useForm<Event>({ defaultValues: { ...event } });
const output = useWatch<Event>({ control });
const { fields, append, remove } = useFieldArray({ control, name: 'event_price_categories_attributes' });
const { t } = useTranslation('admin');
const [isAllDay, setIsAllDay] = useState<boolean>(event?.all_day);
const [categoriesOptions, setCategoriesOptions] = useState<Array<SelectOption<number>>>([]);
const [themesOptions, setThemesOptions] = useState<Array<SelectOption<number>>>(null);
const [ageRangeOptions, setAgeRangeOptions] = useState<Array<SelectOption<number>>>(null);
const [priceCategoriesOptions, setPriceCategoriesOptions] = useState<Array<SelectOption<number>>>(null);
const [isOpenRecurrentModal, setIsOpenRecurrentModal] = useState<boolean>(false);
const [updatingEvent, setUpdatingEvent] = useState<Event>(null);
useEffect(() => {
EventCategoryAPI.index()
.then(data => setCategoriesOptions(data.map(m => decorationToOption(m))))
.catch(onError);
EventThemeAPI.index()
.then(data => setThemesOptions(data.map(t => decorationToOption(t))))
.catch(onError);
AgeRangeAPI.index()
.then(data => setAgeRangeOptions(data.map(r => decorationToOption(r))))
.catch(onError);
EventPriceCategoryAPI.index()
.then(data => setPriceCategoriesOptions(data.map(c => decorationToOption(c))))
.catch(onError);
}, []);
/**
* Callback triggered when the user clicks on the 'remove' button, in the additional prices area
*/
const handlePriceRemove = (price: EventPriceCategoryAttributes, index: number) => {
if (!price.id) return remove(index);
setValue(`event_price_categories_attributes.${index}._destroy`, true);
};
/**
* Callback triggered when the user validates the machine form: handle create or update
*/
const onSubmit: SubmitHandler<Event> = (data: Event) => {
if (action === 'update') {
if (event?.recurrence_events?.length > 0) {
setUpdatingEvent(data);
toggleRecurrentModal();
} else {
handleUpdateRecurrentConfirmed(data, 'single');
}
} else {
EventAPI.create(data).then(res => {
onSuccess(t(`app.admin.event_form.${action}_success`));
window.location.href = `/#!/events/${res.id}`;
}).catch(onError);
}
};
/**
* Open/closes the confirmation modal for updating recurring events
*/
const toggleRecurrentModal = () => {
setIsOpenRecurrentModal(!isOpenRecurrentModal);
};
/**
* Check if any dates have changed
*/
const datesHaveChanged = (): boolean => {
return ((event?.start_date !== (updatingEvent?.start_date as Date)?.toISOString()?.substring(0, 10)) ||
(event?.end_date !== (updatingEvent?.end_date as Date)?.toISOString()?.substring(0, 10)));
};
/**
* When the user has confirmed the update of the other occurences (or not), proceed with the API update
* and handle the result
*/
const handleUpdateRecurrentConfirmed = (data: Event, mode: 'single' | 'next' | 'all') => {
EventAPI.update(data, mode).then(res => {
if (res.total === res.updated) {
onSuccess(t('app.admin.event_form.events_updated', { COUNT: res.updated }));
} else {
onError(t('app.admin.event_form.events_not_updated', { TOTAL: res.total, COUNT: res.total - res.updated }));
if (res.details.events.find(d => d.error === 'EventPriceCategory')) {
onError(t('app.admin.event_form.error_deleting_reserved_price'));
} else {
onError(t('app.admin.event_form.other_error'));
}
}
window.location.href = '/#!/events';
}).catch(onError);
};
/**
* Convert an event-decoration (category/theme/etc.) to an option usable by react-select
*/
const decorationToOption = (item: EventDecoration): SelectOption<number> => {
return { value: item.id, label: item.name };
};
/**
* In 'create' mode, the user can choose if the new event will be recurrent.
* This method provides teh various options for recurrence
*/
const buildRecurrenceOptions = (): Array<SelectOption<RecurrenceOption>> => {
return [
{ label: t('app.admin.event_form.recurring.none'), value: 'none' },
{ label: t('app.admin.event_form.recurring.every_days'), value: 'day' },
{ label: t('app.admin.event_form.recurring.every_week'), value: 'week' },
{ label: t('app.admin.event_form.recurring.every_month'), value: 'month' },
{ label: t('app.admin.event_form.recurring.every_year'), value: 'year' }
];
};
return (
<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}
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 && <FormMultiSelect control={control}
id="event_theme_ids"
formState={formState}
options={themesOptions}
label={t('app.admin.event_form.event_themes')} />}
{ageRangeOptions && <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"
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>
);
};
const EventFormWrapper: React.FC<EventFormProps> = (props) => {
return (
<Loader>
<ErrorBoundary>
<EventForm {...props} />
</ErrorBoundary>
</Loader>
);
};
Application.Components.component('eventForm', react2angular(EventFormWrapper, ['action', 'event', 'onError', 'onSuccess']));

View File

@ -1,96 +0,0 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Select from 'react-select';
import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';
import { Event } from '../../models/event';
import { EventTheme } from '../../models/event-theme';
import { IApplication } from '../../models/application';
import EventThemeAPI from '../../api/event-theme';
import { SelectOption } from '../../models/select';
declare const Application: IApplication;
interface EventThemesProps {
event: Event,
onChange: (themes: Array<EventTheme>) => void
}
/**
* This component shows a select input to edit the themes associated with the event
*/
export const EventThemes: React.FC<EventThemesProps> = ({ event, onChange }) => {
const { t } = useTranslation('shared');
const [themes, setThemes] = useState<Array<EventTheme>>([]);
useEffect(() => {
EventThemeAPI.index().then(data => setThemes(data));
}, []);
/**
* Check if there's any EventTheme in DB, otherwise we won't display the selector
*/
const hasThemes = (): boolean => {
return themes.length > 0;
};
/**
* Return the current theme(s) for the given event, formatted to match the react-select format
*/
const defaultValues = (): Array<SelectOption<number>> => {
const res = [];
themes.forEach(t => {
if (event.event_theme_ids && event.event_theme_ids.indexOf(t.id) > -1) {
res.push({ value: t.id, label: t.name });
}
});
return res;
};
/**
* Callback triggered when the selection has changed.
* Convert the react-select specific format to an array of EventTheme, and call the provided callback.
*/
const handleChange = (selectedOptions: Array<SelectOption<number>>): void => {
const res = [];
selectedOptions.forEach(opt => {
res.push(themes.find(t => t.id === opt.value));
});
onChange(res);
};
/**
* Convert all themes to the react-select format
*/
const buildOptions = (): Array<SelectOption<number>> => {
return themes.map(t => {
return { value: t.id, label: t.name };
});
};
return (
<div className="event-themes">
{hasThemes() && <div className="event-themes--panel">
<h3>{ t('app.shared.event_themes.title') }</h3>
<div className="content">
<Select defaultValue={defaultValues()}
placeholder={t('app.shared.event_themes.select_theme')}
onChange={handleChange}
options={buildOptions()}
isMulti />
</div>
</div>}
</div>
);
};
const EventThemesWrapper: React.FC<EventThemesProps> = ({ event, onChange }) => {
return (
<Loader>
<EventThemes event={event} onChange={onChange}/>
</Loader>
);
};
Application.Components.component('eventThemes', react2angular(EventThemesWrapper, ['event', 'onChange']));

View File

@ -0,0 +1,67 @@
import { useState } from 'react';
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { Event } from '../../models/event';
import { FabModal } from '../base/fab-modal';
import { FabAlert } from '../base/fab-alert';
type EditionMode = 'single' | 'next' | 'all';
interface UpdateRecurrentModalProps {
isOpen: boolean,
toggleModal: () => void,
event: Event,
onConfirmed: (data: Event, mode: EditionMode) => void,
datesChanged: boolean,
}
/**
* Ask the user for confimation about the update of only the current event or also its recurrences
*/
export const UpdateRecurrentModal: React.FC<UpdateRecurrentModalProps> = ({ isOpen, toggleModal, event, onConfirmed, datesChanged }) => {
const { t } = useTranslation('admin');
const [editMode, setEditMode] = useState<EditionMode>(null);
/**
* Callback triggered when the user confirms the update
*/
const handleConfirmation = () => {
onConfirmed(event, editMode);
};
/**
* The user cannot confirm unless he chooses an option
*/
const preventConfirm = () => {
return !editMode;
};
return (
<FabModal isOpen={isOpen}
toggleModal={toggleModal}
title={t('app.admin.update_recurrent_modal.title')}
className="update-recurrent-modal"
onConfirm={handleConfirmation}
preventConfirm={preventConfirm()}
confirmButton={t('app.admin.update_recurrent_modal.confirm', { MODE: editMode })}
closeButton>
<p>{t('app.admin.update_recurrent_modal.edit_recurring_event')}</p>
<label>
<input name="edit_mode" type="radio" value="single" onClick={() => setEditMode('single')} />
<span>{t('app.admin.update_recurrent_modal.edit_this_event')}</span>
</label>
<label>
<input name="edit_mode" type="radio" value="next" onClick={() => setEditMode('next')} />
<span>{t('app.admin.update_recurrent_modal.edit_this_and_next')}</span>
</label>
<label>
<input name="edit_mode" type="radio" value="all" onClick={() => setEditMode('all')} />
<span>{t('app.admin.update_recurrent_modal.edit_all')}</span>
</label>
{datesChanged && editMode !== 'single' && <FabAlert level="warning">
{t('app.admin.update_recurrent_modal.date_wont_change')}
</FabAlert>}
</FabModal>
);
};

View File

@ -1,4 +1,5 @@
import React, { PropsWithChildren, ReactNode, useEffect, useState } from 'react';
import { PropsWithChildren, ReactNode, useEffect, useState } from 'react';
import * as React from 'react';
import { AbstractFormComponent } from '../../models/form-component';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { get as _get } from 'lodash';

View File

@ -1,4 +1,4 @@
import React from 'react';
import * as React from 'react';
import { Controller, Path, FieldPathValue } from 'react-hook-form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { FieldPath } from 'react-hook-form/dist/types/path';

View File

@ -1,4 +1,5 @@
import React, { useState } from 'react';
import { useState } from 'react';
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { Path } from 'react-hook-form';
import { UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form';
@ -23,7 +24,7 @@ interface FormFileUploadProps<TFieldValues> extends FormComponent<TFieldValues>,
/**
* This component allows to upload file, in forms managed by react-hook-form.
*/
export const FormFileUpload = <TFieldValues extends FieldValues>({ id, register, defaultFile, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue }: FormFileUploadProps<TFieldValues>) => {
export const FormFileUpload = <TFieldValues extends FieldValues>({ id, label, register, defaultFile, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue }: FormFileUploadProps<TFieldValues>) => {
const { t } = useTranslation('shared');
const [file, setFile] = useState<FileType>(defaultFile);
@ -72,7 +73,7 @@ export const FormFileUpload = <TFieldValues extends FieldValues>({ id, register,
const placeholder = (): string => hasFile() ? t('app.shared.form_file_upload.edit') : t('app.shared.form_file_upload.browse');
return (
<div className={`form-file-upload ${classNames}`}>
<div className={`form-file-upload ${label ? 'with-label' : ''} ${classNames}`}>
{hasFile() && (
<span>{file.attachment_name}</span>
)}
@ -86,17 +87,19 @@ export const FormFileUpload = <TFieldValues extends FieldValues>({ id, register,
</a>
)}
<FormInput type="file"
className="image-file-input"
accept={accept}
register={register}
formState={formState}
rules={rules}
disabled={disabled}
error={error}
warning={warning}
id={`${id}[attachment_files]`}
onChange={onFileSelected}
placeholder={placeholder()}/>
ariaLabel={label as string}
className="image-file-input"
accept={accept}
register={register}
label={label}
formState={formState}
rules={rules}
disabled={disabled}
error={error}
warning={warning}
id={`${id}[attachment_files]`}
onChange={onFileSelected}
placeholder={placeholder()}/>
{hasFile() &&
<FabButton onClick={onRemoveFile} icon={<Trash size={20} weight="fill" />} className="is-main" />
}

View File

@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { Path, Controller } from 'react-hook-form';
import { UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form';
@ -21,7 +22,7 @@ interface FormImageUploadProps<TFieldValues, TContext extends object> extends Fo
mainOption?: boolean,
onFileChange?: (value: ImageType) => void,
onFileRemove?: () => void,
onFileIsMain?: (setIsMain: () => void) => void,
onFileIsMain?: (setIsMain: (isMain: boolean) => void) => void,
}
/**
@ -31,7 +32,7 @@ export const FormImageUpload = <TFieldValues extends FieldValues, TContext exten
const { t } = useTranslation('shared');
const [file, setFile] = useState<ImageType>(defaultImage);
const [image, setImage] = useState<string | ArrayBuffer>(defaultImage.attachment_url);
const [image, setImage] = useState<string | ArrayBuffer>(defaultImage?.attachment_url);
useEffect(() => {
setFile(defaultImage);
@ -107,7 +108,7 @@ export const FormImageUpload = <TFieldValues extends FieldValues, TContext exten
render={({ field: { onChange, value } }) =>
<input id={`${id}.is_main`}
type="radio"
checked={value}
checked={value || false}
onChange={() => { onFileIsMain(onChange); }} />
} />
</label>
@ -118,7 +119,7 @@ export const FormImageUpload = <TFieldValues extends FieldValues, TContext exten
register={register}
label={label}
formState={formState}
rules={rules}
rules={{ ...rules, required: rules?.required && !hasImage() }}
disabled={disabled}
error={error}
warning={warning}

View File

@ -1,4 +1,5 @@
import React, { ReactNode, useCallback } from 'react';
import { ReactNode, useCallback } from 'react';
import * as React from 'react';
import { FieldPathValue } from 'react-hook-form';
import { debounce as _debounce } from 'lodash';
import { FieldValues } from 'react-hook-form/dist/types/fields';
@ -11,6 +12,7 @@ interface FormInputProps<TFieldValues, TInputType> extends FormComponent<TFieldV
addOn?: ReactNode,
addOnAction?: (event: React.MouseEvent<HTMLButtonElement>) => void,
addOnClassName?: string,
addOnAriaLabel?: string,
debounce?: number,
type?: 'text' | 'date' | 'password' | 'url' | 'time' | 'tel' | 'search' | 'number' | 'month' | 'email' | 'datetime-local' | 'week' | 'hidden' | 'file',
accept?: string,
@ -18,13 +20,14 @@ interface FormInputProps<TFieldValues, TInputType> extends FormComponent<TFieldV
placeholder?: string,
step?: number | 'any',
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void,
nullable?: boolean
nullable?: boolean,
ariaLabel?: string,
}
/**
* 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, placeholder, error, warning, formState, step, onChange, debounce, accept, nullable = false }: 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 }: FormInputProps<TFieldValues, TInputType>) => {
/**
* Debounced (ie. temporised) version of the 'on change' callback.
*/
@ -55,7 +58,7 @@ export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, re
disabled={disabled}
rules={rules} error={error} warning={warning}>
{icon && <span className="icon">{icon}</span>}
<input id={id}
<input id={id} aria-label={ariaLabel}
{...register(id as FieldPath<TFieldValues>, {
...rules,
valueAsDate: type === 'date',
@ -69,7 +72,8 @@ export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, re
placeholder={placeholder}
accept={accept} />
{(type === 'file' && placeholder) && <span className='fab-button is-black file-placeholder'>{placeholder}</span>}
{addOn && <span onClick={addOnAction} className={`addon ${addOnClassName || ''} ${addOnAction ? 'is-btn' : ''}`}>{addOn}</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>
);
};

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