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 uid from '../../object/uid';
function AppSelections({ app }) {
function AppSelections({ app, halo }) {
const theme = useTheme();
const [appSelections] = useAppSelections(app);
if (!appSelections) return null;
@@ -33,7 +33,7 @@ function AppSelections({ app }) {
<Nav api={appSelections} app={app} />
</Grid>
<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>
);
@@ -41,6 +41,6 @@ function AppSelections({ app }) {
export { AppSelections };
export default function mount({ element, app }) {
return ReactDOM.createPortal(<AppSelections app={app} />, element, uid());
export default function mount({ element, app, halo }) {
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 [showMoreItems, setShowMoreItems] = useState(false);
const [showItemIx, setShowItemIx] = useState(-1);
@@ -66,7 +66,13 @@ export default function More({ items = [], api }) {
if (showItemIx > -1) {
CurrentItem =
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
field={items[showItemIx]}
@@ -74,6 +80,7 @@ export default function More({ items = [], api }) {
skipHandleShowListBoxPopover
moreAlignTo={alignTo}
onClose={handleCloseShowItem}
isPinFieldEnabled={isPinFieldEnabled}
/>
);
}
@@ -125,7 +132,11 @@ export default function More({ items = [], api }) {
// eslint-disable-next-line react/no-array-index-key
<ListItem key={ix} title={s.name} onClick={(e) => handleShowItem(e, ix)}>
<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>
</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
const [showFields, setShowFields] = useState(!!moreAlignTo);
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
<ListItem key={ix} title={field.label} onClick={(e) => handleShowState(e, ix)}>
<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>
</ListItem>
))}

View File

@@ -9,6 +9,7 @@ import { useTheme } from '@nebula.js/ui/theme';
import ListBoxPopover from '../listbox/ListBoxPopover';
import InstanceContext from '../../contexts/InstanceContext';
import PinItem from './PinItem';
export default function OneField({
field,
@@ -17,11 +18,15 @@ export default function OneField({
skipHandleShowListBoxPopover = false,
moreAlignTo = null,
onClose = () => {},
isPinFieldEnabled = false,
}) {
const { translator } = useContext(InstanceContext);
const alignTo = moreAlignTo || useRef();
const theme = useTheme();
const [showListBoxPopover, setShowListBoxPopover] = useState(false);
let Component = null;
let selection;
const isPinnedItem = !!field.isPinned && isPinFieldEnabled;
const handleShowListBoxPopover = (e) => {
if (e.currentTarget.contains(e.target)) {
@@ -35,153 +40,167 @@ export default function OneField({
onClose();
};
const 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;
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>
if (isPinnedItem) {
Component = (
<PinItem
field={field}
api={api}
showListBoxPopover={showListBoxPopover}
alignTo={alignTo}
skipHandleShowListBoxPopover={skipHandleShowListBoxPopover}
handleShowListBoxPopover={handleShowListBoxPopover}
handleCloseShowListBoxPopover={handleCloseShowListBoxPopover}
/>
);
} 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) {
Icon = (
<Grid item>
<IconButton size="large">
<Lock />
</IconButton>
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;
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 if (!selection.qOneAndOnlyOne) {
Icon = (
<Grid item>
<IconButton
title={translator.get('Selection.Clear')}
onClick={(e) => {
e.stopPropagation();
api.clearField(selection.qField, field.states[stateIx]);
}}
size="large"
>
<Remove />
</IconButton>
if (selection.qLocked) {
Icon = (
<Grid item>
<IconButton size="large">
<Lock />
</IconButton>
</Grid>
);
} else if (!selection.qOneAndOnlyOne) {
Icon = (
<Grid item>
<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>
);
}
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 ? (
<ListBoxPopover
alignTo={alignTo}
show
close={handleCloseShowListBoxPopover}
app={api.model}
fieldName={selection.qField}
stateName={field.states[stateIx]}
fieldName={isPinnedItem ? field.qField : selection.qField}
stateName={isPinnedItem ? undefined : field.states[stateIx]}
/>
) : (
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 useRect from '../../hooks/useRect';
import InstanceContext from '../../contexts/InstanceContext';
import { getSinglePublicObjectProps, SINGLE_OBJECT_ID } from './single-public';
import { sortAllFields, sortSelections } from './utils';
import OneField from './OneField';
import MultiState from './MultiState';
@@ -54,7 +56,7 @@ function getItems(layout) {
.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 [currentSelectionsModel] = useCurrentSelectionsModel(app);
const [layout] = useLayout(currentSelectionsModel);
@@ -63,6 +65,11 @@ export default function SelectedFields({ api, app }) {
const { modalObjectStore } = useContext(InstanceContext).selectionStore;
const [containerRef, containerRect] = useRect();
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 { model } = modalObjectStore.get(app.id) || {};
@@ -77,11 +84,39 @@ export default function SelectedFields({ api, app }) {
setMaxItems(items);
}, [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(() => {
if (!app || !currentSelectionsModel || !layout || !maxItems) {
return;
}
const items = getItems(layout);
let items = isPinFieldEnabled ? getItems(layout).sort(sortSelections) : getItems(layout);
if (isPinFieldEnabled) {
items = sortAllFields(fieldList, pinnedItems, items, masterDimList);
}
setState((currState) => {
const newItems = items;
// Maintain modal state in app selections
@@ -96,21 +131,49 @@ export default function SelectedFields({ api, app }) {
}
let newMoreItems = [];
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 {
items: newItems,
more: newMoreItems,
};
});
}, [app, currentSelectionsModel, layout, api.isInModal(), maxItems]);
}, [
app,
currentSelectionsModel,
layout,
api.isInModal(),
maxItems,
isPinFieldEnabled,
pinnedItems,
masterDimList,
fieldList,
]);
return (
<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) => (
<Grid
item
key={`${s.states.join('::')}::${s.name}`}
key={`${s.states.join('::')}::${s.id ?? s.qField ?? s.name}`}
style={{
position: 'relative',
maxWidth: '240px',
@@ -119,10 +182,14 @@ export default function SelectedFields({ api, app }) {
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>
))}
{state.more.length > 0 && (
{!isPinFieldEnabled && state.more.length > 0 && (
<Grid
item
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,
};
render = async ({ api = {}, app = {}, rendererOptions } = {}) => {
const defaultHalo = { public: { galaxy: { flags: { isEnabled: jest.fn().mockReturnValue(false) } } } };
render = async ({ api = {}, app = {}, halo = {}, rendererOptions } = {}) => {
api = {
...defaultApi,
...api,
@@ -84,10 +86,14 @@ describe('<SelectedFields />', () => {
...defaultApp,
...app,
};
halo = {
...defaultHalo,
...halo,
};
await act(async () => {
renderer = create(
<InstanceContext.Provider value={context}>
<SelectedFields api={api} app={app} />
<SelectedFields api={api} app={app} halo={halo} />
</InstanceContext.Provider>,
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({
element,
app,
halo,
});
root.add(selectionsComponentReference);
},