mirror of
https://github.com/qlik-oss/nebula.js.git
synced 2025-12-19 17:58:43 -05:00
fix: add filters info in footnote (#1710)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
</Grid>
|
||||
{cellNode && layout && state.sn && <Footer layout={layout} titleStyles={titleStyles} />}
|
||||
{cellNode && layout && state.sn && (
|
||||
<Footer
|
||||
layout={layout}
|
||||
titleStyles={titleStyles}
|
||||
translator={translator}
|
||||
flags={flags}
|
||||
isCardTheme={isCardTheme}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
{state.longRunningQuery && <LongRunningQuery canCancel={canCancel} canRetry={canRetry} api={longrunning} />}
|
||||
</Paper>
|
||||
|
||||
54
apis/nucleus/src/components/FiltersFooter.jsx
Normal file
54
apis/nucleus/src/components/FiltersFooter.jsx
Normal file
@@ -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 (
|
||||
<Typography noWrap variant="body2" style={{ ...styles, fontStyle: 'italic' }}>
|
||||
{children}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Tooltip title={filtersFootnoteString}>
|
||||
<Grid
|
||||
container
|
||||
wrap="nowrap"
|
||||
sx={{
|
||||
backgroundColor: footerStyle?.backgroundColor,
|
||||
padding: footerStyle?.padding,
|
||||
borderTop: footerStyle?.borderTop,
|
||||
}}
|
||||
data-testid="filters-footnote"
|
||||
>
|
||||
<Grid item display="flex">
|
||||
<FilterIcon style={{ fontSize: '12px', color: footerStyle.color, margin: 'auto' }} />
|
||||
<ItalicText styles={{ ...styles, marginLeft: '2px' }}>
|
||||
{translator.get('Object.FiltersApplied')}
|
||||
</ItalicText>
|
||||
</Grid>
|
||||
<Grid item display="flex">
|
||||
{filtersFootnoteLabels.map((filter) => (
|
||||
<Grid container wrap="nowrap" key={`${filter.field}-${filter.label}`}>
|
||||
<ItalicText styles={{ ...styles, fontWeight: 'bold' }}>{`${filter.field}:`}</ItalicText>
|
||||
<ItalicText styles={styles}> {filter.label} </ItalicText>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default FiltersFooter;
|
||||
@@ -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) ? (
|
||||
<StyledGrid container>
|
||||
<Grid item className={classes.itemStyle}>
|
||||
<Tooltip title={layout.footnote}>
|
||||
<Typography noWrap variant="body2" className={CellFooter.className} style={titleStyles.footer}>
|
||||
{layout.footnote}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
<Grid
|
||||
item
|
||||
className={classes.itemStyle}
|
||||
data-testid={CellFooter.className}
|
||||
sx={{ paddingTop: (theme) => (themePaddingEnabled ? paddingTop : theme.spacing(1)) }}
|
||||
>
|
||||
{layout.footnote && (
|
||||
<Tooltip title={layout.footnote}>
|
||||
<Typography noWrap variant="body2" className={CellFooter.className} style={footerStyle}>
|
||||
{layout.footnote}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showFilters && (
|
||||
<FiltersFooter
|
||||
layout={layout}
|
||||
translator={translator}
|
||||
filtersFootnoteString={filtersFootnoteString}
|
||||
footerStyle={footerStyle}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
</StyledGrid>
|
||||
) : null;
|
||||
|
||||
@@ -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('<FiltersFooter />', () => {
|
||||
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(<FiltersFooter layout={layout} footerStyle={footerStyle} translator={translator} />);
|
||||
});
|
||||
};
|
||||
});
|
||||
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',
|
||||
'',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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('<Footer />', () => {
|
||||
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(<Footer layout={layout} />);
|
||||
@@ -20,10 +25,19 @@ describe('<Footer />', () => {
|
||||
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(() => <div>Filters footNote</div>),
|
||||
}));
|
||||
await render({ showTitles: true, footnote: '', filters: ['A'], qHyperCube: { qMeasureInfo: [{ cId: 'chart' }] } });
|
||||
const types = renderer.root.findAllByType(FiltersFootnote);
|
||||
expect(types).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
25
apis/nucleus/src/utils/__tests__/escape-field.test.js
Normal file
25
apis/nucleus/src/utils/__tests__/escape-field.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
279
apis/nucleus/src/utils/__tests__/generateFiltersInfo.test.js
Normal file
279
apis/nucleus/src/utils/__tests__/generateFiltersInfo.test.js
Normal file
@@ -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<sum(total fieldName2)');
|
||||
});
|
||||
|
||||
test('should generate correct filter label when the filter has COMPARE type and is CALCULATED_VALUE and is between', async () => {
|
||||
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)<sum(fieldName2)');
|
||||
});
|
||||
|
||||
test('should generate correct filter label when the filter has GENERAL type and is CALCULATED_VALUE and is between', async () => {
|
||||
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*');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
21
apis/nucleus/src/utils/escape-field.js
Normal file
21
apis/nucleus/src/utils/escape-field.js
Normal file
@@ -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;
|
||||
172
apis/nucleus/src/utils/generateFiltersInfo.js
Normal file
172
apis/nucleus/src/utils/generateFiltersInfo.js
Normal file
@@ -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('; ');
|
||||
15
packages/ui/icons/filter.js
Normal file
15
packages/ui/icons/filter.js
Normal file
@@ -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 };
|
||||
Reference in New Issue
Block a user