richie-education 3.2.2-dev27 → 3.2.2-dev36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/jest.config.js +11 -1
- package/js/api/auth/keycloak.spec.ts +126 -0
- package/js/api/auth/keycloak.ts +55 -0
- package/js/api/authentication.ts +3 -0
- package/js/components/PaymentInterfaces/types.ts +1 -0
- package/js/components/SaleTunnel/SaleTunnelInformation/SaleTunnelInformationGroup.tsx +39 -15
- package/js/components/SaleTunnel/SaleTunnelInformation/StepContent.tsx +21 -12
- package/js/components/SaleTunnel/SubscriptionButton/index.tsx +12 -1
- package/js/components/SaleTunnel/index.full-process-b2b.spec.tsx +154 -0
- package/js/pages/TeacherDashboardOrganizationQuotes/index.full-process.spec.tsx +13 -3
- package/js/pages/TeacherDashboardOrganizationQuotes/index.spec.tsx +58 -1
- package/js/pages/TeacherDashboardOrganizationQuotes/index.tsx +15 -4
- package/js/types/Joanie.ts +12 -0
- package/js/types/api.ts +1 -0
- package/js/types/commonDataProps.ts +11 -0
- package/js/utils/test/factories/joanie.ts +12 -0
- package/js/widgets/Dashboard/components/LearnerDashboardSidebar/index.tsx +12 -7
- package/js/widgets/Dashboard/index.spec.tsx +21 -1
- package/package.json +2 -1
- package/tsconfig.json +2 -1
package/jest.config.js
CHANGED
|
@@ -15,7 +15,17 @@ module.exports = {
|
|
|
15
15
|
},
|
|
16
16
|
resolver: '<rootDir>/jest/resolver.js',
|
|
17
17
|
transformIgnorePatterns: [
|
|
18
|
-
'node_modules/(?!(
|
|
18
|
+
'node_modules/(?!(' +
|
|
19
|
+
'react-intl' +
|
|
20
|
+
'|lodash-es' +
|
|
21
|
+
'|@hookform/resolvers' +
|
|
22
|
+
'|query-string' +
|
|
23
|
+
'|decode-uri-component' +
|
|
24
|
+
'|split-on-first' +
|
|
25
|
+
'|filter-obj' +
|
|
26
|
+
'|@openfun/cunningham-react' +
|
|
27
|
+
'|keycloak-js' +
|
|
28
|
+
')/)',
|
|
19
29
|
],
|
|
20
30
|
globals: {
|
|
21
31
|
RICHIE_VERSION: 'test',
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
|
|
2
|
+
import API from './keycloak';
|
|
3
|
+
|
|
4
|
+
const mockKeycloakInit = jest.fn().mockResolvedValue(true);
|
|
5
|
+
const mockKeycloakLogout = jest.fn().mockResolvedValue(undefined);
|
|
6
|
+
const mockKeycloakLogin = jest.fn().mockResolvedValue(undefined);
|
|
7
|
+
const mockKeycloakLoadUserProfile = jest.fn();
|
|
8
|
+
|
|
9
|
+
jest.mock('keycloak-js', () => {
|
|
10
|
+
return jest.fn().mockImplementation(() => ({
|
|
11
|
+
init: mockKeycloakInit,
|
|
12
|
+
logout: mockKeycloakLogout,
|
|
13
|
+
login: mockKeycloakLogin,
|
|
14
|
+
loadUserProfile: mockKeycloakLoadUserProfile,
|
|
15
|
+
}));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
jest.mock('utils/indirection/window', () => ({
|
|
19
|
+
location: {
|
|
20
|
+
origin: 'https://richie.test',
|
|
21
|
+
pathname: '/courses/test-course/',
|
|
22
|
+
},
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
jest.mock('utils/context', () => ({
|
|
26
|
+
__esModule: true,
|
|
27
|
+
default: mockRichieContextFactory({
|
|
28
|
+
authentication: {
|
|
29
|
+
backend: 'keycloak',
|
|
30
|
+
endpoint: 'https://keycloak.test/auth',
|
|
31
|
+
client_id: 'richie-client',
|
|
32
|
+
realm: 'richie-realm',
|
|
33
|
+
auth_url: 'https://keycloak.test/auth/realms/richie-realm/protocol/openid-connect/auth',
|
|
34
|
+
},
|
|
35
|
+
}).one(),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
describe('Keycloak API', () => {
|
|
39
|
+
const authConfig = {
|
|
40
|
+
backend: 'keycloak',
|
|
41
|
+
endpoint: 'https://keycloak.test/auth',
|
|
42
|
+
client_id: 'richie-client',
|
|
43
|
+
realm: 'richie-realm',
|
|
44
|
+
auth_url: 'https://keycloak.test/auth/realms/richie-realm/protocol/openid-connect/auth',
|
|
45
|
+
registration_url:
|
|
46
|
+
'https://keycloak.test/auth/realms/richie-realm/protocol/openid-connect/registrations',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
let keycloakApi: ReturnType<typeof API>;
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
jest.clearAllMocks();
|
|
53
|
+
keycloakApi = API(authConfig);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('user.me', () => {
|
|
57
|
+
it('returns null when loadUserProfile fails', async () => {
|
|
58
|
+
mockKeycloakLoadUserProfile.mockRejectedValueOnce(new Error('Not authenticated'));
|
|
59
|
+
const response = await keycloakApi.user.me();
|
|
60
|
+
expect(response).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns user when loadUserProfile succeeds', async () => {
|
|
64
|
+
mockKeycloakLoadUserProfile.mockResolvedValueOnce({
|
|
65
|
+
firstName: 'John',
|
|
66
|
+
lastName: 'Doe',
|
|
67
|
+
email: 'johndoe@example.com',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const response = await keycloakApi.user.me();
|
|
71
|
+
expect(response).toEqual({
|
|
72
|
+
username: 'John Doe',
|
|
73
|
+
email: 'johndoe@example.com',
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('user.login', () => {
|
|
79
|
+
it('calls keycloak.login with correct redirect URI', async () => {
|
|
80
|
+
await keycloakApi.user.login();
|
|
81
|
+
|
|
82
|
+
expect(mockKeycloakLogin).toHaveBeenCalledWith({
|
|
83
|
+
redirectUri: 'https://richie.test/courses/test-course/',
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('user.register', () => {
|
|
89
|
+
it('calls keycloak.login with register action', async () => {
|
|
90
|
+
await keycloakApi.user.register();
|
|
91
|
+
|
|
92
|
+
expect(mockKeycloakLogin).toHaveBeenCalledWith({
|
|
93
|
+
redirectUri: 'https://richie.test/courses/test-course/',
|
|
94
|
+
action: 'REGISTER',
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('user.logout', () => {
|
|
100
|
+
it('calls keycloak.logout with correct redirect URI', async () => {
|
|
101
|
+
await keycloakApi.user.logout();
|
|
102
|
+
|
|
103
|
+
expect(mockKeycloakLogout).toHaveBeenCalledWith({
|
|
104
|
+
redirectUri: 'https://richie.test/courses/test-course/',
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('Keycloak initialization', () => {
|
|
110
|
+
it('initializes keycloak with correct configuration', () => {
|
|
111
|
+
const Keycloak = require('keycloak-js');
|
|
112
|
+
|
|
113
|
+
expect(Keycloak).toHaveBeenCalledWith({
|
|
114
|
+
url: 'https://keycloak.test/auth',
|
|
115
|
+
realm: 'richie-realm',
|
|
116
|
+
clientId: 'richie-client',
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(mockKeycloakInit).toHaveBeenCalledWith({
|
|
120
|
+
checkLoginIframe: false,
|
|
121
|
+
flow: 'implicit',
|
|
122
|
+
token: undefined,
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import Keycloak from 'keycloak-js';
|
|
2
|
+
import { AuthenticationBackend } from 'types/commonDataProps';
|
|
3
|
+
import { APIAuthentication } from 'types/api';
|
|
4
|
+
import { location } from 'utils/indirection/window';
|
|
5
|
+
import { handle } from 'utils/errors/handle';
|
|
6
|
+
|
|
7
|
+
const API = (APIConf: AuthenticationBackend): { user: APIAuthentication } => {
|
|
8
|
+
const keycloak = new Keycloak({
|
|
9
|
+
url: APIConf.endpoint,
|
|
10
|
+
realm: APIConf.realm!,
|
|
11
|
+
clientId: APIConf.client_id!,
|
|
12
|
+
});
|
|
13
|
+
keycloak.init({
|
|
14
|
+
checkLoginIframe: false,
|
|
15
|
+
flow: 'implicit',
|
|
16
|
+
token: APIConf.token!,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const getRedirectUri = () => {
|
|
20
|
+
return `${location.origin}${location.pathname}`;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
user: {
|
|
25
|
+
me: async () => {
|
|
26
|
+
return keycloak
|
|
27
|
+
.loadUserProfile()
|
|
28
|
+
.then((userProfile) => {
|
|
29
|
+
return {
|
|
30
|
+
username: `${userProfile.firstName} ${userProfile.lastName}`,
|
|
31
|
+
email: userProfile.email,
|
|
32
|
+
};
|
|
33
|
+
})
|
|
34
|
+
.catch((error) => {
|
|
35
|
+
handle(error);
|
|
36
|
+
return null;
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
login: async () => {
|
|
41
|
+
await keycloak.login({ redirectUri: getRedirectUri() });
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
register: async () => {
|
|
45
|
+
await keycloak.login({ redirectUri: getRedirectUri(), action: 'REGISTER' });
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
logout: async () => {
|
|
49
|
+
await keycloak.logout({ redirectUri: getRedirectUri() });
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export default API;
|
package/js/api/authentication.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { Nullable } from 'types/utils';
|
|
|
11
11
|
import context from 'utils/context';
|
|
12
12
|
import { APIAuthentication, APIBackend } from 'types/api';
|
|
13
13
|
import DummyApiInterface from './lms/dummy';
|
|
14
|
+
import KeycloakApiInterface from './auth/keycloak';
|
|
14
15
|
import OpenEdxDogwoodApiInterface from './lms/openedx-dogwood';
|
|
15
16
|
import OpenEdxHawthornApiInterface from './lms/openedx-hawthorn';
|
|
16
17
|
import OpenEdxFonzieApiInterface from './lms/openedx-fonzie';
|
|
@@ -22,6 +23,8 @@ const AuthenticationAPIHandler = (): Nullable<APIAuthentication> => {
|
|
|
22
23
|
switch (AUTHENTICATION.backend) {
|
|
23
24
|
case APIBackend.DUMMY:
|
|
24
25
|
return DummyApiInterface(AUTHENTICATION).user;
|
|
26
|
+
case APIBackend.KEYCLOAK:
|
|
27
|
+
return KeycloakApiInterface(AUTHENTICATION).user;
|
|
25
28
|
case APIBackend.OPENEDX_DOGWOOD:
|
|
26
29
|
return OpenEdxDogwoodApiInterface(AUTHENTICATION).user;
|
|
27
30
|
case APIBackend.OPENEDX_HAWTHORN:
|
|
@@ -10,6 +10,7 @@ export enum SubscriptionErrorMessageId {
|
|
|
10
10
|
ERROR_FULL_PRODUCT = 'errorFullProduct',
|
|
11
11
|
ERROR_WITHDRAWAL_RIGHT = 'errorWithdrawalRight',
|
|
12
12
|
ERROR_BATCH_ORDER_FORM_INVALID = 'batchOrderFormInvalid',
|
|
13
|
+
ERROR_BATCH_ORDER_MAX_ORDERS = 'errorBatchOrderMaxOrders',
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
export enum PaymentProviders {
|
|
@@ -86,6 +86,8 @@ export const SaleTunnelInformationGroup = () => {
|
|
|
86
86
|
);
|
|
87
87
|
};
|
|
88
88
|
|
|
89
|
+
const EMAIL_REGEX = /^[\w.-]+@([\w-]+\.)+[\w-]{2,4}$/;
|
|
90
|
+
|
|
89
91
|
export const validationSchema = Yup.object().shape({
|
|
90
92
|
offering_id: Yup.string().required(),
|
|
91
93
|
company_name: Yup.string().required(),
|
|
@@ -98,23 +100,25 @@ export const validationSchema = Yup.object().shape({
|
|
|
98
100
|
administrative_lastname: Yup.string().required(),
|
|
99
101
|
administrative_firstname: Yup.string().required(),
|
|
100
102
|
administrative_profession: Yup.string().required(),
|
|
101
|
-
administrative_email: Yup.string().
|
|
103
|
+
administrative_email: Yup.string().matches(EMAIL_REGEX).required(),
|
|
102
104
|
administrative_telephone: Yup.string().required(),
|
|
103
105
|
signatory_lastname: Yup.string().required(),
|
|
104
106
|
signatory_firstname: Yup.string().required(),
|
|
105
107
|
signatory_profession: Yup.string().required(),
|
|
106
|
-
signatory_email: Yup.string().
|
|
108
|
+
signatory_email: Yup.string().matches(EMAIL_REGEX).required(),
|
|
107
109
|
signatory_telephone: Yup.string().required(),
|
|
108
|
-
billing_address: Yup.object()
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
110
|
+
billing_address: Yup.object()
|
|
111
|
+
.optional()
|
|
112
|
+
.shape({
|
|
113
|
+
company_name: Yup.string().optional(),
|
|
114
|
+
identification_number: Yup.string().optional(),
|
|
115
|
+
contact_name: Yup.string().optional(),
|
|
116
|
+
contact_email: Yup.string().matches(EMAIL_REGEX).optional(),
|
|
117
|
+
address: Yup.string().optional(),
|
|
118
|
+
postcode: Yup.string().optional(),
|
|
119
|
+
city: Yup.string().optional(),
|
|
120
|
+
country: Yup.string().optional(),
|
|
121
|
+
}),
|
|
118
122
|
nb_seats: Yup.number().required().min(1),
|
|
119
123
|
payment_method: Yup.mixed<PaymentMethod>().oneOf(Object.values(PaymentMethod)).required(),
|
|
120
124
|
funding_entity: Yup.string().optional(),
|
|
@@ -198,9 +202,29 @@ const BatchOrderForm = () => {
|
|
|
198
202
|
}, [values, batchOrder, setBatchOrder]);
|
|
199
203
|
|
|
200
204
|
useEffect(() => {
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
205
|
+
const validateStep = async () => {
|
|
206
|
+
if (activeStep === 0 && batchOrder?.billing_address?.contact_email) {
|
|
207
|
+
const isEmailValid = EMAIL_REGEX.test(batchOrder.billing_address.contact_email);
|
|
208
|
+
if (!isEmailValid) {
|
|
209
|
+
setIsCurrentStepValid(false);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const fieldsToValidate = requiredFieldsByStep[activeStep];
|
|
214
|
+
const validationPromises = fieldsToValidate.map(async (field) => {
|
|
215
|
+
try {
|
|
216
|
+
const fieldSchema = Yup.reach(validationSchema, field) as Yup.Schema;
|
|
217
|
+
await fieldSchema.validate(batchOrder?.[field]);
|
|
218
|
+
return true;
|
|
219
|
+
} catch {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
const results = await Promise.all(validationPromises);
|
|
224
|
+
const isStepValid = results.every((isValid) => isValid);
|
|
225
|
+
setIsCurrentStepValid(isStepValid);
|
|
226
|
+
};
|
|
227
|
+
validateStep();
|
|
204
228
|
}, [activeStep, batchOrder]);
|
|
205
229
|
|
|
206
230
|
return (
|
|
@@ -208,7 +208,7 @@ export const StepContent = ({
|
|
|
208
208
|
const { items: organizations } = useOfferingOrganizations({ id: offering?.id });
|
|
209
209
|
const orgOptions = organizations.map((organization) => ({
|
|
210
210
|
label: organization.title,
|
|
211
|
-
value: organization.
|
|
211
|
+
value: organization.id,
|
|
212
212
|
}));
|
|
213
213
|
const [otherBillingAddress, setOtherBillingAddress] = useState(false);
|
|
214
214
|
|
|
@@ -275,7 +275,19 @@ export const StepContent = ({
|
|
|
275
275
|
/>
|
|
276
276
|
<Checkbox
|
|
277
277
|
label={intl.formatMessage(messages.checkBilling)}
|
|
278
|
-
onChange={() =>
|
|
278
|
+
onChange={() => {
|
|
279
|
+
setOtherBillingAddress(!otherBillingAddress);
|
|
280
|
+
form.resetField('billing_address');
|
|
281
|
+
form.resetField('billing_address.contact_name');
|
|
282
|
+
form.resetField('billing_address.contact_email');
|
|
283
|
+
form.resetField('billing_address.company_name');
|
|
284
|
+
form.resetField('billing_address.identification_number');
|
|
285
|
+
form.resetField('billing_address.address');
|
|
286
|
+
form.resetField('billing_address.postcode');
|
|
287
|
+
form.resetField('billing_address.city');
|
|
288
|
+
form.resetField('billing_address.country');
|
|
289
|
+
form.clearErrors();
|
|
290
|
+
}}
|
|
279
291
|
checked={otherBillingAddress}
|
|
280
292
|
/>
|
|
281
293
|
</div>
|
|
@@ -291,6 +303,13 @@ export const StepContent = ({
|
|
|
291
303
|
className="field"
|
|
292
304
|
{...register('billing_address.contact_email')}
|
|
293
305
|
label={intl.formatMessage(messages.contactEmail)}
|
|
306
|
+
state={formState.errors.billing_address?.contact_email?.message ? 'error' : 'default'}
|
|
307
|
+
text={
|
|
308
|
+
getLocalizedCunninghamErrorProp(
|
|
309
|
+
intl,
|
|
310
|
+
formState.errors.billing_address?.contact_email?.message,
|
|
311
|
+
).text
|
|
312
|
+
}
|
|
294
313
|
/>
|
|
295
314
|
<Input
|
|
296
315
|
className="field"
|
|
@@ -497,21 +516,11 @@ export const StepContent = ({
|
|
|
497
516
|
<Input
|
|
498
517
|
{...register('funding_entity')}
|
|
499
518
|
label={intl.formatMessage(messages.fundingEntityName)}
|
|
500
|
-
required
|
|
501
|
-
state={formState.errors.funding_entity?.message ? 'error' : 'default'}
|
|
502
|
-
text={
|
|
503
|
-
getLocalizedCunninghamErrorProp(intl, formState.errors.funding_entity?.message).text
|
|
504
|
-
}
|
|
505
519
|
/>
|
|
506
520
|
<Input
|
|
507
521
|
{...register('funding_amount')}
|
|
508
522
|
type="number"
|
|
509
523
|
label={intl.formatMessage(messages.fundingEntityAmount)}
|
|
510
|
-
required
|
|
511
|
-
state={formState.errors.funding_amount?.message ? 'error' : 'default'}
|
|
512
|
-
text={
|
|
513
|
-
getLocalizedCunninghamErrorProp(intl, formState.errors.funding_amount?.message).text
|
|
514
|
-
}
|
|
515
524
|
/>
|
|
516
525
|
</div>
|
|
517
526
|
<FormattedMessage {...messages.recommandation} />
|
|
@@ -67,6 +67,13 @@ const messages = defineMessages({
|
|
|
67
67
|
defaultMessage: 'Some required fields are missing in the form.',
|
|
68
68
|
description: 'Some required fields are missing in the form.',
|
|
69
69
|
},
|
|
70
|
+
errorBatchOrderMaxOrders: {
|
|
71
|
+
id: 'components.SubscriptionButton.errorBatchOrderMaxOrders',
|
|
72
|
+
defaultMessage:
|
|
73
|
+
'Unable to create the order: the maximum number of available seats for this offering has been reached. Please contact support for more information.',
|
|
74
|
+
description:
|
|
75
|
+
'Error message shown when batch order creation fails because maximum number of orders is reached by an active offering rule.',
|
|
76
|
+
},
|
|
70
77
|
});
|
|
71
78
|
|
|
72
79
|
enum ComponentStates {
|
|
@@ -172,7 +179,11 @@ const SubscriptionButton = ({ buildOrderPayload }: Props) => {
|
|
|
172
179
|
return;
|
|
173
180
|
}
|
|
174
181
|
batchOrderMethods.create(batchOrder, {
|
|
175
|
-
onError: async () => {
|
|
182
|
+
onError: async (createBatchOrderError: HttpError) => {
|
|
183
|
+
if (createBatchOrderError.code === 422) {
|
|
184
|
+
handleError(SubscriptionErrorMessageId.ERROR_BATCH_ORDER_MAX_ORDERS);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
176
187
|
handleError();
|
|
177
188
|
},
|
|
178
189
|
onSuccess: async (createdBatchOrder: any) => {
|
|
@@ -353,4 +353,158 @@ describe('SaleTunnel', () => {
|
|
|
353
353
|
|
|
354
354
|
screen.getByText('Subscription confirmed!');
|
|
355
355
|
}, 10000);
|
|
356
|
+
|
|
357
|
+
it('should display the appropriate error message when there are not enough seats available', async () => {
|
|
358
|
+
const course = PacedCourseFactory().one();
|
|
359
|
+
const product = ProductFactory().one();
|
|
360
|
+
const offering = OfferingFactory({ course, product, is_withdrawable: false }).one();
|
|
361
|
+
const paymentPlan = PaymentPlanFactory().one();
|
|
362
|
+
const offeringOrganization = OfferingBatchOrderFactory({
|
|
363
|
+
product: { id: product.id, title: product.title },
|
|
364
|
+
}).one();
|
|
365
|
+
|
|
366
|
+
fetchMock.get(
|
|
367
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/`,
|
|
368
|
+
offering,
|
|
369
|
+
);
|
|
370
|
+
fetchMock.get(
|
|
371
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
372
|
+
paymentPlan,
|
|
373
|
+
);
|
|
374
|
+
fetchMock.get(`https://joanie.endpoint/api/v1.0/enrollments/`, []);
|
|
375
|
+
fetchMock.get(
|
|
376
|
+
`https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify({
|
|
377
|
+
product_id: product.id,
|
|
378
|
+
course_code: course.code,
|
|
379
|
+
state: NOT_CANCELED_ORDER_STATES,
|
|
380
|
+
})}`,
|
|
381
|
+
[],
|
|
382
|
+
);
|
|
383
|
+
fetchMock.get(
|
|
384
|
+
`https://joanie.endpoint/api/v1.0/offerings/${offering.id}/get-organizations/`,
|
|
385
|
+
offeringOrganization,
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
render(<CourseProductItem productId={product.id} course={course} />, {
|
|
389
|
+
queryOptions: { client: createTestQueryClient({ user: richieUser }) },
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// Verify product info
|
|
393
|
+
await screen.findByRole('heading', { level: 3, name: product.title });
|
|
394
|
+
await screen.findByText(formatPrice(product.price_currency, product.price));
|
|
395
|
+
expect(screen.queryByText('Purchased')).not.toBeInTheDocument();
|
|
396
|
+
|
|
397
|
+
const user = userEvent.setup();
|
|
398
|
+
const buyButton = screen.getByRole('button', { name: product.call_to_action });
|
|
399
|
+
|
|
400
|
+
expect(screen.queryByTestId('generic-sale-tunnel-payment-step')).not.toBeInTheDocument();
|
|
401
|
+
await user.click(buyButton);
|
|
402
|
+
await screen.findByTestId('generic-sale-tunnel-payment-step');
|
|
403
|
+
|
|
404
|
+
// Verify learning path
|
|
405
|
+
await screen.findByText('Your learning path');
|
|
406
|
+
const targetCourses = await screen.findAllByTestId('product-target-course');
|
|
407
|
+
expect(targetCourses).toHaveLength(product.target_courses.length);
|
|
408
|
+
targetCourses.forEach((targetCourse, index) => {
|
|
409
|
+
const courseItem = product.target_courses[index];
|
|
410
|
+
const courseDetail = within(targetCourse).getByTestId(
|
|
411
|
+
`target-course-detail-${courseItem.code}`,
|
|
412
|
+
);
|
|
413
|
+
const summary = courseDetail.querySelector('summary')!;
|
|
414
|
+
expect(summary).toHaveTextContent(courseItem.title);
|
|
415
|
+
|
|
416
|
+
const courseRuns = targetCourse.querySelectorAll(
|
|
417
|
+
'.product-detail-row__course-run-dates__item',
|
|
418
|
+
);
|
|
419
|
+
const openedCourseRuns = courseItem.course_runs.filter(
|
|
420
|
+
(cr: CourseRun) => cr.state.priority <= Priority.FUTURE_NOT_YET_OPEN,
|
|
421
|
+
);
|
|
422
|
+
expect(courseRuns).toHaveLength(openedCourseRuns.length);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
// Select group buy form
|
|
426
|
+
await screen.findByText('Purchase type');
|
|
427
|
+
const formTypeSelect = screen.getByRole('combobox', { name: 'Purchase type' });
|
|
428
|
+
const menu: HTMLDivElement = screen.getByRole('listbox', { name: 'Purchase type' });
|
|
429
|
+
expectMenuToBeClosed(menu);
|
|
430
|
+
await user.click(formTypeSelect);
|
|
431
|
+
expectMenuToBeOpen(menu);
|
|
432
|
+
await user.click(screen.getByText('Group purchase (B2B)'));
|
|
433
|
+
|
|
434
|
+
// Company step
|
|
435
|
+
const $companyName = await screen.findByRole('textbox', { name: 'Company name' });
|
|
436
|
+
const $idNumber = screen.getByRole('textbox', { name: /Identification number/ });
|
|
437
|
+
const $address = screen.getByRole('textbox', { name: 'Address' });
|
|
438
|
+
const $postCode = screen.getByRole('textbox', { name: 'Post code' });
|
|
439
|
+
const $city = screen.getByRole('textbox', { name: 'City' });
|
|
440
|
+
const $country = screen.getByRole('combobox', { name: 'Country' });
|
|
441
|
+
|
|
442
|
+
await user.type($companyName, 'GIP-FUN');
|
|
443
|
+
await user.type($idNumber, '789 242 229 01694');
|
|
444
|
+
await user.type($address, '61 Bis Rue de la Glaciere');
|
|
445
|
+
await user.type($postCode, '75013');
|
|
446
|
+
await user.type($city, 'Paris');
|
|
447
|
+
|
|
448
|
+
const countryMenu: HTMLDivElement = screen.getByRole('listbox', { name: 'Country' });
|
|
449
|
+
await user.click($country);
|
|
450
|
+
expectMenuToBeOpen(countryMenu);
|
|
451
|
+
await user.click(screen.getByText('France'));
|
|
452
|
+
|
|
453
|
+
expect($companyName).toHaveValue('GIP-FUN');
|
|
454
|
+
const visibleValue = $country.querySelector('.c__select__inner__value span');
|
|
455
|
+
expect(visibleValue!.textContent).toBe('France');
|
|
456
|
+
|
|
457
|
+
// Follow-up step
|
|
458
|
+
await user.click(screen.getByRole('button', { name: 'Next' }));
|
|
459
|
+
const $lastName = await screen.findByRole('textbox', { name: 'Last name' });
|
|
460
|
+
const $firstName = screen.getByRole('textbox', { name: 'First name' });
|
|
461
|
+
const $role = screen.getByRole('textbox', { name: 'Role' });
|
|
462
|
+
const $email = screen.getByRole('textbox', { name: 'Email' });
|
|
463
|
+
const $phone = screen.getByRole('textbox', { name: 'Phone' });
|
|
464
|
+
|
|
465
|
+
await user.type($lastName, 'Doe');
|
|
466
|
+
await user.type($firstName, 'John');
|
|
467
|
+
await user.type($role, 'HR');
|
|
468
|
+
await user.type($email, 'john.doe@fun-mooc.com');
|
|
469
|
+
await user.type($phone, '+338203920103');
|
|
470
|
+
|
|
471
|
+
expect($lastName).toHaveValue('Doe');
|
|
472
|
+
expect($email).toHaveValue('john.doe@fun-mooc.com');
|
|
473
|
+
|
|
474
|
+
// Signatory step
|
|
475
|
+
await user.click(screen.getByRole('button', { name: 'Next' }));
|
|
476
|
+
const $signatoryLastName = await screen.findByRole('textbox', { name: 'Last name' });
|
|
477
|
+
const $signatoryFirstName = screen.getByRole('textbox', { name: 'First name' });
|
|
478
|
+
const $signatoryRole = screen.getByRole('textbox', { name: 'Role' });
|
|
479
|
+
const $signatoryEmail = screen.getByRole('textbox', { name: 'Email' });
|
|
480
|
+
const $signatoryPhone = screen.getByRole('textbox', { name: 'Phone' });
|
|
481
|
+
|
|
482
|
+
await user.type($signatoryLastName, 'Doe');
|
|
483
|
+
await user.type($signatoryFirstName, 'John');
|
|
484
|
+
await user.type($signatoryRole, 'CEO');
|
|
485
|
+
await user.type($signatoryEmail, 'john.doe@fun-mooc.com');
|
|
486
|
+
await user.type($signatoryPhone, '+338203920103');
|
|
487
|
+
|
|
488
|
+
// Participants step
|
|
489
|
+
await user.click(screen.getByRole('button', { name: 'Next' }));
|
|
490
|
+
const $nbParticipants = await screen.findByLabelText('How many participants ?');
|
|
491
|
+
await user.type($nbParticipants, '13');
|
|
492
|
+
expect($nbParticipants).toHaveValue(13);
|
|
493
|
+
|
|
494
|
+
fetchMock.post('https://joanie.endpoint/api/v1.0/batch-orders/', {
|
|
495
|
+
status: 422,
|
|
496
|
+
body: {
|
|
497
|
+
__all__: ['Maximum number of orders reached for product Credential Product'],
|
|
498
|
+
},
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
const $subscribeButton = screen.getByRole('button', {
|
|
502
|
+
name: `Subscribe`,
|
|
503
|
+
}) as HTMLButtonElement;
|
|
504
|
+
await user.click($subscribeButton);
|
|
505
|
+
|
|
506
|
+
await screen.findByText(
|
|
507
|
+
'Unable to create the order: the maximum number of available seats for this offering has been reached. Please contact support for more information.',
|
|
508
|
+
);
|
|
509
|
+
}, 30000);
|
|
356
510
|
});
|
|
@@ -2,7 +2,7 @@ import fetchMock from 'fetch-mock';
|
|
|
2
2
|
import { cleanup, screen } from '@testing-library/react';
|
|
3
3
|
import userEvent, { UserEvent } from '@testing-library/user-event';
|
|
4
4
|
import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
|
|
5
|
-
import { OrganizationQuoteFactory } from 'utils/test/factories/joanie';
|
|
5
|
+
import { OrganizationFactory, OrganizationQuoteFactory } from 'utils/test/factories/joanie';
|
|
6
6
|
import { expectNoSpinner } from 'utils/test/expectSpinner';
|
|
7
7
|
import { setupJoanieSession } from 'utils/test/wrappers/JoanieAppWrapper';
|
|
8
8
|
import { render } from 'utils/test/render';
|
|
@@ -35,6 +35,17 @@ describe('full process for the organization quotes dashboard', () => {
|
|
|
35
35
|
it('should works with the full process workflow for any payment methods', async () => {
|
|
36
36
|
fetchMock.get(`https://joanie.endpoint/api/v1.0/organizations/`, []);
|
|
37
37
|
|
|
38
|
+
const organization = OrganizationFactory({
|
|
39
|
+
abilities: {
|
|
40
|
+
can_submit_for_signature_batch_order: true,
|
|
41
|
+
confirm_bank_transfer: true,
|
|
42
|
+
confirm_quote: true,
|
|
43
|
+
download_quote: true,
|
|
44
|
+
sign_contracts: true,
|
|
45
|
+
},
|
|
46
|
+
}).one();
|
|
47
|
+
fetchMock.get('https://joanie.endpoint/api/v1.0/organizations/1/', organization);
|
|
48
|
+
|
|
38
49
|
const quoteQuoted = OrganizationQuoteFactory({
|
|
39
50
|
batch_order: {
|
|
40
51
|
state: BatchOrderState.QUOTED,
|
|
@@ -129,7 +140,6 @@ describe('full process for the organization quotes dashboard', () => {
|
|
|
129
140
|
await user.click(toggle);
|
|
130
141
|
expect(card).toHaveClass('dashboard-card--opened');
|
|
131
142
|
|
|
132
|
-
// Download quote
|
|
133
143
|
const downloadQuoteButton = await screen.findByRole('button', {
|
|
134
144
|
name: /Download quote/i,
|
|
135
145
|
});
|
|
@@ -147,7 +157,7 @@ describe('full process for the organization quotes dashboard', () => {
|
|
|
147
157
|
|
|
148
158
|
// Second step : to sign quote
|
|
149
159
|
const sendForSignatureButton = await screen.findByRole('button', {
|
|
150
|
-
name: /send
|
|
160
|
+
name: /send contract for signature/i,
|
|
151
161
|
});
|
|
152
162
|
expect(sendForSignatureButton).toBeVisible();
|
|
153
163
|
await user.click(sendForSignatureButton);
|
|
@@ -2,11 +2,12 @@ import fetchMock from 'fetch-mock';
|
|
|
2
2
|
import { screen, waitFor } from '@testing-library/react';
|
|
3
3
|
import userEvent, { UserEvent } from '@testing-library/user-event';
|
|
4
4
|
import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
|
|
5
|
-
import { OrganizationQuoteFactory } from 'utils/test/factories/joanie';
|
|
5
|
+
import { OrganizationFactory, OrganizationQuoteFactory } from 'utils/test/factories/joanie';
|
|
6
6
|
import { expectNoSpinner } from 'utils/test/expectSpinner';
|
|
7
7
|
import { setupJoanieSession } from 'utils/test/wrappers/JoanieAppWrapper';
|
|
8
8
|
import { render } from 'utils/test/render';
|
|
9
9
|
import { expectBannerInfo, expectBannerError } from 'utils/test/expectBanner';
|
|
10
|
+
import { BatchOrderState } from 'types/Joanie';
|
|
10
11
|
import TeacherDashboardOrganizationQuotes from '.';
|
|
11
12
|
|
|
12
13
|
let user: UserEvent;
|
|
@@ -29,6 +30,9 @@ describe('pages/TeacherDashboardOrganizationQuotes', () => {
|
|
|
29
30
|
it('should render a list of quotes for an organization', async () => {
|
|
30
31
|
const quoteList = OrganizationQuoteFactory().many(1);
|
|
31
32
|
fetchMock.get(`https://joanie.endpoint/api/v1.0/organizations/`, []);
|
|
33
|
+
|
|
34
|
+
fetchMock.get('https://joanie.endpoint/api/v1.0/organizations/1/', []);
|
|
35
|
+
|
|
32
36
|
fetchMock.get(`https://joanie.endpoint/api/v1.0/organizations/1/quotes/?page=1&page_size=10`, {
|
|
33
37
|
results: quoteList,
|
|
34
38
|
count: 0,
|
|
@@ -59,6 +63,9 @@ describe('pages/TeacherDashboardOrganizationQuotes', () => {
|
|
|
59
63
|
|
|
60
64
|
it('should render an empty list of quotes for an organization', async () => {
|
|
61
65
|
fetchMock.get(`https://joanie.endpoint/api/v1.0/organizations/`, []);
|
|
66
|
+
|
|
67
|
+
fetchMock.get('https://joanie.endpoint/api/v1.0/organizations/1/', []);
|
|
68
|
+
|
|
62
69
|
fetchMock.get(`https://joanie.endpoint/api/v1.0/organizations/1/quotes/?page=1&page_size=10`, {
|
|
63
70
|
results: [],
|
|
64
71
|
count: 0,
|
|
@@ -81,6 +88,8 @@ describe('pages/TeacherDashboardOrganizationQuotes', () => {
|
|
|
81
88
|
const quoteList = OrganizationQuoteFactory().many(30);
|
|
82
89
|
fetchMock.get(`https://joanie.endpoint/api/v1.0/organizations/`, []);
|
|
83
90
|
|
|
91
|
+
fetchMock.get('https://joanie.endpoint/api/v1.0/organizations/1/', []);
|
|
92
|
+
|
|
84
93
|
fetchMock.get(`https://joanie.endpoint/api/v1.0/organizations/1/quotes/?page=1&page_size=10`, {
|
|
85
94
|
results: quoteList.slice(0, 10),
|
|
86
95
|
count: 30,
|
|
@@ -126,6 +135,9 @@ describe('pages/TeacherDashboardOrganizationQuotes', () => {
|
|
|
126
135
|
|
|
127
136
|
it('should display an error when API fails', async () => {
|
|
128
137
|
fetchMock.get('https://joanie.endpoint/api/v1.0/organizations/', []);
|
|
138
|
+
|
|
139
|
+
fetchMock.get('https://joanie.endpoint/api/v1.0/organizations/1/', []);
|
|
140
|
+
|
|
129
141
|
fetchMock.get(
|
|
130
142
|
'https://joanie.endpoint/api/v1.0/organizations/1/quotes/?page=1&page_size=10',
|
|
131
143
|
500,
|
|
@@ -141,4 +153,49 @@ describe('pages/TeacherDashboardOrganizationQuotes', () => {
|
|
|
141
153
|
await expectNoSpinner();
|
|
142
154
|
await expectBannerError('An error occurred while fetching resources. Please retry later.');
|
|
143
155
|
});
|
|
156
|
+
|
|
157
|
+
it('should render disabled buttons when the user is not allowed', async () => {
|
|
158
|
+
const quoteQuoted = OrganizationQuoteFactory({
|
|
159
|
+
batch_order: {
|
|
160
|
+
state: BatchOrderState.QUOTED,
|
|
161
|
+
available_actions: { next_action: 'confirm_quote' },
|
|
162
|
+
},
|
|
163
|
+
}).one();
|
|
164
|
+
fetchMock.get(`https://joanie.endpoint/api/v1.0/organizations/`, []);
|
|
165
|
+
|
|
166
|
+
const organization = OrganizationFactory({
|
|
167
|
+
abilities: {
|
|
168
|
+
can_submit_for_signature_batch_order: false,
|
|
169
|
+
confirm_bank_transfer: false,
|
|
170
|
+
confirm_quote: false,
|
|
171
|
+
download_quote: true,
|
|
172
|
+
sign_contracts: false,
|
|
173
|
+
},
|
|
174
|
+
}).one();
|
|
175
|
+
fetchMock.get('https://joanie.endpoint/api/v1.0/organizations/1/', organization);
|
|
176
|
+
|
|
177
|
+
fetchMock.get(`https://joanie.endpoint/api/v1.0/organizations/1/quotes/?page=1&page_size=10`, {
|
|
178
|
+
results: [quoteQuoted],
|
|
179
|
+
count: 1,
|
|
180
|
+
previous: null,
|
|
181
|
+
next: null,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
render(<TeacherDashboardOrganizationQuotes />, {
|
|
185
|
+
routerOptions: {
|
|
186
|
+
path: '/organizations/:organizationId/quotes',
|
|
187
|
+
initialEntries: ['/organizations/1/quotes'],
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
await expectNoSpinner();
|
|
192
|
+
|
|
193
|
+
const downloadQuoteButton = await screen.findByRole('button', { name: /Download quote/i });
|
|
194
|
+
expect(downloadQuoteButton).toBeVisible();
|
|
195
|
+
expect(downloadQuoteButton).not.toBeDisabled();
|
|
196
|
+
|
|
197
|
+
const confirmQuoteButton = await screen.findByRole('button', { name: /Confirm quote/i });
|
|
198
|
+
expect(confirmQuoteButton).toBeVisible();
|
|
199
|
+
expect(confirmQuoteButton).toBeDisabled();
|
|
200
|
+
});
|
|
144
201
|
});
|
|
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|
|
4
4
|
import { useParams } from 'react-router';
|
|
5
5
|
import Banner, { BannerType } from 'components/Banner';
|
|
6
6
|
import { useOrganizationsQuotes } from 'hooks/useOrganizationQuotes';
|
|
7
|
+
import { useOrganization } from 'hooks/useOrganizations';
|
|
7
8
|
import { TeacherDashboardContractsParams } from 'pages/TeacherDashboardContractsLayout/hooks/useTeacherContractFilters';
|
|
8
9
|
import { BatchOrderState, OrganizationQuote } from 'types/Joanie';
|
|
9
10
|
import { PaymentMethod } from 'components/PaymentInterfaces/types';
|
|
@@ -71,9 +72,9 @@ const messages = defineMessages({
|
|
|
71
72
|
description: 'Label for confirming a bank transfer',
|
|
72
73
|
},
|
|
73
74
|
sendForSignature: {
|
|
74
|
-
defaultMessage: 'Send
|
|
75
|
+
defaultMessage: 'Send contract for signature',
|
|
75
76
|
id: 'components.OrganizationQuotesTable.sendForSignature',
|
|
76
|
-
description: 'Action label to send a
|
|
77
|
+
description: 'Action label to send a contract for signature',
|
|
77
78
|
},
|
|
78
79
|
waitingSignature: {
|
|
79
80
|
defaultMessage: 'Waiting signature',
|
|
@@ -215,6 +216,13 @@ const messages = defineMessages({
|
|
|
215
216
|
const TeacherDashboardOrganizationQuotes = () => {
|
|
216
217
|
const intl = useIntl();
|
|
217
218
|
const { organizationId: routeOrganizationId } = useParams<TeacherDashboardContractsParams>();
|
|
219
|
+
const {
|
|
220
|
+
item: organization,
|
|
221
|
+
states: { isPending: isOrganizationPending },
|
|
222
|
+
} = useOrganization(routeOrganizationId);
|
|
223
|
+
|
|
224
|
+
const abilities = organization?.abilities;
|
|
225
|
+
|
|
218
226
|
const pagination = usePagination({ itemsPerPage: 10 });
|
|
219
227
|
|
|
220
228
|
const {
|
|
@@ -249,7 +257,7 @@ const TeacherDashboardOrganizationQuotes = () => {
|
|
|
249
257
|
|
|
250
258
|
if (error) return <Banner message={error} type={BannerType.ERROR} rounded />;
|
|
251
259
|
|
|
252
|
-
if (isPending)
|
|
260
|
+
if (isPending || isOrganizationPending)
|
|
253
261
|
return (
|
|
254
262
|
<Spinner size="large">
|
|
255
263
|
<span id="loading-contract-data">
|
|
@@ -336,6 +344,7 @@ const TeacherDashboardOrganizationQuotes = () => {
|
|
|
336
344
|
className="mr-2"
|
|
337
345
|
onClick={() => handleDownloadQuote(quote.id)}
|
|
338
346
|
icon={<span className="material-icons">download</span>}
|
|
347
|
+
disabled={!abilities?.download_quote}
|
|
339
348
|
>
|
|
340
349
|
{intl.formatMessage(messages.downloadQuote)}
|
|
341
350
|
</Button>
|
|
@@ -343,6 +352,7 @@ const TeacherDashboardOrganizationQuotes = () => {
|
|
|
343
352
|
size="small"
|
|
344
353
|
onClick={() => handleOpenConfirm(quote.id)}
|
|
345
354
|
icon={<span className="material-icons">check_circle</span>}
|
|
355
|
+
disabled={!abilities?.confirm_quote}
|
|
346
356
|
>
|
|
347
357
|
{intl.formatMessage(messages.confirmQuote)}
|
|
348
358
|
</Button>
|
|
@@ -365,6 +375,7 @@ const TeacherDashboardOrganizationQuotes = () => {
|
|
|
365
375
|
size="small"
|
|
366
376
|
onClick={() => handleConfirmBankTransfer(quote.batch_order.id)}
|
|
367
377
|
icon={<span className="material-icons">account_balance</span>}
|
|
378
|
+
disabled={!abilities?.confirm_bank_transfer}
|
|
368
379
|
>
|
|
369
380
|
{intl.formatMessage(messages.confirmBank)}
|
|
370
381
|
</Button>
|
|
@@ -373,7 +384,7 @@ const TeacherDashboardOrganizationQuotes = () => {
|
|
|
373
384
|
const submitForSignatureButton = (
|
|
374
385
|
<Button
|
|
375
386
|
size="small"
|
|
376
|
-
disabled={batchOrder.contract_submitted}
|
|
387
|
+
disabled={batchOrder.contract_submitted || !abilities?.can_submit_for_signature_batch_order}
|
|
377
388
|
onClick={() =>
|
|
378
389
|
!batchOrder.contract_submitted && handleSubmitForSignature(quote.batch_order.id)
|
|
379
390
|
}
|
package/js/types/Joanie.ts
CHANGED
|
@@ -35,6 +35,18 @@ export interface Organization {
|
|
|
35
35
|
contact_phone: Nullable<string>;
|
|
36
36
|
dpo_email: Nullable<string>;
|
|
37
37
|
address?: Address;
|
|
38
|
+
abilities: {
|
|
39
|
+
can_submit_for_signature_batch_order: boolean;
|
|
40
|
+
confirm_bank_transfer: boolean;
|
|
41
|
+
confirm_quote: boolean;
|
|
42
|
+
delete: boolean;
|
|
43
|
+
download_quote: boolean;
|
|
44
|
+
get: boolean;
|
|
45
|
+
manage_accesses: boolean;
|
|
46
|
+
patch: boolean;
|
|
47
|
+
put: boolean;
|
|
48
|
+
sign_contracts: boolean;
|
|
49
|
+
};
|
|
38
50
|
}
|
|
39
51
|
|
|
40
52
|
export interface OrganizationResourceQuery extends ResourcesQuery {
|
package/js/types/api.ts
CHANGED
|
@@ -14,6 +14,17 @@ export interface LMSBackend {
|
|
|
14
14
|
export interface AuthenticationBackend {
|
|
15
15
|
backend: string;
|
|
16
16
|
endpoint: string;
|
|
17
|
+
client_id?: string;
|
|
18
|
+
realm?: string;
|
|
19
|
+
token?: string;
|
|
20
|
+
auth_url?: string;
|
|
21
|
+
registration_url?: string;
|
|
22
|
+
user_info_url?: string;
|
|
23
|
+
logout_url?: string;
|
|
24
|
+
user?: {
|
|
25
|
+
username: string;
|
|
26
|
+
email: string;
|
|
27
|
+
};
|
|
17
28
|
}
|
|
18
29
|
|
|
19
30
|
enum FEATURES {
|
|
@@ -171,6 +171,18 @@ export const OrganizationFactory = factory((): Organization => {
|
|
|
171
171
|
dpo_email: faker.internet.email(),
|
|
172
172
|
contact_phone: faker.phone.number(),
|
|
173
173
|
address: AddressFactory().one(),
|
|
174
|
+
abilities: {
|
|
175
|
+
can_submit_for_signature_batch_order: faker.datatype.boolean(),
|
|
176
|
+
confirm_bank_transfer: faker.datatype.boolean(),
|
|
177
|
+
confirm_quote: faker.datatype.boolean(),
|
|
178
|
+
delete: faker.datatype.boolean(),
|
|
179
|
+
download_quote: faker.datatype.boolean(),
|
|
180
|
+
get: faker.datatype.boolean(),
|
|
181
|
+
manage_accesses: faker.datatype.boolean(),
|
|
182
|
+
patch: faker.datatype.boolean(),
|
|
183
|
+
put: faker.datatype.boolean(),
|
|
184
|
+
sign_contracts: faker.datatype.boolean(),
|
|
185
|
+
},
|
|
174
186
|
};
|
|
175
187
|
});
|
|
176
188
|
|
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
DashboardSidebarProps,
|
|
8
8
|
} from 'widgets/Dashboard/components/DashboardSidebar';
|
|
9
9
|
import { useSession } from 'contexts/SessionContext';
|
|
10
|
+
import { APIBackend } from 'types/api';
|
|
11
|
+
import context from 'utils/context';
|
|
10
12
|
import { UserHelper } from 'utils/UserHelper';
|
|
11
13
|
|
|
12
14
|
import { LearnerDashboardPaths } from 'widgets/Dashboard/utils/learnerRoutesPaths';
|
|
@@ -30,15 +32,18 @@ export const LearnerDashboardSidebar = (props: Partial<DashboardSidebarProps>) =
|
|
|
30
32
|
|
|
31
33
|
const getRouteLabel = getDashboardRouteLabel(intl);
|
|
32
34
|
|
|
35
|
+
const dashboardPaths = [
|
|
36
|
+
LearnerDashboardPaths.COURSES,
|
|
37
|
+
LearnerDashboardPaths.CERTIFICATES,
|
|
38
|
+
LearnerDashboardPaths.CONTRACTS,
|
|
39
|
+
LearnerDashboardPaths.BATCH_ORDERS,
|
|
40
|
+
];
|
|
41
|
+
if (context?.authentication.backend !== APIBackend.KEYCLOAK) {
|
|
42
|
+
dashboardPaths.push(LearnerDashboardPaths.PREFERENCES);
|
|
43
|
+
}
|
|
33
44
|
const links = useMemo(
|
|
34
45
|
() =>
|
|
35
|
-
|
|
36
|
-
LearnerDashboardPaths.COURSES,
|
|
37
|
-
LearnerDashboardPaths.CERTIFICATES,
|
|
38
|
-
LearnerDashboardPaths.CONTRACTS,
|
|
39
|
-
LearnerDashboardPaths.PREFERENCES,
|
|
40
|
-
LearnerDashboardPaths.BATCH_ORDERS,
|
|
41
|
-
].map((path) => ({
|
|
46
|
+
dashboardPaths.map((path) => ({
|
|
42
47
|
to: generatePath(path),
|
|
43
48
|
label: getRouteLabel(path),
|
|
44
49
|
activePaths:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getByText, screen } from '@testing-library/react';
|
|
1
|
+
import { getByText, queryByText, screen } from '@testing-library/react';
|
|
2
2
|
import fetchMock from 'fetch-mock';
|
|
3
3
|
import userEvent from '@testing-library/user-event';
|
|
4
4
|
import {
|
|
@@ -17,6 +17,7 @@ import { createTestQueryClient } from 'utils/test/createTestQueryClient';
|
|
|
17
17
|
import { BaseJoanieAppWrapper } from 'utils/test/wrappers/BaseJoanieAppWrapper';
|
|
18
18
|
import { expectNoSpinner } from 'utils/test/expectSpinner';
|
|
19
19
|
import { LearnerDashboardPaths } from 'widgets/Dashboard/utils/learnerRoutesPaths';
|
|
20
|
+
import context from 'utils/context';
|
|
20
21
|
import { DashboardTest } from './components/DashboardTest';
|
|
21
22
|
|
|
22
23
|
jest.mock('utils/context', () => ({
|
|
@@ -210,4 +211,23 @@ describe('<Dashboard />', () => {
|
|
|
210
211
|
expectUrlMatchLocationDisplayed('/dummy/route');
|
|
211
212
|
expect(screen.getByRole('heading', { name: /Page not found/ }));
|
|
212
213
|
});
|
|
214
|
+
|
|
215
|
+
it('should not show preferences link when using keycloak backend', async () => {
|
|
216
|
+
// Temporarily change the authentication backend to keycloak
|
|
217
|
+
const originalBackend = context.authentication.backend;
|
|
218
|
+
context.authentication.backend = 'keycloak';
|
|
219
|
+
|
|
220
|
+
render(<DashboardTest initialRoute={LearnerDashboardPaths.COURSES} />, {
|
|
221
|
+
wrapper: BaseJoanieAppWrapper,
|
|
222
|
+
});
|
|
223
|
+
await expectNoSpinner('Loading orders and enrollments...');
|
|
224
|
+
|
|
225
|
+
const sidebar = screen.getByTestId('dashboard__sidebar');
|
|
226
|
+
|
|
227
|
+
// Verify that "My preferences" link is NOT present in the sidebar
|
|
228
|
+
expect(queryByText(sidebar, 'My preferences')).toBeNull();
|
|
229
|
+
|
|
230
|
+
// Restore original backend
|
|
231
|
+
context.authentication.backend = originalBackend;
|
|
232
|
+
});
|
|
213
233
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "richie-education",
|
|
3
|
-
"version": "3.2.2-
|
|
3
|
+
"version": "3.2.2-dev36",
|
|
4
4
|
"description": "A CMS to build learning portals for Open Education",
|
|
5
5
|
"main": "sandbox/manage.py",
|
|
6
6
|
"scripts": {
|
|
@@ -118,6 +118,7 @@
|
|
|
118
118
|
"jest": "29.7.0",
|
|
119
119
|
"jest-environment-jsdom": "29.7.0",
|
|
120
120
|
"js-cookie": "3.0.5",
|
|
121
|
+
"keycloak-js": "26.2.2",
|
|
121
122
|
"lodash-es": "4.17.21",
|
|
122
123
|
"mdn-polyfills": "5.20.0",
|
|
123
124
|
"msw": "2.7.3",
|
package/tsconfig.json
CHANGED
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
"module": "esnext",
|
|
20
20
|
"moduleResolution": "node",
|
|
21
21
|
"paths": {
|
|
22
|
-
"intl-pluralrules": ["types/libs/intl-pluralrules"]
|
|
22
|
+
"intl-pluralrules": ["types/libs/intl-pluralrules"],
|
|
23
|
+
"keycloak-js": ["../node_modules/keycloak-js/lib/keycloak"]
|
|
23
24
|
},
|
|
24
25
|
"resolveJsonModule": true,
|
|
25
26
|
"skipLibCheck": true,
|