feat: add pin item to selection bar

This commit is contained in:
Jingjing
2025-12-11 14:03:17 +01:00
parent bab50ca1e6
commit b57decdf8f
11 changed files with 632 additions and 151 deletions

View File

@@ -10,7 +10,7 @@ import Nav from './Nav';
import useAppSelections from '../../hooks/useAppSelections'; import useAppSelections from '../../hooks/useAppSelections';
import uid from '../../object/uid'; import uid from '../../object/uid';
function AppSelections({ app }) { function AppSelections({ app, halo }) {
const theme = useTheme(); const theme = useTheme();
const [appSelections] = useAppSelections(app); const [appSelections] = useAppSelections(app);
if (!appSelections) return null; if (!appSelections) return null;
@@ -33,7 +33,7 @@ function AppSelections({ app }) {
<Nav api={appSelections} app={app} /> <Nav api={appSelections} app={app} />
</Grid> </Grid>
<Grid item xs style={{ backgroundColor: theme.palette.background.darker, overflow: 'hidden' }}> <Grid item xs style={{ backgroundColor: theme.palette.background.darker, overflow: 'hidden' }}>
<SelectedFields api={appSelections} app={app} /> <SelectedFields api={appSelections} app={app} halo={halo} />
</Grid> </Grid>
</Grid> </Grid>
); );
@@ -41,6 +41,6 @@ function AppSelections({ app }) {
export { AppSelections }; export { AppSelections };
export default function mount({ element, app }) { export default function mount({ element, app, halo }) {
return ReactDOM.createPortal(<AppSelections app={app} />, element, uid()); return ReactDOM.createPortal(<AppSelections app={app} halo={halo} />, element, uid());
} }

View File

@@ -32,7 +32,7 @@ const StyledGrid = styled(Grid)(({ theme }) => ({
}, },
})); }));
export default function More({ items = [], api }) { export default function More({ items = [], api, isPinFieldEnabled = false }) {
const theme = useTheme(); const theme = useTheme();
const [showMoreItems, setShowMoreItems] = useState(false); const [showMoreItems, setShowMoreItems] = useState(false);
const [showItemIx, setShowItemIx] = useState(-1); const [showItemIx, setShowItemIx] = useState(-1);
@@ -66,7 +66,13 @@ export default function More({ items = [], api }) {
if (showItemIx > -1) { if (showItemIx > -1) {
CurrentItem = CurrentItem =
items[showItemIx].states.length > 1 ? ( items[showItemIx].states.length > 1 ? (
<MultiState field={items[showItemIx]} api={api} moreAlignTo={alignTo} onClose={handleCloseShowItem} /> <MultiState
field={items[showItemIx]}
api={api}
moreAlignTo={alignTo}
onClose={handleCloseShowItem}
isPinFieldEnabled={isPinFieldEnabled}
/>
) : ( ) : (
<OneField <OneField
field={items[showItemIx]} field={items[showItemIx]}
@@ -74,6 +80,7 @@ export default function More({ items = [], api }) {
skipHandleShowListBoxPopover skipHandleShowListBoxPopover
moreAlignTo={alignTo} moreAlignTo={alignTo}
onClose={handleCloseShowItem} onClose={handleCloseShowItem}
isPinFieldEnabled={isPinFieldEnabled}
/> />
); );
} }
@@ -125,7 +132,11 @@ export default function More({ items = [], api }) {
// eslint-disable-next-line react/no-array-index-key // eslint-disable-next-line react/no-array-index-key
<ListItem key={ix} title={s.name} onClick={(e) => handleShowItem(e, ix)}> <ListItem key={ix} title={s.name} onClick={(e) => handleShowItem(e, ix)}>
<Box border={1} width="100%" borderRadius={1} borderColor="divider"> <Box border={1} width="100%" borderRadius={1} borderColor="divider">
{s.states.length > 1 ? <MultiState field={s} api={api} /> : <OneField field={s} api={api} />} {s.states.length > 1 ? (
<MultiState field={s} api={api} isPinFieldEnabled={isPinFieldEnabled} />
) : (
<OneField field={s} api={api} isPinFieldEnabled={isPinFieldEnabled} />
)}
</Box> </Box>
</ListItem> </ListItem>
))} ))}

View File

@@ -28,7 +28,7 @@ const StyledGrid = styled(Grid)(({ theme }) => ({
}, },
})); }));
export default function MultiState({ field, api, moreAlignTo = null, onClose = () => {} }) { export default function MultiState({ field, api, moreAlignTo = null, onClose = () => {}, isPinFieldEnabled = false }) {
// If originated from the `more` item show fields directly // If originated from the `more` item show fields directly
const [showFields, setShowFields] = useState(!!moreAlignTo); const [showFields, setShowFields] = useState(!!moreAlignTo);
const [showStateIx, setShowStateIx] = useState(-1); const [showStateIx, setShowStateIx] = useState(-1);
@@ -100,7 +100,7 @@ export default function MultiState({ field, api, moreAlignTo = null, onClose = (
// eslint-disable-next-line react/no-array-index-key // eslint-disable-next-line react/no-array-index-key
<ListItem key={ix} title={field.label} onClick={(e) => handleShowState(e, ix)}> <ListItem key={ix} title={field.label} onClick={(e) => handleShowState(e, ix)}>
<Box border={1} width="100%" borderRadius={1} borderColor="divider"> <Box border={1} width="100%" borderRadius={1} borderColor="divider">
<OneField field={field} api={api} stateIx={ix} skipHandleShowListBoxPopover /> <OneField field={field} api={api} stateIx={ix} skipHandleShowListBoxPopover isPinFieldEnabled={isPinFieldEnabled}/>
</Box> </Box>
</ListItem> </ListItem>
))} ))}

View File

@@ -9,6 +9,7 @@ import { useTheme } from '@nebula.js/ui/theme';
import ListBoxPopover from '../listbox/ListBoxPopover'; import ListBoxPopover from '../listbox/ListBoxPopover';
import InstanceContext from '../../contexts/InstanceContext'; import InstanceContext from '../../contexts/InstanceContext';
import PinItem from './PinItem';
export default function OneField({ export default function OneField({
field, field,
@@ -17,11 +18,15 @@ export default function OneField({
skipHandleShowListBoxPopover = false, skipHandleShowListBoxPopover = false,
moreAlignTo = null, moreAlignTo = null,
onClose = () => {}, onClose = () => {},
isPinFieldEnabled = false,
}) { }) {
const { translator } = useContext(InstanceContext); const { translator } = useContext(InstanceContext);
const alignTo = moreAlignTo || useRef(); const alignTo = moreAlignTo || useRef();
const theme = useTheme(); const theme = useTheme();
const [showListBoxPopover, setShowListBoxPopover] = useState(false); const [showListBoxPopover, setShowListBoxPopover] = useState(false);
let Component = null;
let selection;
const isPinnedItem = !!field.isPinned && isPinFieldEnabled;
const handleShowListBoxPopover = (e) => { const handleShowListBoxPopover = (e) => {
if (e.currentTarget.contains(e.target)) { if (e.currentTarget.contains(e.target)) {
@@ -35,153 +40,167 @@ export default function OneField({
onClose(); onClose();
}; };
const selection = field.selections[stateIx]; if (isPinnedItem) {
if (typeof selection.qTotal === 'undefined') { Component = (
selection.qTotal = 0; <PinItem
} field={field}
const counts = selection.qStateCounts || { api={api}
qSelected: 0, showListBoxPopover={showListBoxPopover}
qLocked: 0, alignTo={alignTo}
qExcluded: 0, skipHandleShowListBoxPopover={skipHandleShowListBoxPopover}
qLockedExcluded: 0, handleShowListBoxPopover={handleShowListBoxPopover}
qSelectedExcluded: 0, handleCloseShowListBoxPopover={handleCloseShowListBoxPopover}
qAlternative: 0, />
};
const green = (counts.qSelected + counts.qLocked) / selection.qTotal;
const white = counts.qAlternative / selection.qTotal;
const grey = (counts.qExcluded + counts.qLockedExcluded + counts.qSelectedExcluded) / selection.qTotal;
const numSelected = counts.qSelected + counts.qSelectedExcluded + counts.qLocked + counts.qLockedExcluded;
// Maintain modal state in app selections
const noSegments = numSelected === 0 && selection.qTotal === 0;
let label = '';
if (selection.qTotal === numSelected && selection.qTotal > 1) {
label = translator.get('CurrentSelections.All');
} else if (numSelected > 1 && selection.qTotal) {
label = translator.get('CurrentSelections.Of', [numSelected, selection.qTotal]);
} else if (selection.qSelectedFieldSelectionInfo) {
label = selection.qSelectedFieldSelectionInfo.map((v) => v.qName).join(', ');
}
if (field.states[stateIx] !== '$') {
label = `${field.states[stateIx]}: ${label}`;
}
const segments = [
{ color: theme.palette.selected.main, ratio: green },
{ color: theme.palette.selected.alternative, ratio: white },
{ color: theme.palette.selected.excluded, ratio: grey },
];
segments.forEach((s, i) => {
s.offset = i ? segments[i - 1].offset + segments[i - 1].ratio : 0; // eslint-disable-line
});
let Header = null;
let Icon = null;
let SegmentsIndicator = null;
let Component = null;
if (!moreAlignTo) {
Header = (
<Grid item xs style={{ minWidth: 0, flexGrow: 1, opacity: selection.qLocked ? '0.3' : '' }}>
<Typography noWrap style={{ fontSize: '12px', lineHeight: '16px', fontWeight: 600 }}>
{field.label}
</Typography>
<Typography noWrap style={{ fontSize: '12px', opacity: 0.55, lineHeight: '16px' }}>
{label}
</Typography>
</Grid>
); );
} else {
selection = field.selections[stateIx];
if (typeof selection.qTotal === 'undefined') {
selection.qTotal = 0;
}
const counts = selection.qStateCounts || {
qSelected: 0,
qLocked: 0,
qExcluded: 0,
qLockedExcluded: 0,
qSelectedExcluded: 0,
qAlternative: 0,
};
const green = (counts.qSelected + counts.qLocked) / selection.qTotal;
const white = counts.qAlternative / selection.qTotal;
const grey = (counts.qExcluded + counts.qLockedExcluded + counts.qSelectedExcluded) / selection.qTotal;
if (selection.qLocked) { const numSelected = counts.qSelected + counts.qSelectedExcluded + counts.qLocked + counts.qLockedExcluded;
Icon = ( // Maintain modal state in app selections
<Grid item> const noSegments = numSelected === 0 && selection.qTotal === 0;
<IconButton size="large"> let label = '';
<Lock /> if (selection.qTotal === numSelected && selection.qTotal > 1) {
</IconButton> label = translator.get('CurrentSelections.All');
} else if (numSelected > 1 && selection.qTotal) {
label = translator.get('CurrentSelections.Of', [numSelected, selection.qTotal]);
} else if (selection.qSelectedFieldSelectionInfo) {
label = selection.qSelectedFieldSelectionInfo.map((v) => v.qName).join(', ');
}
if (field.states[stateIx] !== '$') {
label = `${field.states[stateIx]}: ${label}`;
}
const segments = [
{ color: theme.palette.selected.main, ratio: green },
{ color: theme.palette.selected.alternative, ratio: white },
{ color: theme.palette.selected.excluded, ratio: grey },
];
segments.forEach((s, i) => {
s.offset = i ? segments[i - 1].offset + segments[i - 1].ratio : 0; // eslint-disable-line
});
let Header = null;
let Icon = null;
let SegmentsIndicator = null;
if (!moreAlignTo) {
Header = (
<Grid item xs style={{ minWidth: 0, flexGrow: 1, opacity: selection.qLocked ? '0.3' : '' }}>
<Typography noWrap style={{ fontSize: '12px', lineHeight: '16px', fontWeight: 600 }}>
{field.label}
</Typography>
<Typography noWrap style={{ fontSize: '12px', opacity: 0.55, lineHeight: '16px' }}>
{label}
</Typography>
</Grid> </Grid>
); );
} else if (!selection.qOneAndOnlyOne) {
Icon = ( if (selection.qLocked) {
<Grid item> Icon = (
<IconButton <Grid item>
title={translator.get('Selection.Clear')} <IconButton size="large">
onClick={(e) => { <Lock />
e.stopPropagation(); </IconButton>
api.clearField(selection.qField, field.states[stateIx]); </Grid>
}} );
size="large" } else if (!selection.qOneAndOnlyOne) {
> Icon = (
<Remove /> <Grid item>
</IconButton> <IconButton
title={translator.get('Selection.Clear')}
onClick={(e) => {
e.stopPropagation();
api.clearField(selection.qField, field.states[stateIx]);
}}
size="large"
>
<Remove />
</IconButton>
</Grid>
);
}
SegmentsIndicator = (
<div
style={{
height: '4px',
position: 'absolute',
bottom: '0',
left: '0',
width: '100%',
}}
>
{noSegments === false &&
segments.map((s) => (
<div
key={s.color}
style={{
position: 'absolute',
background: s.color,
height: '100%',
top: 0,
width: `${s.ratio * 100}%`,
left: `${s.offset * 100}%`,
}}
/>
))}
</div>
);
Component = (
<Grid
container
gap={1}
ref={alignTo}
sx={{
backgroundColor: theme.palette.background.paper,
position: 'relative',
cursor: 'pointer',
padding: '4px',
'&:hover': {
backgroundColor: theme.palette.action.hover,
},
}}
onClick={(skipHandleShowListBoxPopover === false && handleShowListBoxPopover) || null}
>
{Header}
{Icon}
{SegmentsIndicator}
{showListBoxPopover && (
<ListBoxPopover
alignTo={alignTo}
show={showListBoxPopover}
close={handleCloseShowListBoxPopover}
app={api.model}
fieldName={selection.qField}
stateName={field.states[stateIx]}
/>
)}
</Grid> </Grid>
); );
} }
SegmentsIndicator = (
<div
style={{
height: '4px',
position: 'absolute',
bottom: '0',
left: '0',
width: '100%',
}}
>
{noSegments === false &&
segments.map((s) => (
<div
key={s.color}
style={{
position: 'absolute',
background: s.color,
height: '100%',
top: 0,
width: `${s.ratio * 100}%`,
left: `${s.offset * 100}%`,
}}
/>
))}
</div>
);
Component = (
<Grid
container
gap={1}
ref={alignTo}
sx={{
backgroundColor: theme.palette.background.paper,
position: 'relative',
cursor: 'pointer',
padding: '4px',
'&:hover': {
backgroundColor: theme.palette.action.hover,
},
}}
onClick={(skipHandleShowListBoxPopover === false && handleShowListBoxPopover) || null}
>
{Header}
{Icon}
{SegmentsIndicator}
{showListBoxPopover && (
<ListBoxPopover
alignTo={alignTo}
show={showListBoxPopover}
close={handleCloseShowListBoxPopover}
app={api.model}
fieldName={selection.qField}
stateName={field.states[stateIx]}
/>
)}
</Grid>
);
} }
return moreAlignTo ? ( return moreAlignTo ? (
<ListBoxPopover <ListBoxPopover
alignTo={alignTo} alignTo={alignTo}
show show
close={handleCloseShowListBoxPopover} close={handleCloseShowListBoxPopover}
app={api.model} app={api.model}
fieldName={selection.qField} fieldName={isPinnedItem ? field.qField : selection.qField}
stateName={field.states[stateIx]} stateName={isPinnedItem ? undefined : field.states[stateIx]}
/> />
) : ( ) : (
Component Component

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { Grid2, Typography } from '@mui/material';
import { useTheme } from '@nebula.js/ui/theme';
import ListBoxPopover from '../listbox/ListBoxPopover';
const PinItem = ({
field,
api,
showListBoxPopover,
alignTo,
skipHandleShowListBoxPopover,
handleShowListBoxPopover,
handleCloseShowListBoxPopover,
}) => {
const theme = useTheme();
const displayName = field.qName || field.qField;
return (
<Grid2
container
gap={1}
ref={alignTo}
sx={{
backgroundColor: theme.palette.background.paper,
position: 'relative',
cursor: 'pointer',
padding: '4px',
height: '40px',
'&:hover': {
backgroundColor: theme.palette.action.hover,
},
}}
onClick={(skipHandleShowListBoxPopover === false && handleShowListBoxPopover) || null}
>
<Typography noWrap style={{ fontSize: '12px', lineHeight: '16px', fontWeight: 600, marginTop: '8px' }}>
{displayName}
</Typography>
{showListBoxPopover && (
<ListBoxPopover
alignTo={alignTo}
show={showListBoxPopover}
close={handleCloseShowListBoxPopover}
app={api.model}
fieldName={field.qField}
/>
)}
</Grid2>
);
};
export default PinItem;

View File

@@ -7,6 +7,8 @@ import useCurrentSelectionsModel from '../../hooks/useCurrentSelectionsModel';
import useLayout from '../../hooks/useLayout'; import useLayout from '../../hooks/useLayout';
import useRect from '../../hooks/useRect'; import useRect from '../../hooks/useRect';
import InstanceContext from '../../contexts/InstanceContext'; import InstanceContext from '../../contexts/InstanceContext';
import { getSinglePublicObjectProps, SINGLE_OBJECT_ID } from './single-public';
import { sortAllFields, sortSelections } from './utils';
import OneField from './OneField'; import OneField from './OneField';
import MultiState from './MultiState'; import MultiState from './MultiState';
@@ -54,7 +56,7 @@ function getItems(layout) {
.filter((f) => !f.selections.some((s) => s.qIsHidden)); .filter((f) => !f.selections.some((s) => s.qIsHidden));
} }
export default function SelectedFields({ api, app }) { export default function SelectedFields({ api, app, halo }) {
const theme = useTheme(); const theme = useTheme();
const [currentSelectionsModel] = useCurrentSelectionsModel(app); const [currentSelectionsModel] = useCurrentSelectionsModel(app);
const [layout] = useLayout(currentSelectionsModel); const [layout] = useLayout(currentSelectionsModel);
@@ -63,6 +65,11 @@ export default function SelectedFields({ api, app }) {
const { modalObjectStore } = useContext(InstanceContext).selectionStore; const { modalObjectStore } = useContext(InstanceContext).selectionStore;
const [containerRef, containerRect] = useRect(); const [containerRef, containerRect] = useRect();
const [maxItems, setMaxItems] = useState(0); const [maxItems, setMaxItems] = useState(0);
const flags = halo.public.galaxy?.flags;
const isPinFieldEnabled = flags?.isEnabled('TLV_1394_PIN_FIELD_TO_TOOLBAR');
const [pinnedItems, setPinnedItems] = useState([]);
const [masterDimList, setMasterDimList] = useState([]);
const [fieldList, setFieldList] = useState([]);
const isInListboxPopover = () => { const isInListboxPopover = () => {
const { model } = modalObjectStore.get(app.id) || {}; const { model } = modalObjectStore.get(app.id) || {};
@@ -77,11 +84,39 @@ export default function SelectedFields({ api, app }) {
setMaxItems(items); setMaxItems(items);
}, [containerRect]); }, [containerRect]);
useEffect(() => {
if (isPinFieldEnabled) {
const getPinnedItems = async () => {
const singlePublicProps = await getSinglePublicObjectProps(app, SINGLE_OBJECT_ID);
return singlePublicProps?.pinnedItems || [];
};
const masterDimList = async () => {
const dimensionListObject = await app.getDimensionListObject();
return dimensionListObject.getLayout();
};
const getFieldList = async () => {
const fieldListObject = await app.getFieldListObject();
return fieldListObject.expand();
};
Promise.all([getPinnedItems(), masterDimList(), getFieldList()]).then(([pinnedItems, dimList, fieldList]) => {
setPinnedItems(pinnedItems);
setMasterDimList(dimList);
setFieldList(fieldList);
});
}
}, [app]);
useEffect(() => { useEffect(() => {
if (!app || !currentSelectionsModel || !layout || !maxItems) { if (!app || !currentSelectionsModel || !layout || !maxItems) {
return; return;
} }
const items = getItems(layout); let items = isPinFieldEnabled ? getItems(layout).sort(sortSelections) : getItems(layout);
if (isPinFieldEnabled) {
items = sortAllFields(fieldList, pinnedItems, items, masterDimList);
}
setState((currState) => { setState((currState) => {
const newItems = items; const newItems = items;
// Maintain modal state in app selections // Maintain modal state in app selections
@@ -96,21 +131,49 @@ export default function SelectedFields({ api, app }) {
} }
let newMoreItems = []; let newMoreItems = [];
if (maxItems < newItems.length) { if (maxItems < newItems.length) {
newMoreItems = newItems.splice(maxItems - newItems.length); if (isPinFieldEnabled) {
newMoreItems = newItems.splice(0, newItems.length - maxItems);
} else {
newMoreItems = newItems.splice(maxItems - newItems.length);
}
} }
return { return {
items: newItems, items: newItems,
more: newMoreItems, more: newMoreItems,
}; };
}); });
}, [app, currentSelectionsModel, layout, api.isInModal(), maxItems]); }, [
app,
currentSelectionsModel,
layout,
api.isInModal(),
maxItems,
isPinFieldEnabled,
pinnedItems,
masterDimList,
fieldList,
]);
return ( return (
<Grid ref={containerRef} container gap={0} wrap="nowrap" style={{ height: '100%' }}> <Grid ref={containerRef} container gap={0} wrap="nowrap" style={{ height: '100%' }}>
{isPinFieldEnabled && state.more.length > 0 && (
<Grid
item
style={{
position: 'relative',
maxWidth: '98px',
minWidth: `${MIN_WIDTH_MORE}px`,
background: theme.palette.background.paper,
borderRight: `1px solid ${theme.palette.divider}`,
}}
>
<More items={state.more} api={api} isPinFieldEnabled={isPinFieldEnabled} />
</Grid>
)}
{state.items.map((s) => ( {state.items.map((s) => (
<Grid <Grid
item item
key={`${s.states.join('::')}::${s.name}`} key={`${s.states.join('::')}::${s.id ?? s.qField ?? s.name}`}
style={{ style={{
position: 'relative', position: 'relative',
maxWidth: '240px', maxWidth: '240px',
@@ -119,10 +182,14 @@ export default function SelectedFields({ api, app }) {
borderRight: `1px solid ${theme.palette.divider}`, borderRight: `1px solid ${theme.palette.divider}`,
}} }}
> >
{s.states.length > 1 ? <MultiState field={s} api={api} /> : <OneField field={s} api={api} />} {s.states.length > 1 ? (
<MultiState field={s} api={api} isPinFieldEnabled={isPinFieldEnabled} />
) : (
<OneField field={s} api={api} isPinFieldEnabled={isPinFieldEnabled} />
)}
</Grid> </Grid>
))} ))}
{state.more.length > 0 && ( {!isPinFieldEnabled && state.more.length > 0 && (
<Grid <Grid
item item
style={{ style={{

View File

@@ -0,0 +1,207 @@
import React, { act } from 'react';
import * as ReactTestRenderer from 'react-test-renderer';
import * as NebulaThemeModule from '@nebula.js/ui/theme';
import PinItem from '../PinItem';
jest.mock('@nebula.js/ui/theme', () => ({
...jest.requireActual('@nebula.js/ui/theme'),
useTheme: jest.fn(),
}));
jest.mock('../../listbox/ListBoxPopover', () => {
return function MockListBoxPopover(props) {
return <div data-testid="listbox-popover">ListBoxPopover</div>;
};
});
describe('<PinItem />', () => {
let renderer;
beforeEach(() => {
jest
.spyOn(NebulaThemeModule, 'useTheme')
.mockImplementation(() => ({
palette: {
background: { paper: '#ffffff' },
action: { hover: '#f5f5f5' },
divider: '#e0e0e0',
},
}));
});
afterEach(() => {
jest.resetAllMocks();
jest.restoreAllMocks();
if (renderer) {
renderer.unmount();
}
});
test('should render field name from qName when available', () => {
const field = {
qName: 'Product',
qField: 'product_field',
};
const api = {
model: {},
};
act(() => {
renderer = ReactTestRenderer.create(
<PinItem
field={field}
api={api}
showListBoxPopover={false}
alignTo={React.createRef()}
skipHandleShowListBoxPopover={true}
handleShowListBoxPopover={jest.fn()}
handleCloseShowListBoxPopover={jest.fn()}
/>
);
});
const output = renderer.toJSON();
expect(JSON.stringify(output)).toContain('Product');
});
test('should render field name from qField as fallback when qName is not available', () => {
const field = {
qField: 'product_field',
};
const api = {
model: {},
};
act(() => {
renderer = ReactTestRenderer.create(
<PinItem
field={field}
api={api}
showListBoxPopover={false}
alignTo={React.createRef()}
skipHandleShowListBoxPopover={true}
handleShowListBoxPopover={jest.fn()}
handleCloseShowListBoxPopover={jest.fn()}
/>
);
});
const output = renderer.toJSON();
expect(JSON.stringify(output)).toContain('product_field');
});
test('should call handleShowListBoxPopover when clicked and skipHandleShowListBoxPopover is false', () => {
const handleShowListBoxPopover = jest.fn();
const field = {
qName: 'Product',
qField: 'product_field',
};
const api = {
model: {},
};
act(() => {
renderer = ReactTestRenderer.create(
<PinItem
field={field}
api={api}
showListBoxPopover={false}
alignTo={React.createRef()}
skipHandleShowListBoxPopover={false}
handleShowListBoxPopover={handleShowListBoxPopover}
handleCloseShowListBoxPopover={jest.fn()}
/>
);
});
// Find the container div and trigger click
const root = renderer.root;
const containerInstance = root.findByType(PinItem);
expect(containerInstance).toBeDefined();
});
test('should not call handleShowListBoxPopover when skipHandleShowListBoxPopover is true', () => {
const handleShowListBoxPopover = jest.fn();
const field = {
qName: 'Product',
qField: 'product_field',
};
const api = {
model: {},
};
act(() => {
renderer = ReactTestRenderer.create(
<PinItem
field={field}
api={api}
showListBoxPopover={false}
alignTo={React.createRef()}
skipHandleShowListBoxPopover={true}
handleShowListBoxPopover={handleShowListBoxPopover}
handleCloseShowListBoxPopover={jest.fn()}
/>
);
});
expect(handleShowListBoxPopover).not.toHaveBeenCalled();
});
test('should render ListBoxPopover when showListBoxPopover is true', () => {
const field = {
qName: 'Product',
qField: 'product_field',
};
const api = {
model: {},
};
const alignToRef = React.createRef();
act(() => {
renderer = ReactTestRenderer.create(
<PinItem
field={field}
api={api}
showListBoxPopover={true}
alignTo={alignToRef}
skipHandleShowListBoxPopover={true}
handleShowListBoxPopover={jest.fn()}
handleCloseShowListBoxPopover={jest.fn()}
/>
);
});
const output = renderer.toJSON();
const serialized = JSON.stringify(output);
expect(serialized).toContain('ListBoxPopover');
});
test('should not render ListBoxPopover when showListBoxPopover is false', () => {
const field = {
qName: 'Product',
qField: 'product_field',
};
const api = {
model: {},
};
const alignToRef = React.createRef();
act(() => {
renderer = ReactTestRenderer.create(
<PinItem
field={field}
api={api}
showListBoxPopover={false}
alignTo={alignToRef}
skipHandleShowListBoxPopover={true}
handleShowListBoxPopover={jest.fn()}
handleCloseShowListBoxPopover={jest.fn()}
/>
);
});
const output = renderer.toJSON();
const serialized = JSON.stringify(output);
expect(serialized).not.toContain('ListBoxPopover');
});
});

View File

@@ -75,7 +75,9 @@ describe('<SelectedFields />', () => {
selectionStore: selectionsStoreModule, selectionStore: selectionsStoreModule,
}; };
render = async ({ api = {}, app = {}, rendererOptions } = {}) => { const defaultHalo = { public: { galaxy: { flags: { isEnabled: jest.fn().mockReturnValue(false) } } } };
render = async ({ api = {}, app = {}, halo = {}, rendererOptions } = {}) => {
api = { api = {
...defaultApi, ...defaultApi,
...api, ...api,
@@ -84,10 +86,14 @@ describe('<SelectedFields />', () => {
...defaultApp, ...defaultApp,
...app, ...app,
}; };
halo = {
...defaultHalo,
...halo,
};
await act(async () => { await act(async () => {
renderer = create( renderer = create(
<InstanceContext.Provider value={context}> <InstanceContext.Provider value={context}>
<SelectedFields api={api} app={app} /> <SelectedFields api={api} app={app} halo={halo} />
</InstanceContext.Provider>, </InstanceContext.Provider>,
rendererOptions || null rendererOptions || null
); );

View File

@@ -0,0 +1,32 @@
const SINGLE_OBJECT_ID = 'singlepublic-pinnedItems';
const TYPE = 'singlepublic';
const getSinglePublicObject = async (app, qId) => {
// TODO check create permission of single public object
const layout = await app.getLayout();
const canCreate = !!layout.permissions?.createMasterobject;
let singlePublicObject = null;
try {
if (canCreate) {
singlePublicObject = await app.getOrCreateObject({
qInfo: { qType: TYPE, qId },
});
} else {
singlePublicObject = await app.getObject(qId);
}
} catch {
// ignore
}
return singlePublicObject;
};
const getSinglePublicObjectProps = async (app, qId) => {
const singlePublicObject = await getSinglePublicObject(app, qId);
if (!singlePublicObject) {
return undefined;
}
const props = await singlePublicObject.getProperties();
return props;
};
export { getSinglePublicObjectProps, SINGLE_OBJECT_ID };

View File

@@ -0,0 +1,87 @@
const FIELD_KEY = 'qField';
const MASTER_ITEM_KEY = 'id';
/**
* Sort items shown in current selections toolbar.
* The order is as follows:
* 1. qOneAndOnlyOne
* 2. qSortIndex
*
* @param {Item|Object} a
* @param {Item|Object} b
*/
const sortSelections = (a, b) => {
const aSelection = a.selections[0];
const bSelection = b.selections[0];
const aQ = !!aSelection.qOneAndOnlyOne;
const bQ = !!bSelection.qOneAndOnlyOne;
if (aQ === bQ) {
return aSelection.qSortIndex - bSelection.qSortIndex;
}
return aQ ? -1 : 1;
};
// fieldList only has qName properties can identify a field
const getValidPinnedItems = (fieldList, masterDimList, pinnedItems) =>
pinnedItems.filter((field) => {
// Validate field items (those with qField) against fieldList
if (FIELD_KEY in field && field[FIELD_KEY]) {
return fieldList.some((item) => item.qName === field[FIELD_KEY]);
}
// Validate master dimension items (those with id) against masterDimList
if (MASTER_ITEM_KEY in field && field[MASTER_ITEM_KEY]) {
return masterDimList.some((dimItem) => dimItem.qInfo?.qId === field[MASTER_ITEM_KEY]);
}
// Invalid field structure, filter it out
return false;
});
// Sorts valid pinned items first, then selected fields.
// If pinned item is also selected, this pinned item will be replaced with selected field in this index.
const sortAllFields = (fieldList, pinnedItems, selectedFields, masterDimList) => {
if (fieldList.length === 0 || (pinnedItems.length === 0 && selectedFields.length === 0)) {
return [];
}
const validPinnedItems = getValidPinnedItems(fieldList, masterDimList, pinnedItems);
const sortedFields = [];
const remainingSelectedFields = [...selectedFields];
for (let i = 0; i < validPinnedItems.length; i++) {
const pinnedItem = validPinnedItems[i];
const isMasterDim = !!(MASTER_ITEM_KEY in pinnedItem && pinnedItem[MASTER_ITEM_KEY]);
const masterDimInfo = isMasterDim
? masterDimList.find((dimItem) => dimItem.qInfo?.qId === pinnedItem[MASTER_ITEM_KEY])
: null;
const fieldName = isMasterDim ? masterDimInfo.qData?.info[0]?.qName : pinnedItem[FIELD_KEY];
const matchFieldIndex = remainingSelectedFields.findIndex(
(item) =>
(isMasterDim && item.selections[0].qDimensionReferences?.[0]?.qId === pinnedItem[MASTER_ITEM_KEY]) ||
(!isMasterDim && (item.selections[0].qField === fieldName || item.selections[0].qReadableName === fieldName))
);
// Pinned field has already added as selected field, skip it to avoid duplicate display
const isDuplicateFieldSelected = sortedFields.findIndex((sf) => sf.qField === fieldName && !sf.isPinned) !== -1;
if (!isDuplicateFieldSelected) {
if (matchFieldIndex === -1) {
// If pinned field is not selected or is duplicated, keep it as pinned.
const pinnedFieldData = {
...validPinnedItems[i],
isPinned: true,
states: ['$'],
};
if (isMasterDim && masterDimInfo) {
pinnedFieldData.qField = fieldName;
pinnedFieldData.qName = masterDimInfo.qData.labelExpression || masterDimInfo.qData.title || fieldName;
}
sortedFields.push(pinnedFieldData);
} else {
sortedFields.push(remainingSelectedFields[matchFieldIndex]);
remainingSelectedFields.splice(matchFieldIndex, 1);
}
}
}
return [...sortedFields, ...remainingSelectedFields];
};
export { sortAllFields, sortSelections };

View File

@@ -449,6 +449,7 @@ function nuked(configuration = {}) {
selectionsComponentReference = AppSelectionsPortal({ selectionsComponentReference = AppSelectionsPortal({
element, element,
app, app,
halo,
}); });
root.add(selectionsComponentReference); root.add(selectionsComponentReference);
}, },