@cccsaurora/howler-ui 2.17.0-dev.420 → 2.17.0-dev.470
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/commons/components/app/hooks/useAppConfigs.d.ts +1 -1
- package/components/app/providers/HitSearchProvider.d.ts +0 -1
- package/components/app/providers/HitSearchProvider.js +4 -6
- package/components/app/providers/HitSearchProvider.test.js +1 -1
- package/components/app/providers/ParameterProvider.js +3 -3
- package/components/app/providers/ViewProvider.d.ts +1 -1
- package/components/app/providers/ViewProvider.js +3 -6
- package/components/app/providers/ViewProvider.test.js +1 -1
- package/components/elements/PluginChip.d.ts +2 -0
- package/components/elements/PluginChip.js +2 -1
- package/components/elements/PluginTypography.d.ts +4 -3
- package/components/elements/PluginTypography.js +4 -3
- package/components/elements/display/modals/RationaleModal.js +1 -1
- package/components/elements/display/modals/RationaleModal.test.js +1 -1
- package/components/elements/hit/HitBanner.js +2 -2
- package/components/elements/hit/HitDetails.js +9 -9
- package/components/elements/hit/outlines/DefaultOutline.js +1 -1
- package/components/routes/hits/search/ViewLink.js +1 -1
- package/components/routes/hits/search/ViewLink.test.js +3 -3
- package/components/routes/hits/search/grid/EnhancedCell.d.ts +2 -0
- package/components/routes/hits/search/grid/EnhancedCell.js +2 -2
- package/components/routes/hits/search/grid/HitRow.js +1 -1
- package/components/routes/views/ViewComposer.js +2 -2
- package/index.js +5 -0
- package/locales/en/translation.json +1 -0
- package/locales/fr/translation.json +1 -0
- package/models/entities/generated/Analytic.d.ts +2 -2
- package/models/entities/generated/ApiType.d.ts +2 -1
- package/models/entities/generated/Clue.d.ts +8 -0
- package/models/entities/generated/Hit.d.ts +2 -20
- package/models/entities/generated/Labels.d.ts +1 -0
- package/models/entities/generated/Type.d.ts +7 -0
- package/package.json +24 -15
- package/plugins/HowlerPlugin.js +1 -0
- package/plugins/clue/Provider.d.ts +3 -0
- package/plugins/clue/Provider.js +13 -0
- package/plugins/clue/components/ClueChip.d.ts +3 -0
- package/plugins/clue/components/ClueChip.js +29 -0
- package/plugins/clue/components/ClueLeadForm.d.ts +4 -0
- package/plugins/clue/components/ClueLeadForm.js +24 -0
- package/plugins/clue/components/CluePivot.d.ts +3 -0
- package/plugins/clue/components/CluePivot.js +145 -0
- package/plugins/clue/components/CluePivotForm.d.ts +21 -0
- package/plugins/clue/components/CluePivotForm.js +270 -0
- package/plugins/clue/components/ClueTypography.d.ts +3 -0
- package/plugins/clue/components/ClueTypography.js +53 -0
- package/plugins/clue/helpers.d.ts +3 -0
- package/plugins/clue/helpers.js +196 -0
- package/plugins/clue/index.d.ts +21 -0
- package/plugins/clue/index.js +66 -0
- package/plugins/clue/locales/clue.en.json +8 -0
- package/plugins/clue/locales/clue.fr.json +8 -0
- package/plugins/clue/setup.d.ts +2 -0
- package/plugins/clue/setup.js +46 -0
- package/plugins/clue/utils.d.ts +2 -0
- package/plugins/clue/utils.js +19 -0
- package/plugins/store.js +3 -0
package/package.json
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
]
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
+
"@cccsaurora/clue-ui": "1.1.0-dev.91",
|
|
15
16
|
"@dnd-kit/core": "^6.3.1",
|
|
16
17
|
"@dnd-kit/modifiers": "^7.0.0",
|
|
17
18
|
"@dnd-kit/sortable": "^8.0.0",
|
|
@@ -20,46 +21,50 @@
|
|
|
20
21
|
"@iconify/icons-logos": "^1.2.36",
|
|
21
22
|
"@iconify/icons-simple-icons": "^1.2.74",
|
|
22
23
|
"@iconify/react": "^4.1.1",
|
|
23
|
-
"@
|
|
24
|
+
"@jsonforms/core": "^3.7.0",
|
|
25
|
+
"@jsonforms/material-renderers": "^3.7.0",
|
|
26
|
+
"@jsonforms/react": "^3.7.0",
|
|
27
|
+
"@microlink/react-json-view": "^1.27.1",
|
|
24
28
|
"@monaco-editor/react": "^4.7.0",
|
|
25
29
|
"ajv": "^8.17.1",
|
|
26
30
|
"ajv-i18n": "^4.2.0",
|
|
27
|
-
"axios": "^1.
|
|
31
|
+
"axios": "^1.13.4",
|
|
28
32
|
"axios-retry": "^3.9.1",
|
|
29
|
-
"chart.js": "^4.5.
|
|
33
|
+
"chart.js": "^4.5.1",
|
|
30
34
|
"chartjs-adapter-dayjs-4": "^1.0.4",
|
|
31
35
|
"chartjs-plugin-zoom": "^2.2.0",
|
|
32
|
-
"dayjs": "^1.11.
|
|
33
|
-
"dompurify": "^3.
|
|
36
|
+
"dayjs": "^1.11.19",
|
|
37
|
+
"dompurify": "^3.3.1",
|
|
34
38
|
"flat": "^6.0.1",
|
|
35
39
|
"fuse.js": "^7.1.0",
|
|
36
40
|
"handlebars": "^4.7.8",
|
|
37
41
|
"handlebars-async-helpers": "^1.0.6",
|
|
38
42
|
"i18next": "^23.16.8",
|
|
39
43
|
"i18next-browser-languagedetector": "^7.2.2",
|
|
44
|
+
"json-schema": "^0.4.0",
|
|
40
45
|
"lodash-es": "^4.17.23",
|
|
41
46
|
"md5": "^2.3.0",
|
|
42
|
-
"mermaid": "^11.
|
|
47
|
+
"mermaid": "^11.12.2",
|
|
43
48
|
"monaco-editor": "^0.49.0",
|
|
44
49
|
"notistack": "^3.0.2",
|
|
45
50
|
"react": "^18.3.1",
|
|
46
|
-
"react-chartjs-2": "^5.3.
|
|
51
|
+
"react-chartjs-2": "^5.3.1",
|
|
47
52
|
"react-device-detect": "^2.2.3",
|
|
48
53
|
"react-dom": "^18.3.1",
|
|
49
54
|
"react-i18next": "^14.1.3",
|
|
50
|
-
"react-ipynb-renderer": "^2.
|
|
55
|
+
"react-ipynb-renderer": "^2.3.0",
|
|
51
56
|
"react-markdown": "^10.1.0",
|
|
52
57
|
"react-pluggable": "^0.4.3",
|
|
53
58
|
"react-resize-detector": "^9.1.1",
|
|
54
|
-
"react-router": "
|
|
55
|
-
"react-router-dom": "
|
|
56
|
-
"react-syntax-highlighter": "^15.6.
|
|
59
|
+
"react-router": "6.30.1",
|
|
60
|
+
"react-router-dom": "6.30.1",
|
|
61
|
+
"react-syntax-highlighter": "^15.6.6",
|
|
57
62
|
"rehype-raw": "^7.0.0",
|
|
58
63
|
"remark": "^15.0.1",
|
|
59
64
|
"remark-gfm": "^4.0.1",
|
|
60
|
-
"scheduler": "^0.
|
|
65
|
+
"scheduler": "^0.27.0",
|
|
61
66
|
"unified": "^11.0.5",
|
|
62
|
-
"unist-util-visit": "^5.
|
|
67
|
+
"unist-util-visit": "^5.1.0",
|
|
63
68
|
"url-join": "^5.0.0",
|
|
64
69
|
"use-context-selector": "^2.0.0",
|
|
65
70
|
"uuid": "^9.0.1",
|
|
@@ -96,7 +101,7 @@
|
|
|
96
101
|
"internal-slot": "1.0.7"
|
|
97
102
|
},
|
|
98
103
|
"type": "module",
|
|
99
|
-
"version": "2.17.0-dev.
|
|
104
|
+
"version": "2.17.0-dev.470",
|
|
100
105
|
"exports": {
|
|
101
106
|
"./i18n": "./i18n.js",
|
|
102
107
|
"./index.css": "./index.css",
|
|
@@ -251,6 +256,10 @@
|
|
|
251
256
|
"./commons/components/app/providers/*": "./commons/components/app/providers/*.js",
|
|
252
257
|
"./commons/components/display/hooks/*": "./commons/components/display/hooks/*.js",
|
|
253
258
|
"./commons/components/notification/elements/*": "./commons/components/notification/elements/*.js",
|
|
254
|
-
"./commons/components/notification/elements/item/*": "./commons/components/notification/elements/item/*.js"
|
|
259
|
+
"./commons/components/notification/elements/item/*": "./commons/components/notification/elements/item/*.js",
|
|
260
|
+
"./plugins/clue/*": "./plugins/clue/*.js",
|
|
261
|
+
"./plugins/clue": "./plugins/clue/index.js",
|
|
262
|
+
"./plugins/clue/locales/*": "./plugins/clue/locales/*.js",
|
|
263
|
+
"./plugins/clue/components/*": "./plugins/clue/components/*.js"
|
|
255
264
|
}
|
|
256
265
|
}
|
package/plugins/HowlerPlugin.js
CHANGED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { ClueProvider } from '@cccsaurora/clue-ui/hooks/ClueProvider';
|
|
3
|
+
import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
|
|
4
|
+
import { useContext } from 'react';
|
|
5
|
+
import { useTranslation } from 'react-i18next';
|
|
6
|
+
import { StorageKey } from '@cccsaurora/howler-ui/utils/constants';
|
|
7
|
+
import { getStored } from '@cccsaurora/howler-ui/utils/localStorage';
|
|
8
|
+
const Provider = ({ children }) => {
|
|
9
|
+
const { config } = useContext(ApiConfigContext);
|
|
10
|
+
const features = config.configuration?.features ?? {};
|
|
11
|
+
return (_jsx(ClueProvider, { baseURL: location.origin + '/api/v1/clue', getToken: () => getStored(StorageKey.APP_TOKEN), enabled: features.clue, publicIconify: location.origin.includes('localhost'), customIconify: location.origin.replace(/howler(-stg)?/, 'icons'), defaultTimeout: 5, i18next: useTranslation('clue'), chunkSize: 50, children: children }));
|
|
12
|
+
};
|
|
13
|
+
export default Provider;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import EnrichedChip from '@cccsaurora/clue-ui/components/EnrichedChip';
|
|
3
|
+
import { Chip } from '@mui/material';
|
|
4
|
+
import { memo } from 'react';
|
|
5
|
+
import { useType } from '../utils';
|
|
6
|
+
const ClueChip = ({ children, value, context, field, hit, ...props }) => {
|
|
7
|
+
const type = useType(hit, field, value);
|
|
8
|
+
if (!type) {
|
|
9
|
+
return _jsx(Chip, { ...props, children: children });
|
|
10
|
+
}
|
|
11
|
+
let enrichedProps = {
|
|
12
|
+
...props,
|
|
13
|
+
value
|
|
14
|
+
};
|
|
15
|
+
if (context === 'summary') {
|
|
16
|
+
enrichedProps = {
|
|
17
|
+
...enrichedProps,
|
|
18
|
+
sx: [
|
|
19
|
+
...(Array.isArray(enrichedProps.sx) ? enrichedProps.sx : [enrichedProps.sx]),
|
|
20
|
+
[{ height: '24px', '& .iconify': { fontSize: '1em' } }]
|
|
21
|
+
]
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
delete enrichedProps.label;
|
|
26
|
+
}
|
|
27
|
+
return _jsx(EnrichedChip, { ...enrichedProps, type: type, label: props.label });
|
|
28
|
+
};
|
|
29
|
+
export default memo(ClueChip);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useClueFetcherSelector } from '@cccsaurora/clue-ui/hooks/selectors';
|
|
3
|
+
import { Autocomplete, Divider, ListItemText, TextField, Typography } from '@mui/material';
|
|
4
|
+
import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
|
|
5
|
+
import uniq from 'lodash-es/uniq';
|
|
6
|
+
import { useContext, useState } from 'react';
|
|
7
|
+
import { useTranslation } from 'react-i18next';
|
|
8
|
+
const ClueLeadForm = ({ lead, metadata, update, updateMetadata }) => {
|
|
9
|
+
const { t } = useTranslation();
|
|
10
|
+
const { config } = useContext(ApiConfigContext);
|
|
11
|
+
const fetchers = useClueFetcherSelector(ctx => ctx.fetchers);
|
|
12
|
+
const [showCustom, setShowCustom] = useState(false);
|
|
13
|
+
return (_jsxs(_Fragment, { children: [_jsx(Divider, { orientation: "horizontal" }), _jsx(Autocomplete, { disabled: !lead, options: Object.keys(fetchers), renderInput: params => _jsx(TextField, { ...params, size: "small", label: t('route.dossiers.manager.clue') }), value: Object.keys(fetchers).includes(lead?.content) ? lead.content : null, onChange: (_ev, content) => update({ content, metadata: '{}' }), renderOption: ({ key, ...props }, option) => (_jsx(ListItemText, { ...props, sx: { flexDirection: 'column', alignItems: 'start !important' }, primary: _jsx("code", { children: option }), secondary: fetchers[option].description }, key)) }), _jsx(Autocomplete, { options: fetchers[lead?.content]?.supported_types ??
|
|
14
|
+
uniq(Object.values(fetchers).flatMap(fetcher => fetcher.supported_types)), renderInput: params => _jsx(TextField, { ...params, size: "small", label: t('route.dossiers.manager.clue.type') }), value: metadata?.type ?? null, onChange: (_ev, type) => updateMetadata({ type }) }), _jsx(Autocomplete, { options: ['custom', ...Object.keys(config.indexes.hit)], disabled: !metadata?.type, renderInput: params => _jsx(TextField, { ...params, size: "small", label: t('route.dossiers.manager.clue.value') }), getOptionLabel: opt => t(opt), value: metadata?.value ?? null, onChange: (_ev, value) => {
|
|
15
|
+
if (value === 'custom') {
|
|
16
|
+
setShowCustom(true);
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
setShowCustom(false);
|
|
20
|
+
updateMetadata({ value });
|
|
21
|
+
}
|
|
22
|
+
} }), showCustom && (_jsxs(_Fragment, { children: [_jsx(TextField, { size: "small", label: t('route.dossiers.manager.clue.value.custom'), value: metadata?.value ?? '', disabled: !metadata?.type, fullWidth: true, onChange: ev => updateMetadata({ value: ev.target.value }) }), _jsx(Typography, { variant: "caption", color: "text.secondary", sx: { mt: '0 !important' }, children: t('route.dossiers.manager.clue.value.description') })] }))] }));
|
|
23
|
+
};
|
|
24
|
+
export default ClueLeadForm;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useClueActionsSelector, useClueEnrichSelector } from '@cccsaurora/clue-ui';
|
|
3
|
+
import { Icon } from '@iconify/react/dist/iconify.js';
|
|
4
|
+
import { Settings } from '@mui/icons-material';
|
|
5
|
+
import { Divider, IconButton, Stack, Typography } from '@mui/material';
|
|
6
|
+
import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
|
|
7
|
+
import HowlerCard from '@cccsaurora/howler-ui/components/elements/display/HowlerCard';
|
|
8
|
+
import useMySnackbar from '@cccsaurora/howler-ui/components/hooks/useMySnackbar';
|
|
9
|
+
import get from 'lodash-es/get';
|
|
10
|
+
import isBoolean from 'lodash-es/isBoolean';
|
|
11
|
+
import isNil from 'lodash-es/isNil';
|
|
12
|
+
import { memo, useCallback, useContext, useState } from 'react';
|
|
13
|
+
import { useTranslation } from 'react-i18next';
|
|
14
|
+
const CluePivot = ({ pivot, hit, compact }) => {
|
|
15
|
+
const guessType = useClueEnrichSelector(ctx => ctx?.guessType);
|
|
16
|
+
const { showErrorMessage } = useMySnackbar();
|
|
17
|
+
const { config } = useContext(ApiConfigContext);
|
|
18
|
+
const { i18n, t } = useTranslation();
|
|
19
|
+
const actions = useClueActionsSelector(ctx => ctx?.availableActions ?? {});
|
|
20
|
+
const executeAction = useClueActionsSelector(ctx => ctx?.executeAction);
|
|
21
|
+
const [loading, setLoading] = useState(false);
|
|
22
|
+
const getValue = useCallback((actionId, mapping) => {
|
|
23
|
+
const parameterSchema = actions[actionId].params?.properties?.[mapping.key];
|
|
24
|
+
if (mapping.field === 'custom') {
|
|
25
|
+
if (parameterSchema?.type === 'number') {
|
|
26
|
+
return parseFloat(mapping.custom_value);
|
|
27
|
+
}
|
|
28
|
+
if (parameterSchema?.type === 'integer') {
|
|
29
|
+
return Math.floor(parseFloat(mapping.custom_value));
|
|
30
|
+
}
|
|
31
|
+
return mapping.custom_value;
|
|
32
|
+
}
|
|
33
|
+
if (!Object.keys(config.indexes.hit).includes(mapping.field)) {
|
|
34
|
+
return mapping.field;
|
|
35
|
+
}
|
|
36
|
+
const hitData = get(hit, mapping.field);
|
|
37
|
+
// No schema provided - just pass on the value
|
|
38
|
+
if (!parameterSchema) {
|
|
39
|
+
return hitData;
|
|
40
|
+
}
|
|
41
|
+
// JSON schema is a boolean - we just shove whatever they want in there if it's true, or skip if false
|
|
42
|
+
if (isBoolean(parameterSchema)) {
|
|
43
|
+
return parameterSchema ? hitData : null;
|
|
44
|
+
}
|
|
45
|
+
// It wants a list of values
|
|
46
|
+
if (parameterSchema.type === 'array') {
|
|
47
|
+
// We have a list of values
|
|
48
|
+
if (Array.isArray(hitData)) {
|
|
49
|
+
return hitData;
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
// We don't have a list of values
|
|
53
|
+
return isNil(hitData) ? [] : [hitData];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// It wants a single object, but we have a list
|
|
57
|
+
if (Array.isArray(hitData)) {
|
|
58
|
+
// TODO: This is still a little blah.
|
|
59
|
+
return hitData[0];
|
|
60
|
+
}
|
|
61
|
+
// We have a single object and that's what they want
|
|
62
|
+
return hitData;
|
|
63
|
+
}, [actions, config.indexes.hit, hit]);
|
|
64
|
+
const onClueClick = useCallback(async (event, forceMenu = false) => {
|
|
65
|
+
event.preventDefault();
|
|
66
|
+
event.stopPropagation();
|
|
67
|
+
if (loading) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (!actions[pivot.value]) {
|
|
71
|
+
showErrorMessage(t('pivot.clue.missing'));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
setLoading(true);
|
|
75
|
+
const data = Object.fromEntries(pivot.mappings.map(_mapping => {
|
|
76
|
+
const value = getValue(pivot.value, _mapping);
|
|
77
|
+
if (['selector', 'selectors'].includes(_mapping.key)) {
|
|
78
|
+
if (!value) {
|
|
79
|
+
return _mapping.key === 'selector' ? ['selector', null] : ['selectors', []];
|
|
80
|
+
}
|
|
81
|
+
if (Array.isArray(value)) {
|
|
82
|
+
return [
|
|
83
|
+
_mapping.key,
|
|
84
|
+
value
|
|
85
|
+
.filter(val => !isNil(val))
|
|
86
|
+
.map(val => ({
|
|
87
|
+
type: config.configuration?.mapping?.[_mapping.field] || guessType(val.toString()),
|
|
88
|
+
value: val
|
|
89
|
+
}))
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
return [
|
|
93
|
+
_mapping.key,
|
|
94
|
+
{
|
|
95
|
+
type: config.configuration?.mapping?.[_mapping.field] || guessType(value.toString()),
|
|
96
|
+
value
|
|
97
|
+
}
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
return [_mapping.key, value];
|
|
101
|
+
}));
|
|
102
|
+
const selectors = (actions[pivot.value].accept_multiple ? [data.selectors] : [data.selector])
|
|
103
|
+
.flat()
|
|
104
|
+
.filter(val => !isNil(val));
|
|
105
|
+
delete data.selector;
|
|
106
|
+
delete data.selectors;
|
|
107
|
+
try {
|
|
108
|
+
await executeAction(pivot.value, selectors, data, { forceMenu });
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
setLoading(false);
|
|
112
|
+
}
|
|
113
|
+
}, [
|
|
114
|
+
actions,
|
|
115
|
+
config.configuration?.mapping,
|
|
116
|
+
executeAction,
|
|
117
|
+
getValue,
|
|
118
|
+
guessType,
|
|
119
|
+
loading,
|
|
120
|
+
pivot.mappings,
|
|
121
|
+
pivot.value,
|
|
122
|
+
showErrorMessage,
|
|
123
|
+
t
|
|
124
|
+
]);
|
|
125
|
+
if (!actions[pivot.value]) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
return (_jsx(HowlerCard, { variant: compact ? 'outlined' : 'elevation', onClick: e => onClueClick(e), sx: [
|
|
129
|
+
theme => ({
|
|
130
|
+
backgroundColor: 'transparent',
|
|
131
|
+
transition: theme.transitions.create(['border-color']),
|
|
132
|
+
'&:hover': { borderColor: 'primary.main' },
|
|
133
|
+
'& > div': {
|
|
134
|
+
height: '100%'
|
|
135
|
+
}
|
|
136
|
+
}),
|
|
137
|
+
loading
|
|
138
|
+
? { opacity: 0.5, pointerEvents: 'none' }
|
|
139
|
+
: {
|
|
140
|
+
cursor: 'pointer'
|
|
141
|
+
},
|
|
142
|
+
!compact && { border: 'thin solid', borderColor: 'transparent' }
|
|
143
|
+
], children: _jsxs(Stack, { direction: "row", p: compact ? 0.5 : 1, spacing: 1, alignItems: "center", children: [_jsx(Icon, { fontSize: "1.5rem", icon: pivot.icon }), _jsx(Typography, { children: pivot.label[i18n.language] }), _jsx(Divider, { orientation: "vertical", flexItem: true }), _jsx(IconButton, { size: "small", onClick: e => onClueClick(e, true), children: _jsx(Settings, { fontSize: "small" }) })] }) }));
|
|
144
|
+
};
|
|
145
|
+
export default memo(CluePivot);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { PivotFormProps } from '@cccsaurora/howler-ui/components/routes/dossiers/PivotForm';
|
|
2
|
+
import { type FC } from 'react';
|
|
3
|
+
/**
|
|
4
|
+
* CluePivotForm Component
|
|
5
|
+
*
|
|
6
|
+
* A form component that allows users to configure pivot operations for Clue actions.
|
|
7
|
+
* This component provides a dynamic form interface for selecting and configuring
|
|
8
|
+
* Clue actions with their associated parameters and field mappings.
|
|
9
|
+
*
|
|
10
|
+
* Key Features:
|
|
11
|
+
* - Action selection via autocomplete dropdown
|
|
12
|
+
* - Dynamic form generation based on action schemas
|
|
13
|
+
* - Field mapping configuration for hit indexes
|
|
14
|
+
* - Custom value support for non-standard fields
|
|
15
|
+
* - Integration with JsonForms for complex parameter handling
|
|
16
|
+
*
|
|
17
|
+
* @param props - Component props containing pivot data and update callback
|
|
18
|
+
* @returns JSX element representing the pivot configuration form
|
|
19
|
+
*/
|
|
20
|
+
declare const CluePivotForm: FC<PivotFormProps>;
|
|
21
|
+
export default CluePivotForm;
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useClueActionsSelector, useClueEnrichSelector } from '@cccsaurora/clue-ui';
|
|
3
|
+
import { adaptSchema } from '@cccsaurora/clue-ui/components/actions/form/schemaAdapter';
|
|
4
|
+
import { materialCells, materialRenderers } from '@jsonforms/material-renderers';
|
|
5
|
+
import { JsonForms } from '@jsonforms/react';
|
|
6
|
+
import { Autocomplete, createTheme, Divider, Stack, TextField, ThemeProvider, Typography, useTheme } from '@mui/material';
|
|
7
|
+
import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
|
|
8
|
+
import ErrorBoundary from '@cccsaurora/howler-ui/components/routes/ErrorBoundary';
|
|
9
|
+
import { flatten, unflatten } from 'flat';
|
|
10
|
+
import capitalize from 'lodash-es/capitalize';
|
|
11
|
+
import cloneDeep from 'lodash-es/cloneDeep';
|
|
12
|
+
import isBoolean from 'lodash-es/isBoolean';
|
|
13
|
+
import isEqual from 'lodash-es/isEqual';
|
|
14
|
+
import isPlainObject from 'lodash-es/isPlainObject';
|
|
15
|
+
import merge from 'lodash-es/merge';
|
|
16
|
+
import omitBy from 'lodash-es/omitBy';
|
|
17
|
+
import pick from 'lodash-es/pick';
|
|
18
|
+
import pickBy from 'lodash-es/pickBy';
|
|
19
|
+
import { useCallback, useContext, useMemo } from 'react';
|
|
20
|
+
import { useTranslation } from 'react-i18next';
|
|
21
|
+
/**
|
|
22
|
+
* Enhanced material renderers wrapped in ErrorBoundary components to prevent
|
|
23
|
+
* form rendering errors from crashing the entire application
|
|
24
|
+
*/
|
|
25
|
+
const WRAPPED_RENDERERS = materialRenderers.map(value => ({
|
|
26
|
+
...value,
|
|
27
|
+
renderer: ({ ...props }) => (_jsx(ErrorBoundary, { children: _jsx(value.renderer, { ...props }) }))
|
|
28
|
+
}));
|
|
29
|
+
/**
|
|
30
|
+
* CluePivotForm Component
|
|
31
|
+
*
|
|
32
|
+
* A form component that allows users to configure pivot operations for Clue actions.
|
|
33
|
+
* This component provides a dynamic form interface for selecting and configuring
|
|
34
|
+
* Clue actions with their associated parameters and field mappings.
|
|
35
|
+
*
|
|
36
|
+
* Key Features:
|
|
37
|
+
* - Action selection via autocomplete dropdown
|
|
38
|
+
* - Dynamic form generation based on action schemas
|
|
39
|
+
* - Field mapping configuration for hit indexes
|
|
40
|
+
* - Custom value support for non-standard fields
|
|
41
|
+
* - Integration with JsonForms for complex parameter handling
|
|
42
|
+
*
|
|
43
|
+
* @param props - Component props containing pivot data and update callback
|
|
44
|
+
* @returns JSX element representing the pivot configuration form
|
|
45
|
+
*/
|
|
46
|
+
const CluePivotForm = ({ pivot, update }) => {
|
|
47
|
+
const theme = useTheme();
|
|
48
|
+
const { t } = useTranslation();
|
|
49
|
+
const { config } = useContext(ApiConfigContext);
|
|
50
|
+
// Get available Clue actions from the global state
|
|
51
|
+
const actions = useClueActionsSelector(ctx => ctx?.availableActions);
|
|
52
|
+
const ready = useClueEnrichSelector(ctx => ctx.ready);
|
|
53
|
+
/**
|
|
54
|
+
* Generates a dynamic JSON schema for the selected action's parameters.
|
|
55
|
+
* This schema is used to render the form fields for action configuration.
|
|
56
|
+
*
|
|
57
|
+
* The function:
|
|
58
|
+
* 1. Retrieves the action definition based on the pivot value
|
|
59
|
+
* 2. Adapts the action's parameter schema using Clue's schema adapter
|
|
60
|
+
* 3. Enhances string properties with hit index enums for autocomplete
|
|
61
|
+
* 4. Applies any extra schema definitions from the action
|
|
62
|
+
*/
|
|
63
|
+
const formSchema = useMemo(() => {
|
|
64
|
+
const value = pivot?.value;
|
|
65
|
+
// Return null if no action is selected or action doesn't exist
|
|
66
|
+
if (!value || !actions[value]) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
// Clone and adapt the action's parameter schema
|
|
70
|
+
const clueAdaptedSchema = cloneDeep({
|
|
71
|
+
...adaptSchema(actions[value].params),
|
|
72
|
+
...(actions[value].extra_schema ?? {})
|
|
73
|
+
});
|
|
74
|
+
// Enhance string properties with hit index options for better UX
|
|
75
|
+
Object.entries(clueAdaptedSchema.properties).forEach(([key, prop]) => {
|
|
76
|
+
// Handle boolean properties by converting to enum with hit indexes
|
|
77
|
+
if (typeof prop === 'boolean') {
|
|
78
|
+
clueAdaptedSchema.properties[key] = { enum: Object.keys(config.indexes.hit) };
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (prop.type === 'array') {
|
|
82
|
+
if (isBoolean(prop.items)) {
|
|
83
|
+
clueAdaptedSchema.properties[key] = { enum: Object.keys(config.indexes.hit) };
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
else if (!Array.isArray(prop.items)) {
|
|
87
|
+
prop.type = prop.items.type;
|
|
88
|
+
delete prop.items;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Skip non-string properties
|
|
92
|
+
if (prop.type !== 'string' && (!Array.isArray(prop.type) || !prop.type.includes('string'))) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
// Strip date-time format since we're picking from a list of fields
|
|
96
|
+
if (prop.format === 'date-time') {
|
|
97
|
+
delete prop.format;
|
|
98
|
+
}
|
|
99
|
+
// Add hit index options to string properties without existing enums
|
|
100
|
+
if (!prop.enum && !prop.oneOf) {
|
|
101
|
+
prop.enum = Object.keys(config.indexes.hit);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
return clueAdaptedSchema;
|
|
105
|
+
}, [actions, config.indexes.hit, pivot?.value]);
|
|
106
|
+
/**
|
|
107
|
+
* Generates the UI schema for JsonForms based on the form schema.
|
|
108
|
+
* This defines the layout and rendering options for each form field.
|
|
109
|
+
*
|
|
110
|
+
* The UI schema:
|
|
111
|
+
* 1. Creates a vertical layout for all form elements
|
|
112
|
+
* 2. Sorts fields by custom order property or required status
|
|
113
|
+
* 3. Configures autocomplete for enum fields
|
|
114
|
+
* 4. Applies custom options and rules from the schema
|
|
115
|
+
*/
|
|
116
|
+
const uiSchema = useMemo(() => ({
|
|
117
|
+
type: 'VerticalLayout',
|
|
118
|
+
elements: Object.entries(formSchema?.properties ?? {})
|
|
119
|
+
// Sort fields: first by custom order, then by required status
|
|
120
|
+
.sort(([a_key, a_ent], [b_key, b_ent]) => {
|
|
121
|
+
if (!!a_ent.order || !!b_ent.order) {
|
|
122
|
+
return a_ent.order - b_ent.order;
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
// Required fields appear first
|
|
126
|
+
return +formSchema?.required.includes(a_key) - +formSchema?.required.includes(b_key);
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
.map(([key, value]) => ({
|
|
130
|
+
type: 'Control',
|
|
131
|
+
scope: `#/properties/${key}`,
|
|
132
|
+
options: {
|
|
133
|
+
// Enable autocomplete for fields with enum values
|
|
134
|
+
autocomplete: !!value.enum || !!value.oneOf,
|
|
135
|
+
showUnfocusedDescription: true,
|
|
136
|
+
// Apply any custom options from the schema
|
|
137
|
+
...value.options
|
|
138
|
+
},
|
|
139
|
+
// Apply conditional rendering rules if present
|
|
140
|
+
rule: value.rule
|
|
141
|
+
}))
|
|
142
|
+
}), [formSchema?.properties, formSchema?.required]);
|
|
143
|
+
/**
|
|
144
|
+
* Handles updates to the selected action value.
|
|
145
|
+
* When an action is selected, this function:
|
|
146
|
+
* 1. Sets up default field mappings based on action requirements
|
|
147
|
+
* 2. Configures selector mappings for actions that don't accept empty inputs
|
|
148
|
+
* 3. Updates the pivot configuration
|
|
149
|
+
*
|
|
150
|
+
* @param value - The selected action identifier
|
|
151
|
+
*/
|
|
152
|
+
const onUpdate = useCallback((value) => {
|
|
153
|
+
const mappings = [];
|
|
154
|
+
// TODO: Fix type hints and remove the any cast
|
|
155
|
+
// For actions that don't accept empty inputs, add selector mapping
|
|
156
|
+
if (!actions[value].accept_empty) {
|
|
157
|
+
// Use 'selectors' for multiple selection, 'selector' for single selection
|
|
158
|
+
mappings.push({
|
|
159
|
+
key: actions[value].accept_multiple ? 'selectors' : 'selector',
|
|
160
|
+
field: 'howler.id'
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
update({
|
|
164
|
+
value,
|
|
165
|
+
mappings
|
|
166
|
+
});
|
|
167
|
+
}, [actions, update]);
|
|
168
|
+
/**
|
|
169
|
+
* Transforms pivot mappings into form data format for JsonForms.
|
|
170
|
+
* Converts the mappings array into a key-value object where:
|
|
171
|
+
* - Key is the mapping key (e.g., 'selector', 'selectors')
|
|
172
|
+
* - Value is either the custom_value (if set) or the field name
|
|
173
|
+
*/
|
|
174
|
+
const formData = useMemo(() => {
|
|
175
|
+
const rawData = Object.fromEntries((pivot?.mappings ?? []).map(mapping => {
|
|
176
|
+
const parameterSchema = actions[pivot.value]?.params?.properties?.[mapping.key];
|
|
177
|
+
const value = mapping.custom_value ?? mapping.field;
|
|
178
|
+
if (mapping.field === 'custom') {
|
|
179
|
+
if (parameterSchema?.type === 'number') {
|
|
180
|
+
return [mapping.key, parseFloat(mapping.custom_value)];
|
|
181
|
+
}
|
|
182
|
+
if (parameterSchema?.type === 'integer') {
|
|
183
|
+
return [mapping.key, Math.floor(parseFloat(mapping.custom_value))];
|
|
184
|
+
}
|
|
185
|
+
return [mapping.key, mapping.custom_value];
|
|
186
|
+
}
|
|
187
|
+
return [mapping.key, value];
|
|
188
|
+
}));
|
|
189
|
+
// JSONForms will inadvertently nest dicts
|
|
190
|
+
// (e.g. "abc1.field" key will result in)
|
|
191
|
+
// {
|
|
192
|
+
// "abc1": {
|
|
193
|
+
// "field"
|
|
194
|
+
// }
|
|
195
|
+
// }
|
|
196
|
+
// Validation doesn't account for this, so we need to merge the flattened and unflattened dicts.
|
|
197
|
+
// This leads to awful side effects in the onChange function, detailed there.
|
|
198
|
+
return merge({}, rawData, unflatten(rawData));
|
|
199
|
+
}, [actions, pivot?.mappings, pivot.value]);
|
|
200
|
+
// Find the main selector mapping for special handling in the UI
|
|
201
|
+
const selectorMapping = pivot?.mappings?.find(_mapping => ['selector', 'selectors'].includes(_mapping.key));
|
|
202
|
+
return (_jsxs(ErrorBoundary, { children: [_jsx(Autocomplete, { fullWidth: true, disabled: !pivot || !ready, options: Object.entries(actions)
|
|
203
|
+
.filter(([_key, definition]) => !!definition && definition.format === 'pivot')
|
|
204
|
+
.map(([key]) => key), renderOption: ({ key, ...optionProps }, actionId) => {
|
|
205
|
+
const definition = actions[actionId];
|
|
206
|
+
return (_jsxs(Stack, { component: "li", ...optionProps, spacing: 1, children: [_jsxs(Stack, { direction: "row", spacing: 1, alignSelf: "start", alignItems: "center", children: [_jsx(Typography, { children: definition.name }), _jsx("pre", { style: {
|
|
207
|
+
fontSize: '0.85rem',
|
|
208
|
+
border: `thin solid ${theme.palette.divider}`,
|
|
209
|
+
padding: theme.spacing(0.5),
|
|
210
|
+
borderRadius: theme.shape.borderRadius
|
|
211
|
+
}, children: actionId })] }), _jsx(Typography, { variant: "body2", color: "text.secondary", alignSelf: "start", children: definition.summary })] }, key));
|
|
212
|
+
}, getOptionLabel: opt => actions[opt]?.name ?? '', renderInput: params => (_jsx(TextField, { ...params, size: "small", fullWidth: true, label: t('route.dossiers.manager.pivot.value') })), value: pivot?.value ?? '', onChange: (_ev, value) => onUpdate(value) }), _jsx(Divider, { flexItem: true }), _jsx(Typography, { children: t('route.dossiers.manager.pivot.mappings') }), selectorMapping && (_jsxs(_Fragment, { children: [_jsx(Autocomplete, { fullWidth: true, options: ['custom', ...Object.keys(config.indexes.hit)], renderInput: params => (_jsx(TextField, { ...params, size: "small", fullWidth: true, label: capitalize(selectorMapping.key), sx: { minWidth: '150px' } })), value: selectorMapping.field, onChange: (_ev, field) => update({
|
|
213
|
+
mappings: pivot.mappings.map(_mapping => _mapping.key === selectorMapping.key ? { ..._mapping, field } : _mapping)
|
|
214
|
+
}) }), selectorMapping.field === 'custom' && (_jsx(TextField, { size: "small", label: t('route.dossiers.manager.pivot.mapping.custom'), disabled: !pivot, value: selectorMapping?.custom_value ?? null, onChange: ev => update({
|
|
215
|
+
mappings: pivot.mappings.map(_mapping => _mapping.key === selectorMapping.key ? { ..._mapping, custom_value: ev.target.value } : _mapping)
|
|
216
|
+
}) }))] })), formSchema && (_jsx(ThemeProvider, { theme: _theme => createTheme({
|
|
217
|
+
..._theme,
|
|
218
|
+
components: {
|
|
219
|
+
MuiTextField: {
|
|
220
|
+
defaultProps: { size: 'small' },
|
|
221
|
+
styleOverrides: { root: { marginTop: theme.spacing(1) } }
|
|
222
|
+
},
|
|
223
|
+
MuiInputBase: {
|
|
224
|
+
defaultProps: { size: 'small' }
|
|
225
|
+
},
|
|
226
|
+
MuiFormControl: {
|
|
227
|
+
defaultProps: { size: 'small' },
|
|
228
|
+
styleOverrides: {
|
|
229
|
+
root: { marginTop: theme.spacing(1) }
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}), children: _jsx(ErrorBoundary, { children: _jsx(JsonForms, { schema: formSchema, uischema: uiSchema, renderers: WRAPPED_RENDERERS, cells: materialCells, data: formData, onChange: ({ data }) => {
|
|
234
|
+
// We now need to remove the unflattened object we provided for validation to JSON forms
|
|
235
|
+
// EXCEPT, new keys are added via nesting. So we need to flatten the nested data and overwrite it
|
|
236
|
+
// with the flat data.
|
|
237
|
+
const flatData = omitBy(data, isPlainObject);
|
|
238
|
+
const nestedData = flatten(pickBy(data, isPlainObject));
|
|
239
|
+
// Merge existing selector mappings with new form data. This is the crazy complexity I mentioned above.
|
|
240
|
+
//
|
|
241
|
+
// The full flow:
|
|
242
|
+
// 1. A field mapping is chosen, e.g. 'key.1': 'howler.id'
|
|
243
|
+
// 2. The data object has the structure {key: {1: 'howler.id'}}
|
|
244
|
+
// 3. This is added to fullData via nestedData
|
|
245
|
+
// 4. Another field mapping is chosen, e.g. 'key.2': 'howler.id'
|
|
246
|
+
// 5. The data object now looks like: {key.1: 'howler.id', key: {1: 'howler.id', 2: 'howler.id'}}
|
|
247
|
+
// 6. We integrate this again, but this time the existing `key.1` key overwrites
|
|
248
|
+
// the flattened `key: {1}` object.
|
|
249
|
+
// 7. When 'key.1' is changed, the ROOT (flattened) key changes, but the outdated variant is still there.
|
|
250
|
+
// that is: {key.1: 'howler.status', key.2: 'howler.id', key: {1: 'howler.id', 2: 'howler.id'}}
|
|
251
|
+
// 8. This is why the flat data is AFTER the nested data. So that the final, resolved object is:
|
|
252
|
+
// {key.1: 'howler.status', key.2: 'howler.id'}
|
|
253
|
+
//
|
|
254
|
+
// This is very confusing - ask Matt R if you need a better explanation.
|
|
255
|
+
const fullData = { ...nestedData, ...flatData, ...pick(pivot.mappings, ['selector', 'selectors']) };
|
|
256
|
+
// Convert form data back to mappings format
|
|
257
|
+
const newMappings = Object.entries(fullData).map(([key, val]) => ({
|
|
258
|
+
key,
|
|
259
|
+
// Use 'custom' field type if value is not a standard hit index
|
|
260
|
+
field: val in config.indexes.hit ? val : 'custom',
|
|
261
|
+
// Store custom values separately from field names
|
|
262
|
+
custom_value: val in config.indexes.hit ? null : val
|
|
263
|
+
}));
|
|
264
|
+
// Only update if mappings have actually changed (performance optimization)
|
|
265
|
+
if (!isEqual(newMappings, pivot.mappings)) {
|
|
266
|
+
update({ mappings: newMappings });
|
|
267
|
+
}
|
|
268
|
+
}, config: {} }) }) }))] }));
|
|
269
|
+
};
|
|
270
|
+
export default CluePivotForm;
|