@orchestrator-ui/orchestrator-ui-components 8.6.0 → 8.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.turbo/turbo-build.log +7 -7
  2. package/.turbo/turbo-lint.log +1 -1
  3. package/.turbo/turbo-test.log +16 -13
  4. package/CHANGELOG.md +11 -0
  5. package/dist/index.d.ts +106 -8
  6. package/dist/index.js +2540 -2078
  7. package/dist/index.js.map +1 -1
  8. package/package.json +3 -1
  9. package/src/components/WfoPydanticForm/fields/WfoCron.spec.ts +88 -0
  10. package/src/components/WfoPydanticForm/fields/WfoCron.tsx +233 -0
  11. package/src/components/WfoPydanticForm/fields/index.ts +1 -0
  12. package/src/components/WfoPydanticForm/fields/styles.ts +92 -0
  13. package/src/components/WfoSearchPage/WfoSearchResults/WfoSearchResultItem.tsx +2 -1
  14. package/src/components/WfoSearchPage/utils.ts +37 -1
  15. package/src/components/WfoSubscription/WfoInUseByRelations.tsx +6 -2
  16. package/src/components/WfoSubscription/WfoSubscriptionActions/WfoSubscriptionActionExpandableMenuItem.tsx +19 -14
  17. package/src/components/WfoSubscription/WfoSubscriptionActions/WfoSubscriptionActions.tsx +13 -1
  18. package/src/components/WfoSubscription/WfoSubscriptionActions/WfoSubscriptionActionsMenuItem.tsx +3 -0
  19. package/src/components/WfoSubscription/WfoSubscriptionProductBlock/WfoSubscriptionProductBlock.tsx +1 -1
  20. package/src/components/WfoSubscriptionsList/WfoSubscriptionsList.tsx +5 -0
  21. package/src/components/WfoSubscriptionsList/subscriptionResultMappers.ts +3 -1
  22. package/src/components/WfoTable/WfoAdvancedTable/WfoAdvancedTable.tsx +2 -12
  23. package/src/components/WfoTable/WfoStructuredSearchTable/WfoExpandingSearchRow.tsx +21 -14
  24. package/src/components/WfoTable/WfoStructuredSearchTable/WfoFieldSelector.tsx +27 -1
  25. package/src/components/WfoTable/WfoStructuredSearchTable/WfoFilterBuilder.tsx +97 -91
  26. package/src/components/WfoTable/WfoStructuredSearchTable/WfoOperatorSelector.tsx +32 -7
  27. package/src/components/WfoTable/WfoStructuredSearchTable/WfoRuleGroup.tsx +5 -4
  28. package/src/components/WfoTable/WfoStructuredSearchTable/WfoStructuredSearchTable.tsx +32 -30
  29. package/src/components/WfoTable/WfoStructuredSearchTable/WfoValueEditor.tsx +41 -11
  30. package/src/components/WfoTable/WfoStructuredSearchTable/styles.ts +22 -5
  31. package/src/components/WfoTable/WfoTable/WfoTable.tsx +1 -0
  32. package/src/components/WfoTable/WfoTable/WfoTableDataRows.tsx +16 -4
  33. package/src/components/WfoTable/WfoTable/styles.ts +8 -0
  34. package/src/components/WfoTable/WfoTable/utils.spec.ts +62 -1
  35. package/src/components/WfoTable/WfoTable/utils.ts +4 -1
  36. package/src/components/WfoTable/WfoTableSettingsModal/WfoTableSettingsModal.spec.ts +45 -0
  37. package/src/components/WfoTable/WfoTableSettingsModal/index.ts +1 -0
  38. package/src/components/WfoTable/WfoTableSettingsModal/utils.ts +17 -0
  39. package/src/configuration/version.ts +1 -1
  40. package/src/hooks/index.ts +1 -0
  41. package/src/hooks/useGetPydanticFormsConfig.tsx +12 -0
  42. package/src/hooks/useLanguageCode.ts +11 -0
  43. package/src/messages/en-GB.json +19 -0
  44. package/src/messages/nl-NL.json +19 -0
  45. package/src/pages/WfoSearchPocPage.tsx +14 -5
  46. package/src/rtk/endpoints/subscriptionList.ts +1 -0
  47. package/src/theme/baseStyles/formFieldsBaseStyle.ts +0 -1
  48. package/src/types/search.ts +1 -1
  49. package/src/types/types.ts +1 -1
  50. package/src/utils/getDefaultTableConfig.ts +6 -1
  51. package/src/utils/index.ts +1 -0
  52. package/src/utils/integer.spec.ts +20 -0
  53. package/src/utils/integer.ts +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orchestrator-ui/orchestrator-ui-components",
3
- "version": "8.6.0",
3
+ "version": "8.7.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Library of UI Components used to display the workflow orchestrator frontend",
6
6
  "author": {
@@ -39,6 +39,8 @@
39
39
  "@monaco-editor/react": "^4.7.0",
40
40
  "@rtk-query/graphql-request-base-query": "^2.3.1",
41
41
  "@tanstack/react-virtual": "^3.13.24",
42
+ "cron-parser": "^5.6.2",
43
+ "cronstrue": "^3.24.0",
42
44
  "graphql-request": "^6.1.0",
43
45
  "invariant": "^2.2.4",
44
46
  "lodash": "^4.17.21",
@@ -0,0 +1,88 @@
1
+ import { getCronFieldIndexAtCursor, getCronFieldLayout, getCronFieldSelectionRange } from './WfoCron';
2
+
3
+ describe('getCronFieldSelectionRange', () => {
4
+ it('returns the character range of the requested field', () => {
5
+ expect(getCronFieldSelectionRange('5 4 * JAN *', 0)).toEqual([0, 1]);
6
+ expect(getCronFieldSelectionRange('5 4 * JAN *', 1)).toEqual([2, 3]);
7
+ expect(getCronFieldSelectionRange('5 4 * JAN *', 3)).toEqual([6, 9]);
8
+ });
9
+
10
+ it('handles extra whitespace', () => {
11
+ expect(getCronFieldSelectionRange(' 5 4 *', 1)).toEqual([6, 7]);
12
+ });
13
+
14
+ it('returns null when the field is not present in the expression', () => {
15
+ expect(getCronFieldSelectionRange('5 4', 2)).toEqual(null);
16
+ expect(getCronFieldSelectionRange('', 0)).toEqual(null);
17
+ });
18
+ });
19
+
20
+ describe('getCronFieldLayout', () => {
21
+ it('returns the 5 field layout starting with minute for an empty expression', () => {
22
+ expect(getCronFieldLayout('')).toEqual(['minute', 'hour', 'dayOfMonth', 'month', 'dayOfWeek']);
23
+ });
24
+
25
+ it('returns the 5 field layout starting with minute for a 5 field expression', () => {
26
+ expect(getCronFieldLayout('5 4 * JAN *')).toEqual(['minute', 'hour', 'dayOfMonth', 'month', 'dayOfWeek']);
27
+ });
28
+
29
+ it('returns the 6 field layout starting with second for a 6 field expression', () => {
30
+ expect(getCronFieldLayout('0 5 4 * JAN *')).toEqual([
31
+ 'second',
32
+ 'minute',
33
+ 'hour',
34
+ 'dayOfMonth',
35
+ 'month',
36
+ 'dayOfWeek',
37
+ ]);
38
+ });
39
+ });
40
+
41
+ describe('getCronFieldIndexAtCursor', () => {
42
+ const expression = '*/5 8-17 * JAN mon-fri';
43
+
44
+ it('returns the first field for an empty expression', () => {
45
+ expect(getCronFieldIndexAtCursor('', 0)).toEqual(0);
46
+ });
47
+
48
+ it('returns the field the cursor is inside', () => {
49
+ expect(getCronFieldIndexAtCursor(expression, 0)).toEqual(0); // |*/5
50
+ expect(getCronFieldIndexAtCursor(expression, 2)).toEqual(0); // */|5
51
+ expect(getCronFieldIndexAtCursor(expression, 3)).toEqual(0); // */5|
52
+ expect(getCronFieldIndexAtCursor(expression, 5)).toEqual(1); // 8|-17
53
+ expect(getCronFieldIndexAtCursor(expression, 9)).toEqual(2); // |*
54
+ expect(getCronFieldIndexAtCursor(expression, 12)).toEqual(3); // J|AN
55
+ expect(getCronFieldIndexAtCursor(expression, 22)).toEqual(4); // mon-fri|
56
+ });
57
+
58
+ it('returns the next field when the cursor is after a separating space', () => {
59
+ expect(getCronFieldIndexAtCursor(expression, 4)).toEqual(1); // */5 |8-17
60
+ expect(getCronFieldIndexAtCursor(expression, 15)).toEqual(4); // JAN |mon-fri
61
+ });
62
+
63
+ it('handles multiple consecutive spaces', () => {
64
+ expect(getCronFieldIndexAtCursor('* *', 1)).toEqual(0);
65
+ expect(getCronFieldIndexAtCursor('* *', 2)).toEqual(1);
66
+ expect(getCronFieldIndexAtCursor('* *', 3)).toEqual(1);
67
+ });
68
+
69
+ it('never exceeds the last field of the active layout', () => {
70
+ expect(getCronFieldIndexAtCursor('1 2 3 4 5 ', 10)).toEqual(4);
71
+ expect(getCronFieldIndexAtCursor('* * * * * * extra', 17)).toEqual(5);
72
+ });
73
+
74
+ it('handles leading whitespace', () => {
75
+ expect(getCronFieldIndexAtCursor(' * * * * *', 1)).toEqual(0);
76
+ expect(getCronFieldIndexAtCursor(' * * * * *', 3)).toEqual(0);
77
+ });
78
+
79
+ it('maps the first field to minute for 5 fields and to second for 6 fields', () => {
80
+ const fiveFieldExpression = '5 4 * JAN *';
81
+ expect(getCronFieldLayout(fiveFieldExpression)[getCronFieldIndexAtCursor(fiveFieldExpression, 1)]).toEqual(
82
+ 'minute',
83
+ );
84
+
85
+ const sixFieldExpression = '0 5 4 * JAN *';
86
+ expect(getCronFieldLayout(sixFieldExpression)[getCronFieldIndexAtCursor(sixFieldExpression, 1)]).toEqual('second');
87
+ });
88
+ });
@@ -0,0 +1,233 @@
1
+ import React, { useMemo, useRef, useState } from 'react';
2
+
3
+ import { CronExpressionParser } from 'cron-parser';
4
+ import cronstrue from 'cronstrue/i18n';
5
+ import _ from 'lodash';
6
+ import moment from 'moment';
7
+ import { useTranslations } from 'next-intl';
8
+ import type { PydanticFormControlledElement } from 'pydantic-forms';
9
+ import { getFormFieldIdWithPath } from 'pydantic-forms';
10
+
11
+ import { EuiFieldText, EuiText } from '@elastic/eui';
12
+
13
+ import { useLanguageCode, useWithOrchestratorTheme } from '@/hooks';
14
+ import { getFormFieldsBaseStyle } from '@/theme';
15
+
16
+ import { getWfoCronFieldStyles } from './styles';
17
+
18
+ const CRON_FIELD_KEYS = ['second', 'minute', 'hour', 'dayOfMonth', 'month', 'dayOfWeek'] as const;
19
+
20
+ type CronFieldKey = (typeof CRON_FIELD_KEYS)[number];
21
+
22
+ type CronHintRow = {
23
+ symbol: string;
24
+ translationKey: string;
25
+ };
26
+
27
+ const SPECIAL_CHARACTER_HINTS: CronHintRow[] = [
28
+ { symbol: '*', translationKey: 'anyValue' },
29
+ { symbol: ',', translationKey: 'valueListSeparator' },
30
+ { symbol: '-', translationKey: 'rangeOfValues' },
31
+ { symbol: '/', translationKey: 'stepValues' },
32
+ ];
33
+
34
+ const CRON_FIELD_HINTS: Record<CronFieldKey, CronHintRow[]> = {
35
+ second: [{ symbol: '0-59', translationKey: 'allowedValues' }],
36
+ minute: [{ symbol: '0-59', translationKey: 'allowedValues' }],
37
+ hour: [{ symbol: '0-23', translationKey: 'allowedValues' }],
38
+ dayOfMonth: [{ symbol: '1-31', translationKey: 'allowedValues' }],
39
+ month: [
40
+ { symbol: '1-12', translationKey: 'allowedValues' },
41
+ { symbol: 'JAN-DEC', translationKey: 'alternativeSingleValues' },
42
+ ],
43
+ dayOfWeek: [
44
+ { symbol: '0-6', translationKey: 'allowedValues' },
45
+ { symbol: 'SUN-SAT', translationKey: 'alternativeSingleValues' },
46
+ { symbol: '7', translationKey: 'sundayNonStandard' },
47
+ ],
48
+ };
49
+
50
+ // cron-parser and cronstrue treat a 5 field expression as starting with minute;
51
+ // only with 6 fields does the first field become second
52
+ export const getCronFieldLayout = (expression: string): readonly CronFieldKey[] => {
53
+ const fieldCount = expression.trim().split(/\s+/).filter(Boolean).length;
54
+ return fieldCount >= CRON_FIELD_KEYS.length ? CRON_FIELD_KEYS : CRON_FIELD_KEYS.slice(1);
55
+ };
56
+
57
+ export const getCronFieldIndexAtCursor = (expression: string, cursorPosition: number): number => {
58
+ const textBeforeCursor = expression.slice(0, cursorPosition);
59
+ const fieldsBeforeCursor = textBeforeCursor.split(/\s+/).filter(Boolean);
60
+ const cursorIsBetweenFields = /\s$/.test(textBeforeCursor) || fieldsBeforeCursor.length === 0;
61
+ const fieldIndex = cursorIsBetweenFields ? fieldsBeforeCursor.length : fieldsBeforeCursor.length - 1;
62
+ return Math.min(fieldIndex, getCronFieldLayout(expression).length - 1);
63
+ };
64
+
65
+ export const getCronFieldSelectionRange = (expression: string, fieldIndex: number): [number, number] | null => {
66
+ const fieldPattern = /\S+/g;
67
+ let match = fieldPattern.exec(expression);
68
+ let index = 0;
69
+ while (match) {
70
+ if (index === fieldIndex) {
71
+ return [match.index, match.index + match[0].length];
72
+ }
73
+ index++;
74
+ match = fieldPattern.exec(expression);
75
+ }
76
+ return null;
77
+ };
78
+
79
+ export const WfoCron: PydanticFormControlledElement = ({ onChange, value, disabled, pydanticFormField }) => {
80
+ const { formFieldBaseStyle } = useWithOrchestratorTheme(getFormFieldsBaseStyle);
81
+ const {
82
+ cronFieldWrapperStyle,
83
+ cronPossibleValuesStyle,
84
+ cronLegendStyle,
85
+ cronLegendItemStyle,
86
+ cronActiveLegendItemStyle,
87
+ cronInactiveLegendItemStyle,
88
+ cronDescriptionStyle,
89
+ cronHintStyle,
90
+ cronHintListStyle,
91
+ cronHintSymbolStyle,
92
+ cronErrorStyle,
93
+ cronDescriptionContainerStyle,
94
+ } = useWithOrchestratorTheme(getWfoCronFieldStyles);
95
+ const t = useTranslations('pydanticForms.widgets.cron');
96
+ const cronstrueLocale = useLanguageCode();
97
+ const [activeFieldIndex, setActiveFieldIndex] = useState<number | null>(null);
98
+ const inputRef = useRef<HTMLInputElement | null>(null);
99
+
100
+ // If the field is part of an array the value is passed in as an object with the field name as key
101
+ // this is imposed by react-hook-form. We try to detect this and extract the actual value
102
+ const fieldName = getFormFieldIdWithPath(pydanticFormField.id);
103
+ const fieldValue = _.isObject(value) && _.has(value, fieldName) ? _.get(value, fieldName) : value;
104
+
105
+ const { description, parseError, nextOccurrences } = useMemo(() => {
106
+ const expression = typeof fieldValue === 'string' ? fieldValue.trim() : '';
107
+ if (!expression) {
108
+ return { description: undefined, parseError: undefined, nextOccurrences: [] };
109
+ }
110
+ try {
111
+ // cron-parser validates the expression, cronstrue turns it into a human readable description
112
+ const cronExpression = CronExpressionParser.parse(expression);
113
+
114
+ return {
115
+ description: cronstrue.toString(expression, {
116
+ locale: cronstrueLocale,
117
+ throwExceptionOnParseError: true,
118
+ }),
119
+ parseError: undefined,
120
+ nextOccurrences: cronExpression.take(1).map((cronDate) => cronDate.toDate()),
121
+ };
122
+ } catch (error) {
123
+ return {
124
+ description: undefined,
125
+ parseError: error instanceof Error ? error.message : String(error),
126
+ nextOccurrences: [],
127
+ };
128
+ }
129
+ }, [fieldValue, cronstrueLocale]);
130
+
131
+ const updateActiveField = (event: React.SyntheticEvent<HTMLInputElement>) => {
132
+ const { selectionStart, value: expression } = event.currentTarget;
133
+ setActiveFieldIndex(selectionStart === null ? null : getCronFieldIndexAtCursor(expression, selectionStart));
134
+ };
135
+
136
+ const fieldLayout = getCronFieldLayout(typeof fieldValue === 'string' ? fieldValue : '');
137
+ const activeFieldKey =
138
+ activeFieldIndex !== null ? fieldLayout[Math.min(activeFieldIndex, fieldLayout.length - 1)] : null;
139
+ const hintFieldKey = activeFieldKey ?? fieldLayout[0];
140
+
141
+ const focusCronField = (fieldKey: CronFieldKey) => {
142
+ const input = inputRef.current;
143
+ const fieldIndex = fieldLayout.indexOf(fieldKey);
144
+ // The second field is not part of the layout when the expression has 5 fields
145
+ if (!input || fieldIndex === -1) {
146
+ return;
147
+ }
148
+ const expression = typeof fieldValue === 'string' ? fieldValue : '';
149
+ input.focus();
150
+ const selectionRange = getCronFieldSelectionRange(expression, fieldIndex);
151
+ if (selectionRange) {
152
+ input.setSelectionRange(selectionRange[0], selectionRange[1]);
153
+ } else {
154
+ // The selection can't be found so the caret is placed at the end of the selection while maintaining focus on the field
155
+ input.setSelectionRange(expression.length, expression.length);
156
+ }
157
+ setActiveFieldIndex(fieldIndex);
158
+ };
159
+
160
+ return (
161
+ <div css={cronFieldWrapperStyle}>
162
+ <EuiFieldText
163
+ data-testid={pydanticFormField.id}
164
+ css={formFieldBaseStyle}
165
+ inputRef={inputRef}
166
+ disabled={disabled}
167
+ isInvalid={!!parseError}
168
+ placeholder="* * * * *"
169
+ onChange={(event) => {
170
+ onChange(event.target.value);
171
+ }}
172
+ onSelect={updateActiveField}
173
+ onFocus={updateActiveField}
174
+ onBlur={() => setActiveFieldIndex(null)}
175
+ value={fieldValue}
176
+ fullWidth
177
+ />
178
+ <div css={[cronLegendStyle]}>
179
+ {CRON_FIELD_KEYS.map((fieldKey) => {
180
+ const isInLayout = fieldLayout.includes(fieldKey);
181
+ return (
182
+ <button
183
+ key={fieldKey}
184
+ type="button"
185
+ disabled={disabled || !isInLayout}
186
+ aria-pressed={fieldKey === activeFieldKey}
187
+ onClick={() => focusCronField(fieldKey)}
188
+ css={[
189
+ cronLegendItemStyle,
190
+ fieldKey === activeFieldKey && cronActiveLegendItemStyle,
191
+ !isInLayout && cronInactiveLegendItemStyle,
192
+ ]}
193
+ >
194
+ {t(fieldKey)}
195
+ </button>
196
+ );
197
+ })}
198
+ </div>
199
+ {hintFieldKey && (
200
+ <>
201
+ <div css={cronPossibleValuesStyle}>{t('possibleValues')}</div>
202
+ <div css={cronHintListStyle}>
203
+ {[...SPECIAL_CHARACTER_HINTS, ...CRON_FIELD_HINTS[hintFieldKey]].map(({ symbol, translationKey }) => (
204
+ <React.Fragment key={symbol}>
205
+ <span css={cronHintSymbolStyle}>{symbol}</span>
206
+ <span>{t(translationKey)}</span>
207
+ </React.Fragment>
208
+ ))}
209
+ </div>
210
+ </>
211
+ )}
212
+ {parseError && (
213
+ <EuiText size="s" css={cronErrorStyle}>
214
+ {t('invalidExpression', { error: parseError })}
215
+ </EuiText>
216
+ )}
217
+ {description && (
218
+ <div css={cronDescriptionContainerStyle}>
219
+ <EuiText size="s" css={cronDescriptionStyle}>
220
+ {description}
221
+ </EuiText>
222
+ {nextOccurrences.length > 0 && (
223
+ <EuiText size="xs" css={cronHintStyle}>
224
+ {t('nextOccurrences', {
225
+ dates: nextOccurrences.map((date) => moment(date).format('YYYY-MM-DD HH:mm:ss')).join(', '),
226
+ })}
227
+ </EuiText>
228
+ )}
229
+ </div>
230
+ )}
231
+ </div>
232
+ );
233
+ };
@@ -15,3 +15,4 @@ export * from './wfoPydanticFormUtils';
15
15
  export * from './WfoCallout';
16
16
  export * from './WfoMarkdownField';
17
17
  export * from './WfoTimestampField';
18
+ export * from './WfoCron';
@@ -14,6 +14,98 @@ export const getWfoObjectFieldStyles = () => {
14
14
  };
15
15
  };
16
16
 
17
+ export const getWfoCronFieldStyles = ({ theme }: WfoThemeHelpers) => {
18
+ const cronFieldWrapperStyle = css({
19
+ width: '100%',
20
+ });
21
+
22
+ const cronLegendStyle = css({
23
+ display: 'flex',
24
+ flexWrap: 'wrap',
25
+ gap: theme.size.xs,
26
+ marginTop: theme.size.s,
27
+ fontSize: theme.size.m,
28
+ });
29
+
30
+ const cronLegendItemStyle = css({
31
+ color: theme.colors.textSubdued,
32
+ padding: `0 ${theme.size.xs}`,
33
+ borderRadius: theme.border.radius.small,
34
+ border: 'none',
35
+ backgroundColor: 'transparent',
36
+ font: 'inherit',
37
+ cursor: 'pointer',
38
+ });
39
+
40
+ const cronPossibleValuesStyle = css({
41
+ marginTop: theme.size.m,
42
+ marginLeft: theme.size.m,
43
+ fontSize: theme.size.m,
44
+ color: theme.colors.textSubdued,
45
+ });
46
+
47
+ const cronActiveLegendItemStyle = css({
48
+ color: theme.colors.textPrimary,
49
+ backgroundColor: theme.colors.backgroundBasePrimary,
50
+ fontWeight: theme.font.weight.bold,
51
+ });
52
+
53
+ const cronInactiveLegendItemStyle = css({
54
+ color: theme.colors.textDisabled,
55
+ cursor: 'default',
56
+ });
57
+
58
+ const cronDescriptionStyle = css({
59
+ color: theme.colors.textParagraph,
60
+ fontWeight: theme.font.weight.semiBold,
61
+ });
62
+
63
+ const cronHintStyle = css({
64
+ marginTop: theme.size.xs,
65
+ color: theme.colors.textSubdued,
66
+ });
67
+
68
+ const cronHintListStyle = css({
69
+ display: 'grid',
70
+ gridTemplateColumns: 'max-content 1fr',
71
+ columnGap: theme.size.m,
72
+ rowGap: theme.size.xxs,
73
+ margin: theme.size.m,
74
+ marginTop: theme.size.xs,
75
+ fontSize: theme.size.m,
76
+ color: theme.colors.textSubdued,
77
+ });
78
+
79
+ const cronHintSymbolStyle = css({
80
+ fontFamily: theme.font.familyCode,
81
+ color: theme.colors.textPrimary,
82
+ });
83
+
84
+ const cronErrorStyle = css({
85
+ marginTop: theme.size.xs,
86
+ color: theme.colors.textDanger,
87
+ });
88
+ const cronDescriptionContainerStyle = css({
89
+ border: `thin dotted ${theme.colors.borderBasePlain}`,
90
+ borderRadius: theme.border.radius.small,
91
+ padding: theme.size.m,
92
+ });
93
+ return {
94
+ cronFieldWrapperStyle,
95
+ cronLegendStyle,
96
+ cronLegendItemStyle,
97
+ cronActiveLegendItemStyle,
98
+ cronInactiveLegendItemStyle,
99
+ cronDescriptionStyle,
100
+ cronHintStyle,
101
+ cronHintListStyle,
102
+ cronHintSymbolStyle,
103
+ cronErrorStyle,
104
+ cronPossibleValuesStyle,
105
+ cronDescriptionContainerStyle,
106
+ };
107
+ };
108
+
17
109
  export const getCommonFormFieldStyles = ({ theme }: WfoThemeHelpers) => {
18
110
  const formRowStyle = css({
19
111
  marginBottom: theme.base * 2,
@@ -28,7 +28,8 @@ export const WfoSearchResultItem: FC<WfoSearchResultItemProps> = ({
28
28
  index,
29
29
  }) => {
30
30
  const t = useTranslations('search.page');
31
- const matchingField = result.matching_field;
31
+ const matchingFields = result.matching_fields;
32
+ const matchingField = matchingFields?.[0];
32
33
  const { theme } = useOrchestratorTheme();
33
34
  const baseUrl = `${window.location.protocol}//${window.location.host}`;
34
35
  const detailUrl = getDetailUrl(result, baseUrl);
@@ -7,6 +7,7 @@ import {
7
7
  Condition,
8
8
  EntityKind,
9
9
  Filter,
10
+ MatchingField,
10
11
  OperatorDisplay,
11
12
  PathInfo,
12
13
  RetrieverType,
@@ -194,7 +195,42 @@ const buildSubscriptionStatusFilter = (tab: WfoSubscriptionListTab) => {
194
195
  };
195
196
  };
196
197
 
197
- export const combineSearchFilters = (ruleGroup: RuleGroupType | false | undefined, tab: WfoSubscriptionListTab) => {
198
+ /**
199
+ * Search-API field path holding the status of each entity kind. Only the SUBSCRIPTION path is in
200
+ * use; the others follow the same convention but should be verified against the backend paths
201
+ * endpoint before relying on them.
202
+ */
203
+ const STATUS_FIELD_PATHS: Record<EntityKind, string> = {
204
+ [EntityKind.SUBSCRIPTION]: 'subscription.status',
205
+ [EntityKind.PROCESS]: 'process.last_status',
206
+ [EntityKind.PRODUCT]: 'product.status',
207
+ [EntityKind.WORKFLOW]: 'workflow.status',
208
+ };
209
+
210
+ /**
211
+ * The tab implicitly adds a status filter to the search (see addStatusFilterFromTab). The backend
212
+ * reports every matched filter in matching_fields, so those implicit matches are removed here to
213
+ * only show the user matches for filters they provided themselves. Matching on path AND value:
214
+ * a status match that cannot come from the tab filter is kept.
215
+ */
216
+ export const removeTabStatusMatchingFields = (
217
+ matchingFields: MatchingField[] | null | undefined,
218
+ tab: WfoSubscriptionListTab,
219
+ entityKind: EntityKind,
220
+ ): MatchingField[] => {
221
+ const statusFieldPath = STATUS_FIELD_PATHS[entityKind];
222
+ const tabStatuses = getSubscriptionStatusesFromTab(tab);
223
+ return (
224
+ matchingFields?.filter((field) => {
225
+ const isTabStatusMatch =
226
+ field.path === statusFieldPath
227
+ && tabStatuses.some((status) => status.toLowerCase() === field.text.toLowerCase());
228
+ return !isTabStatusMatch;
229
+ }) ?? []
230
+ );
231
+ };
232
+
233
+ export const addStatusFilterFromTab = (ruleGroup: RuleGroupType | false | undefined, tab: WfoSubscriptionListTab) => {
198
234
  const userRuleGroup = ruleGroup === false ? undefined : ruleGroup;
199
235
  const ruleGroups = [buildSubscriptionStatusFilter(tab), userRuleGroup].filter(Boolean) as RuleGroupType[];
200
236
 
@@ -14,9 +14,13 @@ import { getSubscriptionDetailStyles } from './styles';
14
14
 
15
15
  interface WfoInUseByRelationsProps {
16
16
  inUseByRelations: InUseByRelation[];
17
+ subscriptionPath?: string;
17
18
  }
18
19
 
19
- export const WfoInUseByRelations = ({ inUseByRelations }: WfoInUseByRelationsProps) => {
20
+ export const WfoInUseByRelations = ({
21
+ inUseByRelations,
22
+ subscriptionPath = PATH_SUBSCRIPTIONS,
23
+ }: WfoInUseByRelationsProps) => {
20
24
  const t = useTranslations('subscriptions.detail');
21
25
  const { inUseByRelationDetailsStyle } = useWithOrchestratorTheme(getSubscriptionDetailStyles);
22
26
  const subscriptionIds = inUseByRelations.map((relation) => relation.subscription_id).join('|');
@@ -42,7 +46,7 @@ export const WfoInUseByRelations = ({ inUseByRelations }: WfoInUseByRelationsPro
42
46
  {
43
47
  key: t('description'),
44
48
  value: (
45
- <Link href={`${PATH_SUBSCRIPTIONS}/${inUseByRelationDetails.subscriptionId}`} target="_blank">
49
+ <Link href={`${subscriptionPath}/${inUseByRelationDetails.subscriptionId}`} target="_blank">
46
50
  {inUseByRelationDetails.description}
47
51
  </Link>
48
52
  ),
@@ -15,12 +15,14 @@ export type WfoSubscriptionActionExpandableMenuItemProps = {
15
15
  subscriptionAction: SubscriptionAction;
16
16
  onClickLockedRelation: (relation: SubscriptionRelation) => void;
17
17
  children: React.ReactNode;
18
+ subscriptionPath?: string;
18
19
  };
19
20
 
20
21
  export const WfoSubscriptionActionExpandableMenuItem: FC<WfoSubscriptionActionExpandableMenuItemProps> = ({
21
22
  subscriptionAction,
22
23
  onClickLockedRelation,
23
24
  children,
25
+ subscriptionPath = PATH_SUBSCRIPTIONS,
24
26
  }) => {
25
27
  const t = useTranslations('subscriptions.detail.actions');
26
28
 
@@ -29,18 +31,21 @@ export const WfoSubscriptionActionExpandableMenuItem: FC<WfoSubscriptionActionEx
29
31
  const [isExpanded, setIsExpanded] = useState(false);
30
32
 
31
33
  // TODO: remove lockedRelationsUuids fallback when orchestrator-core 6.0.0 is released and only use the _detail variant
32
- const lockedRelationsDetail = subscriptionAction.locked_relations_detail;
33
- const lockedRelationsUuids = subscriptionAction.locked_relations;
34
- const hasLockedRelations = (lockedRelationsDetail?.length ?? 0) > 0 || (lockedRelationsUuids?.length ?? 0) > 0;
34
+ const relationsDetail = [
35
+ ...(subscriptionAction.locked_relations_detail ?? []),
36
+ ...(subscriptionAction.unterminated_in_use_by_subscriptions_detail ?? []),
37
+ ];
38
+ const relationsUuids = [
39
+ ...(subscriptionAction.locked_relations ?? []),
40
+ ...(subscriptionAction.unterminated_in_use_by_subscriptions ?? []),
41
+ ];
42
+ const hasRelations = relationsDetail.length > 0 || relationsUuids.length > 0;
35
43
 
36
44
  return (
37
45
  <div>
38
- <div
39
- css={[expandableMenuItemStyle, hasLockedRelations && clickableStyle]}
40
- onClick={() => setIsExpanded(!isExpanded)}
41
- >
46
+ <div css={[expandableMenuItemStyle, hasRelations && clickableStyle]} onClick={() => setIsExpanded(!isExpanded)}>
42
47
  <div>{children}</div>
43
- {hasLockedRelations && (
48
+ {hasRelations && (
44
49
  <EuiButtonIcon
45
50
  css={expandButtonStyle}
46
51
  iconType={isExpanded ? 'arrowDown' : 'arrowRight'}
@@ -49,17 +54,17 @@ export const WfoSubscriptionActionExpandableMenuItem: FC<WfoSubscriptionActionEx
49
54
  />
50
55
  )}
51
56
  </div>
52
- {hasLockedRelations && isExpanded && (
57
+ {hasRelations && isExpanded && (
53
58
  <div css={expandedContentStyle}>
54
59
  <EuiText size="xs">{t('lockedBySubscriptions')}</EuiText>
55
60
  <ul css={{ margin: 0, paddingLeft: 16, listStyleType: 'disc' }}>
56
- {lockedRelationsDetail ?
57
- lockedRelationsDetail.map((relation) => (
61
+ {relationsDetail.length > 0 ?
62
+ relationsDetail.map((relation) => (
58
63
  <li key={relation.subscription_id}>
59
64
  <EuiText size="xs">
60
65
  <Link
61
66
  css={linkStyle}
62
- href={`${PATH_SUBSCRIPTIONS}/${relation.subscription_id}`}
67
+ href={`${subscriptionPath}/${relation.subscription_id}`}
63
68
  target="_blank"
64
69
  onClick={() => onClickLockedRelation(relation)}
65
70
  >
@@ -68,10 +73,10 @@ export const WfoSubscriptionActionExpandableMenuItem: FC<WfoSubscriptionActionEx
68
73
  </EuiText>
69
74
  </li>
70
75
  ))
71
- : lockedRelationsUuids?.map((id) => (
76
+ : relationsUuids.map((id) => (
72
77
  <li key={id}>
73
78
  <EuiText size="xs">
74
- <Link css={linkStyle} href={`${PATH_SUBSCRIPTIONS}/${id}`} target="_blank">
79
+ <Link css={linkStyle} href={`${subscriptionPath}/${id}`} target="_blank">
75
80
  {id}
76
81
  </Link>
77
82
  </EuiText>
@@ -5,7 +5,13 @@ import { useRouter } from 'next/router';
5
5
 
6
6
  import { EuiButton, EuiButtonIcon, EuiLoadingSpinner, EuiTitle } from '@elastic/eui';
7
7
 
8
- import { PATH_START_NEW_TASK, PATH_START_NEW_WORKFLOW, WfoInSyncField, WfoPopover } from '@/components';
8
+ import {
9
+ PATH_START_NEW_TASK,
10
+ PATH_START_NEW_WORKFLOW,
11
+ PATH_SUBSCRIPTIONS,
12
+ WfoInSyncField,
13
+ WfoPopover,
14
+ } from '@/components';
9
15
  import { WfoSubscriptionActionsMenuItem } from '@/components/WfoSubscription/WfoSubscriptionActions/WfoSubscriptionActionsMenuItem';
10
16
  import { useActiveProcess } from '@/components/WfoSubscription/WfoSubscriptionActions/utils';
11
17
  import { PolicyResource } from '@/configuration/policy-resources';
@@ -27,12 +33,14 @@ export type WfoSubscriptionActionsProps = {
27
33
  subscriptionId: string;
28
34
  isLoading?: boolean;
29
35
  compactMode?: boolean;
36
+ subscriptionPath?: string;
30
37
  };
31
38
 
32
39
  export const WfoSubscriptionActions: FC<WfoSubscriptionActionsProps> = ({
33
40
  subscriptionId,
34
41
  isLoading,
35
42
  compactMode = false,
43
+ subscriptionPath = PATH_SUBSCRIPTIONS,
36
44
  }) => {
37
45
  const t = useTranslations('subscriptions.detail.actions');
38
46
  const { theme } = useOrchestratorTheme();
@@ -132,6 +140,7 @@ export const WfoSubscriptionActions: FC<WfoSubscriptionActionsProps> = ({
132
140
  subscriptionAction={subscriptionAction}
133
141
  target={WorkflowTarget.VALIDATE}
134
142
  setPopover={setPopover}
143
+ subscriptionPath={subscriptionPath}
135
144
  onClick={() => handleActionClick(subscriptionAction.name, compactMode, true)}
136
145
  isLoading={buttonIsLoading}
137
146
  />
@@ -152,6 +161,7 @@ export const WfoSubscriptionActions: FC<WfoSubscriptionActionsProps> = ({
152
161
  }
153
162
  target={WorkflowTarget.RECONCILE}
154
163
  setPopover={setPopover}
164
+ subscriptionPath={subscriptionPath}
155
165
  onClick={() => handleActionClick(subscriptionAction.name, compactMode, false)}
156
166
  isLoading={buttonIsLoading}
157
167
  />
@@ -182,6 +192,7 @@ export const WfoSubscriptionActions: FC<WfoSubscriptionActionsProps> = ({
182
192
  subscriptionAction={subscriptionAction}
183
193
  target={WorkflowTarget.MODIFY}
184
194
  setPopover={setPopover}
195
+ subscriptionPath={subscriptionPath}
185
196
  onClick={() => {
186
197
  redirectToUrl(subscriptionAction.name);
187
198
  }}
@@ -199,6 +210,7 @@ export const WfoSubscriptionActions: FC<WfoSubscriptionActionsProps> = ({
199
210
  subscriptionAction={subscriptionAction}
200
211
  target={WorkflowTarget.TERMINATE}
201
212
  setPopover={setPopover}
213
+ subscriptionPath={subscriptionPath}
202
214
  onClick={() => {
203
215
  redirectToUrl(subscriptionAction.name);
204
216
  }}