@peassoft/mnr-web-topline 0.1.0 → 0.2.1

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.
Files changed (46) hide show
  1. package/dist/css/index.css +17 -0
  2. package/dist/en/modules/logger/index.d.ts +1 -0
  3. package/dist/en/modules/logger/index.js +3 -0
  4. package/dist/en/modules/ui-lang/index.d.ts +8 -0
  5. package/dist/en/modules/ui-lang/index.js +20 -0
  6. package/dist/en/modules/user-data-change-beacon/index.d.ts +14 -0
  7. package/dist/en/modules/user-data-change-beacon/index.js +25 -0
  8. package/dist/en/modules/websocket/index.js +29 -14
  9. package/dist/en/parts/profile/actions/save-user-data/index.d.ts +9 -0
  10. package/dist/en/parts/profile/actions/save-user-data/index.js +53 -0
  11. package/dist/en/parts/profile/index.d.ts +1 -0
  12. package/dist/en/parts/profile/index.js +1 -0
  13. package/dist/en/parts/profile/ui/index.d.ts +6 -0
  14. package/dist/en/parts/profile/ui/index.js +47 -0
  15. package/dist/en/parts/profile/ui/root/get-initial-ui-lang.d.ts +2 -0
  16. package/dist/en/parts/profile/ui/root/get-initial-ui-lang.js +4 -0
  17. package/dist/en/parts/profile/ui/root/get-initial-user-name.d.ts +2 -0
  18. package/dist/en/parts/profile/ui/root/get-initial-user-name.js +3 -0
  19. package/dist/en/parts/profile/ui/root/has-changes.d.ts +7 -0
  20. package/dist/en/parts/profile/ui/root/has-changes.js +11 -0
  21. package/dist/en/parts/profile/ui/root/index.d.ts +9 -0
  22. package/dist/en/parts/profile/ui/root/index.js +95 -0
  23. package/dist/en/parts/shell/ui/shell/index.js +31 -11
  24. package/dist/ru/modules/logger/index.d.ts +1 -0
  25. package/dist/ru/modules/logger/index.js +3 -0
  26. package/dist/ru/modules/ui-lang/index.d.ts +8 -0
  27. package/dist/ru/modules/ui-lang/index.js +20 -0
  28. package/dist/ru/modules/user-data-change-beacon/index.d.ts +14 -0
  29. package/dist/ru/modules/user-data-change-beacon/index.js +25 -0
  30. package/dist/ru/modules/websocket/index.js +29 -14
  31. package/dist/ru/parts/profile/actions/save-user-data/index.d.ts +9 -0
  32. package/dist/ru/parts/profile/actions/save-user-data/index.js +53 -0
  33. package/dist/ru/parts/profile/index.d.ts +1 -0
  34. package/dist/ru/parts/profile/index.js +1 -0
  35. package/dist/ru/parts/profile/ui/index.d.ts +6 -0
  36. package/dist/ru/parts/profile/ui/index.js +47 -0
  37. package/dist/ru/parts/profile/ui/root/get-initial-ui-lang.d.ts +2 -0
  38. package/dist/ru/parts/profile/ui/root/get-initial-ui-lang.js +4 -0
  39. package/dist/ru/parts/profile/ui/root/get-initial-user-name.d.ts +2 -0
  40. package/dist/ru/parts/profile/ui/root/get-initial-user-name.js +3 -0
  41. package/dist/ru/parts/profile/ui/root/has-changes.d.ts +7 -0
  42. package/dist/ru/parts/profile/ui/root/has-changes.js +11 -0
  43. package/dist/ru/parts/profile/ui/root/index.d.ts +9 -0
  44. package/dist/ru/parts/profile/ui/root/index.js +95 -0
  45. package/dist/ru/parts/shell/ui/shell/index.js +31 -11
  46. package/package.json +4 -3
@@ -155,6 +155,23 @@
155
155
  font-weight: bolder;
156
156
  }
157
157
 
158
+ .topline_profile_grid {
159
+ display: grid;
160
+ grid-template-columns: 1fr;
161
+ row-gap: 2rem;
162
+ }
163
+
164
+ .topline_profile_fieldCont {
165
+ margin-top: 1.5rem;
166
+ }
167
+
168
+ .topline_profile_buttonsCont {
169
+ margin-top: 2rem;
170
+ display: flex;
171
+ flex-direction: row-reverse;
172
+ gap: 1rem;
173
+ }
174
+
158
175
  .topline_c_modal_overlay {
159
176
  position: fixed;
160
177
  z-index: 1;
@@ -1 +1,2 @@
1
1
  export declare function logError(err: Error): void;
2
+ export declare function logInvariant(msg: string): void;
@@ -1,3 +1,6 @@
1
1
  export function logError(err) {
2
2
  console.error(err);
3
+ }
4
+ export function logInvariant(msg) {
5
+ console.error(msg);
3
6
  }
@@ -0,0 +1,8 @@
1
+ import type { NonEmptyArray } from '../../types/helpers.js';
2
+ type UiLangDescriptor = {
3
+ code: string;
4
+ name: string;
5
+ };
6
+ export declare function getSupportedUiLangs(): NonEmptyArray<UiLangDescriptor>;
7
+ export declare function getDefaultUiLangForUser(): UiLangDescriptor;
8
+ export {};
@@ -0,0 +1,20 @@
1
+ const supportedUiLangs = [{
2
+ code: 'en',
3
+ name: 'English'
4
+ }, {
5
+ code: 'ru',
6
+ name: 'Русский'
7
+ }];
8
+ export function getSupportedUiLangs() {
9
+ return window.structuredClone(supportedUiLangs);
10
+ }
11
+ export function getDefaultUiLangForUser() {
12
+ for (const lang of window.navigator.languages) {
13
+ const normalizedLangCode = lang.slice(0, 2).toLowerCase();
14
+ const matchedDescriptor = supportedUiLangs.find(x => x.code === normalizedLangCode);
15
+ if (matchedDescriptor) {
16
+ return window.structuredClone(matchedDescriptor);
17
+ }
18
+ }
19
+ return supportedUiLangs[0];
20
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Create new beacon for a user data change operation.
3
+ *
4
+ * Saves the created beacon in the local store.
5
+ */
6
+ export declare function createUserDataChangeBeacon(): string;
7
+ /**
8
+ * Check whether given beacon exists in the local store.
9
+ */
10
+ export declare function userDataChangeBeaconExists(beacon: string): boolean;
11
+ /**
12
+ * Remove given beacon from the local store
13
+ */
14
+ export declare function removeUserDataChangeBeacon(beacon: string): void;
@@ -0,0 +1,25 @@
1
+ import { v4 } from 'uuid';
2
+ // Local store
3
+ const userDataChangeBeacons = new Set();
4
+ /**
5
+ * Create new beacon for a user data change operation.
6
+ *
7
+ * Saves the created beacon in the local store.
8
+ */
9
+ export function createUserDataChangeBeacon() {
10
+ const beacon = v4();
11
+ userDataChangeBeacons.add(beacon);
12
+ return beacon;
13
+ }
14
+ /**
15
+ * Check whether given beacon exists in the local store.
16
+ */
17
+ export function userDataChangeBeaconExists(beacon) {
18
+ return userDataChangeBeacons.has(beacon);
19
+ }
20
+ /**
21
+ * Remove given beacon from the local store
22
+ */
23
+ export function removeUserDataChangeBeacon(beacon) {
24
+ userDataChangeBeacons.delete(beacon);
25
+ }
@@ -4,6 +4,9 @@ import { getGrantToken } from '../tokens/index.js';
4
4
  import { logError } from '../logger/index.js';
5
5
  import { innerToplineService, ToplineEventName } from '../topline-service/index.js';
6
6
  import { isObject } from '../../types/helpers.js';
7
+ import { isToplineUser } from '../../types/data.js';
8
+ import { userDataChangeBeaconExists, removeUserDataChangeBeacon } from '../user-data-change-beacon/index.js';
9
+ import { updateUser } from '../local-db/index.js';
7
10
  let activeSocket = null;
8
11
  let currConnectionRetries = 0;
9
12
  const MAX_RECONNECTION_DELAY_MS = 30000;
@@ -22,26 +25,37 @@ const closeCodes = {
22
25
  };
23
26
  /** Handle incoming message from websocket */
24
27
  function handleSocketMessage(e) {
25
- if (e.data === 'logout') {
26
- innerToplineService.processLogout();
27
- closeWebsocket();
28
- } else {
29
- const message = JSON.parse(e.data);
30
- if (isObject(message) && 'syncId' in message && typeof message.syncId === 'string') {
31
- innerToplineService.emit(ToplineEventName.SyncNotification, message.syncId);
28
+ const message = JSON.parse(e.data);
29
+ if (isObject(message) && 'type' in message && typeof message.type === 'string') {
30
+ switch (message.type) {
31
+ case 'logout':
32
+ innerToplineService.processLogout();
33
+ closeWebsocket();
34
+ break;
35
+ case 'sync':
36
+ if ('syncId' in message && typeof message.syncId === 'string') {
37
+ innerToplineService.emit(ToplineEventName.SyncNotification, message.syncId);
38
+ }
39
+ break;
40
+ case 'user_data_change':
41
+ if ('beacon' in message && typeof message.beacon === 'string' && 'user' in message && isToplineUser(message.user)) {
42
+ if (!userDataChangeBeaconExists(message.beacon)) {
43
+ removeUserDataChangeBeacon(message.beacon);
44
+ void updateUser(message.user);
45
+ innerToplineService.emit(ToplineEventName.UserChange, message.user);
46
+ }
47
+ }
32
48
  }
33
49
  }
34
50
  }
35
51
  /** Open websocket */
36
52
  export function openWebsocket() {
37
- closeWebsocket();
53
+ if (activeSocket) return;
38
54
  const wsUrl = getWebsocketUrl();
39
- const socket = new window.WebSocket(wsUrl);
40
- socket.addEventListener('open', handleSocketOpen);
41
- socket.addEventListener('close', e => void handleSocketClose(e));
42
- socket.addEventListener('message', handleSocketMessage);
43
- activeSocket = socket;
44
- currConnectionRetries = 0;
55
+ activeSocket = new window.WebSocket(wsUrl);
56
+ activeSocket.addEventListener('open', handleSocketOpen);
57
+ activeSocket.addEventListener('close', e => void handleSocketClose(e));
58
+ activeSocket.addEventListener('message', handleSocketMessage);
45
59
  }
46
60
  /** Close websocket */
47
61
  export function closeWebsocket() {
@@ -51,6 +65,7 @@ export function closeWebsocket() {
51
65
  }
52
66
  /** Handle for websocket "open" event */
53
67
  function handleSocketOpen() {
68
+ currConnectionRetries = 0;
54
69
  this.send(JSON.stringify({
55
70
  type: 'connection',
56
71
  data: {
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Save new user data on backend.
3
+ *
4
+ * Returned Promise is resolved with:
5
+ *
6
+ * - true - if the operation succeeds;
7
+ * - false - if the operation fails due to any reason.
8
+ */
9
+ export default function saveUserData(beacon: string, userName: string, uiLang: string): Promise<boolean>;
@@ -0,0 +1,53 @@
1
+ import { getGrantToken } from '../../../../modules/tokens/index.js';
2
+ import { logInvariant } from '../../../../modules/logger/index.js';
3
+ import { getApiBaseUrl, getRequestCredentialsMode } from '../../../../modules/env/index.js';
4
+ import request from '../../../../modules/request/index.js';
5
+ import { innerToplineService } from '../../../../modules/topline-service/index.js';
6
+ /**
7
+ * Save new user data on backend.
8
+ *
9
+ * Returned Promise is resolved with:
10
+ *
11
+ * - true - if the operation succeeds;
12
+ * - false - if the operation fails due to any reason.
13
+ */
14
+ export default async function saveUserData(beacon, userName, uiLang) {
15
+ const grantToken = getGrantToken();
16
+ if (!grantToken) {
17
+ // This situation is impossible: if a logout event is received on a websocket,
18
+ // this modal window is closed automatically; thus the user has no chance to start
19
+ // a saving operation.
20
+ logInvariant('Invariant: grant token must exist while changing user data');
21
+ return false;
22
+ }
23
+ const url = `${getApiBaseUrl()}/private/user/update-data`;
24
+ const options = {
25
+ method: 'POST',
26
+ headers: {
27
+ Authorization: `Bearer ${grantToken}`,
28
+ 'Content-Type': 'application/json; charset=utf-8'
29
+ },
30
+ body: JSON.stringify({
31
+ beacon,
32
+ userName,
33
+ uiLang
34
+ }),
35
+ credentials: getRequestCredentialsMode(),
36
+ cache: 'no-store',
37
+ redirect: 'error'
38
+ };
39
+ const response = await request(url, options, {
40
+ maxRetries: 1
41
+ });
42
+ if (!response) {
43
+ return false;
44
+ }
45
+ if (response.status === 401) {
46
+ const upgradedGrantToken = await innerToplineService.upgradeGrantToken();
47
+ if (!upgradedGrantToken) {
48
+ return false;
49
+ }
50
+ return await saveUserData(beacon, userName, uiLang);
51
+ }
52
+ return true;
53
+ }
@@ -0,0 +1 @@
1
+ export { default as Profile } from './ui/index.js';
@@ -0,0 +1 @@
1
+ export { default as Profile } from './ui/index.js';
@@ -0,0 +1,6 @@
1
+ import { type JSX } from 'react';
2
+ type Props = {
3
+ onClose: () => unknown;
4
+ };
5
+ export default function Profile(props: Props): JSX.Element | null;
6
+ export {};
@@ -0,0 +1,47 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useContext, useState, useCallback } from 'react';
3
+ import Modal from '../../../shared/components/modal/index.js';
4
+ import Root from './root/index.js';
5
+ import { ToplineContext } from '../../shell/index.js';
6
+ var LocalStep;
7
+ (function (LocalStep) {
8
+ LocalStep["Root"] = "root";
9
+ LocalStep["PasswordChange"] = "password_change";
10
+ })(LocalStep || (LocalStep = {}));
11
+ export default function Profile(props) {
12
+ const {
13
+ onClose
14
+ } = props;
15
+ const {
16
+ user
17
+ } = useContext(ToplineContext);
18
+ const [localStep, setLocalStep] = useState(LocalStep.Root);
19
+ const handleSwitchToPasswordChange = useCallback(() => setLocalStep(LocalStep.PasswordChange), []);
20
+ const getContents = user => {
21
+ switch (localStep) {
22
+ case LocalStep.Root:
23
+ return _jsx(Root, {
24
+ user: user,
25
+ onChangePassword: handleSwitchToPasswordChange,
26
+ onCancel: onClose
27
+ });
28
+ case LocalStep.PasswordChange:
29
+ return _jsx("div", {
30
+ children: "PasswordChange"
31
+ });
32
+ default:
33
+ {
34
+ const _exhaustiveCheck = localStep;
35
+ return _exhaustiveCheck;
36
+ }
37
+ }
38
+ };
39
+ if (user == null) {
40
+ return null;
41
+ }
42
+ return _jsx(Modal, {
43
+ title: "My profile",
44
+ onClose: onClose,
45
+ children: getContents(user)
46
+ });
47
+ }
@@ -0,0 +1,2 @@
1
+ import type { ToplineUser } from '../../../../types/data.js';
2
+ export default function getInitialUiLang(user: ToplineUser): string;
@@ -0,0 +1,4 @@
1
+ import { getDefaultUiLangForUser } from '../../../../modules/ui-lang/index.js';
2
+ export default function getInitialUiLang(user) {
3
+ return user.uiLang ?? getDefaultUiLangForUser().code;
4
+ }
@@ -0,0 +1,2 @@
1
+ import type { ToplineUser } from '../../../../types/data.js';
2
+ export default function getInitialUserName(user: ToplineUser): string;
@@ -0,0 +1,3 @@
1
+ export default function getInitialUserName(user) {
2
+ return user.userName ?? '';
3
+ }
@@ -0,0 +1,7 @@
1
+ import type { ToplineUser } from '../../../../types/data.js';
2
+ type CurrData = {
3
+ userName: string;
4
+ uiLang: string;
5
+ };
6
+ export default function hasChanges(user: ToplineUser, currData: CurrData): boolean;
7
+ export {};
@@ -0,0 +1,11 @@
1
+ import getInitialUserName from './get-initial-user-name.js';
2
+ import getInitialUiLang from './get-initial-ui-lang.js';
3
+ export default function hasChanges(user, currData) {
4
+ if (getInitialUserName(user) !== currData.userName) {
5
+ return true;
6
+ }
7
+ if (getInitialUiLang(user) !== currData.uiLang) {
8
+ return true;
9
+ }
10
+ return false;
11
+ }
@@ -0,0 +1,9 @@
1
+ import { type JSX } from 'react';
2
+ import type { ToplineUser } from '../../../../types/data.js';
3
+ type Props = {
4
+ user: ToplineUser;
5
+ onChangePassword: () => unknown;
6
+ onCancel: () => unknown;
7
+ };
8
+ export default function Root(props: Props): JSX.Element;
9
+ export {};
@@ -0,0 +1,95 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useState, useMemo, useCallback } from 'react';
3
+ import { InputField } from '@peassoft/mnr-web-ui-kit/input-field/index.js';
4
+ import { Select } from '@peassoft/mnr-web-ui-kit/select/index.js';
5
+ import { TextButton } from '@peassoft/mnr-web-ui-kit/text-button/index.js';
6
+ import { Button } from '@peassoft/mnr-web-ui-kit/button/index.js';
7
+ import { ErrorMessage } from '@peassoft/mnr-web-ui-kit/error-message/index.js';
8
+ import { createUserDataChangeBeacon } from '../../../../modules/user-data-change-beacon/index.js';
9
+ import { getSupportedUiLangs } from '../../../../modules/ui-lang/index.js';
10
+ import { updateUser } from '../../../../modules/local-db/index.js';
11
+ import { innerToplineService, ToplineEventName } from '../../../../modules/topline-service/index.js';
12
+ import saveUserData from '../../actions/save-user-data/index.js';
13
+ import getInitialUserName from './get-initial-user-name.js';
14
+ import getInitialUiLang from './get-initial-ui-lang.js';
15
+ import hasChanges from './has-changes.js';
16
+ export default function Root(props) {
17
+ const {
18
+ user,
19
+ onChangePassword,
20
+ onCancel
21
+ } = props;
22
+ const [userName, setUserName] = useState(getInitialUserName(user));
23
+ const [uiLang, setUiLang] = useState(getInitialUiLang(user));
24
+ const [isInProgress, setIsInProgress] = useState(false);
25
+ const [hasSavingError, setHasSavingError] = useState(false);
26
+ const uiLangOptions = useMemo(() => getSupportedUiLangs().map(langDescriptor => ({
27
+ id: langDescriptor.code,
28
+ value: langDescriptor.name
29
+ })), []);
30
+ const noop = useCallback(() => {}, []);
31
+ const handleUserNameChange = useCallback(val => setUserName(val), []);
32
+ const handleUiLangChange = useCallback(val => setUiLang(val), []);
33
+ const handleChangePasswordClick = useCallback(() => onChangePassword(), [onChangePassword]);
34
+ const handleSave = useCallback(async () => {
35
+ if (hasChanges(user, {
36
+ userName,
37
+ uiLang
38
+ })) {
39
+ setIsInProgress(true);
40
+ setHasSavingError(false);
41
+ const beacon = createUserDataChangeBeacon();
42
+ const savingResult = await saveUserData(beacon, userName, uiLang);
43
+ if (!savingResult) {
44
+ setIsInProgress(false);
45
+ setHasSavingError(true);
46
+ return;
47
+ }
48
+ const updatedUser = {
49
+ ...user,
50
+ userName,
51
+ uiLang
52
+ };
53
+ void updateUser(updatedUser);
54
+ innerToplineService.emit(ToplineEventName.UserChange, updatedUser);
55
+ onCancel();
56
+ }
57
+ }, [user, userName, uiLang, onCancel]);
58
+ return _jsxs(_Fragment, {
59
+ children: [_jsx(InputField, {
60
+ label: "Your email address. Cannot be changed.",
61
+ value: user.id,
62
+ disabled: true,
63
+ onChange: noop
64
+ }), _jsx(InputField, {
65
+ label: "Your name",
66
+ value: userName,
67
+ onChange: handleUserNameChange
68
+ }), _jsx(Select, {
69
+ label: "Select your preferred language for user interface",
70
+ items: uiLangOptions,
71
+ selectedItemId: uiLang,
72
+ onChange: handleUiLangChange
73
+ }), _jsx("div", {
74
+ className: 'topline_profile_fieldCont',
75
+ children: _jsx(TextButton, {
76
+ label: "Change my password",
77
+ onClick: handleChangePasswordClick
78
+ })
79
+ }), !!hasSavingError && _jsx(ErrorMessage, {
80
+ variant: 'standalone',
81
+ children: "An error occurred while saving changes. Check your internet connection and/or try again."
82
+ }), _jsxs("div", {
83
+ className: 'topline_profile_buttonsCont',
84
+ children: [_jsx(Button, {
85
+ label: "Save",
86
+ isInProgress: isInProgress,
87
+ onClick: handleSave
88
+ }), _jsx(Button, {
89
+ label: "Cancel",
90
+ variant: 'secondary',
91
+ onClick: onCancel
92
+ })]
93
+ })]
94
+ });
95
+ }
@@ -8,6 +8,7 @@ import Alternative from '../../../../shared/components/alternative/index.js';
8
8
  import { Login } from '../../../login/index.js';
9
9
  import { Signup } from '../../../signup/index.js';
10
10
  import { PasswordRecovery } from '../../../password-recovery/index.js';
11
+ import { Profile } from '../../../profile/index.js';
11
12
  import ToplineContext from '../../context.js';
12
13
  import { globalRefs } from '../../../../modules/focus-marshal/index.js';
13
14
  import { initDb, getAllData } from '../../../../modules/local-db/index.js';
@@ -47,7 +48,7 @@ export default function Shell() {
47
48
  setModalWindowOpen('signup');
48
49
  break;
49
50
  case 'profile':
50
- console.log('Opening Profile form...');
51
+ setModalWindowOpen('profile');
51
52
  break;
52
53
  case 'logout':
53
54
  handleUserChange(null);
@@ -93,7 +94,10 @@ export default function Shell() {
93
94
  }
94
95
  }
95
96
  void initData();
96
- }, [handleUserChange, onReady]);
97
+ },
98
+ // We need this effect to run only once - on initial mount
99
+ /* eslint-disable-next-line react-hooks/exhaustive-deps */
100
+ []);
97
101
  useEffect(() => {
98
102
  if (isUserMenuOpen) {
99
103
  globalRefs.userMenuFirstItem.current?.focus();
@@ -114,28 +118,30 @@ export default function Shell() {
114
118
  };
115
119
  }, []);
116
120
  useEffect(() => {
117
- let websocketClosed = false;
118
121
  let timer = null;
119
- function handleVisibilityChange() {
122
+ async function handler() {
120
123
  if (timer) clearTimeout(timer);
121
124
  if (document.visibilityState === 'visible') {
122
- if (websocketClosed) {
123
- websocketClosed = false;
125
+ const {
126
+ user
127
+ } = await getAllData();
128
+ if (user) {
129
+ await refreshUser(user);
124
130
  openWebsocket();
125
131
  }
126
132
  } else {
127
- timer = setTimeout(() => {
128
- websocketClosed = true;
129
- closeWebsocket();
130
- }, 30000);
133
+ timer = setTimeout(closeWebsocket, 30000);
131
134
  }
132
135
  }
136
+ function handleVisibilityChange() {
137
+ void handler();
138
+ }
133
139
  document.addEventListener('visibilitychange', handleVisibilityChange);
134
140
  return () => {
135
141
  document.removeEventListener('visibilitychange', handleVisibilityChange);
136
142
  };
137
143
  }, []);
138
- let ModalWindow = null;
144
+ let ModalWindow;
139
145
  switch (modalWindowActive) {
140
146
  case 'login':
141
147
  ModalWindow = _jsx(Login, {
@@ -155,6 +161,20 @@ export default function Shell() {
155
161
  ModalWindow = _jsx(PasswordRecovery, {
156
162
  onClose: handleModalWindowClose
157
163
  });
164
+ break;
165
+ case 'profile':
166
+ ModalWindow = _jsx(Profile, {
167
+ onClose: handleModalWindowClose
168
+ });
169
+ break;
170
+ case null:
171
+ ModalWindow = null;
172
+ break;
173
+ default:
174
+ {
175
+ const _exhaustiveCheck = modalWindowActive;
176
+ return _exhaustiveCheck;
177
+ }
158
178
  }
159
179
  return _jsxs(_Fragment, {
160
180
  children: [_jsxs("div", {
@@ -1 +1,2 @@
1
1
  export declare function logError(err: Error): void;
2
+ export declare function logInvariant(msg: string): void;
@@ -1,3 +1,6 @@
1
1
  export function logError(err) {
2
2
  console.error(err);
3
+ }
4
+ export function logInvariant(msg) {
5
+ console.error(msg);
3
6
  }
@@ -0,0 +1,8 @@
1
+ import type { NonEmptyArray } from '../../types/helpers.js';
2
+ type UiLangDescriptor = {
3
+ code: string;
4
+ name: string;
5
+ };
6
+ export declare function getSupportedUiLangs(): NonEmptyArray<UiLangDescriptor>;
7
+ export declare function getDefaultUiLangForUser(): UiLangDescriptor;
8
+ export {};
@@ -0,0 +1,20 @@
1
+ const supportedUiLangs = [{
2
+ code: 'en',
3
+ name: 'English'
4
+ }, {
5
+ code: 'ru',
6
+ name: 'Русский'
7
+ }];
8
+ export function getSupportedUiLangs() {
9
+ return window.structuredClone(supportedUiLangs);
10
+ }
11
+ export function getDefaultUiLangForUser() {
12
+ for (const lang of window.navigator.languages) {
13
+ const normalizedLangCode = lang.slice(0, 2).toLowerCase();
14
+ const matchedDescriptor = supportedUiLangs.find(x => x.code === normalizedLangCode);
15
+ if (matchedDescriptor) {
16
+ return window.structuredClone(matchedDescriptor);
17
+ }
18
+ }
19
+ return supportedUiLangs[0];
20
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Create new beacon for a user data change operation.
3
+ *
4
+ * Saves the created beacon in the local store.
5
+ */
6
+ export declare function createUserDataChangeBeacon(): string;
7
+ /**
8
+ * Check whether given beacon exists in the local store.
9
+ */
10
+ export declare function userDataChangeBeaconExists(beacon: string): boolean;
11
+ /**
12
+ * Remove given beacon from the local store
13
+ */
14
+ export declare function removeUserDataChangeBeacon(beacon: string): void;
@@ -0,0 +1,25 @@
1
+ import { v4 } from 'uuid';
2
+ // Local store
3
+ const userDataChangeBeacons = new Set();
4
+ /**
5
+ * Create new beacon for a user data change operation.
6
+ *
7
+ * Saves the created beacon in the local store.
8
+ */
9
+ export function createUserDataChangeBeacon() {
10
+ const beacon = v4();
11
+ userDataChangeBeacons.add(beacon);
12
+ return beacon;
13
+ }
14
+ /**
15
+ * Check whether given beacon exists in the local store.
16
+ */
17
+ export function userDataChangeBeaconExists(beacon) {
18
+ return userDataChangeBeacons.has(beacon);
19
+ }
20
+ /**
21
+ * Remove given beacon from the local store
22
+ */
23
+ export function removeUserDataChangeBeacon(beacon) {
24
+ userDataChangeBeacons.delete(beacon);
25
+ }
@@ -4,6 +4,9 @@ import { getGrantToken } from '../tokens/index.js';
4
4
  import { logError } from '../logger/index.js';
5
5
  import { innerToplineService, ToplineEventName } from '../topline-service/index.js';
6
6
  import { isObject } from '../../types/helpers.js';
7
+ import { isToplineUser } from '../../types/data.js';
8
+ import { userDataChangeBeaconExists, removeUserDataChangeBeacon } from '../user-data-change-beacon/index.js';
9
+ import { updateUser } from '../local-db/index.js';
7
10
  let activeSocket = null;
8
11
  let currConnectionRetries = 0;
9
12
  const MAX_RECONNECTION_DELAY_MS = 30000;
@@ -22,26 +25,37 @@ const closeCodes = {
22
25
  };
23
26
  /** Handle incoming message from websocket */
24
27
  function handleSocketMessage(e) {
25
- if (e.data === 'logout') {
26
- innerToplineService.processLogout();
27
- closeWebsocket();
28
- } else {
29
- const message = JSON.parse(e.data);
30
- if (isObject(message) && 'syncId' in message && typeof message.syncId === 'string') {
31
- innerToplineService.emit(ToplineEventName.SyncNotification, message.syncId);
28
+ const message = JSON.parse(e.data);
29
+ if (isObject(message) && 'type' in message && typeof message.type === 'string') {
30
+ switch (message.type) {
31
+ case 'logout':
32
+ innerToplineService.processLogout();
33
+ closeWebsocket();
34
+ break;
35
+ case 'sync':
36
+ if ('syncId' in message && typeof message.syncId === 'string') {
37
+ innerToplineService.emit(ToplineEventName.SyncNotification, message.syncId);
38
+ }
39
+ break;
40
+ case 'user_data_change':
41
+ if ('beacon' in message && typeof message.beacon === 'string' && 'user' in message && isToplineUser(message.user)) {
42
+ if (!userDataChangeBeaconExists(message.beacon)) {
43
+ removeUserDataChangeBeacon(message.beacon);
44
+ void updateUser(message.user);
45
+ innerToplineService.emit(ToplineEventName.UserChange, message.user);
46
+ }
47
+ }
32
48
  }
33
49
  }
34
50
  }
35
51
  /** Open websocket */
36
52
  export function openWebsocket() {
37
- closeWebsocket();
53
+ if (activeSocket) return;
38
54
  const wsUrl = getWebsocketUrl();
39
- const socket = new window.WebSocket(wsUrl);
40
- socket.addEventListener('open', handleSocketOpen);
41
- socket.addEventListener('close', e => void handleSocketClose(e));
42
- socket.addEventListener('message', handleSocketMessage);
43
- activeSocket = socket;
44
- currConnectionRetries = 0;
55
+ activeSocket = new window.WebSocket(wsUrl);
56
+ activeSocket.addEventListener('open', handleSocketOpen);
57
+ activeSocket.addEventListener('close', e => void handleSocketClose(e));
58
+ activeSocket.addEventListener('message', handleSocketMessage);
45
59
  }
46
60
  /** Close websocket */
47
61
  export function closeWebsocket() {
@@ -51,6 +65,7 @@ export function closeWebsocket() {
51
65
  }
52
66
  /** Handle for websocket "open" event */
53
67
  function handleSocketOpen() {
68
+ currConnectionRetries = 0;
54
69
  this.send(JSON.stringify({
55
70
  type: 'connection',
56
71
  data: {
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Save new user data on backend.
3
+ *
4
+ * Returned Promise is resolved with:
5
+ *
6
+ * - true - if the operation succeeds;
7
+ * - false - if the operation fails due to any reason.
8
+ */
9
+ export default function saveUserData(beacon: string, userName: string, uiLang: string): Promise<boolean>;
@@ -0,0 +1,53 @@
1
+ import { getGrantToken } from '../../../../modules/tokens/index.js';
2
+ import { logInvariant } from '../../../../modules/logger/index.js';
3
+ import { getApiBaseUrl, getRequestCredentialsMode } from '../../../../modules/env/index.js';
4
+ import request from '../../../../modules/request/index.js';
5
+ import { innerToplineService } from '../../../../modules/topline-service/index.js';
6
+ /**
7
+ * Save new user data on backend.
8
+ *
9
+ * Returned Promise is resolved with:
10
+ *
11
+ * - true - if the operation succeeds;
12
+ * - false - if the operation fails due to any reason.
13
+ */
14
+ export default async function saveUserData(beacon, userName, uiLang) {
15
+ const grantToken = getGrantToken();
16
+ if (!grantToken) {
17
+ // This situation is impossible: if a logout event is received on a websocket,
18
+ // this modal window is closed automatically; thus the user has no chance to start
19
+ // a saving operation.
20
+ logInvariant('Invariant: grant token must exist while changing user data');
21
+ return false;
22
+ }
23
+ const url = `${getApiBaseUrl()}/private/user/update-data`;
24
+ const options = {
25
+ method: 'POST',
26
+ headers: {
27
+ Authorization: `Bearer ${grantToken}`,
28
+ 'Content-Type': 'application/json; charset=utf-8'
29
+ },
30
+ body: JSON.stringify({
31
+ beacon,
32
+ userName,
33
+ uiLang
34
+ }),
35
+ credentials: getRequestCredentialsMode(),
36
+ cache: 'no-store',
37
+ redirect: 'error'
38
+ };
39
+ const response = await request(url, options, {
40
+ maxRetries: 1
41
+ });
42
+ if (!response) {
43
+ return false;
44
+ }
45
+ if (response.status === 401) {
46
+ const upgradedGrantToken = await innerToplineService.upgradeGrantToken();
47
+ if (!upgradedGrantToken) {
48
+ return false;
49
+ }
50
+ return await saveUserData(beacon, userName, uiLang);
51
+ }
52
+ return true;
53
+ }
@@ -0,0 +1 @@
1
+ export { default as Profile } from './ui/index.js';
@@ -0,0 +1 @@
1
+ export { default as Profile } from './ui/index.js';
@@ -0,0 +1,6 @@
1
+ import { type JSX } from 'react';
2
+ type Props = {
3
+ onClose: () => unknown;
4
+ };
5
+ export default function Profile(props: Props): JSX.Element | null;
6
+ export {};
@@ -0,0 +1,47 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useContext, useState, useCallback } from 'react';
3
+ import Modal from '../../../shared/components/modal/index.js';
4
+ import Root from './root/index.js';
5
+ import { ToplineContext } from '../../shell/index.js';
6
+ var LocalStep;
7
+ (function (LocalStep) {
8
+ LocalStep["Root"] = "root";
9
+ LocalStep["PasswordChange"] = "password_change";
10
+ })(LocalStep || (LocalStep = {}));
11
+ export default function Profile(props) {
12
+ const {
13
+ onClose
14
+ } = props;
15
+ const {
16
+ user
17
+ } = useContext(ToplineContext);
18
+ const [localStep, setLocalStep] = useState(LocalStep.Root);
19
+ const handleSwitchToPasswordChange = useCallback(() => setLocalStep(LocalStep.PasswordChange), []);
20
+ const getContents = user => {
21
+ switch (localStep) {
22
+ case LocalStep.Root:
23
+ return _jsx(Root, {
24
+ user: user,
25
+ onChangePassword: handleSwitchToPasswordChange,
26
+ onCancel: onClose
27
+ });
28
+ case LocalStep.PasswordChange:
29
+ return _jsx("div", {
30
+ children: "PasswordChange"
31
+ });
32
+ default:
33
+ {
34
+ const _exhaustiveCheck = localStep;
35
+ return _exhaustiveCheck;
36
+ }
37
+ }
38
+ };
39
+ if (user == null) {
40
+ return null;
41
+ }
42
+ return _jsx(Modal, {
43
+ title: "Мой профиль",
44
+ onClose: onClose,
45
+ children: getContents(user)
46
+ });
47
+ }
@@ -0,0 +1,2 @@
1
+ import type { ToplineUser } from '../../../../types/data.js';
2
+ export default function getInitialUiLang(user: ToplineUser): string;
@@ -0,0 +1,4 @@
1
+ import { getDefaultUiLangForUser } from '../../../../modules/ui-lang/index.js';
2
+ export default function getInitialUiLang(user) {
3
+ return user.uiLang ?? getDefaultUiLangForUser().code;
4
+ }
@@ -0,0 +1,2 @@
1
+ import type { ToplineUser } from '../../../../types/data.js';
2
+ export default function getInitialUserName(user: ToplineUser): string;
@@ -0,0 +1,3 @@
1
+ export default function getInitialUserName(user) {
2
+ return user.userName ?? '';
3
+ }
@@ -0,0 +1,7 @@
1
+ import type { ToplineUser } from '../../../../types/data.js';
2
+ type CurrData = {
3
+ userName: string;
4
+ uiLang: string;
5
+ };
6
+ export default function hasChanges(user: ToplineUser, currData: CurrData): boolean;
7
+ export {};
@@ -0,0 +1,11 @@
1
+ import getInitialUserName from './get-initial-user-name.js';
2
+ import getInitialUiLang from './get-initial-ui-lang.js';
3
+ export default function hasChanges(user, currData) {
4
+ if (getInitialUserName(user) !== currData.userName) {
5
+ return true;
6
+ }
7
+ if (getInitialUiLang(user) !== currData.uiLang) {
8
+ return true;
9
+ }
10
+ return false;
11
+ }
@@ -0,0 +1,9 @@
1
+ import { type JSX } from 'react';
2
+ import type { ToplineUser } from '../../../../types/data.js';
3
+ type Props = {
4
+ user: ToplineUser;
5
+ onChangePassword: () => unknown;
6
+ onCancel: () => unknown;
7
+ };
8
+ export default function Root(props: Props): JSX.Element;
9
+ export {};
@@ -0,0 +1,95 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useState, useMemo, useCallback } from 'react';
3
+ import { InputField } from '@peassoft/mnr-web-ui-kit/input-field/index.js';
4
+ import { Select } from '@peassoft/mnr-web-ui-kit/select/index.js';
5
+ import { TextButton } from '@peassoft/mnr-web-ui-kit/text-button/index.js';
6
+ import { Button } from '@peassoft/mnr-web-ui-kit/button/index.js';
7
+ import { ErrorMessage } from '@peassoft/mnr-web-ui-kit/error-message/index.js';
8
+ import { createUserDataChangeBeacon } from '../../../../modules/user-data-change-beacon/index.js';
9
+ import { getSupportedUiLangs } from '../../../../modules/ui-lang/index.js';
10
+ import { updateUser } from '../../../../modules/local-db/index.js';
11
+ import { innerToplineService, ToplineEventName } from '../../../../modules/topline-service/index.js';
12
+ import saveUserData from '../../actions/save-user-data/index.js';
13
+ import getInitialUserName from './get-initial-user-name.js';
14
+ import getInitialUiLang from './get-initial-ui-lang.js';
15
+ import hasChanges from './has-changes.js';
16
+ export default function Root(props) {
17
+ const {
18
+ user,
19
+ onChangePassword,
20
+ onCancel
21
+ } = props;
22
+ const [userName, setUserName] = useState(getInitialUserName(user));
23
+ const [uiLang, setUiLang] = useState(getInitialUiLang(user));
24
+ const [isInProgress, setIsInProgress] = useState(false);
25
+ const [hasSavingError, setHasSavingError] = useState(false);
26
+ const uiLangOptions = useMemo(() => getSupportedUiLangs().map(langDescriptor => ({
27
+ id: langDescriptor.code,
28
+ value: langDescriptor.name
29
+ })), []);
30
+ const noop = useCallback(() => {}, []);
31
+ const handleUserNameChange = useCallback(val => setUserName(val), []);
32
+ const handleUiLangChange = useCallback(val => setUiLang(val), []);
33
+ const handleChangePasswordClick = useCallback(() => onChangePassword(), [onChangePassword]);
34
+ const handleSave = useCallback(async () => {
35
+ if (hasChanges(user, {
36
+ userName,
37
+ uiLang
38
+ })) {
39
+ setIsInProgress(true);
40
+ setHasSavingError(false);
41
+ const beacon = createUserDataChangeBeacon();
42
+ const savingResult = await saveUserData(beacon, userName, uiLang);
43
+ if (!savingResult) {
44
+ setIsInProgress(false);
45
+ setHasSavingError(true);
46
+ return;
47
+ }
48
+ const updatedUser = {
49
+ ...user,
50
+ userName,
51
+ uiLang
52
+ };
53
+ void updateUser(updatedUser);
54
+ innerToplineService.emit(ToplineEventName.UserChange, updatedUser);
55
+ onCancel();
56
+ }
57
+ }, [user, userName, uiLang, onCancel]);
58
+ return _jsxs(_Fragment, {
59
+ children: [_jsx(InputField, {
60
+ label: "Ваш email. Не может быть изменён.",
61
+ value: user.id,
62
+ disabled: true,
63
+ onChange: noop
64
+ }), _jsx(InputField, {
65
+ label: "Ваше имя",
66
+ value: userName,
67
+ onChange: handleUserNameChange
68
+ }), _jsx(Select, {
69
+ label: "Выберите предпочитаемый язык для пользовательского интерфейса",
70
+ items: uiLangOptions,
71
+ selectedItemId: uiLang,
72
+ onChange: handleUiLangChange
73
+ }), _jsx("div", {
74
+ className: 'topline_profile_fieldCont',
75
+ children: _jsx(TextButton, {
76
+ label: "Изменить мой пароль",
77
+ onClick: handleChangePasswordClick
78
+ })
79
+ }), !!hasSavingError && _jsx(ErrorMessage, {
80
+ variant: 'standalone',
81
+ children: "При сохранении изменений произошла ошибка. Проверьте ваше подключение к интернету и/или попробуйте ещё раз."
82
+ }), _jsxs("div", {
83
+ className: 'topline_profile_buttonsCont',
84
+ children: [_jsx(Button, {
85
+ label: "Сохранить",
86
+ isInProgress: isInProgress,
87
+ onClick: handleSave
88
+ }), _jsx(Button, {
89
+ label: "Отменить",
90
+ variant: 'secondary',
91
+ onClick: onCancel
92
+ })]
93
+ })]
94
+ });
95
+ }
@@ -8,6 +8,7 @@ import Alternative from '../../../../shared/components/alternative/index.js';
8
8
  import { Login } from '../../../login/index.js';
9
9
  import { Signup } from '../../../signup/index.js';
10
10
  import { PasswordRecovery } from '../../../password-recovery/index.js';
11
+ import { Profile } from '../../../profile/index.js';
11
12
  import ToplineContext from '../../context.js';
12
13
  import { globalRefs } from '../../../../modules/focus-marshal/index.js';
13
14
  import { initDb, getAllData } from '../../../../modules/local-db/index.js';
@@ -47,7 +48,7 @@ export default function Shell() {
47
48
  setModalWindowOpen('signup');
48
49
  break;
49
50
  case 'profile':
50
- console.log('Opening Profile form...');
51
+ setModalWindowOpen('profile');
51
52
  break;
52
53
  case 'logout':
53
54
  handleUserChange(null);
@@ -93,7 +94,10 @@ export default function Shell() {
93
94
  }
94
95
  }
95
96
  void initData();
96
- }, [handleUserChange, onReady]);
97
+ },
98
+ // We need this effect to run only once - on initial mount
99
+ /* eslint-disable-next-line react-hooks/exhaustive-deps */
100
+ []);
97
101
  useEffect(() => {
98
102
  if (isUserMenuOpen) {
99
103
  globalRefs.userMenuFirstItem.current?.focus();
@@ -114,28 +118,30 @@ export default function Shell() {
114
118
  };
115
119
  }, []);
116
120
  useEffect(() => {
117
- let websocketClosed = false;
118
121
  let timer = null;
119
- function handleVisibilityChange() {
122
+ async function handler() {
120
123
  if (timer) clearTimeout(timer);
121
124
  if (document.visibilityState === 'visible') {
122
- if (websocketClosed) {
123
- websocketClosed = false;
125
+ const {
126
+ user
127
+ } = await getAllData();
128
+ if (user) {
129
+ await refreshUser(user);
124
130
  openWebsocket();
125
131
  }
126
132
  } else {
127
- timer = setTimeout(() => {
128
- websocketClosed = true;
129
- closeWebsocket();
130
- }, 30000);
133
+ timer = setTimeout(closeWebsocket, 30000);
131
134
  }
132
135
  }
136
+ function handleVisibilityChange() {
137
+ void handler();
138
+ }
133
139
  document.addEventListener('visibilitychange', handleVisibilityChange);
134
140
  return () => {
135
141
  document.removeEventListener('visibilitychange', handleVisibilityChange);
136
142
  };
137
143
  }, []);
138
- let ModalWindow = null;
144
+ let ModalWindow;
139
145
  switch (modalWindowActive) {
140
146
  case 'login':
141
147
  ModalWindow = _jsx(Login, {
@@ -155,6 +161,20 @@ export default function Shell() {
155
161
  ModalWindow = _jsx(PasswordRecovery, {
156
162
  onClose: handleModalWindowClose
157
163
  });
164
+ break;
165
+ case 'profile':
166
+ ModalWindow = _jsx(Profile, {
167
+ onClose: handleModalWindowClose
168
+ });
169
+ break;
170
+ case null:
171
+ ModalWindow = null;
172
+ break;
173
+ default:
174
+ {
175
+ const _exhaustiveCheck = modalWindowActive;
176
+ return _exhaustiveCheck;
177
+ }
158
178
  }
159
179
  return _jsxs(_Fragment, {
160
180
  children: [_jsxs("div", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peassoft/mnr-web-topline",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Peassoft Topline widget for mem'n'rev web applications",
5
5
  "type": "module",
6
6
  "exports": {
@@ -30,7 +30,7 @@
30
30
  "@memnrev/dict-replacer": "^0.1.0",
31
31
  "@memnrev/eslint-v9-config": "^0.1.1",
32
32
  "@microsoft/api-extractor": "^7.38.0",
33
- "@peassoft/mnr-web-ui-kit": "^0.1.0",
33
+ "@peassoft/mnr-web-ui-kit": "^0.1.2",
34
34
  "@types/jest": "^29.5.4",
35
35
  "@types/md5": "^2.3.3",
36
36
  "@types/react": "^19.0.2",
@@ -66,7 +66,8 @@
66
66
  "email-validator": "^2.0.4",
67
67
  "md5": "^2.3.0",
68
68
  "react-error-boundary": "^5.0.0",
69
- "stampit": "^4.3.2"
69
+ "stampit": "^4.3.2",
70
+ "uuid": "^11.1.0"
70
71
  },
71
72
  "peerDependencies": {
72
73
  "react": ">= 19 < 20",