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

(ui) use user-profile-form in profile completion screen

This commit is contained in:
Sylvain 2022-05-09 12:11:37 +02:00
parent 55e76e1523
commit 69d595e9f6
16 changed files with 147 additions and 70 deletions

View File

@ -5,7 +5,7 @@ import { get as _get } from 'lodash';
export interface AbstractFormItemProps<TFieldValues> extends PropsWithChildren<AbstractFormComponent<TFieldValues>> {
id: string,
label?: string,
label?: string|ReactNode,
tooltip?: ReactNode,
className?: string,
disabled?: boolean|((id: string) => boolean),
@ -24,6 +24,7 @@ export const AbstractFormItem = <TFieldValues extends FieldValues>({ id, label,
useEffect(() => {
setIsDirty(_get(formState?.dirtyFields, id));
console.log(_get(formState?.dirtyFields, id));
setFieldError(_get(formState?.errors, id));
}, [formState]);

View File

@ -73,6 +73,7 @@ export const FormMultiSelect = <TFieldValues extends FieldValues, TContext exten
<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"

View File

@ -48,6 +48,7 @@ export const FormRichText = <TFieldValues extends FieldValues, TContext extends
<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}

View File

@ -53,6 +53,7 @@ export const FormSelect = <TFieldValues extends FieldValues, TContext extends ob
<Controller name={id as FieldPath<TFieldValues>}
control={control}
defaultValue={valueDefault as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>}
rules={rules}
render={({ field: { onChange, value, ref } }) =>
<Select ref={ref}
classNamePrefix="rs"

View File

@ -33,6 +33,7 @@ export const FormSwitch = <TFieldValues, TContext extends object>({ id, label, t
<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);

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

@ -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

@ -67,7 +67,7 @@ export const AvatarInput = <TFieldValues extends FieldValues>({ currentAvatar, u
return (
<div className={`avatar-input avatar-input--${size}`}>
<Avatar avatar={avatar} userName={userName} size={size} />
<Avatar avatar={avatar} userName={userName} size="large" />
<div className="buttons">
<FabButton onClick={onAddAvatar} className="select-button">
{!hasAvatar() && <span>Add an avatar</span>}

View File

@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { react2angular } from 'react2angular';
import { SubmitHandler, useForm, useWatch } from 'react-hook-form';
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';
@ -18,6 +18,12 @@ 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 { Group } from '../../models/group';
import GroupAPI from '../../api/group';
import CustomAssetAPI from '../../api/custom-asset';
import { CustomAsset, CustomAssetName } from '../../models/custom-asset';
import { HtmlTranslate } from '../base/html-translate';
declare const Application: IApplication;
@ -28,9 +34,20 @@ interface UserProfileFormProps {
className?: string,
onError: (message: string) => void,
onSuccess: (user: User) => void,
showGroupInput?: boolean,
showTermsAndConditionsInput?: boolean,
}
export const UserProfileForm: React.FC<UserProfileFormProps> = ({ action, size, user, className, onError, onSuccess }) => {
/**
* 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 = false, showTermsAndConditionsInput = false }) => {
const { t } = useTranslation('shared');
// regular expression to validate the input fields
@ -42,37 +59,76 @@ export const UserProfileForm: React.FC<UserProfileFormProps> = ({ action, size,
const [isOrganization, setIsOrganization] = useState<boolean>(!_isNil(user.invoicing_profile_attributes.organization_attributes));
const [isLocalDatabaseProvider, setIsLocalDatabaseProvider] = useState<boolean>(false);
const [groups, setGroups] = useState<Group[]>([]);
const [termsAndConditions, setTermsAndConditions] = useState<CustomAsset>(null);
useEffect(() => {
AuthProviderAPI.active().then(data => {
setIsLocalDatabaseProvider(data.providable_type === 'DatabaseProvider');
}).catch(error => onError(error));
GroupAPI.index({ disabled: false, admins: user.role === 'admin' }).then(data => {
setGroups(data);
}).catch(error => onError(error));
CustomAssetAPI.get(CustomAssetName.CguFile).then(data => {
setTermsAndConditions(data);
}).catch(error => onError(error));
}, []);
/**
* Convert all groups to the react-select format
*/
const buildGroupsOptions = (): Array<selectOption> => {
return groups.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: SubmitHandler<User> = (data: User) => {
MemberAPI[action](data)
.then(res => { onSuccess(res); })
.catch((error) => { onError(error); });
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 because it's mapped to the SSO API.
* 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(user);
return (
<form className={`user-profile-form user-profile-form--${size} ${className}`} onSubmit={handleSubmit(onSubmit)}>
<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}`}
@ -231,6 +287,25 @@ export const UserProfileForm: React.FC<UserProfileFormProps> = ({ action, size,
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={buildGroupsOptions()}
control={control}
id="group_id"
rules={{ required: true }}
disabled={isDisabled}
formState={formState}
label={t('app.shared.user_profile_form.group')} />
</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>
@ -251,4 +326,4 @@ const UserProfileFormWrapper: React.FC<UserProfileFormProps> = (props) => {
);
};
Application.Components.component('userProfileForm', react2angular(UserProfileFormWrapper, ['action', 'size', 'user', 'className', 'onError', 'onSuccess']));
Application.Components.component('userProfileForm', react2angular(UserProfileFormWrapper, ['action', 'size', 'user', 'className', 'onError', 'onSuccess', 'showGroupInput', 'showTermsAndConditionsInput']));

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;
@ -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 */
/**
@ -226,6 +246,13 @@ Application.Controllers.controller('CompleteProfileController', ['$scope', '$roo
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,5 @@
import { User, UserRole } from '../models/user';
import { supportedNetworks } from '../models/social-network';
import { User } from '../models/user';
import { supportedNetworks, SupportedSocialNetwork } from '../models/social-network';
export default class UserLib {
private user: User;
@ -12,9 +12,9 @@ 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);
}
@ -28,7 +28,7 @@ export default class UserLib {
const userNetworks = [];
for (const [name, url] of Object.entries(customer.profile_attributes)) {
supportedNetworks.includes(name) && userNetworks.push({ name, url });
supportedNetworks.includes(name as SupportedSocialNetwork) && userNetworks.push({ name, url });
}
return userNetworks;
};

View File

@ -2,11 +2,7 @@ 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
@ -24,6 +20,7 @@ export interface User {
mapped_from_sso?: string[],
password?: string,
password_confirmation?: string,
cgu?: boolean, // Accepted terms and conditions?
profile_attributes: ProfileAttributesSocial & {
id: number,
first_name: string,

View File

@ -31,4 +31,8 @@
&--large {
margin: 80px 40px;
}
&--small {
text-align: center;
}
}

View File

@ -63,51 +63,16 @@
<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>
<user-profile-form user="user"
size="'small'"
action="'update'"
on-error="onError"
on-success="onSuccess"
show-group-input="true"
show-terms-and-conditions-input="true" />
</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>

View File

@ -39,6 +39,7 @@ en:
_click_on_the_synchronization_button_opposite_: "click on the synchronization button opposite"
_disconnect_then_reconnect_: "disconnect then reconnect"
_for_your_changes_to_take_effect: "for your changes to take effect."
your_profile_has_been_successfully_updated: "Your profile has been successfully updated."
dashboard:
#dashboard: public profile
profile:

View File

@ -72,6 +72,9 @@ en:
used_for_invoicing: "This data will be used for billing purposes"
used_for_reservation: "This data will be used in case of change on one of your bookings"
used_for_profile: "This data will only be displayed on your profile"
group: "Group"
terms_and_conditions_html: "I've read and accept <a href=\"{POLICY_URL}\" target=\"_blank\">the terms and conditions<a/>"
must_accept_terms: "You must accept the terms and conditions"
save: "Save"
gender_input:
man: "Man"