diff --git a/apis/locale/locales/en-US.json b/apis/locale/locales/en-US.json
index add70cab7..cfbc75744 100644
--- a/apis/locale/locales/en-US.json
+++ b/apis/locale/locales/en-US.json
@@ -47,6 +47,22 @@
"value": "Excluded lock",
"comment": "Excluded lock state of an item in listbox(buy20180905)"
},
+ "Object.FilterLabel.All": {
+ "value": "ALL",
+ "comment": "Label for when a chart filter has been set to exclude global selections in a field, meaning all field values will be included. (tew 220411)"
+ },
+ "Object.FilterLabel.Exclude": {
+ "value": "NOT",
+ "comment": "Label for when a chart filter has been set to exclude the filter values. (tew 220411)"
+ },
+ "Object.FilterLabel.Unknown": {
+ "value": "Unknown",
+ "comment": "Label for when a chart filter could not be labeled. (tew 220411)"
+ },
+ "Object.FiltersApplied": {
+ "value": "Filters applied:",
+ "comment": "Title for applied filters on a chart. (tew 220411)"
+ },
"Listbox.Search": {
"value": "Search in listbox",
"comment": "Action text to search in listbox"
diff --git a/apis/nucleus/src/components/Cell.jsx b/apis/nucleus/src/components/Cell.jsx
index 1a3230ac3..93e52c0cb 100644
--- a/apis/nucleus/src/components/Cell.jsx
+++ b/apis/nucleus/src/components/Cell.jsx
@@ -584,6 +584,7 @@ const Cell = forwardRef(
);
}
+ const isCardTheme = !!halo.public.theme?.getStyle('', '', '_cards');
const flags = halo.public.galaxy?.flags;
let useOldCellPadding;
let bodyPadding;
@@ -594,13 +595,13 @@ const Cell = forwardRef(
useOldCellPadding = true;
bodyPadding = undefined;
} else {
- const senseTheme = halo.public.theme;
useOldCellPadding = false;
bodyPadding = getPadding({
layout,
isError: state.error,
- senseTheme,
+ isCardTheme,
titleStyles,
+ translator,
});
}
@@ -667,7 +668,15 @@ const Cell = forwardRef(
>
{Content}
- {cellNode && layout && state.sn && }
+ {cellNode && layout && state.sn && (
+
+ )}
{state.longRunningQuery && }
diff --git a/apis/nucleus/src/components/FiltersFooter.jsx b/apis/nucleus/src/components/FiltersFooter.jsx
new file mode 100644
index 000000000..3efcec24f
--- /dev/null
+++ b/apis/nucleus/src/components/FiltersFooter.jsx
@@ -0,0 +1,54 @@
+import React from 'react';
+import { Typography, Grid, Tooltip } from '@mui/material';
+import FilterIcon from '@nebula.js/ui/icons/filter';
+import { generateFiltersLabels } from '../utils/generateFiltersInfo';
+
+function ItalicText({ styles, children }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function FiltersFooter({ layout, translator, filtersFootnoteString, footerStyle }) {
+ const filtersFootnoteLabels = generateFiltersLabels(layout?.filters ?? [], translator);
+ const styles = {
+ color: footerStyle?.color,
+ fontFamily: footerStyle?.fontFamily,
+ fontSize: footerStyle?.fontSize,
+ fontWeight: footerStyle?.fontWeight,
+ };
+
+ return (
+
+
+
+
+
+ {translator.get('Object.FiltersApplied')}
+
+
+
+ {filtersFootnoteLabels.map((filter) => (
+
+ {`${filter.field}:`}
+ {filter.label}
+
+ ))}
+
+
+
+ );
+}
+
+export default FiltersFooter;
diff --git a/apis/nucleus/src/components/Footer.jsx b/apis/nucleus/src/components/Footer.jsx
index 8200da488..15fcc1e3e 100644
--- a/apis/nucleus/src/components/Footer.jsx
+++ b/apis/nucleus/src/components/Footer.jsx
@@ -3,6 +3,8 @@ import React from 'react';
import { styled } from '@mui/material/styles';
import { Typography, Grid, Tooltip } from '@mui/material';
+import { generateFiltersString } from '../utils/generateFiltersInfo';
+import FiltersFooter from './FiltersFooter';
const PREFIX = 'Footer';
@@ -10,10 +12,9 @@ const classes = {
itemStyle: `${PREFIX}-itemStyle`,
};
-const StyledGrid = styled(Grid)(({ theme }) => ({
+const StyledGrid = styled(Grid)(() => ({
[`& .${classes.itemStyle}`]: {
minWidth: 0,
- paddingTop: theme.spacing(1),
width: '100%',
},
}));
@@ -28,15 +29,37 @@ const CellFooter = {
className: 'njs-cell-footer',
};
-function Footer({ layout, titleStyles = {} }) {
- return layout && layout.showTitles && layout.footnote ? (
+function Footer({ layout, titleStyles = {}, translator, flags, isCardTheme }) {
+ const footerStyle = titleStyles.footer;
+ const hasFilters = layout?.filters?.length > 0 && layout?.qHyperCube?.qMeasureInfo?.length > 0;
+ const filtersFootnoteString = generateFiltersString(layout?.filters ?? [], translator);
+ const showFilters = !layout?.footnote && hasFilters && filtersFootnoteString;
+ const themePaddingEnabled = flags?.isEnabled('VNA-13_CELLPADDING_FROM_THEME');
+ const paddingTop = isCardTheme ? '1px' : '6px';
+
+ return layout && layout.showTitles && (layout.footnote || showFilters) ? (
-
-
-
- {layout.footnote}
-
-
+ (themePaddingEnabled ? paddingTop : theme.spacing(1)) }}
+ >
+ {layout.footnote && (
+
+
+ {layout.footnote}
+
+
+ )}
+ {showFilters && (
+
+ )}
) : null;
diff --git a/apis/nucleus/src/components/__tests__/filters-footer.test.jsx b/apis/nucleus/src/components/__tests__/filters-footer.test.jsx
new file mode 100644
index 000000000..9c12dcb20
--- /dev/null
+++ b/apis/nucleus/src/components/__tests__/filters-footer.test.jsx
@@ -0,0 +1,73 @@
+import React from 'react';
+import { create, act } from 'react-test-renderer';
+import { Typography } from '@mui/material';
+import * as generateFiltersInfo from '../../utils/generateFiltersInfo';
+import FiltersFooter from '../FiltersFooter';
+
+describe('', () => {
+ let renderer;
+ let render;
+
+ beforeEach(() => {
+ jest.spyOn(generateFiltersInfo, 'generateFiltersLabels').mockReturnValue([
+ {
+ field: 'Alpha',
+ label: 'B, C',
+ },
+ {
+ field: 'Dim1',
+ label: 'B',
+ },
+ ]);
+ render = async ({ layout, footerStyle, translator }) => {
+ await act(async () => {
+ renderer = create();
+ });
+ };
+ });
+ afterEach(() => {
+ renderer.unmount();
+ });
+
+ it('should render filters footnote', async () => {
+ const translator = { get: (s) => s };
+ const layout = {
+ filters: [
+ {
+ type: 'values',
+ field: 'Alpha',
+ options: {
+ values: ['B', 'C'],
+ },
+ },
+ {
+ type: 'values',
+ field: 'Dim1',
+ options: {
+ values: ['B'],
+ },
+ },
+ ],
+ };
+ const footerStyle = { backgroundColor: 'red', color: 'blue' };
+ await render({ layout, footerStyle, translator });
+ const types = renderer.root.findAllByType(Typography);
+ expect(types).toHaveLength(5);
+ expect(types[0].props.children.map((child) => (typeof child === 'string' ? child.trim() : child))).toEqual([
+ 'Object.FiltersApplied',
+ '',
+ ]);
+ expect(types[1].props.children).toBe('Alpha:');
+ expect(types[2].props.children.map((child) => (typeof child === 'string' ? child.trim() : child))).toEqual([
+ '',
+ 'B, C',
+ '',
+ ]);
+ expect(types[3].props.children).toBe('Dim1:');
+ expect(types[4].props.children.map((child) => (typeof child === 'string' ? child.trim() : child))).toEqual([
+ '',
+ 'B',
+ '',
+ ]);
+ });
+});
diff --git a/apis/nucleus/src/components/__tests__/footer.test.jsx b/apis/nucleus/src/components/__tests__/footer.test.jsx
index 2a5e11855..493b4e5a2 100644
--- a/apis/nucleus/src/components/__tests__/footer.test.jsx
+++ b/apis/nucleus/src/components/__tests__/footer.test.jsx
@@ -2,11 +2,16 @@ import React from 'react';
import { create, act } from 'react-test-renderer';
import { Typography } from '@mui/material';
import Footer from '../Footer';
+import * as generateFiltersInfo from '../../utils/generateFiltersInfo';
+import * as FiltersFooter from '../FiltersFooter';
describe('', () => {
let renderer;
let render;
+ let FiltersFootnote;
beforeEach(() => {
+ jest.spyOn(generateFiltersInfo, 'generateFiltersString').mockReturnValue('filters string');
+ FiltersFootnote = jest.spyOn(FiltersFooter, 'default').mockReturnValue('FiltersFooter');
render = async (layout) => {
await act(async () => {
renderer = create();
@@ -20,10 +25,19 @@ describe('', () => {
await render();
expect(renderer.root.props.layout).toBe(undefined);
});
- it('should render', async () => {
+ it('should render footnote when has footnote', async () => {
await render({ showTitles: true, footnote: 'foo' });
const types = renderer.root.findAllByType(Typography);
expect(types).toHaveLength(1);
expect(types[0].props.children).toBe('foo');
});
+
+ it('should render filters footnote when has filters and no footnote', async () => {
+ jest.mock('../FiltersFooter', () => ({
+ FiltersFooter: jest.fn(() =>
Filters footNote
),
+ }));
+ await render({ showTitles: true, footnote: '', filters: ['A'], qHyperCube: { qMeasureInfo: [{ cId: 'chart' }] } });
+ const types = renderer.root.findAllByType(FiltersFootnote);
+ expect(types).toHaveLength(1);
+ });
});
diff --git a/apis/nucleus/src/components/listbox/utils/frequencyMaxUtil.js b/apis/nucleus/src/components/listbox/utils/frequencyMaxUtil.js
index d98b8b364..b7bb87b7c 100644
--- a/apis/nucleus/src/components/listbox/utils/frequencyMaxUtil.js
+++ b/apis/nucleus/src/components/listbox/utils/frequencyMaxUtil.js
@@ -1,12 +1,4 @@
-const escapeField = (field) => {
- if (!field) {
- return field;
- }
- if (/^[A-Za-z][A-Za-z0-9_]*$/.test(field)) {
- return field;
- }
- return `[${field.replace(/\]/g, ']]')}]`;
-};
+import escapeField from '../../../utils/escape-field';
export const needToFetchFrequencyMax = (layout) => layout?.frequencyMax === 'fetch';
diff --git a/apis/nucleus/src/utils/__tests__/cell-padding.test.js b/apis/nucleus/src/utils/__tests__/cell-padding.test.js
index e6bdbe282..64e82b6f7 100644
--- a/apis/nucleus/src/utils/__tests__/cell-padding.test.js
+++ b/apis/nucleus/src/utils/__tests__/cell-padding.test.js
@@ -1,51 +1,53 @@
import getPadding from '../cell-padding';
+import * as generateFiltersInfo from '../generateFiltersInfo';
describe('cell padding', () => {
let testFn;
let titleStyles;
beforeEach(() => {
+ jest.spyOn(generateFiltersInfo, 'generateFiltersString').mockReturnValue('filters string');
titleStyles = {
main: {},
subTitle: {},
footer: {},
};
testFn = ({
- cardTheme = false,
+ isCardTheme = true,
isError = false,
showTitles = false,
title = false,
subtitle = false,
footer = false,
visualization = 'barchart',
+ footerFilter = false,
}) => {
- const senseTheme = {
- getStyle: () => (cardTheme ? true : undefined),
- };
const layout = {
showTitles,
title: title ? 'yes' : '',
subtitle: subtitle ? 'yes' : '',
footnote: footer ? 'yes' : '',
+ filters: footerFilter ? [{}] : [],
+ qHyperCube: { qMeasureInfo: [{}] },
visualization,
};
- return getPadding({ layout, isError, senseTheme, titleStyles });
+ return getPadding({ layout, isError, isCardTheme, titleStyles });
};
});
test('not card theme return undefined', () => {
- const bodyPadding = testFn({ cardTheme: false });
+ const bodyPadding = testFn({ isCardTheme: false });
expect(bodyPadding).toBeUndefined();
});
test('card theme return bodyPadding', () => {
- const bodyPadding = testFn({ cardTheme: true });
+ const bodyPadding = testFn({ isCardTheme: true });
expect(bodyPadding).toBe('10px 10px 5px');
});
test('card theme with title, subtitel & footer should update titleStyles', () => {
const bodyPadding = testFn({
- cardTheme: true,
+ isCardTheme: true,
showTitles: true,
title: true,
subtitle: true,
@@ -58,9 +60,9 @@ describe('cell padding', () => {
expect(titleStyles.footer.borderTop).toBe('1px solid #d9d9d9');
});
- test('card theme with only subtitel', () => {
+ test('card theme with only subtitle', () => {
const bodyPadding = testFn({
- cardTheme: true,
+ isCardTheme: true,
showTitles: true,
subtitle: true,
});
@@ -73,7 +75,7 @@ describe('cell padding', () => {
test('card theme with only footer', () => {
const bodyPadding = testFn({
- cardTheme: true,
+ isCardTheme: true,
showTitles: true,
footer: true,
});
@@ -84,9 +86,23 @@ describe('cell padding', () => {
expect(titleStyles.footer.borderTop).toBe('1px solid #d9d9d9');
});
+ test('card theme with only filters info in footer', () => {
+ const bodyPadding = testFn({
+ isCardTheme: true,
+ showTitles: true,
+ footer: false,
+ footerFilter: true,
+ });
+ expect(bodyPadding).toBe('10px 10px 0');
+ expect(titleStyles.main.padding).toBeUndefined();
+ expect(titleStyles.subTitle.padding).toBeUndefined();
+ expect(titleStyles.footer.padding).toBe('6px 10px');
+ expect(titleStyles.footer.borderTop).toBe('1px solid #d9d9d9');
+ });
+
test('card theme with sn-filter-pane visualization type the do not include padding', () => {
const bodyPadding = testFn({
- cardTheme: true,
+ isCardTheme: true,
visualization: 'sn-filter-pane',
});
expect(bodyPadding).toBeUndefined();
@@ -94,7 +110,7 @@ describe('cell padding', () => {
test('card theme with action-button visualization do not include padding', () => {
const bodyPadding = testFn({
- cardTheme: true,
+ isCardTheme: true,
visualization: 'action-button',
});
expect(bodyPadding).toBeUndefined();
@@ -102,7 +118,7 @@ describe('cell padding', () => {
test('card theme with action-button visualization do include padding if showTitels is true', () => {
const bodyPadding = testFn({
- cardTheme: true,
+ isCardTheme: true,
visualization: 'action-button',
showTitles: true,
});
@@ -111,7 +127,7 @@ describe('cell padding', () => {
test('card theme with sn-filter-pane visualization type the do include footer styling', () => {
const bodyPadding = testFn({
- cardTheme: true,
+ isCardTheme: true,
visualization: 'sn-filter-pane',
showTitles: true,
footer: true,
@@ -123,7 +139,7 @@ describe('cell padding', () => {
test('not card theme with sn-filter-pane visualization', () => {
const bodyPadding = testFn({
- cardTheme: false,
+ isCardTheme: false,
visualization: 'sn-filter-pane',
showTitles: true,
footer: true,
diff --git a/apis/nucleus/src/utils/__tests__/escape-field.test.js b/apis/nucleus/src/utils/__tests__/escape-field.test.js
new file mode 100644
index 000000000..522bb30f9
--- /dev/null
+++ b/apis/nucleus/src/utils/__tests__/escape-field.test.js
@@ -0,0 +1,25 @@
+import escapeField from '../escape-field';
+
+describe('escaping of fields', () => {
+ test('Should escape non-allowed characters', () => {
+ expect(escapeField('A B')).toEqual('[A B]');
+ expect(escapeField('A[B')).toEqual('[A[B]');
+ expect(escapeField('A]B')).toEqual('[A]]B]');
+ expect(escapeField('123')).toEqual('[123]');
+ expect(escapeField('a-1')).toEqual('[a-1]');
+ expect(escapeField('_a')).toEqual('[_a]');
+ });
+
+ test('Should leave allowed characters unescaped', () => {
+ expect(escapeField('A')).toEqual('A');
+ expect(escapeField('ABC')).toEqual('ABC');
+ expect(escapeField('ABC123')).toEqual('ABC123');
+ expect(escapeField('abc123')).toEqual('abc123');
+ expect(escapeField('a1B2c3')).toEqual('a1B2c3');
+ expect(escapeField('a_b_c')).toEqual('a_b_c');
+ expect(escapeField(']')).toEqual(']');
+ expect(escapeField('')).toEqual('');
+ expect(escapeField(null)).toEqual(null);
+ expect(escapeField(undefined)).toEqual(undefined);
+ });
+});
diff --git a/apis/nucleus/src/utils/__tests__/generateFiltersInfo.test.js b/apis/nucleus/src/utils/__tests__/generateFiltersInfo.test.js
new file mode 100644
index 000000000..2ced5c708
--- /dev/null
+++ b/apis/nucleus/src/utils/__tests__/generateFiltersInfo.test.js
@@ -0,0 +1,279 @@
+import { generateFiltersString, FilterType, SearchMode, ConditionMode, ModifierType } from '../generateFiltersInfo';
+
+describe('generateFiltersString', () => {
+ const translator = { get: (s) => s };
+ describe('Value filter', () => {
+ let filter;
+
+ beforeEach(() => {
+ filter = {
+ type: FilterType.VALUES,
+ field: 'fieldName',
+ exclude: true,
+ id: 'filter1',
+ options: {
+ values: ['value1', 'value2'],
+ },
+ };
+ });
+
+ test('should generate correct filter label when exclude mode is true', async () => {
+ const label = generateFiltersString([filter], translator);
+ expect(label).toEqual('fieldName: Object.FilterLabel.Exclude value1, value2');
+ });
+
+ test('should generate correct filter label when exclude mode is false', async () => {
+ filter.exclude = false;
+ const label = generateFiltersString([filter], translator);
+ expect(label).toEqual('fieldName: value1, value2');
+ });
+
+ test('should generate correct filter label when no field values are selected', async () => {
+ filter.options.values = [];
+ const label = generateFiltersString([filter], translator);
+ expect(label).toEqual('');
+ });
+ });
+
+ describe('Search filter', () => {
+ let filter;
+
+ beforeEach(() => {
+ filter = {
+ type: FilterType.SEARCH,
+ field: 'fieldName',
+ exclude: false,
+ id: 'filter1',
+ options: {
+ values: ['searchInput'],
+ mode: SearchMode.CONTAINS,
+ },
+ };
+ });
+
+ test('should generate correct filter label for search', async () => {
+ const label = generateFiltersString([filter], translator);
+ expect(label).toEqual('fieldName: *searchInput*');
+ });
+
+ test('should generate correct filter label when exclude mode is true and searchMode is CONTAINS', async () => {
+ const label = generateFiltersString([filter], translator);
+ expect(label).toEqual('fieldName: *searchInput*');
+ });
+
+ test('should generate correct filter label when exclude is false and searchMode is CONTAINS', async () => {
+ filter.exclude = false;
+ const label = generateFiltersString([filter], translator);
+ expect(label).toEqual('fieldName: *searchInput*');
+ });
+
+ test('should generate correct filter label when searchMode is MATCHES_EXACTLY', async () => {
+ filter.options.mode = SearchMode.MATCHES_EXACTLY;
+ const label = generateFiltersString([filter], translator);
+ expect(label).toEqual('fieldName: searchInput');
+ });
+
+ test('should generate correct filter label when searchMode is STARTS_WITH', async () => {
+ filter.options.mode = SearchMode.STARTS_WITH;
+ const label = generateFiltersString([filter], translator);
+ expect(label).toEqual('fieldName: searchInput*');
+ });
+
+ test('should generate correct filter label when searchMode is ENDS_WITH', async () => {
+ filter.options.mode = SearchMode.ENDS_WITH;
+ const label = generateFiltersString([filter], translator);
+ expect(label).toEqual('fieldName: *searchInput');
+ });
+
+ test('should generate correct filter label when searchMode is BEGINNING_OF_WORD', async () => {
+ filter.options.mode = SearchMode.BEGINNING_OF_WORD;
+ const label = generateFiltersString([filter], translator);
+ expect(label).toEqual('fieldName: *^searchInput*');
+ });
+ });
+
+ describe('Condition filter', () => {
+ let filter;
+ beforeEach(() => {
+ filter = {
+ type: FilterType.CONDITION,
+ field: 'fieldName',
+ exclude: false,
+ id: 'filter1',
+ options: {
+ modifier: {
+ type: ModifierType.FIXED_VALUE,
+ operator: '<',
+ },
+ mode: 'compare',
+ },
+ };
+ });
+ test('should generate filter label when the filter is invalid', async () => {
+ const label = generateFiltersString([filter], translator);
+ expect(label).toEqual('');
+ });
+
+ test('should generate correct filter label when the filter has COMPARE type and is FIXED_VALUE', async () => {
+ filter.options.firstValue = { fixedValue: '10' };
+ const label = generateFiltersString([filter], translator);
+ expect(label).toEqual('fieldName: <10');
+ });
+
+ test('should generate correct filter label when the filter has COMPARE type and is CALCULATED_VALUE', async () => {
+ filter.options.modifier = { type: 'calculated_value', operator: '<' };
+ filter.options.firstValue = { aggregator: 'sum', field: 'fieldName2' };
+ const label = generateFiltersString([filter], translator);
+ expect(label).toEqual('fieldName: =fieldName {
+ filter.options.modifier = { type: 'calculated_value', operator: '>= <=' };
+ filter.options.firstValue = { aggregator: 'sum', field: 'fieldName2' };
+ filter.options.secondValue = {
+ aggregator: 'count',
+ field: 'fieldName3',
+ };
+ const label = generateFiltersString([filter], translator);
+ expect(label).toEqual('fieldName: =fieldName>=sum(total fieldName2) and fieldName<=count(total fieldName3)');
+ });
+
+ test('should generate correct filter label when the filter has COMPARE type and missing secondValue on between', async () => {
+ filter.options.modifier = { type: 'calculated_value', operator: '>= <=' };
+ filter.options.firstValue = { aggregator: 'sum', field: 'fieldName2' };
+ const label = generateFiltersString([filter], translator);
+ expect(label).toEqual('');
+ });
+
+ test('should generate correct filter label when the filter has GENERAL type and filter is invalid', async () => {
+ filter.options.mode = ConditionMode.GENERAL;
+ filter.options.firstValue = { fixedValue: '10' };
+ const label = generateFiltersString([filter], translator);
+ expect(label).toEqual('');
+ });
+
+ test('should generate correct filter label when the filter has GENERAL type and is FIXED_VALUE', async () => {
+ filter.options.mode = ConditionMode.GENERAL;
+ filter.options.firstValue = { fixedValue: '10' };
+ filter.options.conditionField = {
+ aggregator: 'avg',
+ field: 'conditionFieldName1',
+ };
+ const label = generateFiltersString([filter], translator);
+ expect(label).toEqual('fieldName: =avg(conditionFieldName1)<10');
+ });
+
+ test('should generate correct filter label when the filter has GENERAL type and is FIXED_VALUE and is between', async () => {
+ filter.options.modifier = { type: ModifierType.FIXED_VALUE, operator: '>= <=' };
+ filter.options.mode = ConditionMode.GENERAL;
+ filter.options.firstValue = { fixedValue: '10' };
+ filter.options.secondValue = { fixedValue: '100' };
+ filter.options.conditionField = {
+ aggregator: 'avg',
+ field: 'conditionFieldName1',
+ };
+ const label = generateFiltersString([filter], translator);
+ expect(label).toEqual('fieldName: =avg(conditionFieldName1)>=10 and avg(conditionFieldName1)<=100');
+ });
+
+ test('should generate correct filter label when the filter has GENERAL type and is CALCULATED_VALUE', async () => {
+ filter.options.modifier = { type: 'calculated_value', operator: '<' };
+ filter.options.mode = ConditionMode.GENERAL;
+ filter.options.firstValue = { aggregator: 'sum', field: 'fieldName2' };
+ filter.options.conditionField = {
+ aggregator: 'avg',
+ field: 'conditionFieldName1',
+ };
+ const label = generateFiltersString([filter], translator);
+ expect(label).toEqual('fieldName: =avg(conditionFieldName1) {
+ filter.options.modifier = { type: 'calculated_value', operator: '>= <=' };
+ filter.options.mode = ConditionMode.GENERAL;
+ filter.options.firstValue = { aggregator: 'sum', field: 'fieldName2' };
+ filter.options.secondValue = {
+ aggregator: 'count',
+ field: 'fieldName3',
+ };
+ filter.options.conditionField = {
+ aggregator: 'avg',
+ field: 'conditionFieldName1',
+ };
+ const label = generateFiltersString([filter], translator);
+ expect(label).toEqual(
+ 'fieldName: =avg(conditionFieldName1)>=sum(fieldName2) and avg(conditionFieldName1)<=count(fieldName3)'
+ );
+ });
+
+ test('should handle fields that contains multiple words', async () => {
+ filter.options.modifier = { type: 'calculated_value', operator: '>= <=' };
+ filter.options.mode = ConditionMode.GENERAL;
+ filter.options.firstValue = {
+ aggregator: 'sum',
+ field: 'field Name2',
+ };
+ filter.options.secondValue = {
+ aggregator: 'count',
+ field: 'field Name3',
+ };
+ filter.options.conditionField = {
+ aggregator: 'avg',
+ field: 'conditionField Name1',
+ };
+ const label = generateFiltersString([filter], translator);
+ expect(label).toEqual(
+ 'fieldName: =avg([conditionField Name1])>=sum([field Name2]) and avg([conditionField Name1])<=count([field Name3])'
+ );
+ });
+ });
+
+ describe('Clear selection filter', () => {
+ let filter;
+
+ beforeEach(() => {
+ filter = {
+ type: FilterType.CLEAR_SELECTION,
+ field: 'fieldName',
+ exclude: false,
+ id: 'filter1',
+ options: {},
+ };
+ });
+
+ test('should generate correct filter label for clear selection', async () => {
+ const label = generateFiltersString([filter], translator);
+ expect(label).toEqual('fieldName: Object.FilterLabel.All');
+ });
+ });
+
+ describe('Multiple filters', () => {
+ test('should generate correct filter label for multiple filters', async () => {
+ const label = generateFiltersString(
+ [
+ {
+ type: FilterType.VALUES,
+ field: 'fieldName',
+ exclude: true,
+ id: 'filter1',
+ options: {
+ values: ['value1', 'value2'],
+ },
+ },
+ {
+ type: FilterType.SEARCH,
+ field: 'fieldName',
+ exclude: false,
+ id: 'filter1',
+ options: {
+ values: ['searchInput'],
+ mode: SearchMode.CONTAINS,
+ },
+ },
+ ],
+ translator
+ );
+ expect(label).toEqual('fieldName: Object.FilterLabel.Exclude value1, value2; fieldName: *searchInput*');
+ });
+ });
+});
diff --git a/apis/nucleus/src/utils/cell-padding.js b/apis/nucleus/src/utils/cell-padding.js
index e927a3c19..1e12c1a41 100644
--- a/apis/nucleus/src/utils/cell-padding.js
+++ b/apis/nucleus/src/utils/cell-padding.js
@@ -1,3 +1,5 @@
+import { generateFiltersString } from './generateFiltersInfo';
+
const NP_PADDINH_IN_CARDS_WITHOUT_TITLE = ['action-button', 'sn-nav-menu'];
const NO_PADDING_IN_CARDS = [
'pivot-table',
@@ -35,13 +37,15 @@ const shouldUseCardPadding = ({ isCardTheme, layout, isError }) => {
return true;
};
-const getPadding = ({ layout, isError, senseTheme, titleStyles }) => {
- const isCardTheme = senseTheme?.getStyle('', '', '_cards');
+const getPadding = ({ layout, isError, isCardTheme, titleStyles, translator }) => {
const cardPadding = shouldUseCardPadding({ isCardTheme, layout, isError });
if (isCardTheme) {
const showTitle = layout?.showTitles && !!layout?.title;
const showSubtitle = layout?.showTitles && !!layout?.subtitle;
- const showFootnote = layout?.showTitles && !!layout?.footnote;
+ const hasFilters = layout?.filters?.length > 0 && layout?.qHyperCube?.qMeasureInfo?.length > 0;
+ const filtersFootnoteString = generateFiltersString(layout?.filters ?? [], translator);
+ const showFilters = !layout?.footnote && hasFilters && filtersFootnoteString;
+ const showFootnote = layout?.showTitles && (!!layout?.footnote || showFilters);
if (showTitle && cardPadding) {
// eslint-disable-next-line no-param-reassign
diff --git a/apis/nucleus/src/utils/escape-field.js b/apis/nucleus/src/utils/escape-field.js
new file mode 100644
index 000000000..bd93d2c35
--- /dev/null
+++ b/apis/nucleus/src/utils/escape-field.js
@@ -0,0 +1,21 @@
+/**
+ * Escape a script field name. Will add surrounding brackets if the field name contains special characters.
+ * Examples:
+ * Field1 -> Field1
+ * My field -> [My field]
+ * My] field -> [My]] field]
+ *
+ * @param field
+ * @returns {*}
+ */
+const escapeField = (field) => {
+ if (!field || field === ']') {
+ return field;
+ }
+ if (/^[A-Za-z][A-Za-z0-9_]*$/.test(field)) {
+ return field;
+ }
+ return `[${field.replace(/\]/g, ']]')}]`;
+};
+
+export default escapeField;
diff --git a/apis/nucleus/src/utils/generateFiltersInfo.js b/apis/nucleus/src/utils/generateFiltersInfo.js
new file mode 100644
index 000000000..0e7bb3ab7
--- /dev/null
+++ b/apis/nucleus/src/utils/generateFiltersInfo.js
@@ -0,0 +1,172 @@
+import escapeField from './escape-field';
+
+export const FilterType = {
+ VALUES: 'values',
+ CONDITION: 'condition',
+ SEARCH: 'search',
+ CLEAR_SELECTION: 'clear_selection',
+};
+
+export const SearchMode = {
+ CONTAINS: 'contains',
+ MATCHES_EXACTLY: 'matches_exactly',
+ STARTS_WITH: 'starts_with',
+ ENDS_WITH: 'ends_with',
+ BEGINNING_OF_WORD: 'beginning_of_word',
+};
+
+export const ConditionMode = {
+ GENERAL: 'general',
+};
+
+export const ModifierType = {
+ FIXED_VALUE: 'fixed_value',
+};
+
+const generateSearchValue = (filter) => {
+ let prefix = '';
+ let postfix = '';
+ const mode = filter.options?.mode;
+ switch (mode) {
+ case SearchMode.BEGINNING_OF_WORD:
+ prefix = '*^';
+ postfix = '*';
+ break;
+ case SearchMode.ENDS_WITH:
+ prefix = '*';
+ break;
+ case SearchMode.STARTS_WITH:
+ postfix = '*';
+ break;
+ case SearchMode.MATCHES_EXACTLY:
+ break;
+ case SearchMode.CONTAINS:
+ default:
+ prefix = '*';
+ postfix = '*';
+ break;
+ }
+ const value = filter.options.values?.[0] ?? '';
+ return value !== '' ? `"${prefix}${value}${postfix}"` : '';
+};
+
+const generateConditionValue = (filter) => {
+ const { options } = filter;
+
+ const isGeneralMode = options?.mode === ConditionMode.GENERAL;
+ const isFixedModifier = options?.modifier?.type === ModifierType.FIXED_VALUE;
+ const modifierOperators = options?.modifier?.operator ? options.modifier.operator.split(' ') : [];
+
+ if (
+ modifierOperators.length === 0 ||
+ (!isFixedModifier && (!options.firstValue?.aggregator || !options.firstValue?.field)) ||
+ (isFixedModifier && !options.firstValue?.fixedValue) ||
+ (isGeneralMode && (!options.conditionField?.aggregator || !options.conditionField?.field))
+ ) {
+ return '';
+ }
+
+ let conditionField;
+ let firstValue;
+ let conditionPrefix = '=';
+ if (isGeneralMode && isFixedModifier) {
+ // general mode with fixed modifiers
+ conditionField = `${options.conditionField?.aggregator}(${escapeField(options.conditionField?.field)})`;
+ firstValue = `${options.firstValue?.fixedValue}`;
+ } else if (!isGeneralMode && isFixedModifier) {
+ // comparing mode with fixed modifiers
+ conditionField = ``;
+ firstValue = `${options.firstValue?.fixedValue}`;
+ conditionPrefix = '';
+ } else if (!isGeneralMode && !isFixedModifier) {
+ // comparing mode without fixed modifiers
+ conditionField = `${escapeField(filter.field)}`;
+ firstValue = `${options.firstValue?.aggregator}(total ${escapeField(options.firstValue?.field)})`;
+ } else {
+ // general mode without fixed modifiers
+ conditionField = `${options.conditionField?.aggregator}(${escapeField(options.conditionField?.field)})`;
+ firstValue = `${options.firstValue?.aggregator}(${escapeField(options.firstValue?.field)})`;
+ }
+ const leftConditionResult = `${conditionPrefix}${conditionField}${modifierOperators[0]}${firstValue}`;
+
+ let rightConditionResult = '';
+ if (modifierOperators.length > 1) {
+ if (
+ (!isFixedModifier && (!options.secondValue?.aggregator || !options.secondValue?.field)) ||
+ (isFixedModifier && !options.secondValue?.fixedValue)
+ ) {
+ return '';
+ }
+
+ let secondValue;
+ let rightConditionField;
+ if (isGeneralMode && isFixedModifier) {
+ // general mode with fixed modifiers
+ secondValue = `${options.secondValue?.fixedValue}`;
+ rightConditionField = ` and ${conditionField}`;
+ } else if (!isGeneralMode && isFixedModifier) {
+ // comparing mode with fixed modifiers
+ secondValue = `${options.secondValue?.fixedValue}`;
+ rightConditionField = '';
+ } else if (!isGeneralMode && !isFixedModifier) {
+ // comparing mode without fixed modifiers
+ secondValue = `${options.secondValue?.aggregator}(total ${escapeField(options.secondValue?.field)})`;
+ rightConditionField = ` and ${conditionField}`;
+ } else {
+ // general mode without fixed modifiers
+ secondValue = `${options.secondValue?.aggregator}(${escapeField(options.secondValue?.field)})`;
+ rightConditionField = ` and ${conditionField}`;
+ }
+ rightConditionResult = `${rightConditionField}${modifierOperators[1]}${secondValue}`;
+ }
+
+ return `"${leftConditionResult}${rightConditionResult}"`;
+};
+
+export const generateFiltersLabels = (filters, translator) => {
+ if (!Array.isArray(filters)) {
+ // QB-15075: when the property `filters` was already used by older apps
+ return [];
+ }
+ const EXCLUDE = translator.get('Object.FilterLabel.Exclude');
+ const filtersToShow = filters.filter((filter) => filter.showInFooter !== false);
+ return filtersToShow
+ .map((filter) => {
+ let label = '';
+ switch (filter.type) {
+ case FilterType.SEARCH:
+ label += generateSearchValue(filter);
+ break;
+ case FilterType.CONDITION:
+ label += generateConditionValue(filter);
+ break;
+ case FilterType.CLEAR_SELECTION:
+ label += translator.get('Object.FilterLabel.All');
+ break;
+ case FilterType.VALUES:
+ default:
+ label += filter.options?.values
+ ? filter.options.values.join(', ')
+ : translator.get('Object.FilterLabel.Unknown');
+ break;
+ }
+
+ if (label !== '') {
+ // Trim quotes
+ label = label.replace(/^"|"$/g, '');
+ // Prefix with exclude if filter inverted
+ if (filter.exclude) label = `${EXCLUDE} ${label}`;
+ }
+
+ return {
+ field: filter.field,
+ label,
+ };
+ })
+ .filter((filter) => filter.label !== '');
+};
+
+export const generateFiltersString = (filters, translator) =>
+ generateFiltersLabels(filters, translator)
+ .map((f) => `${f.field}: ${f.label}`)
+ .join('; ');
diff --git a/packages/ui/icons/filter.js b/packages/ui/icons/filter.js
new file mode 100644
index 000000000..ba614db9e
--- /dev/null
+++ b/packages/ui/icons/filter.js
@@ -0,0 +1,15 @@
+import SvgIcon from './SvgIcon';
+
+const filter = (props) => ({
+ ...props,
+ shapes: [
+ {
+ type: 'path',
+ attrs: {
+ d: 'M14.07 0a1 1 0 0 1 .816 1.577L10 8.5v4.398a1 1 0 0 1-.532.884l-2.734 1.445A.5.5 0 0 1 6 14.785V8.5L1.112 1.577A1 1 0 0 1 1.93 0zm0 1H1.93L7 8.183v5.772l2-1.057V8.183z',
+ },
+ },
+ ],
+});
+export default (props) => SvgIcon(filter(props));
+export { filter };