ui-soxo-bootstrap-core 2.6.39 → 2.6.40-dev.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.
- package/.github/workflows/npm-publish.yml +40 -33
- package/DEVELOPER_GUIDE.md +38 -9
- package/core/components/index.js +2 -11
- package/core/components/landing-api/landing-api.js +165 -5
- package/core/components/license-management/license-alert.js +97 -0
- package/core/lib/components/global-header/global-header.js +20 -77
- package/core/lib/components/index.js +2 -2
- package/core/lib/elements/basic/dragabble-wrapper/draggable-wrapper.js +91 -24
- package/core/lib/modules/generic/generic-list/ExportReactCSV.js +334 -8
- package/core/lib/modules/generic/generic-list/generic-list.scss +34 -0
- package/core/models/core-scripts/core-scripts.js +22 -1
- package/core/models/menus/menus.js +29 -1
- package/core/models/roles/components/role-add/menu-label.js +14 -0
- package/core/models/roles/components/role-add/menu-tree.js +127 -0
- package/core/models/roles/components/role-add/role-add.js +44 -167
- package/core/models/roles/components/role-list/role-list.js +20 -0
- package/core/models/users/components/assign-role/assign-role.js +23 -8
- package/core/modules/reporting/components/reporting-dashboard/display-columns/display-cell-renderer.js +202 -5
- package/core/modules/reporting/components/reporting-dashboard/display-columns/display-cell-renderer.test.js +73 -0
- package/core/modules/reporting/components/reporting-dashboard/reporting-dashboard.js +25 -5
- package/core/modules/reporting/components/reporting-dashboard/reporting-dashboard.scss +1 -0
- package/core/modules/reporting/components/reporting-dashboard/reporting-table.js +519 -0
- package/package.json +1 -1
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import React, { useState, useEffect, useContext } from 'react';
|
|
2
2
|
import { useLocation, useParams } from 'react-router-dom';
|
|
3
|
-
import { Skeleton, Typography, message, Form, Input
|
|
3
|
+
import { Skeleton, Typography, message, Form, Input } from 'antd';
|
|
4
4
|
import { GlobalContext } from './../../../../lib';
|
|
5
|
-
import { Button } from './../../../../lib';
|
|
6
5
|
import { ModelsAPI, PagesAPI, RolesAPI, MenusAPI } from '../../..';
|
|
6
|
+
import MenuTree from './menu-tree';
|
|
7
7
|
import './role-add.scss';
|
|
8
8
|
|
|
9
9
|
const { Title } = Typography;
|
|
10
|
-
const { Panel } = Collapse;
|
|
11
10
|
|
|
12
|
-
const RoleAdd = ({ model, callback, edit, formContent = {}, match, additional_queries = [] }) => {
|
|
11
|
+
const RoleAdd = ({ model, callback, edit, formContent = {}, match, additional_queries = [], onSubmittingChange, formRef }) => {
|
|
13
12
|
let mode = 'Add';
|
|
14
13
|
if (formContent.id) mode = 'Edit';
|
|
15
14
|
else if (formContent.copy) mode = 'copy';
|
|
@@ -31,8 +30,10 @@ const RoleAdd = ({ model, callback, edit, formContent = {}, match, additional_qu
|
|
|
31
30
|
|
|
32
31
|
const isEdit = !!(formContent.id || formContent.copy);
|
|
33
32
|
const [initialLoading, setInitialLoading] = useState(isEdit);
|
|
34
|
-
const [loading, setLoading] = useState(false);
|
|
35
33
|
const [form] = Form.useForm();
|
|
34
|
+
|
|
35
|
+
// expose the form instance so the Drawer footer Save button can submit it
|
|
36
|
+
if (formRef) formRef.current = form;
|
|
36
37
|
const [pages, setPages] = useState([]);
|
|
37
38
|
const [models, setModels] = useState([]);
|
|
38
39
|
const [menuList, setMenuList] = useState([]);
|
|
@@ -43,6 +44,9 @@ const RoleAdd = ({ model, callback, edit, formContent = {}, match, additional_qu
|
|
|
43
44
|
// Selected menus (array of IDs)
|
|
44
45
|
const [selectedMenus, setSelectedMenus] = useState([]);
|
|
45
46
|
|
|
47
|
+
// Search term for filtering the menu list
|
|
48
|
+
const [menuSearch, setMenuSearch] = useState('');
|
|
49
|
+
|
|
46
50
|
const location = useLocation();
|
|
47
51
|
const query = new URLSearchParams(location.search);
|
|
48
52
|
let step = parseInt(query.get('step')) || 1;
|
|
@@ -97,40 +101,28 @@ const RoleAdd = ({ model, callback, edit, formContent = {}, match, additional_qu
|
|
|
97
101
|
setSelectedMenus((prev) => (checked ? [...prev, id] : prev.filter((m) => m !== id)));
|
|
98
102
|
};
|
|
99
103
|
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
// additional_queries.forEach(({ field, value }) => {
|
|
121
|
-
// payload[field] = value;
|
|
122
|
-
// });
|
|
123
|
-
// RolesAPI.createRole(payload)
|
|
124
|
-
// .then(() => {
|
|
125
|
-
// message.success('Role Added');
|
|
126
|
-
// setLoading(false);
|
|
127
|
-
// callback();
|
|
128
|
-
// })
|
|
129
|
-
// .catch(() => setLoading(false));
|
|
130
|
-
// }
|
|
131
|
-
// };
|
|
104
|
+
// Recursively filter menus by caption. A menu is kept if its caption matches
|
|
105
|
+
// the search term, or if any of its descendants match (ancestors are kept so
|
|
106
|
+
// the matching child stays reachable).
|
|
107
|
+
const filterMenus = (menus, term) => {
|
|
108
|
+
if (!term) return menus;
|
|
109
|
+
const lower = term.toLowerCase();
|
|
110
|
+
|
|
111
|
+
return menus.reduce((acc, menu) => {
|
|
112
|
+
const children = filterMenus(menu.sub_menus || [], term);
|
|
113
|
+
const selfMatch = (menu.caption || '').toLowerCase().includes(lower);
|
|
114
|
+
|
|
115
|
+
if (selfMatch || children.length > 0) {
|
|
116
|
+
acc.push({ ...menu, sub_menus: children });
|
|
117
|
+
}
|
|
118
|
+
return acc;
|
|
119
|
+
}, []);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const filteredMenuList = filterMenus(menuList, menuSearch);
|
|
123
|
+
|
|
132
124
|
const onSubmit = async (values) => {
|
|
133
|
-
|
|
125
|
+
onSubmittingChange?.(true);
|
|
134
126
|
|
|
135
127
|
// Find menus that were originally selected but now deselected
|
|
136
128
|
const deselectedMenus = originalMenus.filter((id) => !selectedMenus.includes(id));
|
|
@@ -159,7 +151,7 @@ const RoleAdd = ({ model, callback, edit, formContent = {}, match, additional_qu
|
|
|
159
151
|
} catch (err) {
|
|
160
152
|
// message.error('Something went wrong');
|
|
161
153
|
} finally {
|
|
162
|
-
|
|
154
|
+
onSubmittingChange?.(false);
|
|
163
155
|
}
|
|
164
156
|
};
|
|
165
157
|
|
|
@@ -194,6 +186,7 @@ const RoleAdd = ({ model, callback, edit, formContent = {}, match, additional_qu
|
|
|
194
186
|
display: 'flex',
|
|
195
187
|
justifyContent: 'space-between',
|
|
196
188
|
alignItems: 'center',
|
|
189
|
+
gap: 16,
|
|
197
190
|
marginBottom: 16,
|
|
198
191
|
}}
|
|
199
192
|
>
|
|
@@ -204,14 +197,20 @@ const RoleAdd = ({ model, callback, edit, formContent = {}, match, additional_qu
|
|
|
204
197
|
<p style={{ color: '#999', marginBottom: 0 }}>Choose menus and set permissions</p>
|
|
205
198
|
</div>
|
|
206
199
|
|
|
207
|
-
<
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
200
|
+
<Input.Search
|
|
201
|
+
allowClear
|
|
202
|
+
placeholder="Search menus"
|
|
203
|
+
value={menuSearch}
|
|
204
|
+
onChange={(e) => setMenuSearch(e.target.value)}
|
|
205
|
+
style={{ maxWidth: 260 }}
|
|
206
|
+
/>
|
|
212
207
|
</div>
|
|
213
208
|
|
|
214
|
-
|
|
209
|
+
{filteredMenuList.length > 0 ? (
|
|
210
|
+
<MenuTree menus={filteredMenuList} selectedMenus={selectedMenus} toggleMenu={toggleMenu} searchActive={!!menuSearch} />
|
|
211
|
+
) : (
|
|
212
|
+
<p style={{ color: '#999', textAlign: 'center', padding: '16px 0' }}>No menus found</p>
|
|
213
|
+
)}
|
|
215
214
|
</div>
|
|
216
215
|
)}
|
|
217
216
|
</Form>
|
|
@@ -221,125 +220,3 @@ const RoleAdd = ({ model, callback, edit, formContent = {}, match, additional_qu
|
|
|
221
220
|
};
|
|
222
221
|
|
|
223
222
|
export default RoleAdd;
|
|
224
|
-
|
|
225
|
-
// ------------------------
|
|
226
|
-
// Recursive Nested Menu Component
|
|
227
|
-
// ------------------------
|
|
228
|
-
// ------------------------
|
|
229
|
-
// Recursive Nested Menu Component
|
|
230
|
-
// ------------------------
|
|
231
|
-
const MenuTree = ({ menus, selectedMenus, toggleMenu, parentId = null }) => {
|
|
232
|
-
// Helper: check if parent should be checked
|
|
233
|
-
const isParentChecked = (menu) => {
|
|
234
|
-
if (!menu.sub_menus || menu.sub_menus.length === 0) {
|
|
235
|
-
return selectedMenus.includes(menu.id);
|
|
236
|
-
}
|
|
237
|
-
const allChildIds = menu.sub_menus.map((c) => c.id);
|
|
238
|
-
return allChildIds.every((id) => selectedMenus.includes(id));
|
|
239
|
-
};
|
|
240
|
-
|
|
241
|
-
// Helper: check if parent is indeterminate
|
|
242
|
-
const isParentIndeterminate = (menu) => {
|
|
243
|
-
if (!menu.sub_menus || menu.sub_menus.length === 0) return false;
|
|
244
|
-
const allChildIds = menu.sub_menus.map((c) => c.id);
|
|
245
|
-
const checkedCount = allChildIds.filter((id) => selectedMenus.includes(id)).length;
|
|
246
|
-
return checkedCount > 0 && checkedCount < allChildIds.length;
|
|
247
|
-
};
|
|
248
|
-
|
|
249
|
-
return (
|
|
250
|
-
<>
|
|
251
|
-
{menus.map((menu) => {
|
|
252
|
-
const children = menu.sub_menus || [];
|
|
253
|
-
const parentChecked = isParentChecked(menu);
|
|
254
|
-
const parentIndeterminate = isParentIndeterminate(menu);
|
|
255
|
-
|
|
256
|
-
const onParentChange = (checked) => {
|
|
257
|
-
toggleMenu(menu.id, checked);
|
|
258
|
-
// toggle children recursively
|
|
259
|
-
children.forEach((c) => toggleMenuRecursive(c, checked));
|
|
260
|
-
};
|
|
261
|
-
|
|
262
|
-
const toggleMenuRecursive = (menu, checked) => {
|
|
263
|
-
toggleMenu(menu.id, checked);
|
|
264
|
-
if (menu.sub_menus && menu.sub_menus.length > 0) {
|
|
265
|
-
menu.sub_menus.forEach((c) => toggleMenuRecursive(c, checked));
|
|
266
|
-
}
|
|
267
|
-
};
|
|
268
|
-
|
|
269
|
-
if (children.length === 0) {
|
|
270
|
-
return (
|
|
271
|
-
<div
|
|
272
|
-
style={{
|
|
273
|
-
justifyContent: 'space-between',
|
|
274
|
-
display: 'flex',
|
|
275
|
-
alignItems: 'center',
|
|
276
|
-
border: '1px solid rgba(198, 195, 195, 0.85)',
|
|
277
|
-
// borderRadius: 6,
|
|
278
|
-
padding: '12px 16px',
|
|
279
|
-
marginBottom: 6,
|
|
280
|
-
background: '#fff',
|
|
281
|
-
}}
|
|
282
|
-
>
|
|
283
|
-
<div
|
|
284
|
-
key={menu.id}
|
|
285
|
-
style={{
|
|
286
|
-
display: 'flex',
|
|
287
|
-
alignItems: 'center',
|
|
288
|
-
gap: 8,
|
|
289
|
-
}}
|
|
290
|
-
>
|
|
291
|
-
<Checkbox
|
|
292
|
-
checked={selectedMenus.includes(menu.id)}
|
|
293
|
-
onChange={(e) => {
|
|
294
|
-
const checked = e.target.checked;
|
|
295
|
-
|
|
296
|
-
toggleMenu(menu.id, checked);
|
|
297
|
-
|
|
298
|
-
// FORCE parent selection
|
|
299
|
-
if (checked && parentId) {
|
|
300
|
-
toggleMenu(parentId, true);
|
|
301
|
-
}
|
|
302
|
-
}}
|
|
303
|
-
/>
|
|
304
|
-
<span>{menu.caption}</span>
|
|
305
|
-
</div>
|
|
306
|
-
<Tag color={menu.is_visible === true ? 'green' : 'blue'}>{menu.is_visible === true ? 'VISIBLE' : 'HIDDEN'}</Tag>{' '}
|
|
307
|
-
</div>
|
|
308
|
-
);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
return (
|
|
312
|
-
<Collapse
|
|
313
|
-
key={menu.id}
|
|
314
|
-
style={{ marginBottom: 6 }}
|
|
315
|
-
// defaultActiveKey={[menu.id]}
|
|
316
|
-
>
|
|
317
|
-
<Panel
|
|
318
|
-
key={menu.id}
|
|
319
|
-
header={
|
|
320
|
-
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
321
|
-
<div
|
|
322
|
-
style={{
|
|
323
|
-
display: 'flex',
|
|
324
|
-
alignItems: 'center',
|
|
325
|
-
gap: 8,
|
|
326
|
-
}}
|
|
327
|
-
onClick={(e) => e.stopPropagation()}
|
|
328
|
-
>
|
|
329
|
-
<Checkbox checked={parentChecked} indeterminate={parentIndeterminate} onChange={(e) => onParentChange(e.target.checked)} />
|
|
330
|
-
<span>{menu.caption}</span>
|
|
331
|
-
</div>
|
|
332
|
-
<Tag color={menu.is_visible === true ? 'green' : 'blue'}>{menu.is_visible === true ? 'VISIBLE' : 'HIDDEN'}</Tag>{' '}
|
|
333
|
-
</div>
|
|
334
|
-
}
|
|
335
|
-
>
|
|
336
|
-
<div style={{ paddingLeft: 20 }}>
|
|
337
|
-
<MenuTree menus={children} selectedMenus={selectedMenus} toggleMenu={toggleMenu} parentId={menu.id} />
|
|
338
|
-
</div>
|
|
339
|
-
</Panel>
|
|
340
|
-
</Collapse>
|
|
341
|
-
);
|
|
342
|
-
})}
|
|
343
|
-
</>
|
|
344
|
-
);
|
|
345
|
-
};
|
|
@@ -40,6 +40,12 @@ const RoleList = ({ model, match, relativeAdd = false, additional_queries = [],
|
|
|
40
40
|
|
|
41
41
|
const [single, setSingle] = useState({});
|
|
42
42
|
|
|
43
|
+
// tracks RoleAdd form submission so the Drawer footer button shows loading
|
|
44
|
+
const [submitting, setSubmitting] = useState(false);
|
|
45
|
+
|
|
46
|
+
// holds the RoleAdd form instance so the Drawer footer Save button can submit it
|
|
47
|
+
const formRef = useRef(null);
|
|
48
|
+
|
|
43
49
|
var [queryValue, setQueryValue] = useState('');
|
|
44
50
|
|
|
45
51
|
const { Search } = Input;
|
|
@@ -360,12 +366,26 @@ const RoleList = ({ model, match, relativeAdd = false, additional_queries = [],
|
|
|
360
366
|
destroyOnClose
|
|
361
367
|
onClose={closeModal}
|
|
362
368
|
bodyStyle={{ paddingBottom: 80 }}
|
|
369
|
+
footer={
|
|
370
|
+
<div style={{ textAlign: 'right' }}>
|
|
371
|
+
<Space size="small">
|
|
372
|
+
<Button type="default" onClick={closeModal}>
|
|
373
|
+
Cancel
|
|
374
|
+
</Button>
|
|
375
|
+
<Button type="primary" loading={submitting} onClick={() => formRef.current?.submit()}>
|
|
376
|
+
Save
|
|
377
|
+
</Button>
|
|
378
|
+
</Space>
|
|
379
|
+
</div>
|
|
380
|
+
}
|
|
363
381
|
>
|
|
364
382
|
<model.ModalAddComponent
|
|
365
383
|
match={match}
|
|
366
384
|
model={model}
|
|
367
385
|
additional_queries={additional_queries}
|
|
368
386
|
formContent={single}
|
|
387
|
+
formRef={formRef}
|
|
388
|
+
onSubmittingChange={setSubmitting}
|
|
369
389
|
callback={(event) => {
|
|
370
390
|
closeModal();
|
|
371
391
|
getRecords();
|
|
@@ -160,7 +160,7 @@ export default function AssignRole() {
|
|
|
160
160
|
const role_id = role.id;
|
|
161
161
|
try {
|
|
162
162
|
const res = await MenusAPI.getCoreMenuByRoleId(role_id);
|
|
163
|
-
const allMenus = res.result
|
|
163
|
+
const allMenus = Array.isArray(res?.result) ? res.result : [];
|
|
164
164
|
|
|
165
165
|
setModules(allMenus);
|
|
166
166
|
} catch (e) {
|
|
@@ -264,11 +264,12 @@ export default function AssignRole() {
|
|
|
264
264
|
const handleViewAll = async () => {
|
|
265
265
|
setLoadingMenus(true);
|
|
266
266
|
setModules([]);
|
|
267
|
-
setActiveRole({ name: 'All Roles' });
|
|
267
|
+
setActiveRole({ name: 'All Roles', isViewAll: true });
|
|
268
268
|
|
|
269
269
|
try {
|
|
270
270
|
const res = await MenusAPI.getMenubyUser(id);
|
|
271
|
-
|
|
271
|
+
const userMenus = Array.isArray(res?.result?.menus) ? res.result.menus : [];
|
|
272
|
+
setModules(userMenus);
|
|
272
273
|
setLoadingMenus(false);
|
|
273
274
|
} catch (err) {
|
|
274
275
|
setLoadingMenus(false);
|
|
@@ -277,6 +278,18 @@ export default function AssignRole() {
|
|
|
277
278
|
}
|
|
278
279
|
};
|
|
279
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Context-specific empty-state message for the right (menus) panel:
|
|
283
|
+
* - View Access for a user with no roles → no role assigned
|
|
284
|
+
* - A selected role with no menus → no menus assigned
|
|
285
|
+
* - Nothing selected yet → prompt to pick a role
|
|
286
|
+
*/
|
|
287
|
+
const emptyMenusMessage = !activeRole
|
|
288
|
+
? 'Select a role to view menus'
|
|
289
|
+
: activeRole.isViewAll
|
|
290
|
+
? 'No role has been assigned to this user.'
|
|
291
|
+
: 'No menus have been assigned to this role.';
|
|
292
|
+
|
|
280
293
|
return (
|
|
281
294
|
<section className="assign-role">
|
|
282
295
|
{/* LEFT PANEL */}
|
|
@@ -383,17 +396,19 @@ export default function AssignRole() {
|
|
|
383
396
|
padding: 16,
|
|
384
397
|
}}
|
|
385
398
|
>
|
|
386
|
-
|
|
387
|
-
<div className="
|
|
388
|
-
|
|
389
|
-
|
|
399
|
+
{modules.length > 0 && (
|
|
400
|
+
<div className="menus-header">
|
|
401
|
+
<div className="title">Menus {activeRole ? `– ${activeRole.name}` : ''}</div>
|
|
402
|
+
<div className="sub-text">You don’t have permission to edit this here. Update it in Role Settings.</div>
|
|
403
|
+
</div>
|
|
404
|
+
)}
|
|
390
405
|
|
|
391
406
|
<div className="menus-content">
|
|
392
407
|
{loadingMenus ? (
|
|
393
408
|
<Skeleton active paragraph={{ rows: 6 }} />
|
|
394
409
|
) : modules.length === 0 ? (
|
|
395
410
|
<div className="empty-state">
|
|
396
|
-
<Empty description=
|
|
411
|
+
<Empty description={emptyMenusMessage} />
|
|
397
412
|
</div>
|
|
398
413
|
) : (
|
|
399
414
|
<MenuTree menus={modules} selectedMenus={selectedMenus} toggleMenu={toggleMenu} showCheckbox={false} />
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { Tag } from 'antd';
|
|
1
|
+
import React, { useState, useContext } from 'react';
|
|
2
|
+
import { Modal, Tag } from 'antd';
|
|
3
3
|
import * as Icons from '@ant-design/icons';
|
|
4
4
|
import { Link } from 'react-router-dom';
|
|
5
|
+
import { GlobalContext, safeJSON, Location } from '../../../../../lib';
|
|
6
|
+
// import { PdfViewer } from '../../../../../lib';
|
|
7
|
+
import { CoreScripts } from '../../../../../models';
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
10
|
* Utilities for rendering Reporting Dashboard display columns.
|
|
@@ -74,7 +77,7 @@ export function isActionTypeEntry(entry = {}) {
|
|
|
74
77
|
* @param {DisplayRecord} record
|
|
75
78
|
* @returns {string}
|
|
76
79
|
*/
|
|
77
|
-
export function getRedirectLink(entry = {}, record = {}) {
|
|
80
|
+
export function getRedirectLink(entry = {}, record = {}, CustomComponents = {}) {
|
|
78
81
|
let redirectLink = entry.redirect_link || '';
|
|
79
82
|
|
|
80
83
|
if (Array.isArray(entry.replace_variables)) {
|
|
@@ -87,6 +90,41 @@ export function getRedirectLink(entry = {}, record = {}) {
|
|
|
87
90
|
return redirectLink;
|
|
88
91
|
}
|
|
89
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Resolves the PDF/file location for file-based action entries.
|
|
95
|
+
*
|
|
96
|
+
* Supports either:
|
|
97
|
+
* - `entry.file_location` as a direct URL/path
|
|
98
|
+
* - `entry.file_location` as a record field name
|
|
99
|
+
*
|
|
100
|
+
* @param {DisplayColumnEntry} entry
|
|
101
|
+
* @param {DisplayRecord} record
|
|
102
|
+
* @returns {string}
|
|
103
|
+
*/
|
|
104
|
+
export function getFileLocation(entry = {}, record = {}) {
|
|
105
|
+
if (entry.file_location && typeof entry.file_location === 'string' && record[entry.file_location]) {
|
|
106
|
+
return record[entry.file_location];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return record.file_location || entry.file_location || '';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolves a value from the row when a config field points to a record key,
|
|
114
|
+
* otherwise returns the provided literal value.
|
|
115
|
+
*
|
|
116
|
+
* @param {*} value
|
|
117
|
+
* @param {DisplayRecord} record
|
|
118
|
+
* @returns {*}
|
|
119
|
+
*/
|
|
120
|
+
function resolveRecordValue(value, record = {}) {
|
|
121
|
+
if (typeof value === 'string' && Object.prototype.hasOwnProperty.call(record, value)) {
|
|
122
|
+
return record[value];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return value;
|
|
126
|
+
}
|
|
127
|
+
|
|
90
128
|
/**
|
|
91
129
|
* Resolves action label text with backward-compatible precedence.
|
|
92
130
|
*
|
|
@@ -170,6 +208,154 @@ function renderCustomComponent({ entry, record, CustomComponents, refresh }) {
|
|
|
170
208
|
);
|
|
171
209
|
}
|
|
172
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Renders a PDF file action inside a modal viewer.
|
|
213
|
+
*
|
|
214
|
+
* @param {Object} root0
|
|
215
|
+
* @param {DisplayColumnEntry} root0.entry
|
|
216
|
+
* @param {DisplayRecord} root0.record
|
|
217
|
+
* @param {Object.<string, React.ComponentType<any>>} root0.CustomComponents
|
|
218
|
+
* @returns {React.ReactNode}
|
|
219
|
+
*/
|
|
220
|
+
function FileActionLink({ entry, record, CustomComponents }) {
|
|
221
|
+
const [isPdfVisible, setIsPdfVisible] = useState(false);
|
|
222
|
+
const fileUrl = getFileLocation(entry, record);
|
|
223
|
+
const actionLabel = getActionLabel(entry, record);
|
|
224
|
+
const FileLoaderComponent = CustomComponents?.FileLoader;
|
|
225
|
+
|
|
226
|
+
if (!fileUrl) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const fileLoaderProps = {
|
|
231
|
+
url: fileUrl,
|
|
232
|
+
type: resolveRecordValue(entry.file_type, record) || 'pdf',
|
|
233
|
+
defaultScale: resolveRecordValue(entry.default_scale, record),
|
|
234
|
+
viewerType: resolveRecordValue(entry.viewer_type, record),
|
|
235
|
+
config: {
|
|
236
|
+
requireLinuxPath: resolveRecordValue(entry.require_linux_path, record),
|
|
237
|
+
replaceBranch: resolveRecordValue(entry.replace_branch, record),
|
|
238
|
+
...(entry.file_loader_config || {}),
|
|
239
|
+
},
|
|
240
|
+
entry,
|
|
241
|
+
record,
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<>
|
|
246
|
+
{/* 1. Add the trigger link/button here */}
|
|
247
|
+
<a
|
|
248
|
+
onClick={(e) => {
|
|
249
|
+
e.preventDefault();
|
|
250
|
+
setIsPdfVisible(true);
|
|
251
|
+
}}
|
|
252
|
+
style={{ cursor: 'pointer' }}
|
|
253
|
+
>
|
|
254
|
+
{actionLabel}
|
|
255
|
+
</a>
|
|
256
|
+
|
|
257
|
+
<Modal
|
|
258
|
+
open={isPdfVisible}
|
|
259
|
+
onCancel={() => setIsPdfVisible(false)}
|
|
260
|
+
footer={null}
|
|
261
|
+
destroyOnClose
|
|
262
|
+
width={950}
|
|
263
|
+
style={{ top: 10 }}
|
|
264
|
+
title={actionLabel}
|
|
265
|
+
>
|
|
266
|
+
{/* 2. Ensure the loader component exists */}
|
|
267
|
+
{FileLoaderComponent ? <FileLoaderComponent {...fileLoaderProps} /> : <p>Loader not found</p>}
|
|
268
|
+
</Modal>
|
|
269
|
+
</>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Returns the branch access list from the signed-in user's organization details.
|
|
275
|
+
*
|
|
276
|
+
* `organization_details` may arrive as a JSON string, so this helper keeps the
|
|
277
|
+
* parsing logic in one place before branch comparisons are made.
|
|
278
|
+
*
|
|
279
|
+
* @param {Object} user
|
|
280
|
+
* @returns {Array}
|
|
281
|
+
*/
|
|
282
|
+
function getAccessibleBranches(user = {}) {
|
|
283
|
+
const orgDetails = safeJSON(user?.organization_details);
|
|
284
|
+
return Array.isArray(orgDetails?.branch) ? orgDetails.branch : [];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Resolves branch metadata used to decide whether an action link should prompt
|
|
289
|
+
* for a branch switch before navigation.
|
|
290
|
+
*
|
|
291
|
+
* When an action entry includes `replace_variables` with the `index` field, the
|
|
292
|
+
* row is treated as branch-aware content. We compare the record's branch id
|
|
293
|
+
* against the active branch from `localStorage.db_ptr`, and also resolve whether
|
|
294
|
+
* the user has access to the target branch.
|
|
295
|
+
*
|
|
296
|
+
* @param {DisplayColumnEntry} entry
|
|
297
|
+
* @param {DisplayRecord} record
|
|
298
|
+
* @param {Object} user
|
|
299
|
+
* @returns {{requiresBranchSwitch: boolean, hasTargetBranchAccess: boolean}}
|
|
300
|
+
*/
|
|
301
|
+
function getBranchNavigationState(entry = {}, record = {}, user = {}) {
|
|
302
|
+
const branchFieldConfig = entry.replace_variables?.find((variable) => variable.field === 'index');
|
|
303
|
+
|
|
304
|
+
if (!branchFieldConfig) {
|
|
305
|
+
return { requiresBranchSwitch: false, hasTargetBranchAccess: false };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const accessibleBranches = getAccessibleBranches(user);
|
|
309
|
+
const activeDbPtr = localStorage.getItem('db_ptr');
|
|
310
|
+
const activeBranch = accessibleBranches.find((branch) => String(branch.dbPtr) === String(activeDbPtr));
|
|
311
|
+
const targetBranchId = record[branchFieldConfig.field];
|
|
312
|
+
const targetBranch = accessibleBranches.find((branch) => String(branch.branch_id) === String(targetBranchId));
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
requiresBranchSwitch: Boolean(targetBranchId) && String(activeBranch?.branch_id) !== String(targetBranchId),
|
|
316
|
+
hasTargetBranchAccess: Boolean(targetBranch),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Renders a branch-switch confirmation link for cross-branch records.
|
|
322
|
+
*
|
|
323
|
+
* The actual branch change is handled by the landing page via the `index`
|
|
324
|
+
* query parameter. This link only confirms intent and blocks navigation when
|
|
325
|
+
* the user does not have access to the target branch.
|
|
326
|
+
*
|
|
327
|
+
* @param {Object} root0
|
|
328
|
+
* @param {string} root0.label
|
|
329
|
+
* @param {string} root0.redirectLink
|
|
330
|
+
* @param {boolean} root0.hasTargetBranchAccess
|
|
331
|
+
* @returns {React.ReactNode}
|
|
332
|
+
*/
|
|
333
|
+
function BranchAwareActionLink({ label, redirectLink, hasTargetBranchAccess }) {
|
|
334
|
+
return (
|
|
335
|
+
<a
|
|
336
|
+
onClick={(e) => {
|
|
337
|
+
e.preventDefault();
|
|
338
|
+
Modal.confirm({
|
|
339
|
+
title: 'Switch Branch?', // Adding a title makes it look more standard
|
|
340
|
+
content: 'This record belongs to another branch. Would you like to switch branches to view the details?',
|
|
341
|
+
onOk: () => {
|
|
342
|
+
if (hasTargetBranchAccess) {
|
|
343
|
+
Location.navigate({ url: redirectLink });
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
Modal.error({
|
|
348
|
+
title: 'Access Denied',
|
|
349
|
+
content: 'This data belongs to another branch.Would you like to switch branches to view the details?',
|
|
350
|
+
});
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
}}
|
|
354
|
+
>
|
|
355
|
+
{label}
|
|
356
|
+
</a>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
173
359
|
/**
|
|
174
360
|
* Renders table cell content for a configured display column.
|
|
175
361
|
*
|
|
@@ -210,10 +396,21 @@ export function renderDisplayCell({ entry, record, CustomComponents, refresh })
|
|
|
210
396
|
}
|
|
211
397
|
return null;
|
|
212
398
|
}
|
|
213
|
-
|
|
214
399
|
if (isLegacyActionEntry(entry) || isActionTypeEntry(entry)) {
|
|
400
|
+
if (entry.redirect_link_type === 'file') {
|
|
401
|
+
return <FileActionLink entry={entry} record={record} CustomComponents={CustomComponents} />;
|
|
402
|
+
}
|
|
403
|
+
const { user = {} } = useContext(GlobalContext);
|
|
404
|
+
|
|
215
405
|
const redirectLink = getRedirectLink(entry, record);
|
|
216
|
-
|
|
406
|
+
const label = getActionLabel(entry, record);
|
|
407
|
+
const { requiresBranchSwitch, hasTargetBranchAccess } = getBranchNavigationState(entry, record, user);
|
|
408
|
+
|
|
409
|
+
if (requiresBranchSwitch) {
|
|
410
|
+
return <BranchAwareActionLink label={label} redirectLink={redirectLink} hasTargetBranchAccess={hasTargetBranchAccess} />;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return <Link to={`${redirectLink}`}>{label}</Link>;
|
|
217
414
|
}
|
|
218
415
|
|
|
219
416
|
if (entry.field === 'custom') {
|