1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-01-30 19:52:20 +01:00

Merge branch 'profile-form' into v5.4

This commit is contained in:
Sylvain 2022-05-10 11:19:07 +02:00
commit f18c4a2ecd
125 changed files with 3290 additions and 946 deletions

View File

@ -2,6 +2,7 @@
## next deploy
- Ability to define social networks for the FabLab "about page"
- Support for OpenID Connect in Sign-Sign-On authentication providers
- No longer needed to recompile the assets when switching the authentication provider
- Updated the documentation about the minimum docker version
@ -13,6 +14,7 @@
- Updated eslint to v8 and eslint related packages to their latest versions
- Updated typescript to v4.6.3
- Updated react-select to v5.2.2
- Updated sidekiq-scheduler to v4.0.0
- Webpack overlay will now report eslint issues
- Linted all code according to eslint rules
- when generating an avoir, the option "by_wallet" is not present anymore if wallet module is off
@ -458,7 +460,7 @@
## v4.7.14 2021 September 30
- Fix a bug: updated sassc to 2.4.0 to fix ruby runtime error on some CPU architectures
- Fix a bug: update sassc to 2.4.0 to try to fix #270 (ruby runtime error on some CPU architectures)
## v4.7.13 2021 June 11

View File

@ -121,7 +121,6 @@ GEM
dotenv-rails (2.7.5)
dotenv (= 2.7.5)
railties (>= 3.2, < 6.1)
e2mmap (0.1.0)
elasticsearch (5.0.5)
elasticsearch-api (= 5.0.5)
elasticsearch-transport (= 5.0.5)
@ -155,8 +154,8 @@ GEM
forgery (0.7.0)
friendly_id (5.1.0)
activerecord (>= 4.0.0)
fugit (1.5.2)
et-orbi (~> 1.1, >= 1.1.8)
fugit (1.5.3)
et-orbi (~> 1, >= 1.2.7)
raabro (~> 1.4)
globalid (1.0.0)
activesupport (>= 5.0)
@ -390,16 +389,14 @@ GEM
rack-proxy (>= 0.6.1)
railties (>= 5.2)
semantic_range (>= 2.3.0)
sidekiq (6.4.1)
sidekiq (6.4.2)
connection_pool (>= 2.2.2)
rack (~> 2.0)
redis (>= 4.2.0)
sidekiq-scheduler (3.1.1)
e2mmap
redis (>= 3, < 5)
sidekiq-scheduler (4.0.0)
redis (>= 4.2.0)
rufus-scheduler (~> 3.2)
sidekiq (>= 3)
thwait
sidekiq (>= 4)
tilt (>= 1.4.0)
sidekiq-unique-jobs (7.1.15)
brpoplpush-redis_script (> 0.1.1, <= 2.0.0)
@ -435,8 +432,6 @@ GEM
tins (~> 1.0)
thor (1.2.1)
thread_safe (0.3.6)
thwait (0.2.0)
e2mmap
tilt (2.0.10)
tins (1.25.0)
sync

View File

@ -234,6 +234,12 @@ class API::MembersController < API::ApiController
render json: @member
end
def current
@member = current_user
authorize @member
render json: @member
end
private
def set_member

View File

@ -8,14 +8,7 @@ class API::TrainingsController < API::ApiController
before_action :set_training, only: %i[update destroy]
def index
@requested_attributes = params[:requested_attributes]
@trainings = policy_scope(Training)
@trainings = @trainings.where(public_page: true) if params[:public_page]
return unless attribute_requested?(@requested_attributes, 'availabilities')
@trainings = @trainings.includes(availabilities: [slots: [reservation: [user: %i[profile trainings]]]])
.order('availabilities.start_at DESC')
@trainings = TrainingService.list(params)
end
def show

View File

@ -3,11 +3,7 @@
# Devise controller to handle validation of email addresses
class ConfirmationsController < Devise::ConfirmationsController
# The path used after confirmation.
def after_confirmation_path_for(resource_name, resource)
if signed_in?(resource_name)
signed_in_root_path(resource)
else
signed_in_root_path(resource)
end
def after_confirmation_path_for(_resource_name, resource)
signed_in_root_path(resource)
end
end

View File

@ -13,4 +13,9 @@ class PasswordsController < Devise::PasswordsController
head 404
end
end
# POST /password/verify
def verify
current_user.valid_password?(params[:password]) ? head(200) : head(404)
end
end

View File

@ -0,0 +1 @@
<svg width="24" height="24" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>github</title><g fill="none" class="nc-icon-wrapper"><path d="M8.633 17.8c0 .082-.095.147-.214.147-.135.013-.23-.053-.23-.147 0-.082.095-.148.214-.148.123-.012.23.053.23.148zm-1.276-.185c-.029.082.053.176.176.2.107.042.23 0 .255-.081.024-.082-.054-.176-.177-.213-.106-.03-.225.012-.254.094zm1.813-.07c-.119.029-.201.107-.189.2.013.083.12.137.242.108.12-.03.201-.107.189-.19-.012-.077-.123-.13-.242-.118zm2.699-15.717c-5.69 0-10.04 4.32-10.04 10.008 0 4.549 2.862 8.44 6.951 9.81.525.095.71-.23.71-.495 0-.255-.012-1.657-.012-2.52 0 0-2.872.616-3.474-1.221 0 0-.468-1.194-1.14-1.501 0 0-.94-.644.065-.632 0 0 1.021.082 1.583 1.058.898 1.584 2.404 1.128 2.99.858.094-.657.361-1.112.656-1.383-2.292-.254-4.606-.586-4.606-4.532 0-1.128.312-1.694.968-2.416-.106-.266-.455-1.366.107-2.785.857-.266 2.83 1.108 2.83 1.108a9.673 9.673 0 0 1 5.152 0s1.972-1.378 2.83-1.108c.562 1.424.213 2.519.106 2.785.657.726 1.058 1.292 1.058 2.416 0 3.958-2.415 4.274-4.708 4.532.377.324.697.94.697 1.903 0 1.383-.012 3.093-.012 3.43 0 .266.189.59.71.496 4.101-1.362 6.882-5.254 6.882-9.803 0-5.69-4.614-10.008-10.303-10.008zM5.815 15.974c-.053.04-.041.136.029.214.065.065.16.094.213.04.053-.04.04-.135-.029-.213-.065-.065-.16-.094-.213-.04v-.001zm-.443-.332c-.029.053.012.12.094.16.066.04.148.029.177-.03.028-.052-.013-.117-.095-.16-.082-.023-.147-.011-.176.03zm1.329 1.46c-.066.054-.041.177.053.255.094.094.213.106.267.04.053-.053.028-.176-.054-.254-.09-.094-.213-.106-.266-.04v-.001zm-.468-.603c-.065.04-.065.148 0 .242.066.095.177.136.23.095.066-.054.066-.16 0-.255-.058-.094-.164-.135-.23-.082z" fill="#F7F7F7"></path></g></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,73 @@
<svg xmlns="http://www.w3.org/2000/svg" style="display: none;">
<defs><style> .nc-icon-wrapper { display: none } .nc-icon-wrapper:target { display: inline } </style></defs>
<svg viewBox="0 0 24 24">
<g id="lastfm" class="nc-icon-wrapper">
<path d="M20.25 1.5H3.75A2.25 2.25 0 0 0 1.5 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h16.5a2.25 2.25 0 0 0 2.25-2.25V3.75a2.25 2.25 0 0 0-2.25-2.25zm-4.322 14.667c-2.972 0-4.003-1.34-4.551-3.005-.764-2.39-1.008-3.951-2.954-3.951-1.05 0-2.114.755-2.114 2.869 0 1.65.844 2.68 2.03 2.68 1.34 0 2.231-.998 2.231-.998l.549 1.496s-.928.91-2.869.91c-2.405 0-3.745-1.412-3.745-4.023 0-2.714 1.34-4.312 3.867-4.312 3.445 0 3.787 1.94 4.725 4.776.412 1.257 1.134 2.166 2.869 2.166 1.167 0 1.786-.258 1.786-.895 0-.933-1.022-1.032-2.34-1.34-1.425-.343-1.992-1.084-1.992-2.25 0-1.876 1.514-2.457 3.057-2.457 1.753 0 2.817.637 2.953 2.184l-1.72.206c-.07-.74-.516-1.05-1.341-1.05-.755 0-1.219.343-1.219.929 0 .515.225.825.98.998 1.533.333 3.365.563 3.365 2.695.005 1.72-1.439 2.372-3.567 2.372z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="instagram" class="nc-icon-wrapper">
<path d="M12 9.5a2.5 2.5 0 1 0 .002 5 2.5 2.5 0 0 0-.002-5zm5.846-1.922a2.532 2.532 0 0 0-1.426-1.426c-.984-.388-3.328-.301-4.42-.301s-3.434-.09-4.42.301a2.531 2.531 0 0 0-1.426 1.426c-.388.984-.302 3.33-.302 4.421 0 1.092-.086 3.435.304 4.423a2.531 2.531 0 0 0 1.425 1.425c.984.389 3.328.302 4.42.302 1.094 0 3.434.09 4.421-.302a2.532 2.532 0 0 0 1.426-1.425c.391-.985.301-3.33.301-4.422 0-1.091.09-3.434-.301-4.422h-.002zM12 15.844a3.844 3.844 0 1 1 0-7.689 3.844 3.844 0 0 1 0 7.689zm4.002-6.952a.897.897 0 1 1 .002.002l-.002-.002zM20.25 1.5H3.75A2.25 2.25 0 0 0 1.5 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h16.5a2.25 2.25 0 0 0 2.25-2.25V3.75a2.25 2.25 0 0 0-2.25-2.25zm-.802 13.594c-.061 1.201-.335 2.266-1.212 3.14-.877.875-1.94 1.155-3.14 1.212-1.239.07-4.95.07-6.188 0-1.202-.06-2.263-.335-3.141-1.212-.878-.876-1.155-1.941-1.212-3.14-.07-1.239-.07-4.95 0-6.188.06-1.201.332-2.266 1.212-3.14.88-.875 1.944-1.152 3.14-1.209 1.239-.07 4.95-.07 6.188 0 1.202.06 2.266.335 3.14 1.212.876.876 1.155 1.941 1.213 3.143.07 1.234.07 4.942 0 6.182z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="github" class="nc-icon-wrapper">
<path d="M20.25 1.5H3.75C2.50781 1.5 1.5 2.50781 1.5 3.75V20.25C1.5 21.4922 2.50781 22.5 3.75 22.5H20.25C21.4922 22.5 22.5 21.4922 22.5 20.25V3.75C22.5 2.50781 21.4922 1.5 20.25 1.5ZM14.4984 19.4859C14.1047 19.5563 13.9594 19.3125 13.9594 19.1109C13.9594 18.8578 13.9688 17.5641 13.9688 16.5187C13.9688 15.7875 13.725 15.3234 13.4391 15.0797C15.1734 14.8875 17.0016 14.6484 17.0016 11.6531C17.0016 10.8 16.6969 10.3734 16.2 9.825C16.2797 9.62344 16.5469 8.79375 16.1203 7.71563C15.4687 7.51406 13.9781 8.55469 13.9781 8.55469C13.3594 8.38125 12.6891 8.29219 12.0281 8.29219C11.3672 8.29219 10.6969 8.38125 10.0781 8.55469C10.0781 8.55469 8.5875 7.51406 7.93594 7.71563C7.50938 8.78906 7.77188 9.61875 7.85625 9.825C7.35938 10.3734 7.125 10.8 7.125 11.6531C7.125 14.6344 8.87344 14.8875 10.6078 15.0797C10.3828 15.2813 10.1812 15.6281 10.1109 16.125C9.66562 16.3266 8.52656 16.6734 7.84687 15.4734C7.42031 14.7328 6.65156 14.6719 6.65156 14.6719C5.89219 14.6625 6.6 15.15 6.6 15.15C7.10625 15.3844 7.4625 16.2844 7.4625 16.2844C7.91719 17.6766 10.0922 17.2078 10.0922 17.2078C10.0922 17.8594 10.1016 18.9188 10.1016 19.1109C10.1016 19.3125 9.96094 19.5563 9.5625 19.4859C6.46875 18.45 4.30312 15.5062 4.30312 12.0656C4.30312 7.7625 7.59375 4.49531 11.8969 4.49531C16.2 4.49531 19.6875 7.7625 19.6875 12.0656C19.6922 15.5062 17.5922 18.4547 14.4984 19.4859ZM9.9 16.6219C9.81094 16.6406 9.72656 16.6031 9.71719 16.5422C9.70781 16.4719 9.76875 16.4109 9.85781 16.3922C9.94688 16.3828 10.0312 16.4203 10.0406 16.4813C10.0547 16.5422 9.99375 16.6031 9.9 16.6219ZM9.45469 16.5797C9.45469 16.6406 9.38437 16.6922 9.29062 16.6922C9.1875 16.7016 9.11719 16.65 9.11719 16.5797C9.11719 16.5188 9.1875 16.4672 9.28125 16.4672C9.37031 16.4578 9.45469 16.5094 9.45469 16.5797ZM8.8125 16.5281C8.79375 16.5891 8.7 16.6172 8.62031 16.5891C8.53125 16.5703 8.47031 16.5 8.48906 16.4391C8.50781 16.3781 8.60156 16.35 8.68125 16.3688C8.775 16.3969 8.83594 16.4672 8.8125 16.5281ZM8.23594 16.275C8.19375 16.3266 8.10469 16.3172 8.03437 16.2469C7.96406 16.1859 7.94531 16.0969 7.99219 16.0547C8.03437 16.0031 8.12344 16.0125 8.19375 16.0828C8.25469 16.1438 8.27812 16.2375 8.23594 16.275ZM7.80938 15.8484C7.76719 15.8766 7.6875 15.8484 7.63594 15.7781C7.58437 15.7078 7.58437 15.6281 7.63594 15.5953C7.6875 15.5531 7.76719 15.5859 7.80938 15.6562C7.86094 15.7266 7.86094 15.8109 7.80938 15.8484V15.8484ZM7.50469 15.3937C7.4625 15.4359 7.39219 15.4125 7.34062 15.3656C7.28906 15.3047 7.27969 15.2344 7.32187 15.2016C7.36406 15.1594 7.43437 15.1828 7.48594 15.2297C7.5375 15.2906 7.54688 15.3609 7.50469 15.3937ZM7.19063 15.0469C7.17188 15.0891 7.11094 15.0984 7.05937 15.0656C6.99844 15.0375 6.97031 14.9859 6.98906 14.9437C7.00781 14.9156 7.05938 14.9016 7.12031 14.925C7.18125 14.9578 7.20938 15.0094 7.19063 15.0469Z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="echosciences" class="nc-icon-wrapper">
<path d="M3.5 1.5C2.39543 1.5 1.5 2.39543 1.5 3.5V20.5C1.5 21.6046 2.39543 22.5 3.5 22.5H20.5C21.6046 22.5 22.5 21.6046 22.5 20.5V3.5C22.5 2.39543 21.6046 1.5 20.5 1.5H3.5ZM15.099 5H16.5742L16.6848 5.14204C18.2776 7.18714 19.1861 9.48429 19.1838 11.9303C19.1837 14.433 18.2315 16.7805 16.5685 18.8616L16.4578 19H14.9691L15.4785 18.3944C17.1447 16.4138 18.0564 14.2227 18.0564 11.93C18.0579 9.6857 17.1869 7.54197 15.5936 5.60226L15.099 5ZM4.81616 8.48674V5.73684H13.5809V8.48674H4.81616ZM12.7682 13.3182H7.98311V15.5169H13.5964V18.2632H4.83164V10.5757H12.7682V13.3182Z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="flickr" class="nc-icon-wrapper">
<path d="M20.25 1.5H3.75A2.25 2.25 0 0 0 1.5 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h16.5a2.25 2.25 0 0 0 2.25-2.25V3.75a2.25 2.25 0 0 0-2.25-2.25zM8.273 14.953a2.975 2.975 0 0 1-2.976-2.976A2.975 2.975 0 0 1 8.273 9a2.975 2.975 0 0 1 2.977 2.977 2.975 2.975 0 0 1-2.977 2.976zm7.454 0a2.975 2.975 0 0 1-2.977-2.976A2.975 2.975 0 0 1 15.727 9a2.975 2.975 0 0 1 2.976 2.977 2.975 2.975 0 0 1-2.976 2.976z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="facebook" class="nc-icon-wrapper">
<path d="M20.25 1.5H3.75C3.15326 1.5 2.58097 1.73705 2.15901 2.15901C1.73705 2.58097 1.5 3.15326 1.5 3.75L1.5 20.25C1.5 20.8467 1.73705 21.419 2.15901 21.841C2.58097 22.2629 3.15326 22.5 3.75 22.5H10.1836V15.3605H7.23047V12H10.1836V9.43875C10.1836 6.52547 11.918 4.91625 14.5744 4.91625C15.8466 4.91625 17.1769 5.14313 17.1769 5.14313V8.0025H15.7111C14.2669 8.0025 13.8164 8.89875 13.8164 9.81797V12H17.0405L16.5248 15.3605H13.8164V22.5H20.25C20.8467 22.5 21.419 22.2629 21.841 21.841C22.2629 21.419 22.5 20.8467 22.5 20.25V3.75C22.5 3.15326 22.2629 2.58097 21.841 2.15901C21.419 1.73705 20.8467 1.5 20.25 1.5V1.5Z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="youtube" class="nc-icon-wrapper">
<path d="M10.2563 9.47344L14.7188 12.0094L10.2563 14.5453V9.47344ZM22.5 3.75V20.25C22.5 21.4922 21.4922 22.5 20.25 22.5H3.75C2.50781 22.5 1.5 21.4922 1.5 20.25V3.75C1.5 2.50781 2.50781 1.5 3.75 1.5H20.25C21.4922 1.5 22.5 2.50781 22.5 3.75ZM20.5312 12.0141C20.5312 12.0141 20.5312 9.22031 20.175 7.87969C19.9781 7.13906 19.4016 6.55781 18.6656 6.36094C17.3391 6 12 6 12 6C12 6 6.66094 6 5.33438 6.36094C4.59844 6.55781 4.02187 7.13906 3.825 7.87969C3.46875 9.21563 3.46875 12.0141 3.46875 12.0141C3.46875 12.0141 3.46875 14.8078 3.825 16.1484C4.02187 16.8891 4.59844 17.4469 5.33438 17.6437C6.66094 18 12 18 12 18C12 18 17.3391 18 18.6656 17.6391C19.4016 17.4422 19.9781 16.8844 20.175 16.1437C20.5312 14.8078 20.5312 12.0141 20.5312 12.0141V12.0141Z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="vimeo" class="nc-icon-wrapper">
<path d="M20.4 1.5H3.6c-1.158 0-2.1.942-2.1 2.1v16.8c0 1.158.942 2.1 2.1 2.1h16.8c1.158 0 2.1-.942 2.1-2.1V3.6c0-1.158-.942-2.1-2.1-2.1zm-1.228 6.975c-.066 1.477-1.097 3.502-3.094 6.066-2.062 2.68-3.81 4.021-5.236 4.021-.886 0-1.631-.815-2.245-2.451-1.195-4.373-1.706-6.938-2.69-6.938-.113 0-.512.24-1.191.713l-.713-.919C5.752 7.43 7.42 5.723 8.466 5.63c1.18-.113 1.907.693 2.18 2.423.97 6.15 1.4 7.078 3.168 4.294.633-1.003.975-1.767 1.022-2.292.164-1.557-1.214-1.449-2.147-1.05.745-2.443 2.17-3.628 4.275-3.563 1.561.042 2.297 1.055 2.208 3.033z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="vimeo" class="nc-icon-wrapper">
<path d="M20.4 1.5H3.6c-1.158 0-2.1.942-2.1 2.1v16.8c0 1.158.942 2.1 2.1 2.1h16.8c1.158 0 2.1-.942 2.1-2.1V3.6c0-1.158-.942-2.1-2.1-2.1zm-1.228 6.975c-.066 1.477-1.097 3.502-3.094 6.066-2.062 2.68-3.81 4.021-5.236 4.021-.886 0-1.631-.815-2.245-2.451-1.195-4.373-1.706-6.938-2.69-6.938-.113 0-.512.24-1.191.713l-.713-.919C5.752 7.43 7.42 5.723 8.466 5.63c1.18-.113 1.907.693 2.18 2.423.97 6.15 1.4 7.078 3.168 4.294.633-1.003.975-1.767 1.022-2.292.164-1.557-1.214-1.449-2.147-1.05.745-2.443 2.17-3.628 4.275-3.563 1.561.042 2.297 1.055 2.208 3.033z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="viadeo" class="nc-icon-wrapper">
<path d="M20.25 1.5H3.75A2.25 2.25 0 0 0 1.5 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h16.5a2.25 2.25 0 0 0 2.25-2.25V3.75a2.25 2.25 0 0 0-2.25-2.25zm-5.592 16.369c-1.988 2.165-5.625 2.184-7.613 0-3.187-3.45-.928-9.192 3.807-9.192.623 0 1.246.098 1.832.314a3.749 3.749 0 0 0-.393 1.27 3.696 3.696 0 0 0-1.44-.281c-2.287 0-3.965 1.954-3.965 4.167 0 2.016 1.336 3.689 3.258 4.026 2.883-1.125 3.417-5.512 3.417-8.203 0-.342 0-.693-.028-1.036-.525-1.542-1.247-3.028-2.072-4.43 1.27.859 1.964 2.93 2.072 4.412v.018a10.15 10.15 0 0 1 .553 3.282c0 2.536-1.027 4.64-3.202 6.009l-.112.01c2.344.046 4.04-1.81 4.04-4.088a4.29 4.29 0 0 0-.323-1.674 3.763 3.763 0 0 0 1.238-.492 5.554 5.554 0 0 1-1.07 5.888zm1.326-6.914c-.623 0-1.176-.333-1.612-.755 1.026-.563 2.325-1.44 2.92-2.484.07-.141.192-.404.211-.563-.586 1.308-2.072 2.335-3.464 2.658a2.092 2.092 0 0 1-.351-1.14c0-.482.243-1.129.604-1.48 1.013-.961 2.485-.399 3.394-2.344 1.523 2.165.614 6.108-1.702 6.108z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="twitter" class="nc-icon-wrapper">
<path d="M20.25 1.5H3.75A2.25 2.25 0 0 0 1.5 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h16.5a2.25 2.25 0 0 0 2.25-2.25V3.75a2.25 2.25 0 0 0-2.25-2.25zm-2.292 7.444c.01.131.01.267.01.398 0 4.064-3.095 8.747-8.748 8.747a8.706 8.706 0 0 1-4.72-1.378c.248.028.487.037.74.037 1.44 0 2.762-.487 3.816-1.312a3.078 3.078 0 0 1-2.873-2.133c.473.07.9.07 1.387-.056a3.075 3.075 0 0 1-2.46-3.019v-.037c.407.23.885.37 1.387.389a3.068 3.068 0 0 1-1.369-2.56c0-.572.15-1.097.417-1.551a8.73 8.73 0 0 0 6.338 3.215c-.436-2.086 1.125-3.778 3-3.778.886 0 1.683.37 2.245.97a6.024 6.024 0 0 0 1.95-.74 3.066 3.066 0 0 1-1.35 1.692A6.117 6.117 0 0 0 19.5 7.35a6.471 6.471 0 0 1-1.542 1.594z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="pinterest" class="nc-icon-wrapper">
<path d="M22.5 3.75V20.25C22.5 21.4922 21.4922 22.5 20.25 22.5H8.7375C9.19687 21.7313 9.7875 20.625 10.0219 19.7203C10.1625 19.1813 10.7391 16.9828 10.7391 16.9828C11.1141 17.7 12.2109 18.3047 13.3781 18.3047C16.8516 18.3047 19.35 15.1125 19.35 11.1469C19.35 7.34531 16.2469 4.5 12.2531 4.5C7.28437 4.5 4.65 7.83281 4.65 11.4656C4.65 13.1531 5.55 15.2531 6.98438 15.9234C7.20469 16.0266 7.31719 15.9797 7.36875 15.7687C7.40625 15.6094 7.60313 14.8266 7.6875 14.4656C7.71563 14.3484 7.70156 14.25 7.60781 14.1375C7.13438 13.5609 6.75 12.5016 6.75 11.5125C6.75 8.97188 8.67187 6.51562 11.9484 6.51562C14.775 6.51562 16.7578 8.44219 16.7578 11.1984C16.7578 14.3109 15.1875 16.4672 13.1391 16.4672C12.0094 16.4672 11.1656 15.5344 11.4328 14.3859C11.7562 13.0172 12.3844 11.5406 12.3844 10.5516C12.3844 8.06719 8.84531 8.40938 8.84531 11.7234C8.84531 12.7406 9.1875 13.4344 9.1875 13.4344C7.71563 19.6594 7.49531 19.7391 7.8 22.4625L7.90313 22.5H3.75C2.50781 22.5 1.5 21.4922 1.5 20.25V3.75C1.5 2.50781 2.50781 1.5 3.75 1.5H20.25C21.4922 1.5 22.5 2.50781 22.5 3.75Z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="linkedin" class="nc-icon-wrapper">
<path d="M21 1.5H2.995C2.17 1.5 1.5 2.18 1.5 3.014v17.972c0 .834.67 1.514 1.495 1.514H21c.825 0 1.5-.68 1.5-1.514V3.014A1.51 1.51 0 0 0 21 1.5zm-13.153 18H4.734V9.478h3.118V19.5h-.005zM6.29 8.11a1.805 1.805 0 0 1 0-3.61c.993 0 1.804.81 1.804 1.805 0 .998-.806 1.804-1.804 1.804zM19.514 19.5h-3.112v-4.875c0-1.162-.024-2.658-1.618-2.658-1.622 0-1.87 1.266-1.87 2.574V19.5H9.802V9.478h2.986v1.369h.042c.417-.788 1.434-1.617 2.948-1.617 3.15 0 3.736 2.076 3.736 4.776V19.5z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="dailymotion" class="nc-icon-wrapper">
<path d="M15.512 12.516C15.1636 12.3201 14.7697 12.2197 14.37 12.225C13.75 12.225 13.228 12.432 12.803 12.847C12.378 13.261 12.165 13.783 12.165 14.413C12.165 15.074 12.373 15.615 12.788 16.035C13.203 16.455 13.725 16.665 14.354 16.665C14.995 16.665 15.528 16.45 15.954 16.019C16.379 15.589 16.592 15.053 16.594 14.413C16.5962 14.0277 16.4976 13.6485 16.308 13.313C16.1215 12.9788 15.8459 12.7029 15.512 12.516ZM3.5 1.5C2.39543 1.5 1.5 2.39543 1.5 3.5V20.5C1.5 21.6046 2.39543 22.5 3.5 22.5H20.5C21.6046 22.5 22.5 21.6046 22.5 20.5V3.5C22.5 2.39543 21.6046 1.5 20.5 1.5H3.5ZM19.064 18.997H16.576V17.877H16.544C16.051 18.696 15.179 19.105 13.929 19.105C13.069 19.105 12.304 18.9 11.638 18.491C10.9765 18.0875 10.4416 17.5065 10.094 16.814C9.731 16.105 9.55 15.31 9.55 14.429C9.55 13.567 9.734 12.783 10.102 12.075C10.452 11.385 10.986 10.804 11.645 10.397C12.307 9.988 13.053 9.784 13.882 9.783C14.3579 9.77641 14.8308 9.85952 15.276 10.028C15.691 10.19 16.082 10.456 16.448 10.824V7.172L19.063 6.605L19.065 18.997H19.064Z" fill="currentColor"></path>
</g>
</svg>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -1,4 +1,4 @@
import { AuthenticationProvider, MappingFields } from '../models/authentication-provider';
import { ActiveProviderResponse, AuthenticationProvider, MappingFields } from '../models/authentication-provider';
import { AxiosResponse } from 'axios';
import apiClient from './clients/api-client';
@ -36,4 +36,9 @@ export default class AuthProviderAPI {
const res: AxiosResponse<string> = await apiClient.get(`/api/auth_providers/strategy_name?providable_type=${authProvider.providable_type}&name=${authProvider.name}`);
return res?.data;
}
static async active (): Promise<ActiveProviderResponse> {
const res: AxiosResponse<ActiveProviderResponse> = await apiClient.get('/api/auth_providers/active');
return res?.data;
}
}

View File

@ -0,0 +1,23 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { User } from '../models/user';
export default class Authentication {
static async login (email: string, password: string): Promise<User> {
const res: AxiosResponse<User> = await apiClient.post('/users/sign_in.json', { email, password });
return res?.data;
}
static async logout (): Promise<void> {
return apiClient.delete('/users/sign_out.json');
}
static async verifyPassword (password: string): Promise<boolean> {
try {
const res: AxiosResponse<never> = await apiClient.post('/password/verify.json', { password });
return (res.status === 200);
} catch (e) {
return false;
}
}
}

View File

@ -0,0 +1,42 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { serialize } from 'object-to-formdata';
import { User, UserIndexFilter } from '../models/user';
export default class MemberAPI {
static async list (filters: UserIndexFilter): Promise<Array<User>> {
const res: AxiosResponse<Array<User>> = await apiClient.post('/api/members/list', filters);
return res?.data;
}
static async create (user: User): Promise<User> {
const data = serialize({ user });
if (user.profile_attributes.user_avatar_attributes.attachment_files[0]) {
data.set('user[profile_attributes][user_avatar_attributes][attachment]', user.profile_attributes.user_avatar_attributes.attachment_files[0]);
}
const res: AxiosResponse<User> = await apiClient.post('/api/members', data, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return res?.data;
}
static async update (user: User): Promise<User> {
const data = serialize({ user });
if (user.profile_attributes.user_avatar_attributes.attachment_files[0]) {
data.set('user[profile_attributes][user_avatar_attributes][attachment]', user.profile_attributes.user_avatar_attributes.attachment_files[0]);
}
const res: AxiosResponse<User> = await apiClient.patch(`/api/members/${user.id}`, data, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return res?.data;
}
static async current (): Promise<User> {
const res: AxiosResponse<User> = await apiClient.get('/api/members/current');
return res?.data;
}
}

View File

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

View File

@ -0,0 +1,16 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { Training, TrainingIndexFilter } from '../models/training';
export default class TrainingAPI {
static async index (filters?: TrainingIndexFilter): Promise<Array<Training>> {
const res: AxiosResponse<Array<Training>> = await apiClient.get(`/api/trainings${this.filtersToQuery(filters)}`);
return res?.data;
}
private static filtersToQuery (filters?: TrainingIndexFilter): string {
if (!filters) return '';
return '?' + Object.entries(filters).map(f => `${f[0]}=${f[1]}`).join('&');
}
}

View File

@ -16,7 +16,7 @@ interface Oauth2FormProps<TFieldValues> {
export const Oauth2Form = <TFieldValues extends FieldValues>({ register, strategyName }: Oauth2FormProps<TFieldValues>) => {
const { t } = useTranslation('admin');
// regular expression to validate the the input fields
// regular expression to validate the input fields
const endpointRegex = /^\/?([-._~:?#[\]@!$&'()*+,;=%\w]+\/?)*$/;
const urlRegex = /^(https?:\/\/)([\da-z.-]+)\.([-a-z0-9.]{2,30})([/\w .-]*)*\/?$/;

View File

@ -39,7 +39,7 @@ export const OpenidConnectForm = <TFieldValues extends FieldValues, TContext ext
checkForDiscoveryEndpoint({ target: { value: currentFormValues?.issuer } } as React.ChangeEvent<HTMLInputElement>);
}, []);
// regular expression to validate the the input fields
// regular expression to validate the input fields
const endpointRegex = /^\/?([-._~:?#[\]@!$&'()*+,;=%\w]+\/?)*$/;
const urlRegex = /^(https?:\/\/)([\da-z.-]+)\.([-a-z0-9.]{2,30})([/\w .-]*)*\/?$/;
@ -85,7 +85,7 @@ export const OpenidConnectForm = <TFieldValues extends FieldValues, TContext ext
rules={{ required: true, pattern: urlRegex }}
onChange={checkForDiscoveryEndpoint}
debounce={400}
warning={!discoveryAvailable && { 'providable_attributes.issuer': { message: t('app.admin.authentication.openid_connect_form.discovery_unavailable') } }}
warning={!discoveryAvailable && { message: t('app.admin.authentication.openid_connect_form.discovery_unavailable') } }
formState={formState} />
<FormSelect id="providable_attributes.discovery"
label={t('app.admin.authentication.openid_connect_form.discovery')}
@ -112,6 +112,7 @@ export const OpenidConnectForm = <TFieldValues extends FieldValues, TContext ext
label={t('app.admin.authentication.openid_connect_form.scope')}
tooltip={<HtmlTranslate trKey="app.admin.authentication.openid_connect_form.scope_help_html" />}
options={scopesAvailable.map((scope) => ({ value: scope, label: scope }))}
creatable
control={control} />}
<FormSelect id="providable_attributes.prompt"
label={t('app.admin.authentication.openid_connect_form.prompt')}

View File

@ -96,7 +96,7 @@ export const ProviderForm: React.FC<ProviderFormProps> = ({ action, provider, on
<form className="provider-form" onSubmit={handleSubmit(onSubmit)}>
<FormInput id="name"
register={register}
readOnly={action === 'update'}
disabled={action === 'update'}
rules={{ required: true }}
label={t('app.admin.authentication.provider_form.name')} />
<FormSelect id="providable_type"
@ -104,7 +104,7 @@ export const ProviderForm: React.FC<ProviderFormProps> = ({ action, provider, on
options={buildProvidableTypeOptions()}
label={t('app.admin.authentication.provider_form.authentication_type')}
onChange={onProvidableTypeChange}
readOnly={action === 'update'}
disabled={action === 'update'}
rules={{ required: true }} />
{providableType === 'DatabaseProvider' && <DatabaseForm register={register} />}
{providableType === 'OAuth2Provider' && <Oauth2Form register={register} strategyName={strategyName} />}

View File

@ -0,0 +1,32 @@
import React from 'react';
interface ErrorBoundaryState {
hasError: boolean;
}
/**
* 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> {
constructor (props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError () {
return { hasError: true };
}
componentDidCatch (error, errorInfo) {
console.error(error, errorInfo);
}
render () {
if (this.state.hasError) {
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}

View File

@ -1,9 +1,6 @@
import React from 'react';
import React, { forwardRef, RefObject, useImperativeHandle, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import { IApplication } from '../../../models/application';
import { Loader } from '../loader';
import { useEditor, EditorContent } from '@tiptap/react';
import { useEditor, EditorContent, Editor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
import CharacterCount from '@tiptap/extension-character-count';
@ -14,8 +11,6 @@ import Image from '@tiptap/extension-image';
import { MenuBar } from './menu-bar';
import { WarningOctagon } from 'phosphor-react';
declare const Application: IApplication;
interface FabTextEditorProps {
label?: string,
paragraphTools?: boolean,
@ -25,17 +20,30 @@ interface FabTextEditorProps {
image?: boolean,
onChange?: (content: string) => void,
placeholder?: string,
error?: string
error?: string,
readOnly?: boolean,
}
export interface FabTextEditorRef {
focus: () => void
}
/**
* This component is a WYSIWYG text editor
*/
export const FabTextEditor: React.FC<FabTextEditorProps> = ({ label, paragraphTools, content, limit = 400, video, image, onChange, placeholder, error }) => {
export const FabTextEditor: React.ForwardRefRenderFunction<FabTextEditorRef, FabTextEditorProps> = ({ label, paragraphTools, content, limit = 400, video, image, onChange, placeholder, error, readOnly = false }, ref: RefObject<FabTextEditorRef>) => {
const { t } = useTranslation('shared');
const placeholderText = placeholder || t('app.shared.text_editor.text_placeholder');
// TODO: Add ctrl+click on link to visit
const editorRef: React.MutableRefObject<Editor | null> = useRef(null);
// the methods in useImperativeHandle are exposed to the parent component
useImperativeHandle(ref, () => ({
focus () {
focusEditor();
}
}), []);
// Setup the editor
// Extensions add functionalities to the editor (Bold, Italic…)
// Events fire action (onUpdate -> get the content as HTML)
@ -63,21 +71,29 @@ export const FabTextEditor: React.FC<FabTextEditorProps> = ({ label, paragraphTo
}
})
],
editable: !readOnly,
content,
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
}
});
/**
* Callback triggered when the label is clicked: we want to focus the text edition zone
*/
const focusEditor = () => {
editor.commands.focus('start');
editorRef.current?.commands?.focus();
};
// bind the editor to the ref, once it is ready
if (!editor) return null;
editorRef.current = editor;
return (
<>
{label && <label onClick={focusEditor} className="fab-textEditor-label">{label}</label>}
<div className="fab-textEditor">
<MenuBar editor={editor} paragraphTools={paragraphTools} video={video} image={image} />
<MenuBar editor={editor} paragraphTools={paragraphTools} video={video} image={image} disabled={readOnly} />
<EditorContent editor={editor} />
<div className="fab-textEditor-character-count">
{editor?.storage.characterCount.characters()} / {limit}
@ -93,12 +109,4 @@ export const FabTextEditor: React.FC<FabTextEditorProps> = ({ label, paragraphTo
);
};
const FabTextEditorWrapper: React.FC<FabTextEditorProps> = ({ label, paragraphTools, content, limit, video, image, placeholder, error }) => {
return (
<Loader>
<FabTextEditor label={label} paragraphTools={paragraphTools} content={content} limit={limit} video={video} image={image} placeholder={placeholder} error={error} />
</Loader>
);
};
Application.Components.component('fabTextEditor', react2angular(FabTextEditorWrapper, ['label', 'paragraphTools', 'content', 'limit', 'video', 'image', 'placeholder', 'error']));
export default forwardRef(FabTextEditor);

View File

@ -9,12 +9,13 @@ interface MenuBarProps {
paragraphTools?: boolean,
video?: boolean,
image?: boolean,
disabled?: boolean,
}
/**
* This component is the menu bar for the WYSIWYG text editor
*/
export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video, image }) => {
export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video, image, disabled = false }) => {
const { t } = useTranslation('shared');
const [submenu, setSubmenu] = useState('');
@ -140,12 +141,13 @@ export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video,
return (
<>
<div className='fab-textEditor-menu'>
<div className={`fab-textEditor-menu ${disabled ? 'fab-textEditor-menu--disabled' : ''}`}>
{ paragraphTools &&
(<>
<button
type='button'
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
disabled={disabled}
className={editor.isActive('heading', { level: 3 }) ? 'is-active' : ''}
>
<TextAa size={24} />
@ -153,6 +155,7 @@ export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video,
<button
type='button'
onClick={() => editor.chain().focus().toggleBulletList().run()}
disabled={disabled}
className={editor.isActive('bulletList') ? 'is-active' : ''}
>
<ListBullets size={24} />
@ -160,6 +163,7 @@ export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video,
<button
type='button'
onClick={() => editor.chain().focus().toggleBlockquote().run()}
disabled={disabled}
className={editor.isActive('blockquote') ? 'is-active' : ''}
>
<Quotes size={24} />
@ -170,6 +174,7 @@ export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video,
<button
type='button'
onClick={() => editor.chain().focus().toggleBold().run()}
disabled={disabled}
className={editor.isActive('bold') ? 'is-active' : ''}
>
<TextBolder size={24} />
@ -177,6 +182,7 @@ export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video,
<button
type='button'
onClick={() => editor.chain().focus().toggleItalic().run()}
disabled={disabled}
className={editor.isActive('italic') ? 'is-active' : ''}
>
<TextItalic size={24} />
@ -184,6 +190,7 @@ export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video,
<button
type='button'
onClick={() => editor.chain().focus().toggleUnderline().run()}
disabled={disabled}
className={editor.isActive('underline') ? 'is-active' : ''}
>
<TextUnderline size={24} />
@ -191,6 +198,7 @@ export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video,
<button
type='button'
onClick={() => toggleSubmenu('link')}
disabled={disabled}
className={`ignore-onclickoutside ${editor.isActive('link') ? 'is-active' : ''}`}
>
<LinkSimpleHorizontal size={24} />
@ -200,6 +208,7 @@ export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video,
(<>
<button
type='button'
disabled={disabled}
onClick={() => toggleSubmenu('video')}
>
<VideoCamera size={24} />
@ -210,6 +219,7 @@ export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video,
(<>
<button
type='button'
disabled={disabled}
onClick={() => toggleSubmenu('image')}
>
<Image size={24} />

View File

@ -1,9 +1,10 @@
import React, { useEffect, useState } from 'react';
import { LabelledInput } from './base/labelled-input';
import { useTranslation } from 'react-i18next';
import { TDateISODate } from '../typings/date-iso';
interface DocumentFiltersProps {
onFilterChange: (value: { reference: string, customer: string, date: Date }) => void
onFilterChange: (value: { reference: string, customer: string, date: TDateISODate }) => void
}
/**
@ -13,11 +14,11 @@ export const DocumentFilters: React.FC<DocumentFiltersProps> = ({ onFilterChange
const { t } = useTranslation('admin');
// stores the value of reference input
const [referenceFilter, setReferenceFilter] = useState('');
const [referenceFilter, setReferenceFilter] = useState<string>('');
// stores the value of the customer input
const [customerFilter, setCustomerFilter] = useState('');
const [customerFilter, setCustomerFilter] = useState<string>('');
// stores the value of the date input
const [dateFilter, setDateFilter] = useState(null);
const [dateFilter, setDateFilter] = useState<TDateISODate>(null);
/**
* When any filter changes, trigger the callback with the current value of all filters

View File

@ -0,0 +1,79 @@
import React, { PropsWithChildren, ReactNode, useEffect, useState } from 'react';
import { AbstractFormComponent } from '../../models/form-component';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { get as _get } from 'lodash';
export interface AbstractFormItemProps<TFieldValues> extends PropsWithChildren<AbstractFormComponent<TFieldValues>> {
id: string,
label?: string|ReactNode,
tooltip?: ReactNode,
className?: string,
disabled?: boolean|((id: string) => boolean),
readOnly?: boolean
onLabelClick?: (event: React.MouseEvent<HTMLLabelElement, MouseEvent>) => void,
}
/**
* This abstract component should not be used directly.
* Other forms components that are intended to be used with react-hook-form must extend this component.
*/
export const AbstractFormItem = <TFieldValues extends FieldValues>({ id, label, tooltip, className, disabled, readOnly, error, warning, rules, formState, onLabelClick, children }: AbstractFormItemProps<TFieldValues>) => {
const [isDirty, setIsDirty] = useState<boolean>(false);
const [fieldError, setFieldError] = useState<{ message: string }>(error);
const [isDisabled, setIsDisabled] = useState<boolean>(false);
useEffect(() => {
setIsDirty(_get(formState?.dirtyFields, id));
setFieldError(_get(formState?.errors, id));
}, [formState]);
useEffect(() => {
setFieldError(error);
}, [error]);
useEffect(() => {
if (typeof disabled === 'function') {
setIsDisabled(disabled(id));
} else {
setIsDisabled(disabled);
}
}, [disabled]);
// Compose classnames from props
const classNames = [
'form-item',
`${className || ''}`,
`${isDirty && fieldError ? 'is-incorrect' : ''}`,
`${isDirty && warning ? 'is-warned' : ''}`,
`${rules && rules.required ? 'is-required' : ''}`,
`${readOnly ? 'is-readonly' : ''}`,
`${isDisabled ? 'is-disabled' : ''}`
].join(' ');
/**
* This function is called when the label is clicked.
* It is used to focus the input.
*/
function handleLabelClick (event: React.MouseEvent<HTMLLabelElement, MouseEvent>) {
if (typeof onLabelClick === 'function') {
onLabelClick(event);
}
}
return (
<label className={classNames} onClick={handleLabelClick}>
{label && <div className='form-item-header'>
<p>{label}</p>
{tooltip && <div className="item-tooltip">
<span className="trigger"><i className="fa fa-question-circle" /></span>
<div className="content">{tooltip}</div>
</div>}
</div>}
<div className='form-item-field'>
{children}
</div>
{(isDirty && fieldError) && <div className="form-item-error">{fieldError.message}</div> }
{(isDirty && warning) && <div className="form-item-warning">{warning.message}</div> }
</label>
);
};

View File

@ -1,30 +1,29 @@
import React, { InputHTMLAttributes, ReactNode, useCallback, useEffect, useState } from 'react';
import React, { ReactNode, useCallback } from 'react';
import { FieldPathValue } from 'react-hook-form';
import { debounce as _debounce, get as _get } from 'lodash';
import { debounce as _debounce } from 'lodash';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { FieldPath } from 'react-hook-form/dist/types/path';
import { FormComponent } from '../../models/form-component';
import { AbstractFormItem, AbstractFormItemProps } from './abstract-form-item';
interface FormInputProps<TFieldValues> extends InputHTMLAttributes<HTMLInputElement>, FormComponent<TFieldValues>{
id: string,
label?: string,
tooltip?: ReactNode,
interface FormInputProps<TFieldValues, TInputType> extends FormComponent<TFieldValues>, AbstractFormItemProps<TFieldValues> {
icon?: ReactNode,
addOn?: ReactNode,
addOnAction?: (event: React.MouseEvent<HTMLButtonElement>) => void,
addOnClassName?: string,
debounce?: number,
type?: 'text' | 'date' | 'password' | 'url' | 'time' | 'tel' | 'search' | 'number' | 'month' | 'email' | 'datetime-local' | 'week' | 'hidden' | 'file',
accept?: string,
defaultValue?: TInputType,
placeholder?: string,
step?: number | 'any',
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void,
}
/**
* This component is a template for an input component to use within React Hook Form
*/
export const FormInput = <TFieldValues extends FieldValues>({ id, register, label, tooltip, defaultValue, icon, className, rules, readOnly, disabled, type, addOn, addOnClassName, placeholder, error, warning, formState, step, onChange, debounce }: FormInputProps<TFieldValues>) => {
const [isDirty, setIsDirty] = useState(false);
useEffect(() => {
setIsDirty(_get(formState?.dirtyFields, id));
}, [formState]);
export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, register, label, tooltip, defaultValue, icon, className, rules, readOnly, disabled, type, addOn, addOnAction, addOnClassName, placeholder, error, warning, formState, step, onChange, debounce, accept }: FormInputProps<TFieldValues, TInputType>) => {
/**
* Debounced (ie. temporised) version of the 'on change' callback.
*/
@ -45,43 +44,32 @@ export const FormInput = <TFieldValues extends FieldValues>({ id, register, labe
// Compose classnames from props
const classNames = [
'form-input form-item',
'form-input',
`${className || ''}`,
`${type === 'hidden' ? 'is-hidden' : ''}`,
`${isDirty && error && error[id] ? 'is-incorrect' : ''}`,
`${isDirty && warning && warning[id] ? 'is-warned' : ''}`,
`${rules && rules.required ? 'is-required' : ''}`,
`${readOnly ? 'is-readonly' : ''}`,
`${disabled ? 'is-disabled' : ''}`
`${type === 'hidden' ? 'is-hidden' : ''}`
].join(' ');
return (
<label className={classNames}>
{label && <div className='form-item-header'>
<p>{label}</p>
{tooltip && <div className="item-tooltip">
<span className="trigger"><i className="fa fa-question-circle" /></span>
<div className="content">{tooltip}</div>
</div>}
</div>}
<div className='form-item-field'>
<AbstractFormItem id={id} formState={formState} label={label}
className={classNames} tooltip={tooltip}
disabled={disabled} readOnly={readOnly}
rules={rules} error={error} warning={warning}>
{icon && <span className="icon">{icon}</span>}
<input id={id}
{...register(id as FieldPath<TFieldValues>, {
...rules,
valueAsNumber: type === 'number',
valueAsDate: type === 'date',
value: defaultValue as FieldPathValue<TFieldValues, FieldPath<TFieldValues>>,
onChange: (e) => { handleChange(e); }
})}
type={type}
step={step}
disabled={disabled}
disabled={typeof disabled === 'function' ? disabled(id) : disabled}
readOnly={readOnly}
placeholder={placeholder} />
{addOn && <span className={`addon ${addOnClassName || ''}`}>{addOn}</span>}
</div>
{(isDirty && error && error[id]) && <div className="form-item-error">{error[id].message}</div> }
{(isDirty && warning && warning[id]) && <div className="form-item-warning">{warning[id].message}</div> }
</label>
placeholder={placeholder}
accept={accept} />
{addOn && <span onClick={addOnAction} className={`addon ${addOnClassName || ''} ${addOnAction ? 'is-btn' : ''}`}>{addOn}</span>}
</AbstractFormItem>
);
};

View File

@ -1,22 +1,20 @@
import React, { ReactNode } from 'react';
import React, { useEffect } from 'react';
import Select from 'react-select';
import { Controller, Path } from 'react-hook-form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { FieldPath } from 'react-hook-form/dist/types/path';
import { FieldPathValue, UnpackNestedValue } from 'react-hook-form/dist/types';
import { FormControlledComponent } from '../../models/form-component';
import { AbstractFormItem, AbstractFormItemProps } from './abstract-form-item';
import CreatableSelect from 'react-select/creatable';
interface FormSelectProps<TFieldValues, TContext extends object, TOptionValue> extends FormControlledComponent<TFieldValues, TContext> {
id: string,
label?: string,
tooltip?: ReactNode,
interface FormSelectProps<TFieldValues, TContext extends object, TOptionValue> extends FormControlledComponent<TFieldValues, TContext>, AbstractFormItemProps<TFieldValues> {
options: Array<selectOption<TOptionValue>>,
valuesDefault?: Array<TOptionValue>,
onChange?: (values: Array<TOptionValue>) => void,
className?: string,
placeholder?: string,
disabled?: boolean,
expectedResult?: 'array' | 'string'
creatable?: boolean,
}
/**
@ -29,14 +27,21 @@ type selectOption<TOptionValue> = { value: TOptionValue, label: string };
* This component is a wrapper around react-select to use with react-hook-form.
* It is a multi-select component.
*/
export const FormMultiSelect = <TFieldValues extends FieldValues, TContext extends object, TOptionValue>({ id, label, tooltip, className, control, placeholder, options, valuesDefault, error, rules, disabled, onChange, expectedResult }: FormSelectProps<TFieldValues, TContext, TOptionValue>) => {
const classNames = [
'form-multi-select form-item',
`${className || ''}`,
`${error && error[id] ? 'is-incorrect' : ''}`,
`${rules && rules.required ? 'is-required' : ''}`,
`${disabled ? 'is-disabled' : ''}`
].join(' ');
export const FormMultiSelect = <TFieldValues extends FieldValues, TContext extends object, TOptionValue>({ id, label, tooltip, className, control, placeholder, options, valuesDefault, error, rules, disabled, onChange, formState, readOnly, warning, expectedResult, creatable }: FormSelectProps<TFieldValues, TContext, TOptionValue>) => {
const [isDisabled, setIsDisabled] = React.useState<boolean>(false);
const [allOptions, setAllOptions] = React.useState<Array<selectOption<TOptionValue>>>(options);
useEffect(() => {
if (typeof disabled === 'function') {
setIsDisabled(disabled(id) || readOnly);
} else {
setIsDisabled(disabled || readOnly);
}
}, [disabled]);
useEffect(() => {
setAllOptions(options);
}, [options]);
/**
* The following callback will trigger the onChange callback, if it was passed to this component,
@ -64,41 +69,59 @@ export const FormMultiSelect = <TFieldValues extends FieldValues, TContext exten
} else {
values = value;
}
return options.filter(c => values?.includes(c.value));
return allOptions.filter(c => values?.includes(c.value));
};
/**
* When the select is 'creatable', this callback handle the creation and the selection of a new option.
*/
const handleCreate = (value: Array<TOptionValue>|string, rhfOnChange) => {
return (inputValue: string) => {
// add the new value to the list of options
const newOption = { value: inputValue as unknown as TOptionValue, label: inputValue };
setAllOptions([...allOptions, newOption]);
// select the new option
const values = getCurrentValues(value);
values.push(newOption);
onChangeCb(values.map(c => c.value), rhfOnChange);
};
};
// if the user can create new options, we need to use a different component
const AbstractSelect = creatable ? CreatableSelect : Select;
return (
<label className={classNames}>
{label && <div className="form-item-header">
<p>{label}</p>
{tooltip && <div className="item-tooltip">
<span className="trigger"><i className="fa fa-question-circle" /></span>
<div className="content">{tooltip}</div>
</div>}
</div>}
<div className="form-item-field">
<AbstractFormItem id={id} formState={formState} label={label}
className={`form-multi-select ${className || ''}`} tooltip={tooltip}
disabled={disabled} readOnly={readOnly}
rules={rules} error={error} warning={warning}>
<Controller name={id as FieldPath<TFieldValues>}
control={control}
defaultValue={valuesDefault as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>}
rules={rules}
render={({ field: { onChange, value, ref } }) =>
<Select ref={ref}
classNamePrefix="rs"
className="rs"
value={getCurrentValues(value)}
onChange={val => {
const values = val?.map(c => c.value);
onChangeCb(values, onChange);
}}
placeholder={placeholder}
options={options}
isMulti />
<AbstractSelect ref={ref}
classNamePrefix="rs"
className="rs"
value={getCurrentValues(value)}
onChange={val => {
const values = val?.map(c => c.value);
onChangeCb(values, onChange);
}}
onCreateOption={handleCreate(value, onChange)}
placeholder={placeholder}
options={allOptions}
isDisabled={isDisabled}
isMulti />
} />
</div>
{(error && error[id]) && <div className="form-item-error">{error[id].message}</div> }
</label>
</AbstractFormItem>
);
};
FormMultiSelect.defaultProps = {
expectedResult: 'array'
expectedResult: 'array',
creatable: false,
readOnly: false,
disabled: false
};

View File

@ -0,0 +1,64 @@
import React, { useEffect } from 'react';
import { FormControlledComponent } from '../../models/form-component';
import { AbstractFormItem, AbstractFormItemProps } from './abstract-form-item';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import FabTextEditor, { FabTextEditorRef } from '../base/text-editor/fab-text-editor';
import { Controller, Path } from 'react-hook-form';
import { FieldPath } from 'react-hook-form/dist/types/path';
import { FieldPathValue, UnpackNestedValue } from 'react-hook-form/dist/types';
interface FormRichTextProps<TFieldValues, TContext extends object> extends FormControlledComponent<TFieldValues, TContext>, AbstractFormItemProps<TFieldValues> {
valueDefault?: string,
limit?: number,
paragraphTools?: boolean,
video?: boolean,
image?: boolean,
}
/**
* This component is a rich-text editor to use with react-hook-form.
*/
export const FormRichText = <TFieldValues extends FieldValues, TContext extends object>({ id, label, tooltip, className, control, valueDefault, error, warning, rules, disabled = false, readOnly = false, formState, limit, paragraphTools, video, image }: FormRichTextProps<TFieldValues, TContext>) => {
const textEditorRef = React.useRef<FabTextEditorRef>();
const [isDisabled, setIsDisabled] = React.useState<boolean>(false);
useEffect(() => {
if (typeof disabled === 'function') {
setIsDisabled(disabled(id) || readOnly);
} else {
setIsDisabled(disabled || readOnly);
}
}, [disabled]);
/**
* Callback triggered when the user clicks to get the focus on the editor.
* We do not want the default behavior (focus the first child, which is the Bold button)
* but we want to focus the text edition area.
*/
function focusTextEditor (event: React.MouseEvent<HTMLLabelElement, MouseEvent>) {
event.preventDefault();
textEditorRef.current.focus();
}
return (
<AbstractFormItem id={id} label={label} tooltip={tooltip}
className={`form-rich-text ${className || ''}`}
error={error} warning={warning} rules={rules}
disabled={disabled} formState={formState} onLabelClick={focusTextEditor}>
<Controller name={id as FieldPath<TFieldValues>}
control={control}
defaultValue={valueDefault as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>}
rules={rules}
render={({ field: { onChange, value } }) =>
<FabTextEditor onChange={onChange}
content={value}
limit={limit}
paragraphTools={paragraphTools}
video={video}
image={image}
readOnly={isDisabled}
ref={textEditorRef} />
} />
</AbstractFormItem>
);
};

View File

@ -1,23 +1,20 @@
import React, { ReactNode } from 'react';
import React, { useEffect } from 'react';
import Select from 'react-select';
import CreatableSelect from 'react-select/creatable';
import { Controller, Path } from 'react-hook-form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { FieldPath } from 'react-hook-form/dist/types/path';
import { FieldPathValue, UnpackNestedValue } from 'react-hook-form/dist/types';
import { FormControlledComponent } from '../../models/form-component';
import { AbstractFormItem, AbstractFormItemProps } from './abstract-form-item';
interface FormSelectProps<TFieldValues, TContext extends object, TOptionValue> extends FormControlledComponent<TFieldValues, TContext> {
id: string,
label?: string,
tooltip?: ReactNode,
interface FormSelectProps<TFieldValues, TContext extends object, TOptionValue> extends FormControlledComponent<TFieldValues, TContext>, AbstractFormItemProps<TFieldValues> {
options: Array<selectOption<TOptionValue>>,
valueDefault?: TOptionValue,
onChange?: (value: TOptionValue) => void,
className?: string,
placeholder?: string,
disabled?: boolean,
readOnly?: boolean,
clearable?: boolean,
creatable?: boolean,
}
/**
@ -29,14 +26,16 @@ type selectOption<TOptionValue> = { value: TOptionValue, label: string };
/**
* This component is a wrapper for react-select to use with react-hook-form
*/
export const FormSelect = <TFieldValues extends FieldValues, TContext extends object, TOptionValue>({ id, label, tooltip, className, control, placeholder, options, valueDefault, error, rules, disabled, onChange, readOnly, clearable }: FormSelectProps<TFieldValues, TContext, TOptionValue>) => {
const classNames = [
'form-select form-item',
`${className || ''}`,
`${error && error[id] ? 'is-incorrect' : ''}`,
`${rules && rules.required ? 'is-required' : ''}`,
`${disabled ? 'is-disabled' : ''}`
].join(' ');
export const FormSelect = <TFieldValues extends FieldValues, TContext extends object, TOptionValue>({ id, label, tooltip, className, control, placeholder, options, valueDefault, error, warning, rules, disabled = false, onChange, readOnly = false, clearable = false, formState, creatable = false }: FormSelectProps<TFieldValues, TContext, TOptionValue>) => {
const [isDisabled, setIsDisabled] = React.useState<boolean>(false);
useEffect(() => {
if (typeof disabled === 'function') {
setIsDisabled(disabled(id) || readOnly);
} else {
setIsDisabled(disabled || readOnly);
}
}, [disabled]);
/**
* The following callback will trigger the onChange callback, if it was passed to this component,
@ -48,35 +47,32 @@ export const FormSelect = <TFieldValues extends FieldValues, TContext extends ob
}
};
// if the user can create new options, we need to use a different component
const AbstractSelect = creatable ? CreatableSelect : Select;
return (
<label className={classNames}>
{label && <div className="form-item-header">
<p>{label}</p>
{tooltip && <div className="item-tooltip">
<span className="trigger"><i className="fa fa-question-circle" /></span>
<div className="content">{tooltip}</div>
</div>}
</div>}
<div className="form-item-field">
<Controller name={id as FieldPath<TFieldValues>}
control={control}
defaultValue={valueDefault as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>}
render={({ field: { onChange, value, ref } }) =>
<Select ref={ref}
classNamePrefix="rs"
className="rs"
value={options.find(c => c.value === value)}
onChange={val => {
onChangeCb(val.value);
onChange(val.value);
}}
placeholder={placeholder}
isDisabled={readOnly}
isClearable={clearable}
options={options} />
} />
</div>
{(error && error[id]) && <div className="form-item-error">{error[id].message}</div> }
</label>
<AbstractFormItem id={id} label={label} tooltip={tooltip}
className={`form-select ${className || ''}`} formState={formState}
error={error} warning={warning} rules={rules}
disabled={disabled} readOnly={readOnly}>
<Controller name={id as FieldPath<TFieldValues>}
control={control}
defaultValue={valueDefault as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>}
rules={rules}
render={({ field: { onChange, value, ref } }) =>
<AbstractSelect ref={ref}
classNamePrefix="rs"
className="rs"
value={options.find(c => c.value === value)}
onChange={val => {
onChangeCb(val.value);
onChange(val.value);
}}
placeholder={placeholder}
isDisabled={isDisabled}
isClearable={clearable}
options={options} />
} />
</AbstractFormItem>
);
};

View File

@ -0,0 +1,51 @@
import React from 'react';
import { FormControlledComponent } from '../../models/form-component';
import { FieldPath } from 'react-hook-form/dist/types/path';
import { FieldPathValue, UnpackNestedValue } from 'react-hook-form/dist/types';
import { Controller, Path } from 'react-hook-form';
import Switch from 'react-switch';
import { AbstractFormItem, AbstractFormItemProps } from './abstract-form-item';
interface FormSwitchProps<TFieldValues, TContext extends object> extends FormControlledComponent<TFieldValues, TContext>, AbstractFormItemProps<TFieldValues> {
defaultValue?: boolean,
onChange?: (value: boolean) => void,
}
/**
* This component is a wrapper for react-switch, to use with react-hook-form.
*/
export const FormSwitch = <TFieldValues, TContext extends object>({ id, label, tooltip, className, error, rules, disabled, control, defaultValue, formState, readOnly, warning, onChange }: FormSwitchProps<TFieldValues, TContext>) => {
/**
* The following callback will trigger the onChange callback, if it was passed to this component,
* when the selected option changes.
*/
const onChangeCb = (newValue: boolean): void => {
if (typeof onChange === 'function') {
onChange(newValue);
}
};
return (
<AbstractFormItem id={id} formState={formState} label={label}
className={`form-switch ${className || ''}`} tooltip={tooltip}
disabled={disabled} readOnly={readOnly}
rules={rules} error={error} warning={warning}>
<Controller name={id as FieldPath<TFieldValues>}
control={control}
defaultValue={defaultValue as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>}
rules={rules}
render={({ field: { onChange, value, ref } }) =>
<Switch onChange={val => {
onChange(val);
onChangeCb(val);
}}
checked={value as boolean || false}
height={19}
width={40}
ref={ref}
disabled={typeof disabled === 'function' ? disabled(id) : disabled}
readOnly={readOnly} />
} />
</AbstractFormItem>
);
};

View File

@ -42,7 +42,7 @@ export const RequiredTrainingModal: React.FC<RequiredTrainingModalProps> = ({ is
const header = (): ReactNode => {
return (
<div className="user-info">
<Avatar user={user} />
<Avatar userName={user?.name} avatar={user?.profile_attributes?.user_avatar_attributes?.attachment} />
<span className="user-name">{user?.name}</span>
</div>
);

View File

@ -7,7 +7,7 @@ import {
import React, { ReactElement, useState } from 'react';
import { FabButton } from '../base/fab-button';
import { useTranslation } from 'react-i18next';
import { User, UserRole } from '../../models/user';
import { User } from '../../models/user';
import PaymentScheduleAPI from '../../api/payment-schedule';
import { FabModal } from '../base/fab-modal';
import FormatLib from '../../lib/format';
@ -58,7 +58,7 @@ export const PaymentScheduleItemActions: React.FC<PaymentScheduleItemActionsProp
* Check if the current operator has administrative rights or is a normal member
*/
const isPrivileged = (): boolean => {
return (operator.role === UserRole.Admin || operator.role === UserRole.Manager);
return (operator.role === 'admin' || operator.role === 'manager');
};
/**

View File

@ -9,6 +9,7 @@ import { User } from '../../models/user';
import { PaymentSchedule } from '../../models/payment-schedule';
import { IApplication } from '../../models/application';
import PaymentScheduleAPI from '../../api/payment-schedule';
import { TDateISODate } from '../../typings/date-iso';
declare const Application: IApplication;
@ -36,7 +37,7 @@ const PaymentSchedulesList: React.FC<PaymentSchedulesListProps> = ({ currentUser
// current filter, by customer's name, for the schedules
const [customerFilter, setCustomerFilter] = useState<string>(null);
// current filter, by date, for the schedules and the deadlines
const [dateFilter, setDateFilter] = useState<Date>(null);
const [dateFilter, setDateFilter] = useState<TDateISODate>(null);
/**
* Fetch from the API the payments schedules matching the given filters and reset the results table with the new schedules.

View File

@ -3,11 +3,11 @@ import { useTranslation } from 'react-i18next';
import PlanCategoryAPI from '../../api/plan-category';
import { PlanCategory } from '../../models/plan-category';
import { Loader } from '../base/loader';
import { useForm, Controller, SubmitHandler } from 'react-hook-form';
import { FabTextEditor } from '../base/text-editor/fab-text-editor';
import { useForm, SubmitHandler } from 'react-hook-form';
import { FormInput } from '../form/form-input';
import { FabAlert } from '../base/fab-alert';
import { FabButton } from '../base/fab-button';
import { FormRichText } from '../form/form-rich-text';
interface PlanCategoryFormProps {
action: 'create' | 'update',
@ -47,9 +47,7 @@ const PlanCategoryFormComponent: React.FC<PlanCategoryFormProps> = ({ action, ca
<form onSubmit={handleSubmit(onSubmit)}>
<FormInput id='name' register={register} rules={{ required: 'true' }} label={t('app.admin.manage_plan_category.name')} />
<Controller name="description" control={control} render={({ field: { onChange, value } }) =>
<FabTextEditor label={t('app.admin.manage_plan_category.description')} onChange={onChange} content={value} limit={100} />
} />
<FormRichText control={control} id="description" label={t('app.admin.manage_plan_category.description')} limit={100} />
<FormInput id='weight' register={register} type='number' label={t('app.admin.manage_plan_category.significance')} />
<FabAlert level="info" className="significance-info">

View File

@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import moment from 'moment';
import _ from 'lodash';
import { Plan } from '../../models/plan';
import { User, UserRole } from '../../models/user';
import { User } from '../../models/user';
import { Loader } from '../base/loader';
import '../../lib/i18n';
import FormatLib from '../../lib/format';
@ -52,13 +52,13 @@ const PlanCardComponent: React.FC<PlanCardProps> = ({ plan, userId, subscribedPl
* Check if the user can subscribe to the current plan, for himself
*/
const canSubscribeForMe = (): boolean => {
return operator?.role === UserRole.Member || (operator?.role === UserRole.Manager && userId === operator?.id);
return operator?.role === 'member' || (operator?.role === 'manager' && userId === operator?.id);
};
/**
* Check if the user can subscribe to the current plan, for someone else
*/
const canSubscribeForOther = (): boolean => {
return operator?.role === UserRole.Admin || (operator?.role === UserRole.Manager && userId !== operator?.id);
return operator?.role === 'admin' || (operator?.role === 'manager' && userId !== operator?.id);
};
/**
* Check it the user has subscribed to this plan or not

View File

@ -0,0 +1,70 @@
import React, { useEffect } from 'react';
import { ActiveProviderResponse } from '../../models/authentication-provider';
import { useTranslation } from 'react-i18next';
import { User } from '../../models/user';
import { Loader } from '../base/loader';
import { react2angular } from 'react2angular';
import { IApplication } from '../../models/application';
import SettingAPI from '../../api/setting';
import { SettingName } from '../../models/setting';
import UserLib from '../../lib/user';
declare const Application: IApplication;
interface CompletionHeaderInfoProps {
user: User,
activeProvider: ActiveProviderResponse,
onError: (message: string) => void,
}
/**
* This component will show an information message, on the profile completion page.
*/
export const CompletionHeaderInfo: React.FC<CompletionHeaderInfoProps> = ({ user, activeProvider, onError }) => {
const { t } = useTranslation('logged');
const [settings, setSettings] = React.useState<Map<SettingName, string>>(null);
const userLib = new UserLib(user);
useEffect(() => {
SettingAPI.query([SettingName.NameGenre, SettingName.FablabName]).then(setSettings).catch(onError);
}, []);
return (
<div className="completion-header-info">
{activeProvider?.providable_type === 'DatabaseProvider' && <div className="header-info--local-database">
<p>{t('app.logged.profile_completion.completion_header_info.rules_changed')}</p>
</div>}
{activeProvider?.providable_type !== 'DatabaseProvider' && <div className="header-info--sso">
<p className="intro">
<span>
{t('app.logged.profile_completion.completion_header_info.sso_intro', {
GENDER: settings?.get(SettingName.NameGenre),
NAME: settings?.get(SettingName.FablabName)
})}
</span>
<span className="provider-name">
{activeProvider?.name}
{userLib.ssoEmail() && <span className="user-email">({ userLib.ssoEmail() })</span>}
</span>
</p>
{userLib.hasDuplicate() && <p className="duplicate-email-info">
{t('app.logged.profile_completion.completion_header_info.duplicate_email_info')}
</p>}
{!userLib.hasDuplicate() && <p className="details-needed-info">
{t('app.logged.profile_completion.completion_header_info.details_needed_info')}
</p>}
</div>}
</div>
);
};
const CompletionHeaderInfoWrapper: React.FC<CompletionHeaderInfoProps> = (props) => {
return (
<Loader>
<CompletionHeaderInfo {...props} />
</Loader>
);
};
Application.Components.component('completionHeaderInfo', react2angular(CompletionHeaderInfoWrapper, ['user', 'activeProvider', 'onError']));

View File

@ -0,0 +1,88 @@
import React from 'react';
import { User } from '../../models/user';
import { IApplication } from '../../models/application';
import { Loader } from '../base/loader';
import { react2angular } from 'react2angular';
import { ActiveProviderResponse } from '../../models/authentication-provider';
import { useTranslation } from 'react-i18next';
import { HtmlTranslate } from '../base/html-translate';
import { UserProfileForm } from '../user/user-profile-form';
import UserLib from '../../lib/user';
import { FabButton } from '../base/fab-button';
import Authentication from '../../api/authentication';
declare const Application: IApplication;
interface ProfileFormOptionProps {
user: User,
activeProvider: ActiveProviderResponse,
onError: (message: string) => void,
onSuccess: (user: User) => void,
}
export const ProfileFormOption: React.FC<ProfileFormOptionProps> = ({ user, activeProvider, onError, onSuccess }) => {
const { t } = useTranslation('logged');
const userLib = new UserLib(user);
/**
* Route the current user to the interface provided by the authentication provider, to update his profile.
*/
const redirectToSsoProfile = (): void => {
window.open(activeProvider.link_to_sso_profile, '_blank');
};
/**
* Disconnect and re-connect the user to the SSO to force the synchronisation of the profile's data
*/
function syncProfile () {
Authentication.logout().then(() => {
window.location.href = activeProvider.link_to_sso_connect;
}).catch(onError);
}
return (
<div className="profile-form-option">
<h3>{t('app.logged.profile_completion.profile_form_option.title')}</h3>
{!userLib.hasDuplicate() && <div className="normal-flow">
<p>{t('app.logged.profile_completion.profile_form_option.please_fill')}</p>
<p className="disabled-fields-info">{t('app.logged.profile_completion.profile_form_option.disabled_data_from_sso', { NAME: activeProvider?.name })}</p>
<p className="confirm-instructions">
<HtmlTranslate trKey="app.logged.profile_completion.profile_form_option.confirm_instructions_html" />
</p>
<UserProfileForm onError={onError}
action="update"
user={user}
onSuccess={onSuccess}
size="small"
showGroupInput
showTermsAndConditionsInput />
</div>}
{userLib.hasDuplicate() && <div className="duplicate-email">
<p className="duplicate-info">
<HtmlTranslate trKey="app.logged.profile_completion.profile_form_option.duplicate_email_html"
options={{ EMAIL: userLib.ssoEmail(), PROVIDER: activeProvider?.name }} />
</p>
<FabButton onClick={redirectToSsoProfile} icon={<i className="fa fa-edit"/>}>
{t('app.logged.profile_completion.profile_form_option.edit_profile')}
</FabButton>
<p className="after-edition-info">
<HtmlTranslate trKey="app.logged.profile_completion.profile_form_option.after_edition_info_html" />
</p>
<FabButton onClick={syncProfile} icon={<i className="fa fa-refresh"/>}>
{t('app.logged.profile_completion.profile_form_option.sync_profile')}
</FabButton>
</div>}
</div>
);
};
const ProfileFormOptionWrapper: React.FC<ProfileFormOptionProps> = (props) => {
return (
<Loader>
<ProfileFormOption {...props} />
</Loader>
);
};
Application.Components.component('profileFormOption', react2angular(ProfileFormOptionWrapper, ['user', 'activeProvider', 'onError', 'onSuccess']));

View File

@ -0,0 +1,79 @@
import React, { useState, useReducer } from 'react';
import { FormState, UseFormRegister, UseFormSetValue } from 'react-hook-form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { User } from '../../models/user';
import { SocialNetwork } from '../../models/social-network';
import Icons from '../../../../images/social-icons.svg';
import { FormInput } from '../form/form-input';
import { Trash } from 'phosphor-react';
import { useTranslation } from 'react-i18next';
interface EditSocialsProps<TFieldValues> {
register: UseFormRegister<TFieldValues>,
setValue: UseFormSetValue<User>,
networks: SocialNetwork[],
formState: FormState<TFieldValues>,
disabled: boolean|((id: string) => boolean),
}
export const EditSocials = <TFieldValues extends FieldValues>({ register, setValue, networks, formState, disabled }: EditSocialsProps<TFieldValues>) => {
const { t } = useTranslation('shared');
// regular expression to validate the the input fields
const urlRegex = /^(https?:\/\/)([\da-z.-]+)\.([-a-z\d.]{2,30})([/\w .-]*)*\/?$/;
const initSelectedNetworks = networks.filter(el => !['', null, undefined].includes(el.url));
const [selectedNetworks, setSelectedNetworks] = useState(initSelectedNetworks);
const selectNetwork = (network) => {
setSelectedNetworks([...selectedNetworks, network]);
};
const reducer = (state, action) => {
switch (action.type) {
case 'delete':
setSelectedNetworks(selectedNetworks.filter(el => el !== action.payload.network));
setValue(action.payload.field, '');
return state.map(el => el === action.payload.network
? { ...el, url: '' }
: el);
case 'update':
return state.map(el => el === action.payload
? { ...el, url: action.payload.url }
: el);
default:
return state;
}
};
const [userNetworks, dispatch] = useReducer(reducer, networks);
return (
<>
<div className='social-icons'>
{userNetworks.map((network, index) =>
!selectedNetworks.includes(network) && <img key={index} src={`${Icons}#${network.name}`} onClick={() => selectNetwork(network)}></img>
)}
</div>
{selectNetwork.length && <div className='social-inputs'>
{userNetworks.map((network, index) =>
selectedNetworks.includes(network) &&
<FormInput key={index}
id={`profile_attributes.${network.name}`}
register={register}
rules= {{
pattern: {
value: urlRegex,
message: t('app.shared.user_profile_form.website_invalid')
}
}}
formState={formState}
defaultValue={network.url}
label={network.name}
disabled={disabled}
placeholder={t('app.shared.text_editor.url_placeholder')}
icon={<img src={`${Icons}#${network.name}`}></img>}
addOn={<Trash size={16} />}
addOnAction={() => dispatch({ type: 'delete', payload: { network, field: `profile_attributes.${network.name}` } })} />
)}
</div>}
</>
);
};

View File

@ -0,0 +1,122 @@
import React, { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { FormInput } from '../form/form-input';
import SettingAPI from '../../api/setting';
import { supportedNetworks } from '../../models/social-network';
import { IApplication } from '../../models/application';
import { Loader } from '../base/loader';
import { react2angular } from 'react2angular';
import { SettingName } from '../../models/setting';
import Icons from '../../../../images/social-icons.svg';
import { Trash } from 'phosphor-react';
import { useTranslation } from 'react-i18next';
import { FabButton } from '../base/fab-button';
declare const Application: IApplication;
interface FabSocialsProps {
show: boolean,
onError: (message: string) => void,
onSuccess: (message: string) => void
}
export const FabSocials: React.FC<FabSocialsProps> = ({ show = false, onError, onSuccess }) => {
const { t } = useTranslation('shared');
// regular expression to validate the the input fields
const urlRegex = /^(https?:\/\/)([\da-z.-]+)\.([-a-z\d.]{2,30})([/\w .-]*)*\/?$/;
const { handleSubmit, register, setValue, formState } = useForm();
const settingsList = supportedNetworks.map(el => el as SettingName);
const [fabNetworks, setFabNetworks] = useState([]);
const [selectedNetworks, setSelectedNetworks] = useState([]);
useEffect(() => {
SettingAPI.query(settingsList).then(res => {
setFabNetworks(Array.from(res, ([name, url]) => ({ name, url })));
}).catch(error => console.error(error));
}, []);
useEffect(() => {
setSelectedNetworks(fabNetworks.filter(el => el.url !== ''));
}, [fabNetworks]);
const onSubmit = (data) => {
const updatedNetworks = new Map<SettingName, string>();
Object.keys(data).forEach(key => updatedNetworks.set(key as SettingName, data[key]));
SettingAPI.bulkUpdate(updatedNetworks).then(res => {
const errorResults = Array.from(res.values()).filter(item => !item.status);
if (errorResults.length > 0) {
onError(t('app.shared.fab_socials.networks_update_error'));
} else {
onSuccess(t('app.shared.fab_socials.networks_update_success'));
}
});
};
const selectNetwork = (network) => {
setSelectedNetworks([...selectedNetworks, network]);
};
const remove = (network) => {
setSelectedNetworks(selectedNetworks.filter(el => el !== network));
setValue(network.name, '');
};
return (
<>{show
? <div className='social-icons'>
{fabNetworks.map((network, index) =>
selectedNetworks.includes(network) &&
<a key={index} href={network.url} target='_blank' rel="noreferrer">
<img src={`${Icons}#${network.name}`}></img>
</a>
)}
</div>
: <form onSubmit={handleSubmit(onSubmit)}>
<div className='social-icons'>
{fabNetworks.map((network, index) =>
!selectedNetworks.includes(network) &&
<img key={index} src={`${Icons}#${network.name}`} onClick={() => selectNetwork(network)}></img>
)}
</div>
{selectNetwork.length && <div className='social-inputs'>
{fabNetworks.map((network, index) =>
selectedNetworks.includes(network) &&
<FormInput id={network.name}
key={index}
register={register}
rules={{
pattern: {
value: urlRegex,
message: t('app.shared.user_profile_form.website_invalid')
}
}}
formState={formState}
defaultValue={network.url}
label={network.name}
placeholder={t('app.shared.fab_socials.url_placeholder')}
icon={<img src={`${Icons}#${network.name}`}></img>}
addOn={<Trash size={16} />}
addOnAction={() => remove(network)} />
)}
</div>}
<FabButton type='submit'
className='btn-warning'>
{t('app.shared.buttons.save')}
</FabButton>
</form>
}</>
);
};
const FabSocialsWrapper: React.FC<FabSocialsProps> = (props) => {
return (
<Loader>
<FabSocials {...props} />
</Loader>
);
};
Application.Components.component('fabSocials', react2angular(FabSocialsWrapper, ['show', 'onError', 'onSuccess']));

View File

@ -10,6 +10,7 @@ import { react2angular } from 'react2angular';
import { IApplication } from '../../models/application';
import LocalPaymentAPI from '../../api/local-payment';
import { PaymentMethod } from '../../models/payment';
import { TDateISO } from '../../typings/date-iso';
declare const Application: IApplication;
@ -46,7 +47,7 @@ const FreeExtendModal: React.FC<FreeExtendModalProps> = ({ isOpen, toggleModal,
/**
* Return the formatted localized date for the given date
*/
const formatDateTime = (date: Date): string => {
const formatDateTime = (date: TDateISO): string => {
return t('app.admin.free_extend_modal.DATE_TIME', { DATE: FormatLib.date(date), TIME: FormatLib.time(date) });
};

View File

@ -18,6 +18,7 @@ import { PaymentScheduleSummary } from '../payment-schedule/payment-schedule-sum
import { PaymentSchedule } from '../../models/payment-schedule';
import { LocalPaymentModal } from '../payment/local-payment/local-payment-modal';
import { User } from '../../models/user';
import { TDateISO } from '../../typings/date-iso';
declare const Application: IApplication;
@ -83,7 +84,7 @@ const RenewModal: React.FC<RenewModalProps> = ({ isOpen, toggleModal, subscripti
/**
* Return the formatted localized date for the given date
*/
const formatDateTime = (date: Date): string => {
const formatDateTime = (date: Date|TDateISO): string => {
return t('app.admin.free_extend_modal.DATE_TIME', { DATE: FormatLib.date(date), TIME: FormatLib.time(date) });
};

View File

@ -0,0 +1,89 @@
import React, { useState } from 'react';
import { FabButton } from '../base/fab-button';
import { Path, UseFormRegister } from 'react-hook-form';
import { UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form';
import { FieldPathValue } from 'react-hook-form/dist/types/path';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { FormInput } from '../form/form-input';
import { Avatar } from './avatar';
interface AvatarInputProps<TFieldValues> {
register: UseFormRegister<TFieldValues>,
setValue: UseFormSetValue<TFieldValues>,
currentAvatar: string,
userName: string,
size?: 'small' | 'large'
}
/**
* This component allows to set the user's avatar, in forms managed by react-hook-form.
*/
export const AvatarInput = <TFieldValues extends FieldValues>({ currentAvatar, userName, register, setValue, size }: AvatarInputProps<TFieldValues>) => {
const [avatar, setAvatar] = useState<string|ArrayBuffer>(currentAvatar);
/**
* Check if the provided user has a configured avatar
*/
const hasAvatar = (): boolean => {
return !!avatar;
};
/**
* Callback triggered when the user starts to select a file.
*/
const onAddAvatar = (): void => {
setValue(
'profile_attributes.user_avatar_attributes._destroy' as Path<TFieldValues>,
false as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
);
};
/**
* Callback triggered when the user clicks on the delete button.
*/
function onRemoveAvatar () {
setValue(
'profile_attributes.user_avatar_attributes._destroy' as Path<TFieldValues>,
true as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
);
setAvatar(null);
}
/**
* Callback triggered when the user has ended its selection of a file (or when the selection has been cancelled).
*/
function onFileSelected (event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target?.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (): void => {
setAvatar(reader.result);
};
reader.readAsDataURL(file);
} else {
setAvatar(null);
}
}
return (
<div className={`avatar-input avatar-input--${size}`}>
<Avatar avatar={avatar} userName={userName} size="large" />
<div className="buttons">
<FabButton onClick={onAddAvatar} className="select-button">
{!hasAvatar() && <span>Add an avatar</span>}
{hasAvatar() && <span>Change</span>}
<FormInput className="avatar-file-input"
type="file"
accept="image/*"
register={register}
id="profile_attributes.user_avatar_attributes.attachment_files"
onChange={onFileSelected}/>
</FabButton>
{hasAvatar() && <FabButton onClick={onRemoveAvatar} icon={<i className="fa fa-trash-o"/>} className="delete-avatar" />}
<FormInput register={register}
id="profile_attributes.user_avatar_attributes.id"
type="hidden" />
</div>
</div>
);
};

View File

@ -1,28 +1,25 @@
import React from 'react';
import { User } from '../../models/user';
import noAvatar from '../../../../images/no_avatar.png';
interface AvatarProps {
user: User,
avatar?: string | ArrayBuffer,
userName: string,
className?: string,
size?: 'small' | 'large',
}
/**
* This component renders the user-profile's picture or a placeholder.
*/
export const Avatar: React.FC<AvatarProps> = ({ user, className }) => {
/**
* Check if the provided user has a configured avatar
*/
const hasAvatar = (): boolean => {
return !!user?.profile?.user_avatar?.attachment_url;
};
export const Avatar: React.FC<AvatarProps> = ({ avatar, className, userName, size }) => {
return (
<div className={`avatar ${className || ''}`}>
{!hasAvatar() && <img src={noAvatar} alt="avatar placeholder"/>}
{hasAvatar() && <img src={user.profile.user_avatar.attachment_url} alt="user's avatar"/>}
<div className={`avatar avatar--${size} ${className || ''}`}>
<img src={avatar || noAvatar} alt={userName} />
</div>
);
};
Avatar.defaultProps = {
size: 'small'
};

View File

@ -0,0 +1,101 @@
import React, { useEffect } from 'react';
import { FabButton } from '../base/fab-button';
import { FabModal } from '../base/fab-modal';
import { FormInput } from '../form/form-input';
import { useForm, UseFormRegister } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import Authentication from '../../api/authentication';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { PasswordInput } from './password-input';
import { FormState } from 'react-hook-form/dist/types/form';
import MemberAPI from '../../api/member';
interface ChangePasswordProp<TFieldValues> {
register: UseFormRegister<TFieldValues>,
onError: (message: string) => void,
currentFormPassword: string,
formState: FormState<TFieldValues>,
}
/**
* This component shows a button that trigger a modal dialog to verify the user's current password.
* If the user's current password is correct, the modal dialog is closed and the button is replaced by a form to set the new password.
*/
export const ChangePassword = <TFieldValues extends FieldValues>({ register, onError, currentFormPassword, formState }: ChangePasswordProp<TFieldValues>) => {
const { t } = useTranslation('shared');
const [isModalOpen, setIsModalOpen] = React.useState<boolean>(false);
const [isConfirmedPassword, setIsConfirmedPassword] = React.useState<boolean>(false);
const [isPrivileged, setIsPrivileged] = React.useState<boolean>(false);
const { handleSubmit, register: passwordRegister } = useForm<{ password: string }>();
useEffect(() => {
MemberAPI.current().then(user => {
setIsPrivileged(user.role === 'admin' || user.role === 'manager');
}).catch(error => onError(error));
}, []);
/**
* Opens/closes the dialog asking to confirm the current password before changing it.
*/
const toggleConfirmationModal = () => {
setIsModalOpen(!isModalOpen);
};
/**
* Callback triggered when the user clicks on the "change my password" button
*/
const handleChangePasswordRequested = () => {
if (isPrivileged) {
setIsConfirmedPassword(true);
} else {
toggleConfirmationModal();
}
};
/**
* Callback triggered when the user confirms his current password.
*/
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
if (event) {
event.stopPropagation();
event.preventDefault();
}
return handleSubmit((data: { password: string }) => {
Authentication.verifyPassword(data.password).then(res => {
if (res) {
setIsConfirmedPassword(true);
toggleConfirmationModal();
} else {
onError(t('app.shared.change_password.wrong_password'));
}
}).catch(err => {
onError(err);
});
})(event);
};
return (
<div className="change-password">
{!isConfirmedPassword && <FabButton onClick={() => handleChangePasswordRequested()}>
{t('app.shared.change_password.change_my_password')}
</FabButton>}
{isConfirmedPassword && <div className="password-fields">
<PasswordInput register={register} currentFormPassword={currentFormPassword} formState={formState} />
</div>}
<FabModal isOpen={isModalOpen} toggleModal={toggleConfirmationModal} title={t('app.shared.change_password.change_my_password')} closeButton>
<form onSubmit={onSubmit}>
<FormInput id="password"
type="password"
register={passwordRegister}
rules={{ required: true }}
label={t('app.shared.change_password.confirm_current')} />
<FabButton type="submit">
{t('app.shared.change_password.confirm')}
</FabButton>
</form>
</FabModal>
</div>
);
};

View File

@ -0,0 +1,46 @@
import React, { useEffect } from 'react';
import { UseFormRegister } from 'react-hook-form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { FieldPath } from 'react-hook-form/dist/types/path';
import { useTranslation } from 'react-i18next';
interface GenderInputProps<TFieldValues> {
register: UseFormRegister<TFieldValues>,
disabled?: boolean|((id: string) => boolean),
}
/**
* Input component to set the gender for the user
*/
export const GenderInput = <TFieldValues extends FieldValues>({ register, disabled = false }: GenderInputProps<TFieldValues>) => {
const { t } = useTranslation('shared');
const [isDisabled, setIsDisabled] = React.useState<boolean>(false);
useEffect(() => {
if (typeof disabled === 'function') {
setIsDisabled(disabled('statistic_profile_attributes.gender'));
} else {
setIsDisabled(disabled);
}
}, [disabled]);
return (
<div className="gender-input">
<label>
<p>{t('app.shared.gender_input.man')}</p>
<input type="radio"
value="true"
disabled={isDisabled}
{...register('statistic_profile_attributes.gender' as FieldPath<TFieldValues>)} />
</label>
<label>
<p>{t('app.shared.gender_input.woman')}</p>
<input type="radio"
value="false"
disabled={isDisabled}
{...register('statistic_profile_attributes.gender' as FieldPath<TFieldValues>)} />
</label>
</div>
);
};

View File

@ -0,0 +1,51 @@
import React from 'react';
import { UseFormRegister } from 'react-hook-form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { useTranslation } from 'react-i18next';
import { FormInput } from '../form/form-input';
import { FormState } from 'react-hook-form/dist/types/form';
interface PasswordInputProps<TFieldValues> {
register: UseFormRegister<TFieldValues>,
currentFormPassword: string,
formState: FormState<TFieldValues>,
}
/**
* Passwords inputs: new password and confirmation.
*/
export const PasswordInput = <TFieldValues extends FieldValues>({ register, currentFormPassword, formState }: PasswordInputProps<TFieldValues>) => {
const { t } = useTranslation('shared');
return (
<div className="password-input">
<FormInput id="password" register={register}
rules={{
required: true,
validate: (value: string) => {
if (value.length < 8) {
return t('app.shared.password_input.password_too_short') as string;
}
return true;
}
}}
formState={formState}
label={t('app.shared.password_input.new_password')}
type="password" />
<FormInput id="password_confirmation"
register={register}
rules={{
required: true,
validate: (value: string) => {
if (value !== currentFormPassword) {
return t('app.shared.password_input.confirmation_mismatch') as string;
}
return true;
}
}}
formState={formState}
label={t('app.shared.password_input.confirm_password')}
type="password" />
</div>
);
};

View File

@ -0,0 +1,365 @@
import React, { useEffect, useState } from 'react';
import { react2angular } from 'react2angular';
import { useForm, useWatch, ValidateResult } from 'react-hook-form';
import { isNil as _isNil } from 'lodash';
import { User, UserFieldMapping } from '../../models/user';
import { IApplication } from '../../models/application';
import { Loader } from '../base/loader';
import { FormInput } from '../form/form-input';
import { useTranslation } from 'react-i18next';
import { GenderInput } from './gender-input';
import { ChangePassword } from './change-password';
import { PasswordInput } from './password-input';
import { FormSwitch } from '../form/form-switch';
import { FormRichText } from '../form/form-rich-text';
import MemberAPI from '../../api/member';
import { AvatarInput } from './avatar-input';
import { FabButton } from '../base/fab-button';
import { EditSocials } from '../socials/edit-socials';
import UserLib from '../../lib/user';
import AuthProviderAPI from '../../api/auth-provider';
import { FormSelect } from '../form/form-select';
import GroupAPI from '../../api/group';
import CustomAssetAPI from '../../api/custom-asset';
import { CustomAsset, CustomAssetName } from '../../models/custom-asset';
import { HtmlTranslate } from '../base/html-translate';
import TrainingAPI from '../../api/training';
import TagAPI from '../../api/tag';
import { FormMultiSelect } from '../form/form-multi-select';
declare const Application: IApplication;
interface UserProfileFormProps {
action: 'create' | 'update',
size?: 'small' | 'large',
user: User,
className?: string,
onError: (message: string) => void,
onSuccess: (user: User) => void,
showGroupInput?: boolean,
showTermsAndConditionsInput?: boolean,
showTrainingsInput?: boolean,
showTagsInput?: boolean,
}
/**
* Option format, expected by react-select
* @see https://github.com/JedWatson/react-select
*/
type selectOption = { value: number, label: string };
/**
* Form component to create or update a user
*/
export const UserProfileForm: React.FC<UserProfileFormProps> = ({ action, size, user, className, onError, onSuccess, showGroupInput, showTermsAndConditionsInput, showTrainingsInput, showTagsInput }) => {
const { t } = useTranslation('shared');
// regular expression to validate the input fields
const phoneRegex = /^((00|\+)\d{2,3})?\d{4,14}$/;
const urlRegex = /^(https?:\/\/)([\da-z.-]+)\.([-a-z\d.]{2,30})([/\w .-]*)*\/?$/;
const { handleSubmit, register, control, formState, setValue } = useForm<User>({ defaultValues: { ...user } });
const output = useWatch<User>({ control });
const [isOrganization, setIsOrganization] = useState<boolean>(!_isNil(user.invoicing_profile_attributes.organization_attributes));
const [isLocalDatabaseProvider, setIsLocalDatabaseProvider] = useState<boolean>(false);
const [groups, setGroups] = useState<selectOption[]>([]);
const [termsAndConditions, setTermsAndConditions] = useState<CustomAsset>(null);
const [trainings, setTrainings] = useState<selectOption[]>([]);
const [tags, setTags] = useState<selectOption[]>([]);
useEffect(() => {
AuthProviderAPI.active().then(data => {
setIsLocalDatabaseProvider(data.providable_type === 'DatabaseProvider');
}).catch(error => onError(error));
if (showGroupInput) {
GroupAPI.index({ disabled: false, admins: user.role === 'admin' }).then(data => {
setGroups(buildOptions(data));
}).catch(error => onError(error));
}
if (showTermsAndConditionsInput) {
CustomAssetAPI.get(CustomAssetName.CguFile).then(setTermsAndConditions).catch(error => onError(error));
}
if (showTrainingsInput) {
TrainingAPI.index({ disabled: false }).then(data => {
setTrainings(buildOptions(data));
}).catch(error => onError(error));
}
if (showTagsInput) {
TagAPI.index().then(data => {
setTags(buildOptions(data));
}).catch(error => onError(error));
}
}, []);
/**
* Convert the provided array of items to the react-select format
*/
const buildOptions = (items: Array<{ id?: number, name: string }>): Array<selectOption> => {
return items.map(t => {
return { value: t.id, label: t.name };
});
};
/**
* Callback triggered when the form is submitted: process with the user creation or update.
*/
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
if (showTermsAndConditionsInput) {
// When the form is submitted, we consider that the user should have accepted the terms and conditions,
// so we mark the field as dirty, even if he doesn't touch it. Like that, the error message is displayed.
setValue('cgu', !!output.cgu, { shouldDirty: true, shouldTouch: true });
}
return handleSubmit((data: User) => {
MemberAPI[action](data)
.then(res => { onSuccess(res); })
.catch((error) => { onError(error); });
})(event);
};
/**
* Check if the given field path should be disabled
*/
const isDisabled = function (id: string) {
// never allows admins to change their group
if (id === 'group_id' && user.role === 'admin') {
return true;
}
// if the current provider is the local database, then all fields are enabled
if (isLocalDatabaseProvider) {
return false;
}
// if the current provider is not the local database, then fields are disabled based on their mapping status.
return user.mapped_from_sso?.includes(UserFieldMapping[id]);
};
/**
* Check if the user has accepted the terms and conditions
*/
const checkAcceptTerms = function (value: boolean): ValidateResult {
return value === true || (t('app.shared.user_profile_form.must_accept_terms') as string);
};
const userNetworks = new UserLib(user).getUserSocialNetworks();
return (
<form className={`user-profile-form user-profile-form--${size} ${className}`} onSubmit={onSubmit}>
<div className="avatar-group">
<AvatarInput currentAvatar={output.profile_attributes?.user_avatar_attributes?.attachment_url}
userName={`${output.profile_attributes?.first_name} ${output.profile_attributes?.last_name}`}
register={register}
setValue={setValue}
size={size} />
</div>
<div className="fields-group">
<div className="personnal-data">
<h4>{t('app.shared.user_profile_form.personal_data')}</h4>
<GenderInput register={register} disabled={isDisabled} />
<div className="names">
<FormInput id="profile_attributes.last_name"
register={register}
rules={{ required: true }}
disabled={isDisabled}
formState={formState}
label={t('app.shared.user_profile_form.surname')} />
<FormInput id="profile_attributes.first_name"
register={register}
rules={{ required: true }}
disabled={isDisabled}
formState={formState}
label={t('app.shared.user_profile_form.first_name')} />
</div>
<div className="birth-phone">
<FormInput id="statistic_profile_attributes.birthday"
register={register}
label={t('app.shared.user_profile_form.date_of_birth')}
disabled={isDisabled}
type="date" />
<FormInput id="profile_attributes.phone"
register={register}
rules={{
pattern: {
value: phoneRegex,
message: t('app.shared.user_profile_form.phone_number_invalid')
}
}}
disabled={isDisabled}
formState={formState}
label={t('app.shared.user_profile_form.phone_number')} />
</div>
<div className="address">
<FormInput id="invoicing_profile_attributes.address_attributes.id"
register={register}
type="hidden" />
<FormInput id="invoicing_profile_attributes.address_attributes.address"
register={register}
disabled={isDisabled}
label={t('app.shared.user_profile_form.address')} />
</div>
</div>
<div className="account-data">
<h4>{t('app.shared.user_profile_form.account_data')}</h4>
<FormInput id="username"
register={register}
rules={{ required: true }}
disabled={isDisabled}
formState={formState}
label={t('app.shared.user_profile_form.pseudonym')} />
<FormInput id="email"
register={register}
rules={{ required: true }}
disabled={isDisabled}
formState={formState}
label={t('app.shared.user_profile_form.email_address')} />
{isLocalDatabaseProvider && <div className="password">
{ action === 'update' && <ChangePassword register={register}
onError={onError}
currentFormPassword={output.password}
formState={formState} />}
{action === 'create' && <PasswordInput register={register}
currentFormPassword={output.password}
formState={formState} />}
</div>}
</div>
<div className="organization-data">
<h4>{t('app.shared.user_profile_form.organization_data')}</h4>
<FormSwitch control={control}
id="invoicing_profile_attributes.organization"
label={t('app.shared.user_profile_form.declare_organization')}
tooltip={t('app.shared.user_profile_form.declare_organization_help')}
defaultValue={isOrganization}
disabled={isDisabled('invoicing_profile_attributes.organization_attributes.name')}
onChange={setIsOrganization} />
{isOrganization && <div className="organization-fields">
<FormInput id="invoicing_profile_attributes.organization_attributes.id"
register={register}
type="hidden" />
<FormInput id="invoicing_profile_attributes.organization_attributes.name"
register={register}
rules={{ required: isOrganization }}
disabled={isDisabled}
formState={formState}
label={t('app.shared.user_profile_form.organization_name')} />
<FormInput id="invoicing_profile_attributes.organization_attributes.address_attributes.id"
register={register}
type="hidden" />
<FormInput id="invoicing_profile_attributes.organization_attributes.address_attributes.address"
register={register}
rules={{ required: isOrganization }}
disabled={isDisabled}
formState={formState}
label={t('app.shared.user_profile_form.organization_address')} />
</div>}
</div>
<div className="profile-data">
<h4>{t('app.shared.user_profile_form.profile_data')}</h4>
<div className="website-job">
<FormInput id="profile_attributes.website"
register={register}
rules={{
pattern: {
value: urlRegex,
message: t('app.shared.user_profile_form.website_invalid')
}
}}
placeholder="https://www.example.com"
disabled={isDisabled}
formState={formState}
label={t('app.shared.user_profile_form.website')} />
<FormInput id="profile_attributes.job"
register={register}
label={t('app.shared.user_profile_form.job')} />
</div>
<div className="interests-CAD">
<FormRichText control={control}
id="profile_attributes.interest"
disabled={isDisabled}
label={t('app.shared.user_profile_form.interests')} />
<FormRichText control={control}
disabled={isDisabled}
id="profile_attributes.software_mastered"
label={t('app.shared.user_profile_form.CAD_softwares_mastered')} />
</div>
</div>
<div className='account-networks'>
<h4>{t('app.shared.user_profile_form.account_networks')}</h4>
<EditSocials register={register}
disabled={isDisabled}
networks={userNetworks}
setValue={setValue}
formState={formState} />
</div>
<div className="preferences-data">
<h4>{t('app.shared.user_profile_form.preferences_data')}</h4>
<FormSwitch control={control}
id="is_allow_contact"
disabled={isDisabled}
label={t('app.shared.user_profile_form.allow_public_profile')}
tooltip={t('app.shared.user_profile_form.allow_public_profile_help')} />
<FormSwitch control={control}
id="is_allow_newsletter"
disabled={isDisabled}
label={t('app.shared.user_profile_form.allow_newsletter')}
tooltip={t('app.shared.user_profile_form.allow_newsletter_help')} />
</div>
{showGroupInput && <div className="group">
<FormSelect options={groups}
control={control}
id="group_id"
rules={{ required: true }}
disabled={isDisabled}
formState={formState}
label={t('app.shared.user_profile_form.group')} />
</div>}
{showTrainingsInput && <div className="trainings">
<FormMultiSelect control={control}
options={trainings}
formState={formState}
label={t('app.shared.user_profile_form.trainings')}
id="statistic_profile_attributes.training_ids" />
</div>}
{showTagsInput && <div className="tags">
<FormMultiSelect control={control}
options={tags}
formState={formState}
label={t('app.shared.user_profile_form.tags')}
id="tag_ids" />
</div>}
{showTermsAndConditionsInput && termsAndConditions && <div className="terms-and-conditions">
<FormSwitch control={control}
disabled={isDisabled}
id="cgu"
rules={{ validate: checkAcceptTerms }}
formState={formState}
label={<HtmlTranslate trKey="app.shared.user_profile_form.terms_and_conditions_html"
options={{ POLICY_URL: termsAndConditions.custom_asset_file_attributes.attachment_url }} />}
/>
</div>}
<div className="main-actions">
<FabButton type="submit" className="submit-button">{t('app.shared.user_profile_form.save')}</FabButton>
</div>
</div>
</form>
);
};
UserProfileForm.defaultProps = {
size: 'large',
showGroupInput: false,
showTrainingsInput: false,
showTermsAndConditionsInput: false,
showTagsInput: false
};
const UserProfileFormWrapper: React.FC<UserProfileFormProps> = (props) => {
return (
<Loader>
<UserProfileForm {...props} />
</Loader>
);
};
Application.Components.component('userProfileForm', react2angular(UserProfileFormWrapper, ['action', 'size', 'user', 'className', 'onError', 'onSuccess', 'showGroupInput', 'showTermsAndConditionsInput', 'showTagsInput', 'showTrainingsInput']));

View File

@ -344,14 +344,14 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state
});
// on tour end, save the status in database
uitour.on('ended', function () {
if (uitour.getStatus() === uitour.Status.ON && $scope.currentUser.profile.tours.indexOf('calendar') < 0) {
if (uitour.getStatus() === uitour.Status.ON && $scope.currentUser.profile_attributes.tours.indexOf('calendar') < 0) {
Member.completeTour({ id: $scope.currentUser.id }, { tour: 'calendar' }, function (res) {
$scope.currentUser.profile.tours = res.tours;
$scope.currentUser.profile_attributes.tours = res.tours;
});
}
});
// if the user has never seen the tour, show him now
if (settingsPromise.feature_tour_display !== 'manual' && $scope.currentUser.profile.tours.indexOf('calendar') < 0) {
if (settingsPromise.feature_tour_display !== 'manual' && $scope.currentUser.profile_attributes.tours.indexOf('calendar') < 0) {
uitour.start();
}
};
@ -369,8 +369,8 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state
* @return {string} 'male' or 'female'
*/
const getGender = function (user) {
if (user.statistic_profile) {
if (user.statistic_profile.gender === 'true') { return 'male'; } else { return 'female'; }
if (user.statistic_profile_attributes) {
if (user.statistic_profile_attributes.gender === 'true') { return 'male'; } else { return 'female'; }
} else { return 'other'; }
};

View File

@ -473,14 +473,14 @@ Application.Controllers.controller('AdminEventsController', ['$scope', '$state',
});
// on tour end, save the status in database
uitour.on('ended', function () {
if (uitour.getStatus() === uitour.Status.ON && $scope.currentUser.profile.tours.indexOf('events') < 0) {
if (uitour.getStatus() === uitour.Status.ON && $scope.currentUser.profile_attributes.tours.indexOf('events') < 0) {
Member.completeTour({ id: $scope.currentUser.id }, { tour: 'events' }, function (res) {
$scope.currentUser.profile.tours = res.tours;
$scope.currentUser.profile_attributes.tours = res.tours;
});
}
});
// if the user has never seen the tour, show him now
if (settingsPromise.feature_tour_display !== 'manual' && $scope.currentUser.profile.tours.indexOf('events') < 0) {
if (settingsPromise.feature_tour_display !== 'manual' && $scope.currentUser.profile_attributes.tours.indexOf('events') < 0) {
uitour.start();
}
};

View File

@ -1012,14 +1012,14 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
});
// on tour end, save the status in database
uitour.on('ended', function () {
if (uitour.getStatus() === uitour.Status.ON && $scope.currentUser.profile.tours.indexOf('invoices') < 0) {
if (uitour.getStatus() === uitour.Status.ON && $scope.currentUser.profile_attributes.tours.indexOf('invoices') < 0) {
Member.completeTour({ id: $scope.currentUser.id }, { tour: 'invoices' }, function (res) {
$scope.currentUser.profile.tours = res.tours;
$scope.currentUser.profile_attributes.tours = res.tours;
});
}
});
// if the user has never seen the tour, show him now
if (settings.feature_tour_display !== 'manual' && $scope.currentUser.profile.tours.indexOf('invoices') < 0) {
if (settings.feature_tour_display !== 'manual' && $scope.currentUser.profile_attributes.tours.indexOf('invoices') < 0) {
uitour.start();
}
};
@ -1197,7 +1197,7 @@ Application.Controllers.controller('AvoirModalController', ['$scope', '$uibModal
{ name: _t('app.admin.invoices.none'), value: 'none' },
{ name: _t('app.admin.invoices.by_cash'), value: 'cash' },
{ name: _t('app.admin.invoices.by_cheque'), value: 'cheque' },
{ name: _t('app.admin.invoices.by_transfer'), value: 'transfer' },
{ name: _t('app.admin.invoices.by_transfer'), value: 'transfer' }
];
if (Fablab.walletModule) {

View File

@ -569,14 +569,14 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
});
// on tour end, save the status in database
uitour.on('ended', function () {
if (uitour.getStatus() === uitour.Status.ON && $scope.currentUser.profile.tours.indexOf('members') < 0) {
if (uitour.getStatus() === uitour.Status.ON && $scope.currentUser.profile_attributes.tours.indexOf('members') < 0) {
Member.completeTour({ id: $scope.currentUser.id }, { tour: 'members' }, function (res) {
$scope.currentUser.profile.tours = res.tours;
$scope.currentUser.profile_attributes.tours = res.tours;
});
}
});
// if the user has never seen the tour, show him now
if (settingsPromise.feature_tour_display !== 'manual' && $scope.currentUser.profile.tours.indexOf('members') < 0) {
if (settingsPromise.feature_tour_display !== 'manual' && $scope.currentUser.profile_attributes.tours.indexOf('members') < 0) {
uitour.start();
}
};
@ -664,7 +664,7 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state',
$scope.tags = tagsPromise;
// The user to edit
$scope.user = memberPromise;
$scope.user = cleanUser(memberPromise);
// Should the password be modified?
$scope.password = { change: false };
@ -813,6 +813,14 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state',
growl.error(message);
};
/**
* Callback triggered when the user was successfully updated
*/
$scope.onUserSuccess = () => {
growl.success(_t('app.admin.members_edit.update_success'));
$state.go('app.admin.members');
};
$scope.createWalletCreditModal = function (user, wallet) {
const modalInstance = $uibModal.open({
animation: true,
@ -896,7 +904,7 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state',
CSRF.setMetaTags();
// init the birthdate to JS object
$scope.user.statistic_profile.birthday = moment($scope.user.statistic_profile.birthday).toDate();
$scope.user.statistic_profile_attributes.birthday = moment($scope.user.statistic_profile_attributes.birthday).toDate();
// the user subscription
if (($scope.user.subscribed_plan != null) && ($scope.user.subscription != null)) {
@ -914,6 +922,13 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state',
return new MembersController($scope, $state, Group, Training);
};
// prepare the user for the react-hook-form
function cleanUser (user) {
delete user.$promise;
delete user.$resolved;
return user;
}
// !!! MUST BE CALLED AT THE END of the controller
return initialize();
}
@ -922,8 +937,8 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state',
/**
* Controller used in the member's creation page (admin view)
*/
Application.Controllers.controller('NewMemberController', ['$scope', '$state', 'Member', 'Training', 'Group', 'CSRF', 'settingsPromise',
function ($scope, $state, Member, Training, Group, CSRF, settingsPromise) {
Application.Controllers.controller('NewMemberController', ['$scope', '$state', 'Member', 'Training', 'Group', 'CSRF', 'settingsPromise', 'growl', '_t',
function ($scope, $state, Member, Training, Group, CSRF, settingsPromise, growl, _t) {
CSRF.setMetaTags();
/* PUBLIC SCOPE */
@ -946,21 +961,36 @@ Application.Controllers.controller('NewMemberController', ['$scope', '$state', '
// Default member's profile parameters
$scope.user = {
plan_interval: '',
invoicing_profile: {},
statistic_profile: {}
invoicing_profile_attributes: {},
statistic_profile_attributes: {}
};
// Callback when the admin check/uncheck the box telling that the new user is an organization.
// Disable or enable the organization fields in the form, accordingly
$scope.toggleOrganization = function () {
if ($scope.user.organization) {
if (!$scope.user.invoicing_profile) { $scope.user.invoicing_profile = {}; }
$scope.user.invoicing_profile.organization = {};
if (!$scope.user.invoicing_profile_attributes) { $scope.user.invoicing_profile_attributes = {}; }
$scope.user.invoicing_profile_attributes.organization_attributes = {};
} else {
$scope.user.invoicing_profile.organization = undefined;
$scope.user.invoicing_profile_attributes.organization_attributes = undefined;
}
};
/**
* Callback triggered when the user was successfully updated
*/
$scope.onUserSuccess = () => {
growl.success(_t('app.admin.members_new.create_success'));
$state.go('app.admin.members');
};
/**
* Callback triggered in case of error
*/
$scope.onError = (message) => {
growl.error(message);
};
// Using the MembersController
return new MembersController($scope, $state, Group, Training);
}

View File

@ -142,14 +142,14 @@ Application.Controllers.controller('OpenAPIClientsController', ['$scope', 'clien
});
// on tour end, save the status in database
uitour.on('ended', function () {
if (uitour.getStatus() === uitour.Status.ON && $scope.currentUser.profile.tours.indexOf('open-api') < 0) {
if (uitour.getStatus() === uitour.Status.ON && $scope.currentUser.profile_attributes.tours.indexOf('open-api') < 0) {
Member.completeTour({ id: $scope.currentUser.id }, { tour: 'open-api' }, function (res) {
$scope.currentUser.profile.tours = res.tours;
$scope.currentUser.profile_attributes.tours = res.tours;
});
}
});
// if the user has never seen the tour, and if the display behavior is not configured to manual triggering only, show the tour now
if (settingsPromise.feature_tour_display !== 'manual' && $scope.currentUser.profile.tours.indexOf('open-api') < 0) {
if (settingsPromise.feature_tour_display !== 'manual' && $scope.currentUser.profile_attributes.tours.indexOf('open-api') < 0) {
uitour.start();
}
};

View File

@ -740,14 +740,14 @@ Application.Controllers.controller('EditPricingController', ['$scope', '$state',
});
// on tour end, save the status in database
uitour.on('ended', function () {
if (uitour.getStatus() === uitour.Status.ON && $scope.currentUser.profile.tours.indexOf('pricing') < 0) {
if (uitour.getStatus() === uitour.Status.ON && $scope.currentUser.profile_attributes.tours.indexOf('pricing') < 0) {
Member.completeTour({ id: $scope.currentUser.id }, { tour: 'pricing' }, function (res) {
$scope.currentUser.profile.tours = res.tours;
$scope.currentUser.profile_attributes.tours = res.tours;
});
}
});
// if the user has never seen the tour, show him now
if (settingsPromise.feature_tour_display !== 'manual' && $scope.currentUser.profile.tours.indexOf('pricing') < 0) {
if (settingsPromise.feature_tour_display !== 'manual' && $scope.currentUser.profile_attributes.tours.indexOf('pricing') < 0) {
uitour.start();
}
};

View File

@ -253,14 +253,14 @@ Application.Controllers.controller('AdminProjectsController', ['$scope', '$state
});
// on tour end, save the status in database
uitour.on('ended', function () {
if (uitour.getStatus() === uitour.Status.ON && $scope.currentUser.profile.tours.indexOf('projects') < 0) {
if (uitour.getStatus() === uitour.Status.ON && $scope.currentUser.profile_attributes.tours.indexOf('projects') < 0) {
Member.completeTour({ id: $scope.currentUser.id }, { tour: 'projects' }, function (res) {
$scope.currentUser.profile.tours = res.tours;
$scope.currentUser.profile_attributes.tours = res.tours;
});
}
});
// if the user has never seen the tour, show him now
if (settingsPromise.feature_tour_display !== 'manual' && $scope.currentUser.profile.tours.indexOf('projects') < 0) {
if (settingsPromise.feature_tour_display !== 'manual' && $scope.currentUser.profile_attributes.tours.indexOf('projects') < 0) {
uitour.start();
}
};

View File

@ -454,18 +454,26 @@ Application.Controllers.controller('SettingsController', ['$scope', '$rootScope'
});
// on tour end, save the status in database
uitour.on('ended', function () {
if (uitour.getStatus() === uitour.Status.ON && $scope.currentUser.profile.tours.indexOf('settings') < 0) {
if (uitour.getStatus() === uitour.Status.ON && $scope.currentUser.profile_attributes.tours.indexOf('settings') < 0) {
Member.completeTour({ id: $scope.currentUser.id }, { tour: 'settings' }, function (res) {
$scope.currentUser.profile.tours = res.tours;
$scope.currentUser.profile_attributes.tours = res.tours;
});
}
});
// if the user has never seen the tour, show him now
if ($scope.allSettings.feature_tour_display !== 'manual' && $scope.currentUser.profile.tours.indexOf('settings') < 0) {
if ($scope.allSettings.feature_tour_display !== 'manual' && $scope.currentUser.profile_attributes.tours.indexOf('settings') < 0) {
uitour.start();
}
};
$scope.onSuccess = function (message) {
growl.success(message);
};
$scope.onError = function (message) {
growl.error(message);
};
/* PRIVATE SCOPE */
/**
@ -522,10 +530,10 @@ Application.Controllers.controller('SettingsController', ['$scope', '$rootScope'
if (newValue === oldValue) return;
if (newValue === 'session') {
$scope.currentUser.profile.tours = Fablab.sessionTours;
$scope.currentUser.profile_attributes.tours = Fablab.sessionTours;
} else if (newValue === 'once') {
Member.get({ id: $scope.currentUser.id }, function (user) {
$scope.currentUser.profile.tours = user.profile.tours;
$scope.currentUser.profile_attributes.tours = user.profile_attributes.tours;
});
}
});

View File

@ -387,14 +387,14 @@ Application.Controllers.controller('StatisticsController', ['$scope', '$state',
});
// on tour end, save the status in database
uitour.on('ended', function () {
if (uitour.getStatus() === uitour.Status.ON && $scope.currentUser.profile.tours.indexOf('statistics') < 0) {
if (uitour.getStatus() === uitour.Status.ON && $scope.currentUser.profile_attributes.tours.indexOf('statistics') < 0) {
Member.completeTour({ id: $scope.currentUser.id }, { tour: 'statistics' }, function (res) {
$scope.currentUser.profile.tours = res.tours;
$scope.currentUser.profile_attributes.tours = res.tours;
});
}
});
// if the user has never seen the tour, show him now
if (settingsPromise.feature_tour_display !== 'manual' && $scope.currentUser.profile.tours.indexOf('statistics') < 0) {
if (settingsPromise.feature_tour_display !== 'manual' && $scope.currentUser.profile_attributes.tours.indexOf('statistics') < 0) {
uitour.start();
}
};

View File

@ -391,14 +391,14 @@ Application.Controllers.controller('TrainingsAdminController', ['$scope', '$stat
});
// on tour end, save the status in database
uitour.on('ended', function () {
if (uitour.getStatus() === uitour.Status.ON && $scope.currentUser.profile.tours.indexOf('trainings') < 0) {
if (uitour.getStatus() === uitour.Status.ON && $scope.currentUser.profile_attributes.tours.indexOf('trainings') < 0) {
Member.completeTour({ id: $scope.currentUser.id }, { tour: 'trainings' }, function (res) {
$scope.currentUser.profile.tours = res.tours;
$scope.currentUser.profile_attributes.tours = res.tours;
});
}
});
// if the user has never seen the tour, show him now
if (settingsPromise.feature_tour_display !== 'manual' && $scope.currentUser.profile.tours.indexOf('trainings') < 0) {
if (settingsPromise.feature_tour_display !== 'manual' && $scope.currentUser.profile_attributes.tours.indexOf('trainings') < 0) {
uitour.start();
}
};

View File

@ -41,6 +41,20 @@ Application.Controllers.controller('DashboardController', ['$scope', 'memberProm
return trainingsPromise.find(t => t.id === trainingId).name;
};
/**
* Callback used in PaymentScheduleDashboard, in case of error
*/
$scope.onError = function (message) {
growl.error(message);
};
/**
* Callback triggered when the user has successfully updated his card
*/
$scope.onCardUpdateSuccess = function (message) {
growl.success(message);
};
/* PRIVATE SCOPE */
/**
@ -56,27 +70,13 @@ Application.Controllers.controller('DashboardController', ['$scope', 'memberProm
const filterNetworks = function () {
const networks = [];
for (const network of Array.from(SocialNetworks)) {
if ($scope.user.profile[network] && ($scope.user.profile[network].length > 0)) {
if ($scope.user.profile_attributes[network] && ($scope.user.profile_attributes[network].length > 0)) {
networks.push(network);
}
}
return networks;
};
/**
* Callback used in PaymentScheduleDashboard, in case of error
*/
$scope.onError = function (message) {
growl.error(message);
};
/**
* Callback triggered when the user has successfully updated his card
*/
$scope.onCardUpdateSuccess = function (message) {
growl.success(message);
};
// !!! MUST BE CALLED AT THE END of the controller
return initialize();
}

View File

@ -296,14 +296,14 @@ Application.Controllers.controller('HomeController', ['$scope', '$transition$',
});
// on tour end, save the status in database
uitour.on('ended', function () {
if (uitour.getStatus() === uitour.Status.ON && $scope.currentUser.profile.tours.indexOf('welcome') < 0) {
if (uitour.getStatus() === uitour.Status.ON && $scope.currentUser.profile_attributes.tours.indexOf('welcome') < 0) {
Member.completeTour({ id: $scope.currentUser.id }, { tour: 'welcome' }, function (res) {
$scope.currentUser.profile.tours = res.tours;
$scope.currentUser.profile_attributes.tours = res.tours;
});
}
});
// if the user has never seen the tour, show him now
if (settingsPromise.feature_tour_display !== 'manual' && $scope.currentUser.profile.tours.indexOf('welcome') < 0) {
if (settingsPromise.feature_tour_display !== 'manual' && $scope.currentUser.profile_attributes.tours.indexOf('welcome') < 0) {
uitour.start();
}
};

View File

@ -86,7 +86,7 @@ Application.Controllers.controller('EditProfileController', ['$scope', '$rootSco
$scope.method = 'patch';
// Current user's profile
$scope.user = memberPromise;
$scope.user = cleanUser(memberPromise);
// default : do not show the group changing form
$scope.group =
@ -184,8 +184,8 @@ Application.Controllers.controller('EditProfileController', ['$scope', '$rootSco
)
);
} else {
$scope.currentUser.profile.user_avatar = content.profile.user_avatar;
Auth._currentUser.profile.user_avatar = content.profile.user_avatar;
$scope.currentUser.profile_attributes.user_avatar_attributes = content.profile_attributes.user_avatar_attributes;
Auth._currentUser.profile_attributes.user_avatar_attributes = content.profile_attributes.user_avatar_attributes;
$scope.currentUser.name = content.name;
Auth._currentUser.name = content.name;
$scope.currentUser = content;
@ -275,6 +275,25 @@ Application.Controllers.controller('EditProfileController', ['$scope', '$rootSco
$injector.get('$state').reload();
};
/**
* Callback triggered when an error is raised on a lower-level component
* @param message {string}
*/
$scope.onError = function (message) {
growl.error(message);
};
/**
* Callback triggered when the user was successfully updated
* @param user {object} the updated user
*/
$scope.onSuccess = function (user) {
$scope.currentUser = _.cloneDeep(user);
Auth._currentUser = _.cloneDeep(user);
$rootScope.currentUser = _.cloneDeep(user);
growl.success(_t('app.logged.dashboard.settings.your_profile_has_been_successfully_updated'));
};
/* PRIVATE SCOPE */
/**
@ -283,9 +302,6 @@ Application.Controllers.controller('EditProfileController', ['$scope', '$rootSco
const initialize = function () {
CSRF.setMetaTags();
// init the birth date to JS object
$scope.user.statistic_profile.birthday = moment($scope.user.statistic_profile.birthday).toDate();
if ($scope.activeProvider.providable_type !== 'DatabaseProvider') {
$scope.preventPassword = true;
}
@ -293,6 +309,13 @@ Application.Controllers.controller('EditProfileController', ['$scope', '$rootSco
return angular.forEach(activeProviderPromise.mapping, map => $scope.preventField[map] = true);
};
// prepare the user for the react-hook-form
function cleanUser (user) {
delete user.$promise;
delete user.$resolved;
return user;
}
// !!! MUST BE CALLED AT THE END of the controller
return initialize();
}
@ -326,7 +349,7 @@ Application.Controllers.controller('ShowProfileController', ['$scope', 'memberPr
const filterNetworks = function () {
const networks = [];
for (const network of Array.from(SocialNetworks)) {
if ($scope.user.profile[network] && ($scope.user.profile[network].length > 0)) {
if ($scope.user.profile_attributes[network] && ($scope.user.profile_attributes[network].length > 0)) {
networks.push(network);
}
}

View File

@ -151,8 +151,8 @@ Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScop
* @return {string} 'male' or 'female'
*/
$scope.getGender = function (user) {
if (user && user.statistic_profile) {
if (user.statistic_profile.gender === 'true') { return 'male'; } else { return 'female'; }
if (user && user.statistic_profile_attributes) {
if (user.statistic_profile_attributes.gender === 'true') { return 'male'; } else { return 'female'; }
} else { return 'other'; }
};

View File

@ -36,7 +36,7 @@ Application.Controllers.controller('CompleteProfileController', ['$scope', '$roo
$scope.groups = groupsPromise;
// current user, contains information retrieved from the SSO
$scope.user = memberPromise;
$scope.user = cleanUser(memberPromise);
// disallow the user to change his password as he connect from SSO
$scope.preventPassword = true;
@ -91,8 +91,8 @@ Application.Controllers.controller('CompleteProfileController', ['$scope', '$roo
});
});
} else {
$scope.user.profile.user_avatar = content.profile.user_avatar;
Auth._currentUser.profile.user_avatar = content.profile.user_avatar;
$scope.user.profile_attributes.user_avatar_attributes = content.profile_attributes.user_avatar_attributes;
Auth._currentUser.profile_attributes.user_avatar_attributes = content.profile_attributes.user_avatar_attributes;
$scope.user.name = content.name;
Auth._currentUser.name = content.name;
$scope.user = content;
@ -211,6 +211,26 @@ Application.Controllers.controller('CompleteProfileController', ['$scope', '$roo
return !$scope.activeProvider.previous_provider || $scope.activeProvider.previous_provider.id === $scope.activeProvider.id;
};
/**
* Callback triggered when an error is raised on a lower-level component
* @param message {string}
*/
$scope.onError = function (message) {
growl.error(message);
};
/**
* Callback triggered when the user was successfully updated
* @param user {object} the updated user
*/
$scope.onSuccess = function (user) {
$scope.currentUser = _.cloneDeep(user);
Auth._currentUser = _.cloneDeep(user);
$rootScope.currentUser = _.cloneDeep(user);
growl.success(_t('app.logged.profile_completion.your_profile_has_been_successfully_updated'));
$state.go('app.public.home');
};
/* PRIVATE SCOPE */
/**
@ -220,12 +240,19 @@ Application.Controllers.controller('CompleteProfileController', ['$scope', '$roo
CSRF.setMetaTags();
// init the birth date to JS object
$scope.user.statistic_profile.birthday = $scope.user.statistic_profile.birthday ? moment($scope.user.statistic_profile.birthday).toDate() : undefined;
$scope.user.statistic_profile_attributes.birthday = $scope.user.statistic_profile_attributes.birthday ? moment($scope.user.statistic_profile_attributes.birthday).toDate() : undefined;
// bind fields protection with sso fields
angular.forEach(activeProviderPromise.mapping, function (map) { $scope.preventField[map] = true; });
};
// prepare the user for the react-hook-form
function cleanUser (user) {
delete user.$promise;
delete user.$resolved;
return user;
}
// !!! MUST BE CALLED AT THE END of the controller
return initialize();
}

View File

@ -1,5 +1,6 @@
import moment, { unitOfTime } from 'moment';
import { IFablab } from '../models/fablab';
import { TDateISO } from '../typings/date-iso';
declare let Fablab: IFablab;
@ -7,14 +8,14 @@ export default class FormatLib {
/**
* Return the formatted localized date for the given date
*/
static date = (date: Date): string => {
static date = (date: Date|TDateISO): string => {
return Intl.DateTimeFormat().format(moment(date).toDate());
};
/**
* Return the formatted localized time for the given date
*/
static time = (date: Date): string => {
static time = (date: Date|TDateISO): string => {
return Intl.DateTimeFormat(Fablab.intl_locale, { hour: 'numeric', minute: 'numeric' }).format(moment(date).toDate());
};

View File

@ -1,4 +1,6 @@
import { User, UserRole } from '../models/user';
import { isNil, isEmpty } from 'lodash';
import { User } from '../models/user';
import { supportedNetworks, SupportedSocialNetwork } from '../models/social-network';
export default class UserLib {
private user: User;
@ -11,12 +13,63 @@ export default class UserLib {
* Check if the current user has privileged access for resources concerning the provided customer
*/
isPrivileged = (customer: User): boolean => {
if (this.user.role === UserRole.Admin) return true;
if (this.user.role === 'admin') return true;
if (this.user.role === UserRole.Manager) {
if (this.user.role === 'manager') {
return (this.user.id !== customer.id);
}
return false;
};
/**
* Filter social networks from the user's profile
*/
getUserSocialNetworks = (): { name: string, url: string }[] => {
if (!this.isUser()) {
return supportedNetworks.map(network => {
return { name: network, url: '' };
});
}
const userNetworks = [];
for (const [name, url] of Object.entries(this.user.profile_attributes)) {
supportedNetworks.includes(name as SupportedSocialNetwork) && userNetworks.push({ name, url });
}
return userNetworks;
};
/**
* Return the email given by the SSO provider, parsed if needed
* @return {String} E-mail of the current user
*/
ssoEmail = (): string => {
const { email } = this.user;
if (email) {
const duplicate = email.match(/^<([^>]+)>.{20}-duplicate$/);
if (duplicate) {
return duplicate[1];
}
}
return email;
};
/**
* Test if the user's mail is marked as duplicate
*/
hasDuplicate = (): boolean => {
const { email } = this.user;
if (email) {
return !(email.match(/^<([^>]+)>.{20}-duplicate$/) === null);
}
};
/**
* Check if the current user is not empty
*/
private isUser = (): boolean => {
if (isNil(this.user)) return false;
return !(isEmpty(this.user.invoicing_profile_attributes) && isEmpty(this.user.statistic_profile_attributes));
};
}

View File

@ -65,3 +65,10 @@ export interface MappingFields {
user: Array<[string, mappingType]>,
profile: Array<[string, mappingType]>
}
export interface ActiveProviderResponse extends AuthenticationProvider {
previous_provider?: AuthenticationProvider
mapping: Array<string>,
link_to_sso_profile: string,
link_to_sso_connect: string,
}

View File

@ -1,3 +1,5 @@
import { TDateISO } from '../typings/date-iso';
export interface Event {
id: number,
title: string,
@ -22,18 +24,18 @@ export interface Event {
age_range: {
name: string
},
start_date: Date,
start_time: Date,
end_date: Date,
end_time: Date,
start_date: TDateISO,
start_time: TDateISO,
end_date: TDateISO,
end_time: TDateISO,
month: string;
month_id: number,
year: number,
all_day: boolean,
availability: {
id: number,
start_at: Date,
end_at: Date
start_at: TDateISO,
end_at: TDateISO
},
availability_id: number,
amount: number,

View File

@ -1,28 +1,32 @@
import { FieldErrors, UseFormRegister, Validate } from 'react-hook-form';
import { UseFormRegister, Validate } from 'react-hook-form';
import { Control, FormState } from 'react-hook-form/dist/types/form';
export type ruleTypes<TFieldValues> = {
export type ruleTypes = {
required?: boolean | string,
pattern?: RegExp | { value: RegExp, message: string },
minLength?: number,
maxLength?: number,
min?: number,
max?: number,
validate?: Validate<TFieldValues>;
validate?: Validate<unknown>;
};
export interface FormComponent<TFieldValues> {
register: UseFormRegister<TFieldValues>,
error?: FieldErrors,
warning?: FieldErrors,
rules?: ruleTypes<TFieldValues>,
/**
* `error` and `warning` props can be manually set.
* Automatic error handling is done through the `formState` prop.
* Even for manual error/warning, the `formState` prop is required, because it is used to determine is the field is dirty.
*/
export interface AbstractFormComponent<TFieldValues> {
error?: { message: string },
warning?: { message: string },
rules?: ruleTypes,
formState?: FormState<TFieldValues>;
}
export interface FormControlledComponent<TFieldValues, TContext extends object> {
control: Control<TFieldValues, TContext>,
error?: FieldErrors,
warning?: FieldErrors,
rules?: ruleTypes<TFieldValues>,
formState?: FormState<TFieldValues>;
export interface FormComponent<TFieldValues> extends AbstractFormComponent<TFieldValues> {
register: UseFormRegister<TFieldValues>,
}
export interface FormControlledComponent<TFieldValues, TContext extends object> extends AbstractFormComponent<TFieldValues> {
control: Control<TFieldValues, TContext>
}

View File

@ -1,7 +1,9 @@
import { TDateISO } from '../typings/date-iso';
export interface HistoryValue {
id: number,
value: string,
created_at: Date
created_at: TDateISO
user: {
id: number,
name: string

View File

@ -1,8 +1,10 @@
import { TDateISO } from '../typings/date-iso';
export interface Invoice {
id: number,
created_at: Date,
created_at: TDateISO,
reference: string,
avoir_date: Date,
avoir_date: TDateISO,
description: string
user_id: number,
total: number,
@ -11,7 +13,7 @@ export interface Invoice {
is_avoir: boolean,
is_subscription_invoice: boolean,
is_online_card: boolean,
date: Date,
date: TDateISO,
chained_footprint: boolean,
main_object: {
type: string,

View File

@ -1,3 +1,5 @@
import { TDateISO, TDateISODate } from '../typings/date-iso';
export enum PaymentScheduleItemState {
New = 'new',
Pending = 'pending',
@ -17,7 +19,7 @@ export enum PaymentMethod {
export interface PaymentScheduleItem {
id: number,
amount: number,
due_date: Date,
due_date: TDateISO,
state: PaymentScheduleItemState,
invoice_id: number,
payment_method: PaymentMethod,
@ -31,7 +33,7 @@ export interface PaymentSchedule {
reference?: string,
payment_method: PaymentMethod,
items?: Array<PaymentScheduleItem>,
created_at?: Date,
created_at?: TDateISO,
chained_footprint?: boolean,
main_object?: {
type: string,
@ -53,7 +55,7 @@ export interface PaymentScheduleIndexRequest {
query: {
reference?: string,
customer?: string,
date?: Date,
date?: TDateISODate,
page: number,
size: number
}
@ -74,5 +76,5 @@ export interface PayItemResponse {
}
export interface CancelScheduleResponse {
canceled_at: Date
canceled_at: TDateISO
}

View File

@ -1,3 +1,5 @@
import { TDateISO } from '../typings/date-iso';
export interface PriceIndexFilter {
priceable_type?: string,
priceable_id?: number,
@ -20,7 +22,7 @@ export interface ComputePriceResult {
price_without_coupon: number,
details?: {
slots: Array<{
start_at: Date,
start_at: TDateISO,
price: number,
promo: boolean
}>
@ -29,7 +31,7 @@ export interface ComputePriceResult {
schedule?: {
items: Array<{
amount: number,
due_date: Date
due_date: TDateISO
}>
}
}

View File

@ -1,7 +1,9 @@
import { TDateISO } from '../typings/date-iso';
export interface ReservationSlot {
id?: number,
start_at: Date,
end_at: Date,
start_at: TDateISO,
end_at: TDateISO,
availability_id: number,
offered: boolean
}

View File

@ -1,4 +1,5 @@
import { HistoryValue } from './history-value';
import { TDateISO } from '../typings/date-iso';
export enum SettingName {
AboutTitle = 'about_title',
@ -118,7 +119,20 @@ export enum SettingName {
PackOnlyForSubscription = 'pack_only_for_subscription',
OverlappingCategories = 'overlapping_categories',
ExtendedPricesInSameDay = 'extended_prices_in_same_day',
PublicRegistrations = 'public_registrations'
PublicRegistrations = 'public_registrations',
SocialsFacebook = 'facebook',
SocialsTwitter = 'twitter',
SocialsViadeo = 'viadeo',
SocialsLinkedin = 'linkedin',
SocialsInstagram = 'instagram',
SocialsYoutube = 'youtube',
SocialsVimeo = 'vimeo',
SocialsDailymotion = 'dailymotion',
SocialsGithub = 'github',
SocialsEchosciences = 'echosciences',
SocialsPinterest = 'pinterest',
SocialsLastfm = 'lastfm',
SocialsFlickr = 'flickr'
}
export type SettingValue = string|boolean|number;
@ -127,7 +141,7 @@ export interface Setting {
name: SettingName,
localized?: string,
value: string,
last_update?: Date,
last_update?: TDateISO,
history?: Array<HistoryValue>
}

View File

@ -0,0 +1,22 @@
export interface SocialNetwork {
name: string,
url: string
}
export const supportedNetworks = [
'facebook',
'twitter',
'viadeo',
'linkedin',
'instagram',
'youtube',
'vimeo',
'dailymotion',
'github',
'echosciences',
'pinterest',
'lastfm',
'flickr'
] as const;
export type SupportedSocialNetwork = typeof supportedNetworks[number];

View File

@ -23,7 +23,7 @@ export interface Charge {
application_fee_amount?: number,
calculated_statement_descriptor: string,
captured: boolean,
created: Date,
created: number,
failure_code?: string
failure_message?: string,
fraud_details: Record<string, unknown>,
@ -61,7 +61,7 @@ export interface Price {
object: 'price',
active: boolean,
billing_scheme: 'per_unit' | 'tiered',
created: Date,
created: number,
currency: string,
livemode: boolean,
lookup_key: string,
@ -108,7 +108,7 @@ export interface TaxRate {
metadata: Record<string, unknown>,
percentage: number,
state: string,
created: Date,
created: number,
livemode: boolean,
tax_type: 'vat' | 'sales_tax' | string
}
@ -120,7 +120,7 @@ export interface SubscriptionItem {
billing_thresholds: {
usage_gte: number,
},
created: Date,
created: number,
metadata: Record<string, unknown>,
price: Price,
quantity: number,
@ -142,8 +142,8 @@ export interface Invoice {
lines: [],
metadata: Record<string, unknown>,
payment_intent: PaymentIntent,
period_end: Date,
period_start: Date,
period_end: number,
period_start: number,
status: 'draft' | 'open' | 'paid' | 'uncollectible' | 'void',
subscription: string,
total: number
@ -154,8 +154,8 @@ export interface Subscription {
id: string,
object: 'subscription',
cancel_at_period_end: boolean,
current_period_end: Date,
current_period_start: Date,
current_period_end: number,
current_period_start: number,
customer: string,
default_payment_method: string,
items: [

View File

@ -1,21 +1,22 @@
import { Plan } from './plan';
import { TDateISO } from '../typings/date-iso';
export interface Subscription {
id: number,
plan_id: number,
expired_at: Date,
canceled_at?: Date,
expired_at: TDateISO,
canceled_at?: TDateISO,
plan: Plan
}
export interface SubscriptionRequest {
plan_id: number,
start_at?: Date
start_at?: TDateISO
}
export interface UpdateSubscriptionRequest {
id: number,
expired_at: Date,
expired_at: TDateISO,
free: boolean
}

View File

@ -0,0 +1,4 @@
export interface Tag {
id?: number,
name: string,
}

View File

@ -0,0 +1,18 @@
export interface Training {
id?: number,
name: string,
description: string,
machine_ids: number[],
nb_total_places: number,
slug: string,
public_page?: boolean,
disabled?: boolean,
plan_ids?: number[],
training_image?: string,
}
export interface TrainingIndexFilter {
disabled?: boolean,
public_page?: boolean,
requested_attributes?: ['availabillities'],
}

View File

@ -1,3 +1,4 @@
import { TDateISO } from '../typings/date-iso';
export interface UserPackIndexFilter {
user_id?: number,
@ -7,7 +8,7 @@ export interface UserPackIndexFilter {
export interface UserPack {
minutes_used: number,
expires_at: Date,
expires_at: TDateISO,
prepaid_pack: {
minutes: number,
}

View File

@ -1,9 +1,11 @@
import { Plan } from './plan';
import { TDateISO, TDateISODate } from '../typings/date-iso';
import { supportedNetworks, SupportedSocialNetwork } from './social-network';
export enum UserRole {
Member = 'member',
Manager = 'manager',
Admin = 'admin'
export type UserRole = 'member' | 'manager' | 'admin';
type ProfileAttributesSocial = {
[network in SupportedSocialNetwork]: string
}
export interface User {
@ -15,7 +17,11 @@ export interface User {
name: string,
need_completion: boolean,
ip_address: string,
profile: {
mapped_from_sso?: string[],
password?: string,
password_confirmation?: string,
cgu?: boolean, // Accepted terms and conditions?
profile_attributes: ProfileAttributesSocial & {
id: number,
first_name: string,
last_name: string,
@ -25,50 +31,40 @@ export interface User {
website: string,
job: string,
tours: Array<string>,
facebook: string,
twitter: string,
google_plus: string,
viadeo: string,
linkedin: string,
instagram: string,
youtube: string,
vimeo: string,
dailymotion: string,
github: string,
echosciences: string,
pinterest: string,
lastfm: string,
flickr: string,
user_avatar: {
user_avatar_attributes: {
id: number,
attachment_url: string
attachment?: File,
attachment_url?: string,
attachment_files: FileList,
_destroy?: boolean
}
},
invoicing_profile: {
invoicing_profile_attributes: {
id: number,
address: {
address_attributes: {
id: number,
address: string
},
organization: {
organization_attributes: {
id: number,
name: string,
address: {
address_attributes: {
id: number,
address: string
}
}
},
statistic_profile: {
statistic_profile_attributes: {
id: number,
gender: string,
birthday: Date
birthday: TDateISODate
training_ids: Array<number>
},
subscribed_plan: Plan,
subscription: {
id: number,
expired_at: Date,
canceled_at: Date,
expired_at: TDateISO,
canceled_at: TDateISO,
stripe: boolean,
plan: {
id: number,
@ -80,6 +76,40 @@ export interface User {
}
},
training_credits: Array<number>,
machine_credits: Array<{machine_id: number, hours_used: number}>,
last_sign_in_at: Date
machine_credits: Array<{ machine_id: number, hours_used: number }>,
last_sign_in_at: TDateISO
}
type OrderingKey = 'last_name' | 'first_name' | 'email' | 'phone' | 'group' | 'plan' | 'id'
export interface UserIndexFilter {
search?: string,
filter?: 'inactive_for_3_years' | 'not_confirmed',
order_by?: OrderingKey | `-${OrderingKey}`,
page?: number,
size?: number
}
const socialMappings = supportedNetworks.map(network => {
return { [`profile_attributes.${network}`]: `profile.${network}` };
});
export const UserFieldMapping = Object.assign({
'profile_attributes.user_avatar_attributes.attachment': 'profile.avatar',
'statistic_profile_attributes.gender': 'profile.gender',
'profile_attributes.last_name': 'profile.last_name',
'profile_attributes.first_name': 'profile.first_name',
'statistic_profile_attributes.birthday': 'profile.birthday',
'profile_attributes.phone': 'profile.phone',
username: 'user.username',
email: 'user.email',
'invoicing_profile_attributes.address_attributes.address': 'profile.address',
'invoicing_profile_attributes.organization_attributes.name': 'profile.organization_name',
'invoicing_profile_attributes.organization_attributes.address_attributes.address': 'profile.organization_address',
'profile_attributes.website': 'profile.website',
'profile_attributes.job': 'profile.job',
'profile_attributes.interest': 'profile.interest',
'profile_attributes.software_mastered': 'profile.software_mastered',
is_allow_contact: 'user.is_allow_contact',
is_allow_newsletter: 'user.is_allow_newsletter'
}, ...socialMappings);

View File

@ -0,0 +1,36 @@
// from https://gist.github.com/MrChocolatine/367fb2a35d02f6175cc8ccb3d3a20054
interface Date {
/**
* Give a more precise return type to the method `toISOString()`:
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString
*/
toISOString(): TDateISO;
}
type TYear = `${number}${number}${number}${number}`;
type TMonth = `${number}${number}`;
type TDay = `${number}${number}`;
type THours = `${number}${number}`;
type TMinutes = `${number}${number}`;
type TSeconds = `${number}${number}`;
type TMilliseconds = `${number}${number}${number}`;
/**
* Represent a string like `2021-01-08`
*/
type TDateISODate = `${TYear}-${TMonth}-${TDay}`;
/**
* Represent a string like `14:42:34.678`
*/
type TDateISOTime = `${THours}:${TMinutes}:${TSeconds}.${TMilliseconds}`;
/**
* Represent a string like `2021-01-08T14:42:34.678Z` (format: ISO 8601).
*
* It is not possible to type more precisely (list every possible values for months, hours etc) as
* it would result in a warning from TypeScript:
* "Expression produces a union type that is too complex to represent. ts(2590)
*/
export type TDateISO = `${TDateISODate}T${TDateISOTime}Z`;

View File

@ -0,0 +1,5 @@
declare module '*.svg' {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const value: any;
export default value;
}

View File

@ -520,7 +520,7 @@
position: absolute;
bottom: 0;
right: 10px;
z-index: 100;
z-index: 200;
padding: 3px 15px;
border: 1px solid $border-color;
border-top-left-radius: 8px;

View File

@ -566,20 +566,40 @@ body.container {
// profile edition -- add a social network buttons
.social-icons {
& > div {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 1.6rem;
& > * {
cursor: pointer;
padding: 0.2em;
width: 3em;
display: inline-block;
text-align: center;
height: 3em;
display: flex;
justify-content: center;
align-items: center;
border-radius: 3px;
border: 1px solid transparent;
&:hover {
border: 1px solid $border-color;
overflow: hidden;
}
& > img {
border: 1px solid var(--gray-soft-dark);
background-color: var(--gray-soft-lightest);
&:hover { opacity: 0.65; }
}
& > a {
transition: transform 200ms ease-in-out;
&:hover { transform: translateY(-4px); }
img {
max-width: 100%;
height: inherit;
}
}
}
.social-inputs {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
column-gap: 3rem;
}
// public profile view
.profile-top {

View File

@ -28,7 +28,11 @@
@import "modules/base/fab-popover";
@import "modules/base/fab-text-editor";
@import "modules/base/labelled-input";
@import "modules/calendar/calendar";
@import "modules/form/form-input";
@import "modules/form/form-item";
@import "modules/form/form-rich-text";
@import "modules/form/form-switch";
@import "modules/machines/machine-card";
@import "modules/machines/machines-filters";
@import "modules/machines/machines-list";
@ -67,11 +71,16 @@
@import "modules/pricing/spaces/delete-extended-price";
@import "modules/pricing/spaces/edit-extended-price";
@import "modules/pricing/spaces/spaces-pricing";
@import "modules/profile-completion/completion-header-info";
@import "modules/profile-completion/profile-form-option";
@import "modules/select-gateway-modal";
@import "modules/settings/check-list-setting";
@import "modules/subscriptions/free-extend-modal";
@import "modules/subscriptions/renew-modal";
@import "modules/user/avatar";
@import "modules/user/avatar-input";
@import "modules/user/gender-input";
@import "modules/user/user-profile-form";
@import "modules/abuses";
@import "modules/cookies";

View File

@ -45,6 +45,14 @@
background-color: var(--gray-soft-dark);
border-radius: 1px;
}
&--disabled {
opacity: 0.5;
button:hover {
background-color: transparent;
}
}
}
// tiptap class for the editor

View File

@ -35,7 +35,7 @@
&-info {
position: absolute;
top: 0;
left: calc(100% - $tab-width);
left: calc(100% - #{$tab-width});
width: $tab-width;
height: 100%;
display: block;
@ -44,7 +44,7 @@
&.is-empty {
opacity: 0;
}
.content {
position: relative;
min-width: 220px;
@ -77,7 +77,7 @@
}
input[type="checkbox"]:checked ~ .content {
background: var(--gray-soft-lightest);
transform: translateX(calc(-100% + $tab-width));
transform: translateX(calc(-100% + #{$tab-width}));
cursor: default;
&::before {
transform: translateX(-$tab-width) rotateZ(-180deg);
@ -96,12 +96,12 @@
font-style: italic;
font-weight: 500;
}
&-group {
display: flex;
flex-wrap: wrap;
}
&-item {
display: inline-block;
padding: 2px;

View File

@ -0,0 +1,5 @@
.form-input {
&.is-hidden {
display: none;
}
}

View File

@ -12,11 +12,12 @@
p {
@include text-sm;
margin: 0;
&::first-letter { text-transform: uppercase; }
}
.item-tooltip {
position: relative;
cursor: pointer;
cursor: help;
.trigger i { display: block; }
.content {
@ -58,6 +59,7 @@
display: grid;
grid-template-areas: "icon field addon";
grid-template-columns: min-content minmax(0, 1fr) min-content;
background-color: var(--gray-soft-lightest);
border: 1px solid var(--gray-soft-dark);
border-radius: var(--border-radius);
transition: border-color ease-in-out 0.15s;
@ -74,13 +76,33 @@
.icon {
grid-area: icon;
border-right: 1px solid var(--gray-soft-dark);
border-top-left-radius: var(--border-radius);
border-bottom-left-radius: var(--border-radius);
& > * {
max-width: 24px;
max-height: 24px;
}
}
.addon {
grid-area: addon;
border-left: 1px solid var(--gray-soft-dark);
border-top-right-radius: var(--border-radius);
border-bottom-right-radius: var(--border-radius);
&.is-btn {
color: var(--gray-soft-lightest);
background-color: var(--gray-hard-darkest);
&:hover {
cursor: pointer;
background-color: var(--gray-hard-light);
}
}
}
& > input {
grid-area: field;
border: none;
border-radius: var(--border-radius);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, .08);
padding: 0 0.8rem;
color: var(--gray-hard-darkest);
white-space: nowrap;
@ -126,11 +148,6 @@
color: var(--gray-soft-darkest);
}
}
.addon {
grid-area: addon;
border-left: 1px solid var(--gray-soft-dark);
}
}
&.is-incorrect &-field {
border-color: var(--error);

View File

@ -0,0 +1,6 @@
.form-rich-text {
.form-item-field {
border: 0;
display: block;
}
}

View File

@ -0,0 +1,28 @@
.form-switch {
position: relative;
.form-item-header {
position: absolute;
top: 16px;
left: 16px;
padding-top: 2px;
width: fit-content;
.item-tooltip {
margin-left: 12px;
position: unset;
.content {
right: unset;
left: 0;
}
}
}
.form-item-field {
display: flex;
flex-direction: row-reverse;
background-color: white;
padding: 16px;
}
}

View File

@ -0,0 +1,17 @@
.completion-header-info {
padding: 15px;
background-color: #f4f3f3;
margin-bottom: 30px;
border: 1px solid #ddd;
border-radius: var(--border-radius);
.provider-name {
font-weight: bold;
display: block;
margin-left: 40px;
.user-email {
margin-left: 5px;
}
}
}

View File

@ -0,0 +1,10 @@
.profile-form-option {
.disabled-fields-info,
.duplicate-info {
font-style: italic;
}
.after-edition-info {
margin-top: 20px;
}
}

View File

@ -0,0 +1,38 @@
.avatar-input {
margin-right: 20px;
.avatar {
background-color: #fff;
border: 1px solid var(--gray-soft);
padding: 4px;
}
.buttons {
display: flex;
justify-content: center;
margin-top: 20px;
.select-button {
position: relative;
.avatar-file-input {
position: absolute;
z-index: 2;
opacity: 0;
top: 0;
left: 0;
}
}
.delete-avatar {
background-color: var(--error);
color: white;
}
}
&--large {
margin: 80px 40px;
}
&--small {
text-align: center;
}
}

View File

@ -2,5 +2,16 @@
display: inline-block;
img {
border-radius: 50%;
object-fit: cover;
}
&--small img {
width: 50px;
height: 50px;
}
&--large img {
width: 180px;
height: 180px;
}
}

View File

@ -0,0 +1,36 @@
.gender-input {
margin-bottom: 1.6rem;
label {
display: inline-flex;
justify-content: flex-start;
flex-direction: row-reverse;
border: 1px solid #c9c9c9;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
font-weight: 400;
line-height: 1.5;
text-align: center;
touch-action: manipulation;
user-select: none;
vertical-align: middle;
white-space: nowrap;
position: relative;
max-width: 100%;
background-color: #fbfbfb;
color: #000;
margin-bottom: 0;
margin-top: 0;
padding: 7px 12px 6px;
margin-right: 16px;
p {
margin: 0 8px;
}
input {
margin: 5px 0 0;
}
}
}

View File

@ -0,0 +1,61 @@
.user-profile-form {
display: flex;
flex-direction: row;
.fields-group {
width: 100%;
& > * {
margin-top: 1.5rem;
}
.names, .birth-phone, .website-job, .interests-CAD {
display: flex;
flex-direction: row;
.form-item:first-child {
margin-right: 32px;
}
}
.organization-toggle {
p {
font-family: var(--font-text);
font-weight: normal;
font-size: 1.4rem;
line-height: normal;
margin: 0;
}
}
}
.main-actions {
display: flex;
flex-direction: row-reverse;
.submit-button {
margin-top: 2em;
border-color: var(--information-light);
background-color: var(--information);
color: white;
&:hover {
border-color: var(--information);
background-color: var(--information-dark);
color: white;
}
}
}
&--small {
flex-direction: column;
.names, .birth-phone, .website-job, .interests-CAD {
flex-direction: column;
.form-item:first-child {
margin-right: 0;
}
}
}
}

View File

@ -42,22 +42,18 @@
</div>
</section>
<form role="form" name="userForm" class="form-horizontal col-md-8" novalidate action="{{ actionUrl }}" ng-upload="submited(content)" upload-options-enable-rails-csrf="true">
<section class="panel panel-default bg-light m-lg">
<div class="panel-body m-r">
<user-profile-form user="user"
action="'update'"
on-error="onError"
on-success="onUserSuccess"
show-group-input="true"
show-tags-input="true"
show-trainings-input="true" />
</div>
</section>
<section class="panel panel-default bg-light m-lg">
<div class="panel-body m-r">
<ng-include src="'/shared/_member_form.html'"></ng-include>
<ng-include src="'/admin/members/_form.html'"></ng-include>
</div> <!-- ./panel-body -->
<div class="panel-footer no-padder">
<input type="submit" value="{{ 'app.shared.buttons.confirm_changes' | translate }}" class="r-b btn-valid btn btn-warning btn-block p-lg btn-lg text-u-c" ng-disabled="userForm.$invalid"/>
</div>
</section>
</form>
</uib-tab>
<uib-tab heading="{{ 'app.admin.members_edit.subscription' | translate }}" ng-if="$root.modules.plans">

View File

@ -29,38 +29,20 @@
<div class="row no-gutter">
<div class=" col-sm-12 col-md-9 b-r nopadding">
<div class="col-md-12 b-r nopadding">
<form role="form" name="userForm" class="form-horizontal" novalidate action="{{ actionUrl }}" ng-upload="submited(content)" upload-options-enable-rails-csrf="true">
<section class="panel panel-default bg-light m-lg">
<div class="panel-body m-r">
<section class="panel panel-default bg-light m-lg">
<div class="panel-body m-r">
<div class="row m-t">
<div class="col-sm-6 col-sm-offset-5">
<div class="form-group checkbox-group">
<input type="checkbox"
name="organization"
id="organization"
ng-model="user.organization"
ng-change="toggleOrganization()"
value="false"/>
<label for="organization" translate>{{ 'app.admin.members_new.user_is_an_organization' }}</label>
</div>
</div>
</div>
<ng-include src="'/shared/_member_form.html'"></ng-include>
<ng-include src="'/admin/members/_form.html'"></ng-include>
</div> <!-- ./panel-body -->
<div class="panel-footer no-padder">
<input type="submit" value="{{ 'app.shared.buttons.save' | translate }}" class="r-b btn-valid btn btn-warning btn-block p-lg btn-lg text-u-c" ng-disabled="userForm.$invalid"/>
</div>
</section>
</form>
<user-profile-form user="user"
action="'create'"
on-error="onError"
on-success="onUserSuccess"
show-group-input="true"
show-tags-input="true"
show-trainings-input="true" />
</div>
</section>
</div>
</div>

View File

@ -56,8 +56,10 @@
<span class="help-block text-info text-xs"><i class="fa fa-lightbulb-o"></i> {{ 'app.admin.settings.shift_enter_to_force_carriage_return' | translate }}</span>
<button name="button" class="btn btn-warning" ng-click="save(aboutContactsSetting)" translate>{{ 'app.shared.buttons.save' }}</button>
</div>
<div class="col-md-4 col-md-offset-2 m-t-xl">
<h2 translate>{{ 'app.admin.settings.about_follow_us' }}</h2>
<fab-socials on-success="onSuccess" on-error="onError"></fab-socials>
</div>
</div>
</div>
</div>

View File

@ -9,7 +9,7 @@
<div class="widget panel b-a m m-t-lg">
<div class="panel-heading b-b small text-center">
<span class="avatar ">
<fab-user-avatar ng-model="user.profile.user_avatar" avatar-class="thumb-50">test</fab-user-avatar>
<fab-user-avatar ng-model="user.profile_attributes.user_avatar_attributes" avatar-class="thumb-50">test</fab-user-avatar>
</span>
<div class="font-sbold m-t-sm">{{user.name}}</div>
<div>{{user.email}}</div>
@ -101,7 +101,6 @@
<section class="panel panel-default bg-light m p-lg row" ng-if="hasSsoFields()">
<div class="panel-heading">
<h2>
<img class="v-middle" height="16" width="16" src='https://www.google.com/s2/favicons?domain={{activeProvider.domain}}' />
<span class="v-middle">{{activeProvider.name}}</span>
</h2>
</div>
@ -121,13 +120,10 @@
</section>
<section class="panel panel-default bg-light m">
<div class="panel-body m-r">
<ng-include src="'/shared/_member_form.html'"></ng-include>
<user-profile-form user="user" action="'update'" on-error="onError" on-success="onSuccess" />
</div> <!-- ./panel-body -->
</section>
</div>
<div class="panel-footer no-padder">
<input type="submit" value="{{ 'app.logged.dashboard.settings.confirm_changes' | translate }}" class="r-b btn-valid btn btn-warning btn-block p-lg btn-lg text-u-c" ng-disabled="userForm.$invalid"/>
</div>
</form>
</div>

View File

@ -21,17 +21,9 @@
<div class="row no-gutter">
<div class="col-sm-12 col-md-12 b-r">
<div class="row" ng-hide="hideNewAccountConfirmation()">
<div class="row">
<div class="col-md-offset-2 col-md-8 m-t-md">
<section class="panel panel-default bg-light m-lg">
<div class="panel-body m-r">
{{ 'app.logged.profile_completion.you_ve_just_created_a_new_account_on_the_fablab_by_logging_from' | translate:{ GENDER: nameGenre, NAME: fablabName } }}<br/>
<img class="m-l v-middle" height="16" width="16" src='https://www.google.com/s2/favicons?domain={{activeProvider.domain}}' />
<strong class="v-middle">{{activeProvider.name}} <span ng-if="ssoEmail()">({{ssoEmail()}})</span></strong><br/>
<p class="m-t-md" ng-hide="hasDuplicate()" translate>{{ 'app.logged.profile_completion.we_need_some_more_details' }}.</p>
<p class="m-t-md" ng-show="hasDuplicate()" translate>{{ 'app.logged.profile_completion.your_email_is_already_used_by_another_account_on_the_platform' }}</p>
</div>
</section>
<completion-header-info on-error="onError" user="user" active-provider="activeProvider" />
</div>
</div>
<div class="row col-md-2 col-md-offset-5 hidden-sm hidden-xs" ng-hide="user.merged_at || hideNewAccountConfirmation()">
@ -43,107 +35,16 @@
</div>
<div class="row">
<div class="col-md-6">
<div class="m-lg panel panel-default bg-light pos-rlt" ng-hide="hasDuplicate()">
<div class="m-lg panel panel-default bg-light pos-rlt">
<div ng-class="{'disabling-overlay' : !!user.auth_token}">
<div class="panel-body">
<h3 translate ng-hide="hideNewAccountConfirmation()">{{ 'app.logged.profile_completion.new_on_this_platform' }}</h3>
<p translate>{{ 'app.logged.profile_completion.please_fill_the_following_form'}}.</p>
<p class="text-italic">{{ 'app.logged.profile_completion.some_data_may_have_already_been_provided_by_provider_and_cannot_be_modified' | translate:{NAME:activeProvider.name} }}.<br/>
{{ 'app.logged.profile_completion.then_click_on_' | translate }} <strong translate>{{ 'app.shared.buttons.confirm_changes' }}</strong> {{ 'app.logged.profile_completion._to_start_using_the_application' | translate }}.</p>
<profile-form-option on-error="onError"
on-success="onSuccess"
user="user"
active-provider="activeProvider" />
</div>
<form role="form"
name="userForm"
class="form-horizontal"
action="{{ actionUrl }}"
ng-upload="submited(content)"
upload-options-enable-rails-csrf="true"
novalidate>
<section>
<div class="panel-body m-r">
<!-- common fields -->
<ng-include src="'/shared/_member_form.html'"></ng-include>
<div class="row">
<div class="col-sm-3 col-sm-offset-1"></div>
<div class="col-sm-offset-1 col-sm-6">
<!-- group -->
<div class="form-group" ng-class="{'has-error': userForm['user[group_id]'].$dirty && userForm['user[group_id]'].$invalid}">
<div class="input-group">
<span class="input-group-addon">
<i class="fa fa-users"></i>
<span class="exponent m-l-xs help-cursor" title="{{ 'app.logged.profile_completion.used_for_statistics' | translate }}">
<i class="fa fa-asterisk" aria-hidden="true"></i>
</span>
</span>
<select ng-model="user.group_id" class="form-control" ng-disabled="user.role === 'admin'" required>
<option value=null translate>{{ 'app.logged.profile_completion.your_user_s_profile' }}</option>
<option ng-repeat="group in groups" ng-value="group.id" ng-selected="group.id == user.group_id">{{group.name}}</option>
</select>
<input type="hidden" name="user[group_id]" ng-value="user.group_id">
</div>
<span class="help-block" ng-show="userForm['user[group_id]'].$dirty && userForm['user[group_id]'].$error.required" translate>{{ 'app.logged.profile_completion.user_s_profile_is_required' }}</span>
</div>
<!-- accept cgu -->
<div class="form-group" ng-class="{'has-error': userForm.cgu.$dirty && userForm.cgu.$invalid}" ng-show="cgu">
<input type="checkbox"
name="cgu"
ng-model="user.cgu"
value="true"
ng-required="cgu != null"/> {{ 'app.logged.profile_completion.i_ve_read_and_i_accept_' | translate }}
<a href="{{cgu.custom_asset_file_attributes.attachment_url}}" target="_blank" translate>{{ 'app.logged.profile_completion._the_fablab_policy' }}</a>
<span class="exponent m-l-xs"><i class="fa fa-asterisk" aria-hidden="true"></i></span>
</div>
</div>
</div>
</div> <!-- ./panel-body -->
<div class="panel-footer no-padder">
<input type="submit"
value="{{ 'app.shared.buttons.confirm_changes' | translate }}"
class="r-b btn-valid btn btn-warning btn-block p-lg btn-lg text-u-c"
ng-disabled="userForm.$invalid"/>
</div>
</section>
</form>
</div>
</div>
<section class="m-lg panel panel-default bg-light pos-rlt" ng-show="hasDuplicate()">
<div ng-class="{'disabling-overlay' : !!user.auth_token}">
<div class="panel-body">
<h3 translate>{{ 'app.logged.profile_completion.new_on_this_platform' }}</h3>
<p class="text-italic">
{{ 'app.logged.profile_completion.your_email_' | translate }}
<strong>({{ssoEmail()}})</strong>
{{ 'app.logged.profile_completion._is_currently_associated_with_another_account_on_this_platform' | translate }}
{{ 'app.logged.profile_completion.please_click_to_change_email_associated_with_your_PROVIDER_account' | translate:{PROVDER: activeProvider.name} }}
</p>
<div class="row">
<div class="col-lg-6 col-md-6 col-sm-12 col-xs-12">
<a class="btn btn-default" ng-href="{{activeProvider.link_to_sso_profile}}" target="_blank">
<i class="fa fa-edit"></i> {{ 'app.logged.profile_completion.change_my_data' | translate }}
</a>
<p class="text-italic">
{{ 'app.logged.profile_completion.once_your_data_are_up_to_date_' | translate }}
<strong translate>{{ 'app.logged.profile_completion._click_on_the_synchronization_button_opposite_' }}</strong>
{{ 'app.logged.profile_completion.or' | translate}}
<strong translate>{{ 'app.logged.profile_completion._disconnect_then_reconnect_' }}</strong>
{{ 'app.logged.profile_completion._for_your_changes_to_take_effect' | translate }}
</p>
</div>
<div class="col-lg-6 col-md-6 col-sm-12 col-xs-12">
<a class="btn btn-default" ng-click="syncProfile()">
<i class="fa fa-refresh"></i> {{ 'app.logged.profile_completion.sync_my_profile' | translate }}
</a>
</div>
</div>
</div>
</div>
</section>
</div>
<div class="row col-xs-2 col-xs-offset-5 hidden-md hidden-lg" ng-hide="hideNewAccountConfirmation()">
<p class="font-felt fleche-left text-lg upper text-center">

View File

@ -1,26 +1,26 @@
<uib-alert ng-repeat="alert in alerts" type="{{alert.type}}" close="closeAlert($index)">{{alert.msg}}</uib-alert>
<input name="_method" type="hidden" ng-value="method">
<input name="user[profile_attributes][id]" type="hidden" ng-value="user.profile.id">
<input name="user[profile_attributes][id]" type="hidden" ng-value="user.profile_attributes.id">
<input name="user[invoicing_profile_attributes][id]" type="hidden" ng-value="user.invoicing_profile.id">
<input name="user[statistic_profile_attributes][id]" type="hidden" ng-value="user.statistic_profile.id">
<div class="row m-t">
<div class="col-sm-3 col-sm-offset-1">
<div class="form-group m-t-lg">
<div class="fileinput text-center" data-provides="fileinput" ng-class="fileinputClass(user.profile.user_avatar.attachment_url)">
<div class="fileinput text-center" data-provides="fileinput" ng-class="fileinputClass(user.profile_attributes.user_avatar_attributes.attachment_url)">
<div class="fileinput-new thumbnail rounded thumb-128-wrapper" style="width: 140px; height: 140px;">
<img src="../../images/no_avatar.png" class="img-circle">
</div>
<div class="fileinput-preview fileinput-exists thumbnail rounded thumb-128-wrapper" data-trigger="fileinput" style="width: 140px; height: 140px; line-height: 140px;">
<img ng-src="{{ user.profile.user_avatar.attachment_url }}" />
<img ng-src="{{ user.profile_attributes.user_avatar_attributes.attachment_url }}" />
</div>
<div class="m-t-sm">
<input type="hidden" name="user[profile_attributes][user_avatar_attributes][id]" ng-value="user.profile.user_avatar.id">
<input type="hidden" name="user[profile_attributes][user_avatar_attributes][_destroy]" ng-value="true" ng-if="user.profile.user_avatar._destory">
<input type="hidden" name="user[profile_attributes][user_avatar_attributes][id]" ng-value="user.profile_attributes.user_avatar_attributes.id">
<input type="hidden" name="user[profile_attributes][user_avatar_attributes][_destroy]" ng-value="true" ng-if="user.profile_attributes.user_avatar._destory">
<span class="btn btn-default btn-file"
ng-click="user.profile.user_avatar._destory = false"
ng-hide="preventField['profile.avatar'] && user.profile.user_avatar.attachment_url && !userForm['user[profile_attributes][user_avatar_attributes]'].$dirty">
ng-click="user.profile_attributes.user_avatar_attributes._destory = false"
ng-hide="preventField['profile.avatar'] && user.profile_attributes.user_avatar_attributes.attachment_url && !userForm['user[profile_attributes][user_avatar_attributes]'].$dirty">
<span class="fileinput-new" translate>{{ 'app.shared.user.add_an_avatar' }}</span>
<span class="fileinput-exists" translate>{{ 'app.shared.buttons.change' }}</span>
<input type="file" name="user[profile_attributes][user_avatar_attributes][attachment]" accept="image/jpeg,image/gif,image/png">
@ -28,8 +28,8 @@
<button class="btn btn-danger fileinput-exists"
data-dismiss="fileinput"
ng-click="user.profile.user_avatar._destory = true"
ng-hide="preventField['profile.avatar'] && user.profile.user_avatar.attachment_url && !userForm['user[profile_attributes][user_avatar_attributes]'].$dirty">
ng-click="user.profile_attributes.user_avatar_attributes._destory = true"
ng-hide="preventField['profile.avatar'] && user.profile_attributes.user_avatar.attachment_url && !userForm['user[profile_attributes][user_avatar_attributes]'].$dirty">
<i class="fa fa-trash-o"></i>
</button>
</div>
@ -43,18 +43,18 @@
<label class="checkbox-inline btn btn-default">
<input type="radio"
name="user[statistic_profile_attributes][gender]"
ng-model="user.statistic_profile.gender"
ng-model="user.statistic_profile_attributes.gender"
value="true"
ng-disabled="preventField['profile.gender'] && user.statistic_profile.gender && !userForm['user[statistic_profile_attributes][gender]'].$dirty"
ng-disabled="preventField['profile.gender'] && user.statistic_profile_attributes.gender && !userForm['user[statistic_profile_attributes][gender]'].$dirty"
required/>
<i class="fa fa-male m-l-sm"></i> {{ 'app.shared.user.man' | translate }}
</label>
<label class="checkbox-inline btn btn-default">
<input type="radio"
name="user[statistic_profile_attributes][gender]"
ng-model="user.statistic_profile.gender"
ng-model="user.statistic_profile_attributes.gender"
value="false"
ng-disabled="preventField['profile.gender'] && user.statistic_profile.gender && !userForm['user[statistic_profile_attributes][gender]'].$dirty"/>
ng-disabled="preventField['profile.gender'] && user.statistic_profile_attributes.gender && !userForm['user[statistic_profile_attributes][gender]'].$dirty"/>
<i class="fa fa-female m-l-sm"></i> {{ 'app.shared.user.woman' | translate }}
</label>
<span class="exponent m-l-xs help-cursor" title="{{ 'app.shared.user.used_for_statistics' | translate }}"><i class="fa fa-asterisk" aria-hidden="true"></i></span>
@ -86,11 +86,11 @@
<span class="input-group-addon help-cursor" title="{{ 'app.shared.user.used_for_invoicing' | translate }}"><i class="fa fa-user"></i> <span class="exponent"><i class="fa fa-asterisk" aria-hidden="true"></i></span></span>
<input type="text"
name="user[profile_attributes][last_name]"
ng-model="user.profile.last_name"
ng-model="user.profile_attributes.last_name"
class="form-control"
id="user_last_name"
placeholder="{{ 'app.shared.user.surname' | translate }}"
ng-disabled="preventField['profile.last_name'] && user.profile.last_name && !userForm['user[profile_attributes][last_name]'].$dirty"
ng-disabled="preventField['profile.last_name'] && user.profile_attributes.last_name && !userForm['user[profile_attributes][last_name]'].$dirty"
required/>
</div>
<span class="help-block" ng-show="userForm['user[profile_attributes][last_name]'].$dirty && userForm['user[profile_attributes][last_name]'].$error.required" translate>{{ 'app.shared.user.surname_is_required' }}</span>
@ -101,11 +101,11 @@
<span class="input-group-addon help-cursor" title="{{ 'app.shared.user.used_for_invoicing' | translate }}"><i class="fa fa-user"></i> <span class="exponent"><i class="fa fa-asterisk" aria-hidden="true"></i></span></span>
<input type="text"
name="user[profile_attributes][first_name]"
ng-model="user.profile.first_name"
ng-model="user.profile_attributes.first_name"
class="form-control"
id="user_first_name"
placeholder="{{ 'app.shared.user.first_name' | translate }}"
ng-disabled="preventField['profile.first_name'] && user.profile.first_name && !userForm['user[profile_attributes][first_name]'].$dirty"
ng-disabled="preventField['profile.first_name'] && user.profile_attributes.first_name && !userForm['user[profile_attributes][first_name]'].$dirty"
required/>
</div>
<span class="help-block" ng-show="userForm['user[profile_attributes][first_name]'].$dirty && userForm['user[profile_attributes][first_name]'].$error.required" translate>{{ 'app.shared.user.first_name_is_required' }}</span>
@ -167,15 +167,15 @@
<span class="help-block" ng-show="userForm['user[password_confirmation]'].$error.match" translate>{{ 'app.shared.user.confirmation_mismatch_with_password' }}</span>
</div>
<div class="form-group" ng-if="user.invoicing_profile.organization" ng-class="{'has-error': userForm['user[invoicing_profile_attributes][organization_attributes][name]'].$dirty && userForm['user[invoicing_profile_attributes][organization_attributes][name]'].$invalid}">
<div class="form-group" ng-if="user.invoicing_profile_attributes.organization" ng-class="{'has-error': userForm['user[invoicing_profile_attributes][organization_attributes][name]'].$dirty && userForm['user[invoicing_profile_attributes][organization_attributes][name]'].$invalid}">
<div class="input-group">
<span class="input-group-addon help-cursor" title="{{ 'app.shared.user.used_for_invoicing' | translate }}"><i class="fa fa-building-o"></i> <span class="exponent"><i class="fa fa-asterisk" aria-hidden="true"></i></span></span>
<input type="hidden"
name="user[invoicing_profile_attributes][organization_attributes][id]"
ng-value="user.invoicing_profile.organization.id" />
ng-value="user.invoicing_profile_attributes.organization.id" />
<input type="text"
name="user[invoicing_profile_attributes][organization_attributes][name]"
ng-model="user.invoicing_profile.organization.name"
ng-model="user.invoicing_profile_attributes.organization.name"
class="form-control"
placeholder="{{ 'app.shared.user.organization_name' | translate }}"
ng-required="user.invoicing_profile.organization"
@ -184,19 +184,19 @@
<span class="help-block" ng-show="userForm['user[invoicing_][organization_attributes][name]'].$dirty && userForm['user[invoicing_profile_attributes][organization_attributes][name]'].$error.required" translate>{{ 'app.shared.user.organization_name_is_required' }}</span>
</div>
<div class="form-group" ng-if="user.invoicing_profile.organization" ng-class="{'has-error': userForm['user[invoicing_profile_attributes][organization_attributes][address_attributes][address]'].$dirty && userForm['user[invoicing_profile_attributes][organization_attributes][address_attributes][address]'].$invalid}">
<div class="form-group" ng-if="user.invoicing_profile_attributes.organization" ng-class="{'has-error': userForm['user[invoicing_profile_attributes][organization_attributes][address_attributes][address]'].$dirty && userForm['user[invoicing_profile_attributes][organization_attributes][address_attributes][address]'].$invalid}">
<div class="input-group">
<span class="input-group-addon help-cursor" title="{{ 'app.shared.user.used_for_invoicing' | translate }}"><i class="fa fa-map-marker"></i> <span class="exponent"><i class="fa fa-asterisk" aria-hidden="true"></i></span></span>
<input type="hidden"
name="user[invoicing_profile_attributes][organization_attributes][address_attributes][id]"
ng-value="user.invoicing_profile.organization.address.id" />
ng-value="user.invoicing_profile_attributes.organization.address.id" />
<input type="text"
name="user[invoicing_profile_attributes][organization_attributes][address_attributes][address]"
ng-model="user.invoicing_profile.organization.address.address"
ng-model="user.invoicing_profile_attributes.organization.address.address"
class="form-control"
placeholder="{{ 'app.shared.user.organization_address' | translate }}"
ng-required="user.invoicing_profile.organization"
ng-disabled="preventField['profile.organization_address'] && user.invoicing_profile.organization.address.address && !userForm['user[invoicing_profile_attributes][organization_attributes][address_attributes][address]'].$dirty">
ng-disabled="preventField['profile.organization_address'] && user.invoicing_profile_attributes.organization.address.address && !userForm['user[invoicing_profile_attributes][organization_attributes][address_attributes][address]'].$dirty">
</div>
<span class="help-block" ng-show="userForm['user[invoicing_profile_attributes][organization_attributes][address_attributes][address]'].$dirty && userForm['user[invoicing_profile_attributes][organization_attributes][address_attributes][address]'].$error.required" translate>{{ 'app.shared.user.organization_address_is_required' }}</span>
</div>
@ -207,17 +207,17 @@
<input type="text"
id="user_birthday"
class="form-control"
ng-model="user.statistic_profile.birthday"
ng-model="user.statistic_profile_attributes.birthday"
uib-datepicker-popup="{{datePicker.format}}"
datepicker-options="datePicker.options"
is-open="datePicker.opened"
placeholder="{{ 'app.shared.user.date_of_birth' | translate }}"
ng-click="openDatePicker($event)"
ng-disabled="preventField['profile.birthday'] && user.statistic_profile.birthday && !userForm['user[statistic_profile_attributes][birthday]'].$dirty"
ng-disabled="preventField['profile.birthday'] && user.statistic_profile_attributes.birthday && !userForm['user[statistic_profile_attributes][birthday]'].$dirty"
required/>
<input type="hidden"
name="user[statistic_profile_attributes][birthday]"
value="{{user.statistic_profile.birthday | toIsoDate}}" />
value="{{user.statistic_profile_attributes.birthday | toIsoDate}}" />
</div>
<span class="help-block" ng-show="userForm['user[statistic_profile_attributes][birthday]'].$dirty && userForm['user[statistic_profile_attributes][birthday]'].$error.required" translate>{{ 'app.shared.user.date_of_birth_is_required' }}</span>
</div>
@ -227,13 +227,13 @@
<span class="input-group-addon help-cursor" title="{{ 'app.shared.user.used_for_invoicing' | translate }}"><i class="fa fa-map-marker"></i> <span class="exponent" ng-show="addressRequired"><i class="fa fa-asterisk" aria-hidden="true"></i></span></span>
<input type="hidden"
name="user[invoicing_profile_attributes][address_attributes][id]"
ng-value="user.invoicing_profile.address.id" />
ng-value="user.invoicing_profile_attributes.address.id" />
<input type="text"
name="user[invoicing_profile_attributes][address_attributes][address]"
ng-model="user.invoicing_profile.address.address"
ng-model="user.invoicing_profile_attributes.address.address"
class="form-control"
id="user_address"
ng-disabled="preventField['profile.address'] && user.invoicing_profile.address.address && !userForm['user[invoicing_profile_attributes][address_attributes][address]'].$dirty"
ng-disabled="preventField['profile.address'] && user.invoicing_profile_attributes.address.address && !userForm['user[invoicing_profile_attributes][address_attributes][address]'].$dirty"
placeholder="{{ 'app.shared.user.address' | translate }}"
ng-required="addressRequired"/>
</div>
@ -244,11 +244,11 @@
<span class="input-group-addon help-cursor" title="{{ 'app.shared.user.used_for_reservation' | translate }}"><i class="fa fa-phone"></i> <span class="exponent" ng-show="phoneRequired"><i class="fa fa-asterisk" aria-hidden="true"></i></span></span>
<input type="text"
name="user[profile_attributes][phone]"
ng-model="user.profile.phone"
ng-model="user.profile_attributes.phone"
class="form-control"
id="user_phone"
placeholder="{{ 'app.shared.user.phone_number' | translate }}"
ng-disabled="preventField['profile.phone'] && user.profile.phone && !userForm['user[profile_attributes][phone]'].$dirty"
ng-disabled="preventField['profile.phone'] && user.profile_attributes.phone && !userForm['user[profile_attributes][phone]'].$dirty"
ng-required="phoneRequired"/>
</div>
<span class="help-block" ng-show="userForm['user[profile_attributes][phone]'].$dirty && userForm['user[profile_attributes][phone]'].$error.required" translate>{{ 'app.shared.user.phone_number_is_required' }}</span>
@ -259,12 +259,12 @@
<span class="input-group-addon help-cursor" title="{{ 'app.shared.user.used_for_profile' | translate }}"><i class="fa fa-globe"></i> </span>
<input type="url"
name="user[profile_attributes][website]"
ng-model="user.profile.website"
ng-model="user.profile_attributes.website"
class="form-control"
id="user_website"
ng-pattern="/^https?:\/\//"
placeholder="{{ 'app.shared.user.website' | translate }} (http://...)"
ng-disabled="preventField['profile.website'] && user.profile.website && !userForm['user[profile_attributes][website]'].$dirty"/>
ng-disabled="preventField['profile.website'] && user.profile_attributes.website && !userForm['user[profile_attributes][website]'].$dirty"/>
</div>
</div>
@ -273,34 +273,34 @@
<span class="input-group-addon help-cursor" title="{{ 'app.shared.user.used_for_profile' | translate }}"><i class="fa fa-briefcase"></i> </span>
<input type="text"
name="user[profile_attributes][job]"
ng-model="user.profile.job"
ng-model="user.profile_attributes.job"
class="form-control"
id="user_job"
placeholder="{{ 'app.shared.user.job' | translate }}"
ng-disabled="preventField['profile.job'] && user.profile.job && !userForm['user[profile_attributes][job]'].$dirty"/>
ng-disabled="preventField['profile.job'] && user.profile_attributes.job && !userForm['user[profile_attributes][job]'].$dirty"/>
</div>
</div>
<div class="form-group">
<label for="user_interest" class="help-cursor" title="{{ 'app.shared.user.used_for_profile' | translate }}" translate>{{ 'app.shared.user.interests' }}</label>
<textarea name="user[profile_attributes][interest]"
ng-model="user.profile.interest"
ng-model="user.profile_attributes.interest"
rows="5"
class="form-control"
id="user_interest"
placeholder=""
ng-disabled="preventField['profile.interest'] && user.profile.interest && !userForm['user[profile_attributes][interest]'].$dirty"></textarea>
ng-disabled="preventField['profile.interest'] && user.profile_attributes.interest && !userForm['user[profile_attributes][interest]'].$dirty"></textarea>
</div>
<div class="form-group">
<label for="user_software_mastered" class="help-cursor" title="{{ 'app.shared.user.used_for_profile' | translate }}" translate>{{ 'app.shared.user.CAD_softwares_mastered' }}</label>
<textarea name="user[profile_attributes][software_mastered]"
ng-model="user.profile.software_mastered"
ng-model="user.profile_attributes.software_mastered"
rows="5"
class="form-control"
id="user_software_mastered"
placeholder=""
ng-disabled="preventField['profile.software_mastered'] && user.profile.software_mastered && !userForm['user[profile_attributes][software_mastered]'].$dirty"></textarea>
ng-disabled="preventField['profile.software_mastered'] && user.profile_attributes.software_mastered && !userForm['user[profile_attributes][software_mastered]'].$dirty"></textarea>
</div>
<!-- allow contact-->
@ -332,232 +332,232 @@
</div>
<div id="social" ng-init="social={}">
<div class="form-group" ng-show="social.facebook || user.profile.facebook" ng-class="{'has-error': userForm['user[profile_attributes][facebook]'].$dirty && userForm['user[profile_attributes][facebook]'].$invalid}">
<div class="form-group" ng-show="social.facebook || user.profile_attributes.facebook" ng-class="{'has-error': userForm['user[profile_attributes][facebook]'].$dirty && userForm['user[profile_attributes][facebook]'].$invalid}">
<div class="input-group">
<span class="input-group-addon help-cursor" title="{{ 'app.shared.user.used_for_profile' | translate }}"><i class="fa fa-facebook"></i></span>
<input type="text"
name="user[profile_attributes][facebook]"
ng-model="user.profile.facebook"
ng-model="user.profile_attributes.facebook"
class="form-control"
id="user_facebook"
ng-pattern="/^https?:\/\/.*?facebook/i"
placeholder="https://www.facebook.com/..."
ng-disabled="preventField['profile.facebook'] && user.profile.first_name && !userForm['user[profile_attributes][facebook]'].$dirty"
ng-disabled="preventField['profile.facebook'] && user.profile_attributes.first_name && !userForm['user[profile_attributes][facebook]'].$dirty"
/>
</div>
</div>
<div class="form-group" ng-show="social.twitter || user.profile.twitter" ng-class="{'has-error': userForm['user[profile_attributes][twitter]'].$dirty && userForm['user[profile_attributes][twitter]'].$invalid}">
<div class="form-group" ng-show="social.twitter || user.profile_attributes.twitter" ng-class="{'has-error': userForm['user[profile_attributes][twitter]'].$dirty && userForm['user[profile_attributes][twitter]'].$invalid}">
<div class="input-group">
<span class="input-group-addon help-cursor" title="{{ 'app.shared.user.used_for_profile' | translate }}"><i class="fa fa-twitter"></i></span>
<input type="text"
name="user[profile_attributes][twitter]"
ng-model="user.profile.twitter"
ng-model="user.profile_attributes.twitter"
class="form-control"
id="user_twitter"
ng-pattern="/^https?:\/\/.*?twitter/"
placeholder="https://twitter.com/..."
ng-disabled="preventField['profile.twitter'] && user.profile.first_name && !userForm['user[profile_attributes][twitter]'].$dirty"
ng-disabled="preventField['profile.twitter'] && user.profile_attributes.first_name && !userForm['user[profile_attributes][twitter]'].$dirty"
/>
</div>
</div>
<div class="form-group" ng-show="social.google_plus || user.profile.google_plus" ng-class="{'has-error': userForm['user[profile_attributes][google_plus]'].$dirty && userForm['user[profile_attributes][google_plus]'].$invalid}">
<div class="form-group" ng-show="social.google_plus || user.profile_attributes.google_plus" ng-class="{'has-error': userForm['user[profile_attributes][google_plus]'].$dirty && userForm['user[profile_attributes][google_plus]'].$invalid}">
<div class="input-group">
<span class="input-group-addon help-cursor" title="{{ 'app.shared.user.used_for_profile' | translate }}"><i class="fa fa-google-plus"></i></span>
<input type="text"
name="user[profile_attributes][google_plus]"
ng-model="user.profile.google_plus"
ng-model="user.profile_attributes.google_plus"
class="form-control"
id="user_google_plus"
ng-pattern="/^https?:\/\/.*?google/"
placeholder="https://plus.google.com/+..."
ng-disabled="preventField['profile.google_plus'] && user.profile.first_name && !userForm['user[profile_attributes][google_plus]'].$dirty"
ng-disabled="preventField['profile.google_plus'] && user.profile_attributes.first_name && !userForm['user[profile_attributes][google_plus]'].$dirty"
/>
</div>
</div>
<div class="form-group" ng-show="social.viadeo || user.profile.viadeo" ng-class="{'has-error': userForm['user[profile_attributes][viadeo]'].$dirty && userForm['user[profile_attributes][viadeo]'].$invalid}">
<div class="form-group" ng-show="social.viadeo || user.profile_attributes.viadeo" ng-class="{'has-error': userForm['user[profile_attributes][viadeo]'].$dirty && userForm['user[profile_attributes][viadeo]'].$invalid}">
<div class="input-group">
<span class="input-group-addon help-cursor" title="{{ 'app.shared.user.used_for_profile' | translate }}"><i class="fa fa-viadeo"></i></span>
<input type="text"
name="user[profile_attributes][viadeo]"
ng-model="user.profile.viadeo"
ng-model="user.profile_attributes.viadeo"
class="form-control"
id="user_viadeo"
ng-pattern="/^https?:\/\/.*?viadeo/"
placeholder="http://www.viadeo.com/fr/profile/..."
ng-disabled="preventField['profile.viadeo'] && user.profile.first_name && !userForm['user[profile_attributes][viadeo]'].$dirty"
ng-disabled="preventField['profile.viadeo'] && user.profile_attributes.first_name && !userForm['user[profile_attributes][viadeo]'].$dirty"
/>
</div>
</div>
<div class="form-group" ng-show="social.linkedin || user.profile.linkedin" ng-class="{'has-error': userForm['user[profile_attributes][linkedin]'].$dirty && userForm['user[profile_attributes][linkedin]'].$invalid}">
<div class="form-group" ng-show="social.linkedin || user.profile_attributes.linkedin" ng-class="{'has-error': userForm['user[profile_attributes][linkedin]'].$dirty && userForm['user[profile_attributes][linkedin]'].$invalid}">
<div class="input-group">
<span class="input-group-addon help-cursor" title="{{ 'app.shared.user.used_for_profile' | translate }}"><i class="fa fa-linkedin"></i></span>
<input type="text"
name="user[profile_attributes][linkedin]"
ng-model="user.profile.linkedin"
ng-model="user.profile_attributes.linkedin"
class="form-control"
id="user_linkedin"
ng-pattern="/^https?:\/\/.*?linkedin/"
placeholder="https://www.linkedin.com/in/..."
ng-disabled="preventField['profile.linkedin'] && user.profile.first_name && !userForm['user[profile_attributes][linkedin]'].$dirty"
ng-disabled="preventField['profile.linkedin'] && user.profile_attributes.first_name && !userForm['user[profile_attributes][linkedin]'].$dirty"
/>
</div>
</div>
<div class="form-group" ng-show="social.instagram || user.profile.instragram" ng-class="{'has-error': userForm['user[profile_attributes][instagram]'].$dirty && userForm['user[profile_attributes][instagram]'].$invalid}">
<div class="form-group" ng-show="social.instagram || user.profile_attributes.instragram" ng-class="{'has-error': userForm['user[profile_attributes][instagram]'].$dirty && userForm['user[profile_attributes][instagram]'].$invalid}">
<div class="input-group">
<span class="input-group-addon help-cursor" title="{{ 'app.shared.user.used_for_profile' | translate }}"><i class="fa fa-instagram"></i></span>
<input type="text"
name="user[profile_attributes][instagram]"
ng-model="user.profile.instagram"
ng-model="user.profile_attributes.instagram"
class="form-control"
id="user_instagram"
ng-pattern="/^https?:\/\/.*?instagram/"
placeholder="https://www.instagram.com/..."
ng-disabled="preventField['profile.instagram'] && user.profile.first_name && !userForm['user[profile_attributes][instagram]'].$dirty"
ng-disabled="preventField['profile.instagram'] && user.profile_attributes.first_name && !userForm['user[profile_attributes][instagram]'].$dirty"
/>
</div>
</div>
<div class="form-group" ng-show="social.youtube || user.profile.youtube" ng-class="{'has-error': userForm['user[profile_attributes][youtube]'].$dirty && userForm['user[profile_attributes][youtube]'].$invalid}">
<div class="form-group" ng-show="social.youtube || user.profile_attributes.youtube" ng-class="{'has-error': userForm['user[profile_attributes][youtube]'].$dirty && userForm['user[profile_attributes][youtube]'].$invalid}">
<div class="input-group">
<span class="input-group-addon help-cursor" title="{{ 'app.shared.user.used_for_profile' | translate }}"><i class="fa fa-youtube"></i></span>
<input type="text"
name="user[profile_attributes][youtube]"
ng-model="user.profile.youtube"
ng-model="user.profile_attributes.youtube"
class="form-control"
id="user_youtube"
ng-pattern="/^https?:\/\/.*?youtube/"
placeholder="https://www.youtube.com/..."
ng-disabled="preventField['profile.youtube'] && user.profile.first_name && !userForm['user[profile_attributes][youtube]'].$dirty"
ng-disabled="preventField['profile.youtube'] && user.profile_attributes.first_name && !userForm['user[profile_attributes][youtube]'].$dirty"
/>
</div>
</div>
<div class="form-group" ng-show="social.vimeo || user.profile.vimeo" ng-class="{'has-error': userForm['user[profile_attributes][vimeo]'].$dirty && userForm['user[profile_attributes][vimeo]'].$invalid}">
<div class="form-group" ng-show="social.vimeo || user.profile_attributes.vimeo" ng-class="{'has-error': userForm['user[profile_attributes][vimeo]'].$dirty && userForm['user[profile_attributes][vimeo]'].$invalid}">
<div class="input-group">
<span class="input-group-addon help-cursor" title="{{ 'app.shared.user.used_for_profile' | translate }}"><i class="fa fa-vimeo"></i></span>
<input type="text"
name="user[profile_attributes][vimeo]"
ng-model="user.profile.vimeo"
ng-model="user.profile_attributes.vimeo"
class="form-control"
id="user_vimeo"
ng-pattern="/^https?:\/\/.*?vimeo/"
placeholder="https://vimeo.com/..."
ng-disabled="preventField['profile.vimeo'] && user.profile.first_name && !userForm['user[profile_attributes][vimeo]'].$dirty"
ng-disabled="preventField['profile.vimeo'] && user.profile_attributes.first_name && !userForm['user[profile_attributes][vimeo]'].$dirty"
/>
</div>
</div>
<div class="form-group" ng-show="social.dailymotion || user.profile.dailymotion" ng-class="{'has-error': userForm['user[profile_attributes][dailymotion]'].$dirty && userForm['user[profile_attributes][dailymotion]'].$invalid}">
<div class="form-group" ng-show="social.dailymotion || user.profile_attributes.dailymotion" ng-class="{'has-error': userForm['user[profile_attributes][dailymotion]'].$dirty && userForm['user[profile_attributes][dailymotion]'].$invalid}">
<div class="input-group">
<span class="input-group-addon help-cursor" title="{{ 'app.shared.user.used_for_profile' | translate }}"><img src="../../images/social/dailymotion.png" alt="d" class="fa-img"/></span>
<input type="text"
name="user[profile_attributes][dailymotion]"
ng-model="user.profile.dailymotion"
ng-model="user.profile_attributes.dailymotion"
class="form-control"
id="user_dailymotion"
ng-pattern="/^https?:\/\/.*?dailymotion/"
placeholder="http://www.dailymotion.com/..."
ng-disabled="preventField['profile.dailymotion'] && user.profile.first_name && !userForm['user[profile_attributes][dailymotion]'].$dirty"
ng-disabled="preventField['profile.dailymotion'] && user.profile_attributes.first_name && !userForm['user[profile_attributes][dailymotion]'].$dirty"
/>
</div>
</div>
<div class="form-group" ng-show="social.github || user.profile.github" ng-class="{'has-error': userForm['user[profile_attributes][github]'].$dirty && userForm['user[profile_attributes][github]'].$invalid}">
<div class="form-group" ng-show="social.github || user.profile_attributes.github" ng-class="{'has-error': userForm['user[profile_attributes][github]'].$dirty && userForm['user[profile_attributes][github]'].$invalid}">
<div class="input-group">
<span class="input-group-addon help-cursor" title="{{ 'app.shared.user.used_for_profile' | translate }}"><i class="fa fa-github"></i></span>
<input type="text"
name="user[profile_attributes][github]"
ng-model="user.profile.github"
ng-model="user.profile_attributes.github"
class="form-control"
id="user_github"
ng-pattern="/^https?:\/\/.*?github/"
placeholder="https://github.com/..."
ng-disabled="preventField['profile.github'] && user.profile.first_name && !userForm['user[profile_attributes][github]'].$dirty"
ng-disabled="preventField['profile.github'] && user.profile_attributes.first_name && !userForm['user[profile_attributes][github]'].$dirty"
/>
</div>
</div>
<div class="form-group" ng-show="social.echosciences || user.profile.echosciences" ng-class="{'has-error': userForm['user[profile_attributes][echosciences]'].$dirty && userForm['user[profile_attributes][echosciences]'].$invalid}">
<div class="form-group" ng-show="social.echosciences || user.profile_attributes.echosciences" ng-class="{'has-error': userForm['user[profile_attributes][echosciences]'].$dirty && userForm['user[profile_attributes][echosciences]'].$invalid}">
<div class="input-group">
<span class="input-group-addon help-cursor" title="{{ 'app.shared.user.used_for_profile' | translate }}"><img src="../../images/social/echosciences.png" alt="d" class="fa-img"/></span>
<input type="text"
name="user[profile_attributes][echosciences]"
ng-model="user.profile.echosciences"
ng-model="user.profile_attributes.echosciences"
class="form-control"
id="user_echosciences"
ng-pattern="/^https?:\/\/.*?echosciences/"
placeholder="http://www.echosciences-local.fr/membres/..."
ng-disabled="preventField['profile.echosciences'] && user.profile.first_name && !userForm['user[profile_attributes][echosciences]'].$dirty"
ng-disabled="preventField['profile.echosciences'] && user.profile_attributes.first_name && !userForm['user[profile_attributes][echosciences]'].$dirty"
/>
</div>
</div>
<div class="form-group" ng-show="social.pinterest || user.profile.pinterest" ng-class="{'has-error': userForm['user[profile_attributes][pinterest]'].$dirty && userForm['user[profile_attributes][pinterest]'].$invalid}">
<div class="form-group" ng-show="social.pinterest || user.profile_attributes.pinterest" ng-class="{'has-error': userForm['user[profile_attributes][pinterest]'].$dirty && userForm['user[profile_attributes][pinterest]'].$invalid}">
<div class="input-group">
<span class="input-group-addon help-cursor" title="{{ 'app.shared.user.used_for_profile' | translate }}"><i class="fa fa-pinterest"></i></span>
<input type="text"
name="user[profile_attributes][pinterest]"
ng-model="user.profile.pinterest"
ng-model="user.profile_attributes.pinterest"
class="form-control"
id="user_pinterest"
ng-pattern="/^https?:\/\/.*?pinterest/"
placeholder="https://fr.pinterest.com/..."
ng-disabled="preventField['profile.pinterest'] && user.profile.first_name && !userForm['user[profile_attributes][pinterest]'].$dirty"
ng-disabled="preventField['profile.pinterest'] && user.profile_attributes.first_name && !userForm['user[profile_attributes][pinterest]'].$dirty"
/>
</div>
</div>
<div class="form-group" ng-show="social.lastfm || user.profile.lastfm" ng-class="{'has-error': userForm['user[profile_attributes][lastfm]'].$dirty && userForm['user[profile_attributes][lastfm]'].$invalid}">
<div class="form-group" ng-show="social.lastfm || user.profile_attributes.lastfm" ng-class="{'has-error': userForm['user[profile_attributes][lastfm]'].$dirty && userForm['user[profile_attributes][lastfm]'].$invalid}">
<div class="input-group">
<span class="input-group-addon help-cursor" title="{{ 'app.shared.user.used_for_profile' | translate }}"><i class="fa fa-lastfm"></i></span>
<input type="text"
name="user[profile_attributes][lastfm]"
ng-model="user.profile.lastfm"
ng-model="user.profile_attributes.lastfm"
class="form-control"
id="user_lastfm"
ng-pattern="/^https?:\/\/.*?last.fm/"
placeholder="http://www.last.fm/fr/user/..."
ng-disabled="preventField['profile.lastfm'] && user.profile.first_name && !userForm['user[profile_attributes][lastfm]'].$dirty"
ng-disabled="preventField['profile.lastfm'] && user.profile_attributes.first_name && !userForm['user[profile_attributes][lastfm]'].$dirty"
/>
</div>
</div>
<div class="form-group" ng-show="social.flickr || user.profile.flickr" ng-class="{'has-error': userForm['user[profile_attributes][flickr]'].$dirty && userForm['user[profile_attributes][flickr]'].$invalid}">
<div class="form-group" ng-show="social.flickr || user.profile_attributes.flickr" ng-class="{'has-error': userForm['user[profile_attributes][flickr]'].$dirty && userForm['user[profile_attributes][flickr]'].$invalid}">
<div class="input-group">
<span class="input-group-addon help-cursor" title="{{ 'app.shared.user.used_for_profile' | translate }}"><i class="fa fa-flickr"></i></span>
<input type="text"
name="user[profile_attributes][flickr]"
ng-model="user.profile.flickr"
ng-model="user.profile_attributes.flickr"
class="form-control"
id="user_flickr"
ng-pattern="/^https?:\/\/.*?flickr/"
placeholder="https://www.flickr.com/photos/..."
ng-disabled="preventField['profile.flickr'] && user.profile.flickr && !userForm['user[profile_attributes][flickr]'].$dirty"
ng-disabled="preventField['profile.flickr'] && user.profile_attributes.flickr && !userForm['user[profile_attributes][flickr]'].$dirty"
/>
</div>
</div>
<div class="social-icons m-b">
<div ng-click="social.facebook = !social.facebook" ng-hide="social.facebook || user.profile.facebook"><i class="fa fa-facebook fa-2x"></i></div>
<div ng-click="social.twitter = !social.twitter" ng-hide="social.twitter || user.profile.twitter"><i class="fa fa-twitter fa-2x"></i></div>
<div ng-click="social.google_plus = !social.google_plus" ng-hide="social.google_plus || user.profile.google_plus"><i class="fa fa-google-plus fa-2x"></i></div>
<div ng-click="social.viadeo = !social.viadeo" ng-hide="social.viadeo || user.profile.viadeo"><i class="fa fa-viadeo fa-2x"></i></div>
<div ng-click="social.linkedin = !social.linkedin" ng-hide="social.linkedin || user.profile.linkedin"><i class="fa fa-linkedin fa-2x"></i></div>
<div ng-click="social.instagram = !social.instagram" ng-hide="social.instagram || user.profile.instagram"><i class="fa fa-instagram fa-2x"></i></div>
<div ng-click="social.youtube = !social.youtube" ng-hide="social.youtube || user.profile.youtube"><i class="fa fa-youtube fa-2x"></i></div>
<div ng-click="social.vimeo = !social.vimeo" ng-hide="social.vimeo || user.profile.vimeo"><i class="fa fa-vimeo fa-2x"></i></div>
<div ng-click="social.dailymotion = !social.dailymotion" ng-hide="social.dailymotion || user.profile.dailymotion"><img src="../../images/social/dailymotion.png" alt="d" class="fa-img contrast-250 fa-2x"/></div>
<div ng-click="social.github = !social.github" ng-hide="social.github || user.profile.github"><i class="fa fa-github fa-2x"></i></div>
<div ng-click="social.echosciences = !social.echosciences" ng-hide="social.echosciences || user.profile.echosciences"><img src="../../images/social/echosciences.png" alt="E" class="fa-img contrast-250 fa-2x"/></div>
<div ng-click="social.pinterest = !social.pinterest" ng-hide="social.pinterest || user.profile.pinterest"><i class="fa fa-pinterest fa-2x"></i></div>
<div ng-click="social.lastfm = !social.lastfm" ng-hide="social.lastfm || user.profile.lastfm"><i class="fa fa-lastfm fa-2x"></i></div>
<div ng-click="social.flickr = !social.flickr" ng-hide="social.flickr || user.profile.flickr"><i class="fa fa-flickr fa-2x"></i></div>
<div ng-click="social.facebook = !social.facebook" ng-hide="social.facebook || user.profile_attributes.facebook"><i class="fa fa-facebook fa-2x"></i></div>
<div ng-click="social.twitter = !social.twitter" ng-hide="social.twitter || user.profile_attributes.twitter"><i class="fa fa-twitter fa-2x"></i></div>
<div ng-click="social.google_plus = !social.google_plus" ng-hide="social.google_plus || user.profile_attributes.google_plus"><i class="fa fa-google-plus fa-2x"></i></div>
<div ng-click="social.viadeo = !social.viadeo" ng-hide="social.viadeo || user.profile_attributes.viadeo"><i class="fa fa-viadeo fa-2x"></i></div>
<div ng-click="social.linkedin = !social.linkedin" ng-hide="social.linkedin || user.profile_attributes.linkedin"><i class="fa fa-linkedin fa-2x"></i></div>
<div ng-click="social.instagram = !social.instagram" ng-hide="social.instagram || user.profile_attributes.instagram"><i class="fa fa-instagram fa-2x"></i></div>
<div ng-click="social.youtube = !social.youtube" ng-hide="social.youtube || user.profile_attributes.youtube"><i class="fa fa-youtube fa-2x"></i></div>
<div ng-click="social.vimeo = !social.vimeo" ng-hide="social.vimeo || user.profile_attributes.vimeo"><i class="fa fa-vimeo fa-2x"></i></div>
<div ng-click="social.dailymotion = !social.dailymotion" ng-hide="social.dailymotion || user.profile_attributes.dailymotion"><img src="../../images/social/dailymotion.png" alt="d" class="fa-img contrast-250 fa-2x"/></div>
<div ng-click="social.github = !social.github" ng-hide="social.github || user.profile_attributes.github"><i class="fa fa-github fa-2x"></i></div>
<div ng-click="social.echosciences = !social.echosciences" ng-hide="social.echosciences || user.profile_attributes.echosciences"><img src="../../images/social/echosciences.png" alt="E" class="fa-img contrast-250 fa-2x"/></div>
<div ng-click="social.pinterest = !social.pinterest" ng-hide="social.pinterest || user.profile_attributes.pinterest"><i class="fa fa-pinterest fa-2x"></i></div>
<div ng-click="social.lastfm = !social.lastfm" ng-hide="social.lastfm || user.profile_attributes.lastfm"><i class="fa fa-lastfm fa-2x"></i></div>
<div ng-click="social.flickr = !social.flickr" ng-hide="social.flickr || user.profile_attributes.flickr"><i class="fa fa-flickr fa-2x"></i></div>
</div>
</div>

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