Unify records model and add notification center

This commit is contained in:
Amer Agovic
2026-05-05 12:24:13 -05:00
parent 166ddcc803
commit 2157e1aea6
18 changed files with 962 additions and 544 deletions
+10 -8
View File
@@ -150,7 +150,7 @@ export function DirView({
onRowPress = null,
onRefresh = null,
showHeader = true,
showSummary = true,
showSummary = false,
density = 'comfortable',
striped = false
}) {
@@ -199,12 +199,14 @@ export function DirView({
setLoading(true);
setError('');
try {
const offset = (currentPage - 1) * pageSize;
const sortBy = orderBy ? [{ field: orderBy, direction: order }] : [];
const filterBy = effectiveSearchTerm ? { search: effectiveSearchTerm } : {};
const query = {
page: currentPage,
pageSize,
search: effectiveSearchTerm,
orderBy,
order
offset,
page_size: pageSize,
sort_by: sortBy,
filter_by: filterBy
};
const [recordResult, summaryResult] = await Promise.all([
@@ -213,8 +215,8 @@ export function DirView({
]);
if (!cancelled) {
setRecords(recordResult.records || []);
setTotalRecords(recordResult.totalRecords || 0);
setRecords(recordResult.rows || []);
setTotalRecords(recordResult.total || 0);
setSummary(summaryResult || null);
}
} catch (loadError) {
+69 -6
View File
@@ -7,6 +7,7 @@
import React, { useState, useRef, useEffect } from 'react';
import { Button, XStack, YStack, Text } from 'tamagui';
import { getIcon } from './IconMapper.jsx';
import { NotificationManager } from './Shell.jsx';
import {
getBounds,
addDocumentEventListener,
@@ -46,14 +47,14 @@ export function MenuItemButton({
selected = false,
hovered = false,
width,
size = '$4',
size = '$5',
onClick,
onExpand,
expanded: controlledExpanded,
defaultExpanded = false,
collapsed = false,
displayStyle,
padding = '$2',
padding = '$1',
style,
testID,
stateVersion,
@@ -99,6 +100,12 @@ export function MenuItemButton({
const [internalExpanded, setInternalExpanded] = useState(() => getMenuItemExpandedPreference(menuItem.path, defaultExpanded));
const [popupOpen, setPopupOpen] = useState(false);
const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0, alignRight: false, alignRightSide: false, alignBottom: false });
const [notificationCount, setNotificationCount] = useState(() => {
if (menuItem.id !== 'notifications') {
return 0;
}
return NotificationManager.getModel?.().getAllRows?.().length || 0;
});
const popupRef = useRef(null);
const buttonRef = useRef(null);
const isExpanded = controlledExpanded !== undefined ? controlledExpanded : internalExpanded;
@@ -114,6 +121,21 @@ export function MenuItemButton({
setInternalExpanded(getMenuItemExpandedPreference(menuItem.path, defaultExpanded));
}, [controlledExpanded, defaultExpanded, menuItem.path, stateVersion]);
useEffect(() => {
if (menuItem.id !== 'notifications' || !NotificationManager?.subscribe) {
return undefined;
}
const syncCount = ({ model } = {}) => {
const nextModel = model || NotificationManager.getModel?.();
const nextCount = nextModel?.getAllRows?.().length || 0;
setNotificationCount(nextCount);
};
syncCount();
return NotificationManager.subscribe(syncCount);
}, [menuItem.id]);
// Close popup when clicking outside (popup mode only)
useEffect(() => {
if (effectiveExpandMode === 'popup' && popupOpen) {
@@ -283,8 +305,8 @@ export function MenuItemButton({
return '$textMuted';
};
const ICON_SIZE = 'sm';
const CHEVRON_SIZE = 'sm';
const ICON_SIZE = 'md';
const CHEVRON_SIZE = 'md';
// Determine display style (both, label_only, icon_only)
// Use displayStyle prop if provided, otherwise fall back to menuItem.style
@@ -292,6 +314,7 @@ export function MenuItemButton({
const showIcon = (effectiveDisplayStyle === 'both' || effectiveDisplayStyle === 'icon_only') && IconComponent;
// Hide label when collapsed (unless it's icon_only style which never shows label)
const showLabel = !collapsed && (effectiveDisplayStyle === 'both' || effectiveDisplayStyle === 'label_only') && menuItem.label;
const showNotificationBadge = menuItem.id === 'notifications' && notificationCount > 0;
// Lucide chevron icons for groups
// For popup mode in vertical orientation, show chevron right (>)
@@ -337,12 +360,32 @@ export function MenuItemButton({
>
{/* Icon */}
{showIcon && (
<XStack marginRight={showLabel ? '$2' : 0} alignItems="center" justifyContent="center">
<XStack marginRight={showLabel ? '$2' : 0} alignItems="center" justifyContent="center" position="relative">
{typeof IconComponent === 'string' ? (
<Text fontSize={size} lineHeight={size}>{IconComponent}</Text>
) : IconComponent ? (
<IconComponent size={ICON_SIZE} color={getIconColor()} />
) : null}
{showNotificationBadge ? (
<XStack
position="absolute"
top={-3}
right={-7}
minWidth={14}
height={14}
paddingHorizontal="$1"
borderRadius={999}
backgroundColor="$danger"
alignItems="center"
justifyContent="center"
borderWidth={1}
borderColor="$bgPanel"
>
<Text fontSize={10} lineHeight={10} color="white" fontWeight="700">
{notificationCount > 9 ? '9+' : '!'}
</Text>
</XStack>
) : null}
</XStack>
)}
@@ -466,12 +509,32 @@ export function MenuItemButton({
>
{/* Icon */}
{showIcon && (
<XStack marginRight={showLabel ? '$2' : 0} alignItems="center" justifyContent="center">
<XStack marginRight={showLabel ? '$2' : 0} alignItems="center" justifyContent="center" position="relative">
{typeof IconComponent === 'string' ? (
<Text fontSize={size} lineHeight={size}>{IconComponent}</Text>
) : IconComponent ? (
<IconComponent size={ICON_SIZE} color={getIconColor()} />
) : null}
{showNotificationBadge ? (
<XStack
position="absolute"
top={-3}
right={-7}
minWidth={14}
height={14}
paddingHorizontal="$1"
borderRadius={999}
backgroundColor="$danger"
alignItems="center"
justifyContent="center"
borderWidth={1}
borderColor="$bgPanel"
>
<Text fontSize={10} lineHeight={10} color="white" fontWeight="700">
{notificationCount > 9 ? '9+' : '!'}
</Text>
</XStack>
) : null}
</XStack>
)}
+409 -26
View File
@@ -6,7 +6,9 @@
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
import { XStack, YStack, Text, Button } from 'tamagui';
import RecordsModel from '../../data/RecordsModel.js';
import { getIcon } from './IconMapper.jsx';
import { SidePanelShell } from './SidePanelShell.jsx';
// ============================================================================
// Shell Context
@@ -198,7 +200,8 @@ class ToastManager {
show(title, message = '', options = {}) {
const {
type = 'info',
duration = this._defaultDuration
duration = this._defaultDuration,
persistToNotifications = true
} = options;
const startTime = Date.now();
@@ -208,6 +211,7 @@ class ToastManager {
message,
type,
duration,
persistToNotifications,
timestamp: startTime,
startTime: startTime // Store start time for pause/resume calculations
};
@@ -226,6 +230,9 @@ class ToastManager {
// Auto-dismiss after duration
if (duration > 0) {
const timeoutId = setTimeout(() => {
if (toast.persistToNotifications) {
notificationCenterManager.addFromToast(toast);
}
this.hide(toast.id);
this._timeouts.delete(toast.id);
}, duration);
@@ -355,11 +362,241 @@ class ToastManager {
getToasts() {
return [...this._toasts];
}
getNotificationModel() {
return notificationCenterManager.getModel();
}
setNotificationModel(model) {
notificationCenterManager.setModel(model);
}
openNotifications() {
notificationCenterManager.open();
}
closeNotifications() {
notificationCenterManager.close();
}
toggleNotifications() {
notificationCenterManager.toggle();
}
}
// Create singleton instance
const toastManager = new ToastManager();
function createDefaultNotificationModel() {
const now = Date.now();
return new RecordsModel({
rows: [
{
id: 'welcome-notification',
title: 'Welcome',
message: 'Notifications that expire from toast popups will appear here until dismissed.',
type: 'info',
timestamp: now,
created_at: now,
source: 'system',
status: 'pending',
is_read: false,
resource_path: '/notifications',
action_label: null,
action_target: null,
action_kind: null,
meta: {}
}
],
idField: 'id',
searchFields: ['title', 'message', 'type'],
summaryDefinitions: [
{ id: 'total', label: 'Pending', type: 'count' }
]
});
}
class NotificationCenterManager {
constructor() {
this._open = false;
this._setOpen = null;
this._model = createDefaultNotificationModel();
this._listeners = new Set();
this._unbindModelListener = null;
this._bindModelListener();
}
_init(setOpen) {
this._setOpen = setOpen;
}
_bindModelListener() {
if (this._unbindModelListener) {
this._unbindModelListener();
this._unbindModelListener = null;
}
if (this._model?.subscribe) {
this._unbindModelListener = this._model.subscribe(() => {
this._notifyListeners();
});
}
}
_setOpenState(open) {
this._open = Boolean(open);
this._setOpen?.(this._open);
this._notifyListeners();
}
_notifyListeners() {
this._listeners.forEach((listener) => {
try {
listener({
open: this._open,
model: this._model
});
} catch (error) {
console.warn('[NotificationCenterManager] Listener failed:', error);
}
});
}
subscribe(listener) {
this._listeners.add(listener);
return () => {
this._listeners.delete(listener);
};
}
isOpen() {
return this._open;
}
open() {
this._setOpenState(true);
}
close() {
this._setOpenState(false);
}
toggle() {
this._setOpenState(!this._open);
}
getModel() {
return this._model;
}
setModel(model) {
if (!model) {
return;
}
this._model = model;
this._bindModelListener();
this._notifyListeners();
}
async addFromToast(toast) {
if (!toast || !this._model?.createRecord) {
return;
}
const existing = this._model.getRecord ? await this._model.getRecord(toast.id) : null;
if (existing && this._model.updateRecord) {
await this._model.updateRecord(toast.id, {
title: toast.title,
message: toast.message,
type: toast.type,
timestamp: toast.timestamp,
created_at: toast.timestamp,
source: 'toast',
status: 'pending',
is_read: false,
resource_path: '/notifications',
action_label: toast.action_label ?? null,
action_target: toast.action_target ?? null,
action_kind: toast.action_kind ?? null,
meta: toast.meta ?? {}
});
return;
}
await this._model.createRecord({
id: toast.id,
title: toast.title,
message: toast.message,
type: toast.type,
timestamp: toast.timestamp,
created_at: toast.timestamp,
source: 'toast',
status: 'pending',
is_read: false,
resource_path: '/notifications',
action_label: toast.action_label ?? null,
action_target: toast.action_target ?? null,
action_kind: toast.action_kind ?? null,
meta: toast.meta ?? {}
});
}
async dismiss(id) {
if (!this._model?.deleteRecord) {
return;
}
await this._model.deleteRecord(id);
}
async clear() {
const model = this._model;
if (!model?.queryRecords || !model?.deleteRecord) {
return;
}
const result = await model.queryRecords({
offset: 0,
page_size: 500,
sort_by: [{ field: 'timestamp', direction: 'desc' }]
});
await Promise.all((result.rows || []).map((row) => model.deleteRecord(row.id)));
}
}
const notificationCenterManager = new NotificationCenterManager();
function getNotificationTypeStyle(type = 'info') {
return {
info: {
backgroundColor: '$blue3',
borderColor: '$blue8',
icon: 'info'
},
success: {
backgroundColor: '$green3',
borderColor: '$green8',
icon: 'success'
},
warning: {
backgroundColor: '$yellow3',
borderColor: '$yellow8',
icon: 'warning'
},
error: {
backgroundColor: '$red3',
borderColor: '$red8',
icon: 'error'
}
}[type] || {
backgroundColor: '$blue3',
borderColor: '$blue8',
icon: 'info'
};
}
// ============================================================================
// Shell Provider
// ============================================================================
@@ -389,6 +626,7 @@ export function ShellProvider({
// Toast state
const [toasts, setToasts] = useState([]);
const [notificationsOpen, setNotificationsOpen] = useState(notificationCenterManager.isOpen());
// Initialize shell manager with setters
useEffect(() => {
@@ -405,6 +643,10 @@ export function ShellProvider({
toastManager._init(setToasts);
}, []);
useEffect(() => {
notificationCenterManager._init(setNotificationsOpen);
}, []);
// Update shell manager state when it changes
useEffect(() => {
shellManager._updateState({
@@ -469,6 +711,7 @@ export function ShellProvider({
return (
<ShellContext.Provider value={value}>
{children}
<NotificationCenterPanel open={notificationsOpen} />
</ShellContext.Provider>
);
}
@@ -518,30 +761,7 @@ function Toast({ toast, onClose, onPause, onResume }) {
};
// Type-specific styling
const typeStyles = {
info: {
backgroundColor: '$blue3',
borderColor: '$blue8',
icon: 'info'
},
success: {
backgroundColor: '$green3',
borderColor: '$green8',
icon: 'success'
},
warning: {
backgroundColor: '$yellow3',
borderColor: '$yellow8',
icon: 'warning'
},
error: {
backgroundColor: '$red3',
borderColor: '$red8',
icon: 'error'
}
};
const style = typeStyles[toast.type] || typeStyles.info;
const style = getNotificationTypeStyle(toast.type);
const Icon = getIcon(style.icon);
return (
@@ -603,6 +823,168 @@ function Toast({ toast, onClose, onPause, onResume }) {
);
}
function NotificationRecord({ record, onDismiss }) {
const style = getNotificationTypeStyle(record.type);
const Icon = getIcon(style.icon);
const CloseIcon = getIcon('close');
return (
<XStack
backgroundColor={style.backgroundColor}
borderWidth={1}
borderColor={style.borderColor}
borderRadius="$4"
padding="$3"
gap="$2"
alignItems="flex-start"
>
{Icon ? (
<XStack alignItems="center" justifyContent="center" width={20} height={20} flexShrink={0} marginTop="$1">
<Icon size="md" color="$textSecondary" />
</XStack>
) : null}
<YStack flex={1} gap="$1">
{record.title ? (
<Text fontWeight="600" fontSize="$4" color="$color">
{record.title}
</Text>
) : null}
{record.message ? (
<Text fontSize="$3" color="$textSecondary">
{record.message}
</Text>
) : null}
{record.timestamp ? (
<Text fontSize="$2" color="$textMuted">
{new Date(record.timestamp).toLocaleString()}
</Text>
) : null}
{record.action_label ? (
<Text fontSize="$2" color="$accent">
{record.action_label}
</Text>
) : null}
</YStack>
<Button
size="$2"
circular
chromeless
onPress={() => onDismiss?.(record.id)}
aria-label="Dismiss notification"
icon={CloseIcon ? <CloseIcon size="sm" color="$textSecondary" /> : undefined}
/>
</XStack>
);
}
function NotificationCenterPanel({ open = false }) {
const [model, setModel] = useState(notificationCenterManager.getModel());
const [dataVersion, setDataVersion] = useState(0);
const [records, setRecords] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
return notificationCenterManager.subscribe(({ model: nextModel }) => {
setModel(nextModel);
setDataVersion((value) => value + 1);
});
}, []);
useEffect(() => {
if (!model?.subscribe) {
return undefined;
}
return model.subscribe(() => {
setDataVersion((value) => value + 1);
});
}, [model]);
useEffect(() => {
let cancelled = false;
async function loadNotifications() {
if (!open || !model?.queryRecords) {
if (!cancelled) {
setRecords([]);
}
return;
}
setLoading(true);
setError('');
try {
const result = await model.queryRecords({
offset: 0,
page_size: 100,
sort_by: [{ field: 'timestamp', direction: 'desc' }]
});
if (!cancelled) {
setRecords(result?.rows || []);
}
} catch (loadError) {
if (!cancelled) {
setError(String(loadError?.message || loadError));
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
loadNotifications();
return () => {
cancelled = true;
};
}, [open, model, dataVersion]);
return (
<SidePanelShell
open={open}
onClose={() => notificationCenterManager.close()}
title="Notifications"
width={360}
toolbar={records.length > 0 ? [
{
id: 'clear-notifications',
label: 'Clear All',
chromeless: true,
onPress: () => notificationCenterManager.clear()
}
] : []}
>
{error ? (
<Text color="$danger" fontWeight="600">
{error}
</Text>
) : null}
{loading ? (
<Text color="$textMuted">Loading notifications...</Text>
) : null}
{!loading && records.length === 0 ? (
<YStack gap="$2">
<Text fontWeight="600" color="$textPrimary">No pending notifications</Text>
<Text color="$textMuted">Expired toast messages will appear here until dismissed.</Text>
</YStack>
) : null}
{records.map((record) => (
<NotificationRecord
key={record.id}
record={record}
onDismiss={(id) => notificationCenterManager.dismiss(id)}
/>
))}
</SidePanelShell>
);
}
/**
* ToastViewport Component
* Container for toast notifications (fixed bottom-right)
@@ -677,11 +1059,12 @@ export function ShellPlacement({ placement = 'mainContent', children }) {
// Attach static properties
ShellProvider.Manager = shellManager;
ShellProvider.ToastManager = toastManager;
ShellProvider.NotificationManager = notificationCenterManager;
ShellProvider.Context = ShellContext;
ShellProvider.Placement = ShellPlacement;
export default ShellProvider;
export { shellManager as ShellManager, toastManager as ToastManager };
export { shellManager as ShellManager, toastManager as ToastManager, notificationCenterManager as NotificationManager };
export { ShellContext };
// ShellPlacement is already exported as a function declaration above
+1 -1
View File
@@ -1,9 +1,9 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { GridViewContext } from './context.js';
import { GridSegmentsLayout } from './layout.jsx';
import { normalizeColumnDefinition } from '../../../data/utils.js';
import {
areSortEntriesEqual,
normalizeColumnDefinition,
normalizeColumnDefinitionsInput,
resolveVisibleColumns
} from './utils.js';
+2 -3
View File
@@ -19,7 +19,6 @@ They are intentionally related, and their data-facing APIs are aligned where pra
## Exports
- `GridDataModel`
- `GridView`
- `GridSegmentsLayout`
- `PanelHeader`
@@ -73,15 +72,15 @@ for cell-level custom rendering.
```jsx
import {
GridDataModel,
GridView,
PanelHeader,
PanelBodyView,
PanelFooter,
createPanelGridViewProps
} from '@reliancy/bface/ui/components';
import { RecordsModel } from '@reliancy/bface';
const model = new GridDataModel({
const model = new RecordsModel({
rows: [
{ id: 1, customer: 'Northwind', total: 1200 },
{ id: 2, customer: 'Blue Harbor', total: 980 }
-1
View File
@@ -1,5 +1,4 @@
export { GridView, default as GridViewDefault } from './GridView.jsx';
export { GridDataModel } from './model.js';
export { useGridView } from './context.js';
export { GridSegmentsLayout } from './layout.jsx';
export {
+1 -1
View File
@@ -60,7 +60,7 @@ function SegmentContainer({ direction, segmentKey, size, children }) {
return (
<YStack
key={segmentKey}
overflow="hidden"
overflow={segmentKey === 'body' ? 'hidden' : 'visible'}
{...resolveSegmentLayout(direction, size, { isFlexible: segmentKey === 'body' })}
>
{children}
-123
View File
@@ -1,123 +0,0 @@
import {
compareValues,
getColumnKeysFromRows,
normalizeColumnDefinition
} from './utils.js';
export class GridDataModel {
constructor({ rows = [], columns = {}, latency = 0 } = {}) {
this.rows = rows;
this.columns = columns;
this.latency = latency;
}
async queryStructure() {
const sampleRow = this.rows[0] || {};
const inferredFields = getColumnKeysFromRows(this.rows);
const fields = inferredFields.length ? inferredFields : Object.keys(this.columns || {});
const resolvedColumns = {};
for (const field of fields) {
resolvedColumns[field] = normalizeColumnDefinition(
field,
this.columns[field],
sampleRow[field]
);
}
return { columns: resolvedColumns };
}
filterRows(rows, filterBy = {}) {
const filters = Object.entries(filterBy || {}).filter(
([, value]) => value !== null && value !== undefined && value !== ''
);
if (!filters.length) {
return rows;
}
return rows.filter((row) =>
filters.every(([field, value]) => {
if (field === 'search') {
const haystack = Object.values(row || {}).join(' ').toLowerCase();
return haystack.includes(String(value).trim().toLowerCase());
}
return String(row?.[field] ?? '')
.toLowerCase()
.includes(String(value).trim().toLowerCase());
})
);
}
sortRows(rows, sortBy = []) {
const activeSorts = Array.isArray(sortBy)
? sortBy.filter((entry) => entry?.field && entry?.direction)
: [];
if (!activeSorts.length) {
return rows;
}
return [...rows].sort((leftRow, rightRow) => {
for (const sort of activeSorts) {
const result = compareValues(
leftRow?.[sort.field],
rightRow?.[sort.field],
sort.direction
);
if (result !== 0) {
return result;
}
}
return 0;
});
}
async queryRecords({ offset = 0, page_size = 10, sort_by = [], filter_by = {} } = {}) {
const filteredRows = this.filterRows(this.rows, filter_by);
const sortedRows = this.sortRows(filteredRows, sort_by);
const rows = sortedRows.slice(offset, offset + page_size);
if (this.latency) {
await new Promise((resolve) => window.setTimeout(resolve, this.latency));
}
return {
rows,
total: sortedRows.length,
offset,
page_size
};
}
async queryAggregate({ metric, field, filter_by = {} } = {}) {
const filteredRows = this.filterRows(this.rows, filter_by);
if (metric === 'count') {
return filteredRows.length;
}
if (metric === 'sum' && field) {
return filteredRows.reduce((sum, row) => sum + (Number(row?.[field]) || 0), 0);
}
return null;
}
async queryAggregates({ metrics = [], filter_by = {} } = {}) {
const result = {};
for (const metric of metrics) {
if (typeof metric === 'string' && metric.startsWith('sum:')) {
const field = metric.slice(4);
result[metric] = await this.queryAggregate({ metric: 'sum', field, filter_by });
} else {
result[metric] = await this.queryAggregate({ metric, filter_by });
}
}
return result;
}
}
+22 -3
View File
@@ -42,17 +42,36 @@ function renderToolbarItem(item) {
if (item.kind === 'search') {
const SearchIcon = getIcon('search');
return (
<XStack key={item.key || item.placeholder || 'search'} alignItems="center" gap="$2">
{SearchIcon ? <SearchIcon size="sm" color="$textMuted" /> : null}
<XStack
key={item.key || item.placeholder || 'search'}
alignItems="center"
position="relative"
width={item.width || 240}
>
<Input
width={item.width || 240}
width="100%"
value={item.value}
placeholder={item.placeholder || 'Search'}
onChangeText={(value) => item.onChange?.(value)}
backgroundColor="$bgPanel"
borderColor="$lineSubtle"
focusStyle={{ borderColor: '$accent' }}
paddingRight="$8"
/>
{SearchIcon ? (
<XStack
position="absolute"
right="$3"
top={0}
bottom={0}
alignItems="center"
justifyContent="center"
pointerEvents="none"
zIndex={0}
>
<SearchIcon size="sm" color="$textMuted" />
</XStack>
) : null}
</XStack>
);
}
-73
View File
@@ -1,47 +1,3 @@
export function prettyLabel(value) {
if (!value) {
return '';
}
const withSpaces = String(value)
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/[_-]+/g, ' ')
.trim();
return withSpaces.charAt(0).toUpperCase() + withSpaces.slice(1);
}
export function inferColumnType(value) {
if (typeof value === 'boolean') {
return 'boolean';
}
if (typeof value === 'number') {
return 'number';
}
if (typeof value === 'string' && /^-?\d+(\.\d+)?$/.test(value)) {
return 'number';
}
return 'text';
}
export function normalizeColumnDefinition(field, columnDefinition = {}, sampleValue) {
return {
field,
id: field,
label: columnDefinition.label || columnDefinition.display_name || prettyLabel(field),
sortable: columnDefinition.sortable ?? true,
filterable: columnDefinition.filterable ?? true,
align: columnDefinition.align || (inferColumnType(sampleValue) === 'number' ? 'right' : 'left'),
width: columnDefinition.width ?? null,
type: columnDefinition.type || inferColumnType(sampleValue),
format: columnDefinition.format || null,
renderer: columnDefinition.renderer || columnDefinition.render || null,
currency: columnDefinition.currency || 'USD',
priority: columnDefinition.priority || null,
alwaysVisible: columnDefinition.alwaysVisible ?? false
};
}
export function normalizeColumnDefinitionsInput(input = {}) {
if (Array.isArray(input)) {
return Object.fromEntries(
@@ -114,35 +70,6 @@ export function normalizeColumnsArray(input = []) {
return [];
}
export function compareValues(left, right, direction = 'asc') {
if (left === right) {
return 0;
}
if (left === null || left === undefined || left === '') {
return 1;
}
if (right === null || right === undefined || right === '') {
return -1;
}
const leftNumber = Number(left);
const rightNumber = Number(right);
const bothNumeric = !Number.isNaN(leftNumber) && !Number.isNaN(rightNumber);
const result = bothNumeric
? leftNumber - rightNumber
: String(left).localeCompare(String(right), undefined, { sensitivity: 'base' });
return direction === 'desc' ? -result : result;
}
export function getColumnKeysFromRows(rows = []) {
const fields = new Set();
for (const row of rows) {
Object.keys(row || {}).forEach((field) => fields.add(field));
}
return Array.from(fields);
}
export function resolveCellValue(row, column) {
return row?.[column.field];
}
+1 -1
View File
@@ -6,7 +6,7 @@
export { EmptyShell, default as EmptyShellDefault } from './EmptyShell.jsx';
export { LandingShell, LandingShell as TopBarShell, default as LandingShellDefault } from './LandingShell.jsx';
export { DashboardShell, default as DashboardShellDefault } from './DashboardShell.jsx';
export { ShellProvider, useShell, ShellPlacement, ShellManager, ShellContext, ToastViewport, ToastManager } from './Shell.jsx';
export { ShellProvider, useShell, ShellPlacement, ShellManager, ShellContext, ToastViewport, ToastManager, NotificationManager } from './Shell.jsx';
export { AppInfo, default as AppInfoDefault } from './AppInfo.jsx';
export { TopBar, default as TopBarDefault } from './TopBar.jsx';
export { SideBar, default as SideBarDefault } from './SideBar.jsx';