mirror of
https://github.com/qlik-oss/nebula.js.git
synced 2025-12-19 09:48:18 -05:00
feat: add pin item to selection bar
This commit is contained in:
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
|
||||
51
apis/nucleus/src/components/selections/PinItem.jsx
Normal file
51
apis/nucleus/src/components/selections/PinItem.jsx
Normal 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;
|
||||
@@ -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={{
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
|
||||
32
apis/nucleus/src/components/selections/single-public.js
Normal file
32
apis/nucleus/src/components/selections/single-public.js
Normal 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 };
|
||||
87
apis/nucleus/src/components/selections/utils.js
Normal file
87
apis/nucleus/src/components/selections/utils.js
Normal 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 };
|
||||
@@ -449,6 +449,7 @@ function nuked(configuration = {}) {
|
||||
selectionsComponentReference = AppSelectionsPortal({
|
||||
element,
|
||||
app,
|
||||
halo,
|
||||
});
|
||||
root.add(selectionsComponentReference);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user