fix: add filters info in footnote (#1710)

This commit is contained in:
Jingjing Xie
2025-05-05 15:05:36 +02:00
committed by GitHub
parent ec55ad9efa
commit 6b84132273
14 changed files with 755 additions and 42 deletions

View File

@@ -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"

View File

@@ -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>

View 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')} &nbsp;
</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}> &nbsp; {filter.label} &nbsp;</ItalicText>
</Grid>
))}
</Grid>
</Grid>
</Tooltip>
);
}
export default FiltersFooter;

View File

@@ -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;

View File

@@ -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',
'',
]);
});
});

View File

@@ -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);
});
});

View File

@@ -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';

View File

@@ -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,

View 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);
});
});

View 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*');
});
});
});

View File

@@ -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

View 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;

View 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('; ');

View 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 };