@cccsaurora/howler-ui 2.17.0-dev.420 → 2.17.0-dev.473
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 +135 -126
- 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
|
@@ -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;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import EnrichedTypography from '@cccsaurora/clue-ui/components/EnrichedTypography';
|
|
3
|
+
import { Typography } from '@mui/material';
|
|
4
|
+
import { memo } from 'react';
|
|
5
|
+
import { useType } from '../utils';
|
|
6
|
+
const ClueTypography = ({ children, value, context, field, hit, ...props }) => {
|
|
7
|
+
const type = useType(hit, field, value);
|
|
8
|
+
if (!type) {
|
|
9
|
+
return _jsx(Typography, { ...props, children: children ?? value });
|
|
10
|
+
}
|
|
11
|
+
let enrichedProps = {
|
|
12
|
+
...props,
|
|
13
|
+
value
|
|
14
|
+
};
|
|
15
|
+
if (context === 'banner') {
|
|
16
|
+
enrichedProps = {
|
|
17
|
+
...enrichedProps,
|
|
18
|
+
slotProps: { stack: { component: 'span' } }
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
else if (context === 'outline') {
|
|
22
|
+
enrichedProps = {
|
|
23
|
+
...enrichedProps,
|
|
24
|
+
hideLoading: true,
|
|
25
|
+
slotProps: {
|
|
26
|
+
stack: {
|
|
27
|
+
sx: { mr: 'auto' },
|
|
28
|
+
onClick: e => {
|
|
29
|
+
e.preventDefault();
|
|
30
|
+
e.stopPropagation();
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
popover: {
|
|
34
|
+
onClick: e => {
|
|
35
|
+
e.preventDefault();
|
|
36
|
+
e.stopPropagation();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
else if (context === 'table') {
|
|
43
|
+
enrichedProps = {
|
|
44
|
+
...enrichedProps,
|
|
45
|
+
hideLoading: true,
|
|
46
|
+
slotProps: {
|
|
47
|
+
stack: { sx: { width: '100%', '& > p': { textOverflow: 'ellipsis', overflow: 'hidden' } } }
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
return _jsx(EnrichedTypography, { ...enrichedProps, type: type });
|
|
52
|
+
};
|
|
53
|
+
export default memo(ClueTypography);
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/* eslint-disable no-console */
|
|
3
|
+
import EnrichedTypography, {} from '@cccsaurora/clue-ui/components/EnrichedTypography';
|
|
4
|
+
import Fetcher from '@cccsaurora/clue-ui/components/fetchers/Fetcher';
|
|
5
|
+
import Entry from '@cccsaurora/clue-ui/components/group/Entry';
|
|
6
|
+
import Group from '@cccsaurora/clue-ui/components/group/Group';
|
|
7
|
+
import { useClueEnrichSelector } from '@cccsaurora/clue-ui/hooks/selectors';
|
|
8
|
+
import { Checkbox, Paper, Stack, Table, TableBody, TableCell, TableHead, TableRow } from '@mui/material';
|
|
9
|
+
import FlexOne from '@cccsaurora/howler-ui/components/elements/addons/layout/FlexOne';
|
|
10
|
+
import i18nInstance from '@cccsaurora/howler-ui/i18n';
|
|
11
|
+
import capitalize from 'lodash-es/capitalize';
|
|
12
|
+
import groupBy from 'lodash-es/groupBy';
|
|
13
|
+
import uniq from 'lodash-es/uniq';
|
|
14
|
+
import { useState } from 'react';
|
|
15
|
+
import { useTranslation } from 'react-i18next';
|
|
16
|
+
const MarkdownTypography = ({ type, value, ...props }) => {
|
|
17
|
+
const { t } = useTranslation();
|
|
18
|
+
try {
|
|
19
|
+
const guessType = useClueEnrichSelector(ctx => ctx.guessType);
|
|
20
|
+
if (!type || type?.toString().toLowerCase() === 'guess') {
|
|
21
|
+
type = guessType(value.toString());
|
|
22
|
+
}
|
|
23
|
+
if (!type) {
|
|
24
|
+
return _jsx("span", { children: value });
|
|
25
|
+
}
|
|
26
|
+
return _jsx(EnrichedTypography, { ...props, type: type, value: value });
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
return (_jsxs(Stack, { children: [_jsx("strong", { style: { color: 'red' }, children: t('markdown.error') }), _jsx("strong", { children: err.toString() }), _jsx("code", { style: { fontSize: '0.8rem' }, children: _jsx("pre", { children: err.stack }) })] }));
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
const ClueGroup = props => {
|
|
33
|
+
if (!props.enabled) {
|
|
34
|
+
return _jsx(_Fragment, { children: props.children });
|
|
35
|
+
}
|
|
36
|
+
if (!props.type) {
|
|
37
|
+
console.error('Missing required props for group helper');
|
|
38
|
+
return (_jsxs(Stack, { spacing: 1, children: [_jsx("strong", { style: { color: 'red' }, children: i18nInstance.t('markdown.error') }), _jsxs("code", { style: { fontSize: '0.8rem' }, children: [i18nInstance.t('markdown.props.missing'), ": type"] })] }));
|
|
39
|
+
}
|
|
40
|
+
return _jsx(Group, { type: props.type, children: props.children });
|
|
41
|
+
};
|
|
42
|
+
const ClueEntry = ({ value }) => {
|
|
43
|
+
const [checked, setChecked] = useState(false);
|
|
44
|
+
return (_jsx(Entry, { entry: value, selected: checked, children: _jsx(Paper, { sx: { p: 1 }, children: _jsxs(Stack, { direction: "row", spacing: 1, children: [_jsx(Checkbox, { checked: checked, onChange: (_event, _checked) => setChecked(_checked) }), _jsx(MarkdownTypography, { value: value }), _jsx(FlexOne, {})] }) }) }));
|
|
45
|
+
};
|
|
46
|
+
const ClueCheckbox = ({ value }) => {
|
|
47
|
+
const [checked, setChecked] = useState(false);
|
|
48
|
+
return (_jsx(Entry, { entry: value, selected: checked, children: _jsx(Checkbox, { checked: checked, onChange: (_event, _checked) => setChecked(_checked) }) }));
|
|
49
|
+
};
|
|
50
|
+
const HELPERS = [
|
|
51
|
+
{
|
|
52
|
+
keyword: 'clue',
|
|
53
|
+
documentation: {
|
|
54
|
+
en: 'Given a selector, this helper enriches the selector through clue.',
|
|
55
|
+
fr: 'Étant donné un sélecteur, cet assistant enrichit le sélecteur via clue.'
|
|
56
|
+
},
|
|
57
|
+
componentCallback: (type, value) => {
|
|
58
|
+
if (typeof type !== 'string' || typeof value !== 'string') {
|
|
59
|
+
return (_jsxs(Stack, { spacing: 1, children: [_jsx("strong", { style: { color: 'red' }, children: i18nInstance.t('markdown.error') }), _jsx("code", { style: { fontSize: '0.8rem' }, children: i18nInstance.t('markdown.helpers.clue.arguments') })] }));
|
|
60
|
+
}
|
|
61
|
+
return (_jsx(MarkdownTypography, { slotProps: { stack: { component: 'span', sx: { width: 'fit-content' } } }, component: "span", type: type, value: typeof value === 'string' ? value : null }));
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
keyword: 'fetcher',
|
|
66
|
+
documentation: {
|
|
67
|
+
en: 'Given a selector, this helper fetches data for the selector through clue.',
|
|
68
|
+
fr: 'Étant donné un sélecteur, cet assistant récupère les données pour le sélecteur via clue.'
|
|
69
|
+
},
|
|
70
|
+
componentCallback: (...args) => {
|
|
71
|
+
const options = args.pop();
|
|
72
|
+
const props = options?.hash ?? {};
|
|
73
|
+
if (!props.type || !props.value || !props.fetcherId) {
|
|
74
|
+
console.error('Missing required props for fetcher helper');
|
|
75
|
+
return (_jsxs(Stack, { spacing: 1, children: [_jsx("strong", { style: { color: 'red' }, children: i18nInstance.t('markdown.error') }), _jsxs("code", { style: { fontSize: '0.8rem' }, children: [i18nInstance.t('markdown.props.missing'), ":", ' ', ['type', 'value', 'fetcherId'].filter(key => !props[key]).join(', ')] })] }));
|
|
76
|
+
}
|
|
77
|
+
if (props.fetcherId.includes('eml')) {
|
|
78
|
+
props.fetcherId = props.fetcherId.replace('eml', 'email');
|
|
79
|
+
}
|
|
80
|
+
console.debug(`Rendering fetcher (${props.fetcherId}) for selector ${props.type}:${props.value}`);
|
|
81
|
+
return (_jsx(Fetcher, { slotProps: { stack: { component: 'span', sx: { width: 'fit-content' } } }, component: "span", ...props }));
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
keyword: 'clue_group',
|
|
86
|
+
documentation: {
|
|
87
|
+
en: 'Initializes a clue group',
|
|
88
|
+
fr: 'Initialise un groupe clue'
|
|
89
|
+
},
|
|
90
|
+
componentCallback: (values, ...args) => {
|
|
91
|
+
const options = args.pop();
|
|
92
|
+
const props = options?.hash ?? {};
|
|
93
|
+
const missing = [];
|
|
94
|
+
if (!Array.isArray(values)) {
|
|
95
|
+
missing.push('values');
|
|
96
|
+
}
|
|
97
|
+
if (!props.type) {
|
|
98
|
+
missing.push('type');
|
|
99
|
+
}
|
|
100
|
+
if (missing.length > 0) {
|
|
101
|
+
return (_jsxs(Stack, { spacing: 1, children: [_jsx("strong", { style: { color: 'red' }, children: i18nInstance.t('markdown.error') }), _jsxs("code", { style: { fontSize: '0.8rem' }, children: [i18nInstance.t('markdown.props.missing'), ": ", missing.join(', ')] })] }));
|
|
102
|
+
}
|
|
103
|
+
return (_jsx(ClueGroup, { type: props.type, enabled: true, children: _jsx(Stack, { spacing: 1, mt: 1, children: uniq(values).map(value => (_jsx(ClueEntry, { value: value }, value))) }) }));
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
keyword: 'clue_table',
|
|
108
|
+
documentation: {
|
|
109
|
+
en: `Render a table with optional Clue enrichments, fetchers and actions.
|
|
110
|
+
|
|
111
|
+
Clue enrichments are performed for cells with a clue_type and no clue_fetcher.
|
|
112
|
+
|
|
113
|
+
Clue fetchers are used for cells with a clue_type and a clue_fetcher.
|
|
114
|
+
|
|
115
|
+
Clue actions are enabled by specifying a clue action type using the optional clue_action_type parameter. If enabled, cells with clue_entity==true will be selectable for use with Clue enrichments and actions, with a value of action_value if present, and otherwise value.
|
|
116
|
+
|
|
117
|
+
Example:
|
|
118
|
+
\`\`\`markdown
|
|
119
|
+
{{curly 'clue_table clue_table_cells clue_action_type="ip"'}}
|
|
120
|
+
\`\`\`
|
|
121
|
+
where clue_table_cells is an array with properties:
|
|
122
|
+
|
|
123
|
+
\`\`\`
|
|
124
|
+
column: string;
|
|
125
|
+
row: string;
|
|
126
|
+
value: string;
|
|
127
|
+
clue_type (optional): string;
|
|
128
|
+
clue_fetcher (optional): string;
|
|
129
|
+
fetcher_width (optional): string;
|
|
130
|
+
clue_entity (optional): boolean;
|
|
131
|
+
action_value (optional): string;
|
|
132
|
+
\`\`\`
|
|
133
|
+
`,
|
|
134
|
+
fr: `Affiche un tableau avec des enrichissements, des extractions et des actions Clue optionnelles.
|
|
135
|
+
|
|
136
|
+
Les enrichissements Clue sont effectués pour les cellules avec un clue_type et sans un clue_fetcher.
|
|
137
|
+
|
|
138
|
+
Clue récupère les données pour le sélecteur pour les cellules avec un clue_type et un clue_fetcher.
|
|
139
|
+
|
|
140
|
+
Les actions Clue sont activées en spécifiant un type d'action clue en utilisant le paramètre optionnel clue_action_type. Si activé, les cellules avec clue_entity==true seront sélectionnables pour utilisation avec les enrichissements et actions Clue, avec une valeur de action_value si présente, sinon la valeur.
|
|
141
|
+
|
|
142
|
+
Exemple :
|
|
143
|
+
\`\`\`markdown
|
|
144
|
+
{{curly 'clue_table clue_table_cells clue_action_type="ip"'}}
|
|
145
|
+
\`\`\`
|
|
146
|
+
où clue_table_cells est un tableau avec les propriétés :
|
|
147
|
+
|
|
148
|
+
\`\`\`
|
|
149
|
+
column: string;
|
|
150
|
+
row: string;
|
|
151
|
+
value: string;
|
|
152
|
+
clue_type (optionnel): string;
|
|
153
|
+
clue_fetcher (optionnel): string;
|
|
154
|
+
fetcher_width (optionnel): string;
|
|
155
|
+
clue_entity (optionnel): boolean;
|
|
156
|
+
action_value (optionnel): string;
|
|
157
|
+
\`\`\`
|
|
158
|
+
`
|
|
159
|
+
},
|
|
160
|
+
componentCallback: (cells, ...args) => {
|
|
161
|
+
const options = args.pop();
|
|
162
|
+
const props = options?.hash ?? {};
|
|
163
|
+
const columns = Object.keys(groupBy(cells, 'column'));
|
|
164
|
+
const rows = groupBy(cells, 'row');
|
|
165
|
+
const clueActionType = props.clue_action_type;
|
|
166
|
+
const enableClueActions = !!clueActionType;
|
|
167
|
+
return (_jsx(Paper, { sx: { width: '95%', overflowX: 'auto', m: 1 }, children: _jsx(ClueGroup, { type: clueActionType, enabled: enableClueActions, children: _jsxs(Table, { children: [_jsx(TableHead, { children: _jsx(TableRow, { children: columns.map(col => (_jsx(TableCell, { sx: { maxWidth: '150px' }, children: col
|
|
168
|
+
.split(/[_-]/)
|
|
169
|
+
.map(word => capitalize(word))
|
|
170
|
+
.join(' ') }, col))) }) }), _jsx(TableBody, { sx: { '& td': { wordBreak: 'break-word' } }, children: Object.entries(rows).map(([rowId, _cells]) => {
|
|
171
|
+
return (_jsx(TableRow, { children: columns.map(col => {
|
|
172
|
+
const cell = _cells.find(row => row.column === col);
|
|
173
|
+
if (!cell) {
|
|
174
|
+
return _jsx(TableCell, {}, col);
|
|
175
|
+
}
|
|
176
|
+
return (_jsxs(TableCell, { children: [enableClueActions && cell.clue_entity && (_jsx(ClueCheckbox, { value: cell.action_value ?? cell.value })), !!cell.clue_fetcher && !!cell.clue_type ? (_jsx(Fetcher, { slotProps: {
|
|
177
|
+
image: { width: !!cell.fetcher_width ? cell.fetcher_width : 'fit-content' },
|
|
178
|
+
stack: {
|
|
179
|
+
component: 'span',
|
|
180
|
+
sx: !!cell.fetcher_width
|
|
181
|
+
? { width: cell.fetcher_width, display: 'block' }
|
|
182
|
+
: { width: 'fit-content' }
|
|
183
|
+
}
|
|
184
|
+
}, fetcherId: cell.clue_fetcher, value: cell.value, type: cell.clue_type })) : !!cell.clue_type ? (_jsx(MarkdownTypography, { slotProps: {
|
|
185
|
+
stack: {
|
|
186
|
+
component: 'span',
|
|
187
|
+
sx: { width: 'fit-content' },
|
|
188
|
+
display: 'inline-flex'
|
|
189
|
+
}
|
|
190
|
+
}, component: "span", type: cell.clue_type, value: cell.value })) : ((cell.value ?? 'N/A'))] }, col + cell.value));
|
|
191
|
+
}) }, rowId));
|
|
192
|
+
}) })] }) }) }));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
];
|
|
196
|
+
export default HELPERS;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { HowlerHelper } from '@cccsaurora/howler-ui/components/elements/display/handlebars/helpers';
|
|
2
|
+
import type { PluginChipProps } from '@cccsaurora/howler-ui/components/elements/PluginChip';
|
|
3
|
+
import type { PluginTypographyProps } from '@cccsaurora/howler-ui/components/elements/PluginTypography';
|
|
4
|
+
import { type i18n as I18N } from 'i18next';
|
|
5
|
+
import HowlerPlugin from '@cccsaurora/howler-ui/plugins/HowlerPlugin';
|
|
6
|
+
declare class CluePlugin extends HowlerPlugin {
|
|
7
|
+
name: string;
|
|
8
|
+
version: string;
|
|
9
|
+
author: string;
|
|
10
|
+
description: string;
|
|
11
|
+
activate(): void;
|
|
12
|
+
provider(): import("react").FC<{
|
|
13
|
+
children?: import("react").ReactNode | undefined;
|
|
14
|
+
}>;
|
|
15
|
+
setup(): () => void;
|
|
16
|
+
localization(i18nInstance: I18N): void;
|
|
17
|
+
helpers(): HowlerHelper[];
|
|
18
|
+
typography(_props: PluginTypographyProps): import("react/jsx-runtime").JSX.Element;
|
|
19
|
+
chip(_props: PluginChipProps): import("react/jsx-runtime").JSX.Element;
|
|
20
|
+
}
|
|
21
|
+
export default CluePlugin;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import Fetcher from '@cccsaurora/clue-ui/components/fetchers/Fetcher';
|
|
3
|
+
import clueEN from '@cccsaurora/clue-ui/en/translation.json';
|
|
4
|
+
import clueFR from '@cccsaurora/clue-ui/fr/translation.json';
|
|
5
|
+
import { Box } from '@mui/material';
|
|
6
|
+
import {} from 'i18next';
|
|
7
|
+
import get from 'lodash-es/get';
|
|
8
|
+
import HowlerPlugin from '@cccsaurora/howler-ui/plugins/HowlerPlugin';
|
|
9
|
+
import ClueChip from './components/ClueChip';
|
|
10
|
+
import ClueLeadForm from './components/ClueLeadForm';
|
|
11
|
+
import CluePivot from './components/CluePivot';
|
|
12
|
+
import CluePivotForm from './components/CluePivotForm';
|
|
13
|
+
import ClueTypography from './components/ClueTypography';
|
|
14
|
+
import HELPERS from './helpers';
|
|
15
|
+
import howlerClueEN from './locales/clue.en.json';
|
|
16
|
+
import howlerClueFR from './locales/clue.fr.json';
|
|
17
|
+
import Provider from './Provider';
|
|
18
|
+
import useSetup from './setup';
|
|
19
|
+
class CluePlugin extends HowlerPlugin {
|
|
20
|
+
name = 'CluePlugin';
|
|
21
|
+
version = '0.0.1';
|
|
22
|
+
author = 'Matthew Rafuse <matthew.rafuse@cyber.gc.ca>';
|
|
23
|
+
description = 'This plugin enables clue enrichment in Howler.';
|
|
24
|
+
activate() {
|
|
25
|
+
super.activate();
|
|
26
|
+
const leadForm = props => _jsx(ClueLeadForm, { ...props });
|
|
27
|
+
const leadRenderer = (content, metadata, hit) => {
|
|
28
|
+
const parsedProps = JSON.parse(metadata);
|
|
29
|
+
const value = get(hit, parsedProps.value);
|
|
30
|
+
if (Array.isArray(value)) {
|
|
31
|
+
// TODO: Revisit handling for array values
|
|
32
|
+
parsedProps.value = value[0];
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
parsedProps.value = value;
|
|
36
|
+
}
|
|
37
|
+
return (_jsx(Box, { p: 1, flex: 1, display: "flex", alignItems: "stretch", children: _jsx(Fetcher, { fetcherId: content, ...parsedProps }) }));
|
|
38
|
+
};
|
|
39
|
+
super.addLead('clue', leadForm, leadRenderer);
|
|
40
|
+
const pivotForm = (props) => _jsx(CluePivotForm, { ...props });
|
|
41
|
+
const pivotRenderer = (props) => _jsx(CluePivot, { ...props });
|
|
42
|
+
super.addPivot('clue', pivotForm, pivotRenderer);
|
|
43
|
+
}
|
|
44
|
+
provider() {
|
|
45
|
+
return Provider;
|
|
46
|
+
}
|
|
47
|
+
setup() {
|
|
48
|
+
return useSetup;
|
|
49
|
+
}
|
|
50
|
+
localization(i18nInstance) {
|
|
51
|
+
i18nInstance.addResourceBundle('en', 'clue', clueEN, true, true);
|
|
52
|
+
i18nInstance.addResourceBundle('fr', 'clue', clueFR, true, true);
|
|
53
|
+
i18nInstance.addResourceBundle('en', 'translation', howlerClueEN, true, false);
|
|
54
|
+
i18nInstance.addResourceBundle('fr', 'translation', howlerClueFR, true, false);
|
|
55
|
+
}
|
|
56
|
+
helpers() {
|
|
57
|
+
return HELPERS;
|
|
58
|
+
}
|
|
59
|
+
typography(_props) {
|
|
60
|
+
return _jsx(ClueTypography, { ..._props });
|
|
61
|
+
}
|
|
62
|
+
chip(_props) {
|
|
63
|
+
return _jsx(ClueChip, { ..._props });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export default CluePlugin;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"markdown.helpers.clue.arguments": "You must provide at least two arguments: type and value.",
|
|
3
|
+
"route.dossiers.manager.clue": "Clue Fetcher ID",
|
|
4
|
+
"route.dossiers.manager.clue.type": "Selector Type",
|
|
5
|
+
"route.dossiers.manager.clue.value": "Selector Value",
|
|
6
|
+
"route.dossiers.manager.clue.value.custom": "Custom Selector Value",
|
|
7
|
+
"route.dossiers.manager.clue.value.description": "You can use handlebars notation to insert fields from the corresponding alert (i.e. {{howler.id}})."
|
|
8
|
+
}
|