diff --git a/apis/nucleus/src/components/selections/AppSelections.jsx b/apis/nucleus/src/components/selections/AppSelections.jsx index 8c6ae63fa..b188d4b34 100644 --- a/apis/nucleus/src/components/selections/AppSelections.jsx +++ b/apis/nucleus/src/components/selections/AppSelections.jsx @@ -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 }) { - + ); @@ -41,6 +41,6 @@ function AppSelections({ app }) { export { AppSelections }; -export default function mount({ element, app }) { - return ReactDOM.createPortal(, element, uid()); +export default function mount({ element, app, halo }) { + return ReactDOM.createPortal(, element, uid()); } diff --git a/apis/nucleus/src/components/selections/More.jsx b/apis/nucleus/src/components/selections/More.jsx index 3ae2f611f..85a5d22c2 100644 --- a/apis/nucleus/src/components/selections/More.jsx +++ b/apis/nucleus/src/components/selections/More.jsx @@ -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 ? ( - + ) : ( ); } @@ -125,7 +132,11 @@ export default function More({ items = [], api }) { // eslint-disable-next-line react/no-array-index-key handleShowItem(e, ix)}> - {s.states.length > 1 ? : } + {s.states.length > 1 ? ( + + ) : ( + + )} ))} diff --git a/apis/nucleus/src/components/selections/MultiState.jsx b/apis/nucleus/src/components/selections/MultiState.jsx index 8eb71e464..eed2057d7 100644 --- a/apis/nucleus/src/components/selections/MultiState.jsx +++ b/apis/nucleus/src/components/selections/MultiState.jsx @@ -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 handleShowState(e, ix)}> - + ))} diff --git a/apis/nucleus/src/components/selections/OneField.jsx b/apis/nucleus/src/components/selections/OneField.jsx index 493be8c99..512bab5a1 100644 --- a/apis/nucleus/src/components/selections/OneField.jsx +++ b/apis/nucleus/src/components/selections/OneField.jsx @@ -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 = ( - - - {field.label} - - - {label} - - + if (isPinnedItem) { + Component = ( + ); + } 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 = ( - - - - + 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 = ( + + + {field.label} + + + {label} + ); - } else if (!selection.qOneAndOnlyOne) { - Icon = ( - - { - e.stopPropagation(); - api.clearField(selection.qField, field.states[stateIx]); - }} - size="large" - > - - + + if (selection.qLocked) { + Icon = ( + + + + + + ); + } else if (!selection.qOneAndOnlyOne) { + Icon = ( + + { + e.stopPropagation(); + api.clearField(selection.qField, field.states[stateIx]); + }} + size="large" + > + + + + ); + } + + SegmentsIndicator = ( + + {noSegments === false && + segments.map((s) => ( + + ))} + + ); + Component = ( + + {Header} + {Icon} + {SegmentsIndicator} + {showListBoxPopover && ( + + )} ); } - - SegmentsIndicator = ( - - {noSegments === false && - segments.map((s) => ( - - ))} - - ); - Component = ( - - {Header} - {Icon} - {SegmentsIndicator} - {showListBoxPopover && ( - - )} - - ); } + return moreAlignTo ? ( ) : ( Component diff --git a/apis/nucleus/src/components/selections/PinItem.jsx b/apis/nucleus/src/components/selections/PinItem.jsx new file mode 100644 index 000000000..4445c3317 --- /dev/null +++ b/apis/nucleus/src/components/selections/PinItem.jsx @@ -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 ( + + + {displayName} + + {showListBoxPopover && ( + + )} + + ); +}; + +export default PinItem; diff --git a/apis/nucleus/src/components/selections/SelectedFields.jsx b/apis/nucleus/src/components/selections/SelectedFields.jsx index 581ebb807..68350c09a 100644 --- a/apis/nucleus/src/components/selections/SelectedFields.jsx +++ b/apis/nucleus/src/components/selections/SelectedFields.jsx @@ -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 ( + {isPinFieldEnabled && state.more.length > 0 && ( + + + + )} {state.items.map((s) => ( - {s.states.length > 1 ? : } + {s.states.length > 1 ? ( + + ) : ( + + )} ))} - {state.more.length > 0 && ( + {!isPinFieldEnabled && state.more.length > 0 && ( ({ + ...jest.requireActual('@nebula.js/ui/theme'), + useTheme: jest.fn(), +})); + +jest.mock('../../listbox/ListBoxPopover', () => { + return function MockListBoxPopover(props) { + return ListBoxPopover; + }; +}); + +describe('', () => { + 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( + + ); + }); + + 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( + + ); + }); + + 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( + + ); + }); + + // 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( + + ); + }); + + 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( + + ); + }); + + 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( + + ); + }); + + const output = renderer.toJSON(); + const serialized = JSON.stringify(output); + expect(serialized).not.toContain('ListBoxPopover'); + }); +}); diff --git a/apis/nucleus/src/components/selections/__tests__/selected-fields.test.jsx b/apis/nucleus/src/components/selections/__tests__/selected-fields.test.jsx index b5d865455..2e7ae7880 100644 --- a/apis/nucleus/src/components/selections/__tests__/selected-fields.test.jsx +++ b/apis/nucleus/src/components/selections/__tests__/selected-fields.test.jsx @@ -75,7 +75,9 @@ describe('', () => { 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('', () => { ...defaultApp, ...app, }; + halo = { + ...defaultHalo, + ...halo, + }; await act(async () => { renderer = create( - + , rendererOptions || null ); diff --git a/apis/nucleus/src/components/selections/single-public.js b/apis/nucleus/src/components/selections/single-public.js new file mode 100644 index 000000000..03f8e5855 --- /dev/null +++ b/apis/nucleus/src/components/selections/single-public.js @@ -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 }; diff --git a/apis/nucleus/src/components/selections/utils.js b/apis/nucleus/src/components/selections/utils.js new file mode 100644 index 000000000..850f16d4e --- /dev/null +++ b/apis/nucleus/src/components/selections/utils.js @@ -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 }; diff --git a/apis/nucleus/src/index.js b/apis/nucleus/src/index.js index 66f44539d..49168fc54 100644 --- a/apis/nucleus/src/index.js +++ b/apis/nucleus/src/index.js @@ -449,6 +449,7 @@ function nuked(configuration = {}) { selectionsComponentReference = AppSelectionsPortal({ element, app, + halo, }); root.add(selectionsComponentReference); },