From 2157e1aea64ce3bf7f83f190a52f9ee1f893fa26 Mon Sep 17 00:00:00 2001 From: Amer Agovic Date: Tue, 5 May 2026 12:24:13 -0500 Subject: [PATCH] Unify records model and add notification center --- README.md | 2 +- src/data/DataModel.js | 62 --- src/data/InMemoryDataModel.js | 220 --------- src/data/RecordsModel.js | 360 +++++++++++++++ src/data/index.js | 3 +- src/data/utils.js | 72 +++ src/ui/components/DirView.jsx | 18 +- src/ui/components/MenuItemButton.jsx | 75 ++- src/ui/components/Shell.jsx | 435 ++++++++++++++++-- src/ui/components/grid/GridView.jsx | 2 +- src/ui/components/grid/README.md | 5 +- src/ui/components/grid/index.js | 1 - src/ui/components/grid/layout.jsx | 2 +- src/ui/components/grid/model.js | 123 ----- src/ui/components/grid/panel.jsx | 25 +- src/ui/components/grid/utils.js | 73 --- src/ui/components/index.js | 2 +- ...ta-model.test.js => records-model.test.js} | 26 +- 18 files changed, 962 insertions(+), 544 deletions(-) delete mode 100644 src/data/DataModel.js delete mode 100644 src/data/InMemoryDataModel.js create mode 100644 src/data/RecordsModel.js create mode 100644 src/data/utils.js delete mode 100644 src/ui/components/grid/model.js rename test/{grid-data-model.test.js => records-model.test.js} (69%) diff --git a/README.md b/README.md index f2f3357..f3db0c2 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ Security and data types are re-exported from the root entry; there are no separa ### Other areas - **`security/`** — policies, models, login and account pages, route guards, `securityService` -- **`data/`** — `DataModel`, `InMemoryDataModel` +- **`data/`** — `RecordsModel` ### Application profile and `initEnv` diff --git a/src/data/DataModel.js b/src/data/DataModel.js deleted file mode 100644 index b81ae50..0000000 --- a/src/data/DataModel.js +++ /dev/null @@ -1,62 +0,0 @@ -export class DataModel { - constructor(options = {}) { - this.idField = options.idField || 'id'; - this.listeners = new Set(); - } - - subscribe(listener) { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } - - notifyChange(payload = {}) { - this.listeners.forEach((listener) => { - try { - listener(payload); - } catch (error) { - console.warn('[DataModel] Listener failed:', error); - } - }); - } - - getIdField() { - return this.idField; - } - - normalizeQuery(query = {}) { - return { - page: Math.max(1, Number(query.page) || 1), - pageSize: Math.max(1, Number(query.pageSize) || 10), - search: typeof query.search === 'string' ? query.search.trim() : '', - orderBy: query.orderBy || '', - order: query.order === 'desc' ? 'desc' : 'asc', - filters: query.filters && typeof query.filters === 'object' ? query.filters : {} - }; - } - - async queryRecords(_query = {}) { - throw new Error('queryRecords() not implemented'); - } - - async querySummary(_query = {}, _summaryDefinitions = []) { - throw new Error('querySummary() not implemented'); - } - - async getRecord(_id) { - throw new Error('getRecord() not implemented'); - } - - async createRecord(_values) { - throw new Error('createRecord() not implemented'); - } - - async updateRecord(_id, _patch) { - throw new Error('updateRecord() not implemented'); - } - - async deleteRecord(_id) { - throw new Error('deleteRecord() not implemented'); - } -} - -export default DataModel; diff --git a/src/data/InMemoryDataModel.js b/src/data/InMemoryDataModel.js deleted file mode 100644 index ba251d0..0000000 --- a/src/data/InMemoryDataModel.js +++ /dev/null @@ -1,220 +0,0 @@ -import { DataModel } from './DataModel.js'; - -function clone(value) { - return JSON.parse(JSON.stringify(value)); -} - -function normalizeString(value) { - if (value === null || value === undefined) { - return ''; - } - return String(value).toLowerCase(); -} - -function getValueByPath(record, path) { - if (!path) { - return undefined; - } - - return path.split('.').reduce((current, key) => current?.[key], record); -} - -function compareValues(left, right) { - if (left === right) return 0; - if (left === null || left === undefined) return -1; - if (right === null || right === undefined) return 1; - if (typeof left === 'number' && typeof right === 'number') { - return left - right; - } - return String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base' }); -} - -export class InMemoryDataModel extends DataModel { - constructor(options = {}) { - super(options); - this.records = Array.isArray(options.records) ? clone(options.records) : []; - this.searchFields = Array.isArray(options.searchFields) ? options.searchFields : []; - this.defaultSummaryDefinitions = Array.isArray(options.summaryDefinitions) ? options.summaryDefinitions : []; - this.nextId = options.nextId || this.computeNextId(); - } - - computeNextId() { - const idField = this.getIdField(); - const numericIds = this.records - .map((record) => Number(record?.[idField])) - .filter((value) => Number.isFinite(value)); - - if (numericIds.length === 0) { - return 1; - } - - return Math.max(...numericIds) + 1; - } - - setRecords(records = []) { - this.records = Array.isArray(records) ? clone(records) : []; - this.nextId = this.computeNextId(); - this.notifyChange({ type: 'reset' }); - } - - getAllRecords() { - return clone(this.records); - } - - applyFilters(records, filters = {}) { - const entries = Object.entries(filters).filter(([, value]) => value !== undefined && value !== null && value !== ''); - if (entries.length === 0) { - return records; - } - - return records.filter((record) => { - return entries.every(([field, expected]) => { - const actual = getValueByPath(record, field); - if (Array.isArray(expected)) { - return expected.includes(actual); - } - return actual === expected; - }); - }); - } - - applySearch(records, search = '') { - const normalizedSearch = normalizeString(search).trim(); - if (!normalizedSearch) { - return records; - } - - const fields = this.searchFields.length > 0 - ? this.searchFields - : Array.from(new Set(records.flatMap((record) => Object.keys(record || {})))); - - return records.filter((record) => { - return fields.some((field) => normalizeString(getValueByPath(record, field)).includes(normalizedSearch)); - }); - } - - applySort(records, orderBy = '', order = 'asc') { - if (!orderBy) { - return records; - } - - const sorted = [...records].sort((left, right) => compareValues(getValueByPath(left, orderBy), getValueByPath(right, orderBy))); - return order === 'desc' ? sorted.reverse() : sorted; - } - - paginate(records, page = 1, pageSize = 10) { - const startIndex = (page - 1) * pageSize; - return records.slice(startIndex, startIndex + pageSize); - } - - summarize(records, definitions = []) { - const summaryDefinitions = definitions.length > 0 ? definitions : this.defaultSummaryDefinitions; - const items = summaryDefinitions.map((definition) => { - const type = definition.type || 'count'; - const field = definition.field || ''; - let value = 0; - - if (type === 'sum') { - value = records.reduce((total, record) => total + (Number(getValueByPath(record, field)) || 0), 0); - } else if (type === 'avg') { - value = records.length > 0 - ? records.reduce((total, record) => total + (Number(getValueByPath(record, field)) || 0), 0) / records.length - : 0; - } else if (type === 'countBy') { - value = records.reduce((accumulator, record) => { - const key = getValueByPath(record, field) ?? 'unknown'; - accumulator[key] = (accumulator[key] || 0) + 1; - return accumulator; - }, {}); - } else { - value = records.length; - } - - return { - ...definition, - value - }; - }); - - return { - totalRecords: records.length, - items - }; - } - - buildFilteredRecords(query = {}) { - const normalized = this.normalizeQuery(query); - const filtered = this.applySearch(this.applyFilters([...this.records], normalized.filters), normalized.search); - const sorted = this.applySort(filtered, normalized.orderBy, normalized.order); - - return { - query: normalized, - filteredRecords: sorted - }; - } - - async queryRecords(query = {}) { - const { query: normalized, filteredRecords } = this.buildFilteredRecords(query); - return { - records: clone(this.paginate(filteredRecords, normalized.page, normalized.pageSize)), - totalRecords: filteredRecords.length, - page: normalized.page, - pageSize: normalized.pageSize - }; - } - - async querySummary(query = {}, summaryDefinitions = []) { - const { filteredRecords, query: normalized } = this.buildFilteredRecords(query); - return { - query: normalized, - ...this.summarize(filteredRecords, summaryDefinitions) - }; - } - - async getRecord(id) { - const idField = this.getIdField(); - const record = this.records.find((item) => String(item?.[idField]) === String(id)); - return record ? clone(record) : null; - } - - async createRecord(values = {}) { - const idField = this.getIdField(); - const record = { - ...clone(values), - [idField]: values?.[idField] ?? this.nextId++ - }; - this.records.unshift(record); - this.notifyChange({ type: 'create', record: clone(record) }); - return clone(record); - } - - async updateRecord(id, patch = {}) { - const idField = this.getIdField(); - const recordIndex = this.records.findIndex((item) => String(item?.[idField]) === String(id)); - if (recordIndex < 0) { - throw new Error(`Record not found: ${id}`); - } - - this.records[recordIndex] = { - ...this.records[recordIndex], - ...clone(patch), - [idField]: this.records[recordIndex][idField] - }; - this.notifyChange({ type: 'update', record: clone(this.records[recordIndex]) }); - return clone(this.records[recordIndex]); - } - - async deleteRecord(id) { - const idField = this.getIdField(); - const beforeLength = this.records.length; - this.records = this.records.filter((item) => String(item?.[idField]) !== String(id)); - if (this.records.length === beforeLength) { - throw new Error(`Record not found: ${id}`); - } - - this.notifyChange({ type: 'delete', id }); - return true; - } -} - -export default InMemoryDataModel; diff --git a/src/data/RecordsModel.js b/src/data/RecordsModel.js new file mode 100644 index 0000000..7cbbd10 --- /dev/null +++ b/src/data/RecordsModel.js @@ -0,0 +1,360 @@ +import { + compareValues, + getColumnKeysFromRows, + normalizeColumnDefinition +} from './utils.js'; + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function getValueByPath(record, path) { + if (!path) { + return undefined; + } + + return String(path).split('.').reduce((current, key) => current?.[key], record); +} + +function normalizeString(value) { + if (value === null || value === undefined) { + return ''; + } + + return String(value).trim().toLowerCase(); +} + +export class RecordsModel { + constructor({ + rows = [], + columns = {}, + latency = 0, + idField = 'id', + searchFields = [], + summaryDefinitions = [] + } = {}) { + this.idField = idField; + this.listeners = new Set(); + this.rows = clone(Array.isArray(rows) ? rows : []); + this.columns = columns && typeof columns === 'object' ? columns : {}; + this.latency = latency; + this.searchFields = Array.isArray(searchFields) ? searchFields : []; + this.summaryDefinitions = Array.isArray(summaryDefinitions) ? summaryDefinitions : []; + this.nextId = this.computeNextId(); + } + + subscribe(listener) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + notifyChange(payload = {}) { + this.listeners.forEach((listener) => { + try { + listener(payload); + } catch (error) { + console.warn('[RecordsModel] Listener failed:', error); + } + }); + } + + getIdField() { + return this.idField; + } + + getAllRows() { + return clone(this.rows); + } + + setRows(rows = []) { + this.rows = clone(Array.isArray(rows) ? rows : []); + this.nextId = this.computeNextId(); + this.notifyChange({ type: 'reset' }); + } + + computeNextId() { + const idField = this.getIdField(); + const numericIds = this.rows + .map((row) => Number(row?.[idField])) + .filter((value) => Number.isFinite(value)); + + if (numericIds.length === 0) { + return 1; + } + + return Math.max(...numericIds) + 1; + } + + async waitForLatency() { + if (!this.latency) { + return; + } + + await new Promise((resolve) => window.setTimeout(resolve, this.latency)); + } + + async queryStructure() { + const sampleRow = this.rows[0] || {}; + const inferredFields = getColumnKeysFromRows(this.rows); + const fields = inferredFields.length > 0 ? inferredFields : Object.keys(this.columns); + const resolvedColumns = {}; + + for (const field of fields) { + resolvedColumns[field] = normalizeColumnDefinition( + field, + this.columns[field], + sampleRow[field] + ); + } + + return { columns: resolvedColumns }; + } + + normalizeQuery(query = {}) { + const offset = Math.max(0, Number(query.offset) || 0); + const pageSize = Math.max(1, Number(query.page_size) || 10); + const sortBy = Array.isArray(query.sort_by) + ? query.sort_by.filter((entry) => entry?.field && entry?.direction) + : []; + const filterBy = query.filter_by && typeof query.filter_by === 'object' + ? { ...query.filter_by } + : {}; + + return { + offset, + page_size: pageSize, + sort_by: sortBy, + filter_by: filterBy + }; + } + + matchesSearch(row, search) { + const needle = normalizeString(search); + if (!needle) { + return true; + } + + const fields = this.searchFields.length > 0 + ? this.searchFields + : Object.keys(row || {}); + + return fields.some((field) => { + const value = getValueByPath(row, field); + if (Array.isArray(value)) { + return value.some((entry) => normalizeString(entry).includes(needle)); + } + return normalizeString(value).includes(needle); + }); + } + + filterRows(rows, filterBy = {}) { + const filters = Object.entries(filterBy).filter( + ([, value]) => value !== null && value !== undefined && value !== '' + ); + + if (filters.length === 0) { + return rows; + } + + return rows.filter((row) => + filters.every(([field, value]) => { + if (field === 'search') { + return this.matchesSearch(row, value); + } + + const actual = getValueByPath(row, field); + if (Array.isArray(value)) { + return value.includes(actual); + } + + return normalizeString(actual).includes(normalizeString(value)); + }) + ); + } + + sortRows(rows, sortBy = []) { + const activeSorts = Array.isArray(sortBy) + ? sortBy.filter((entry) => entry?.field && entry?.direction) + : []; + + if (activeSorts.length === 0) { + return rows; + } + + return [...rows].sort((leftRow, rightRow) => { + for (const sort of activeSorts) { + const result = compareValues( + getValueByPath(leftRow, sort.field), + getValueByPath(rightRow, sort.field), + sort.direction + ); + + if (result !== 0) { + return result; + } + } + + return 0; + }); + } + + buildRows(query = {}) { + const normalized = this.normalizeQuery(query); + const filteredRows = this.filterRows(this.rows, normalized.filter_by); + const sortedRows = this.sortRows(filteredRows, normalized.sort_by); + + return { + query: normalized, + rows: sortedRows + }; + } + + async queryRecords(query = {}) { + const { query: normalized, rows } = this.buildRows(query); + const pageRows = rows.slice(normalized.offset, normalized.offset + normalized.page_size); + + await this.waitForLatency(); + + return { + rows: clone(pageRows), + total: rows.length, + offset: normalized.offset, + page_size: normalized.page_size + }; + } + + async querySummary(query = {}, metrics = this.summaryDefinitions) { + const { query: normalized, rows } = this.buildRows(query); + + await this.waitForLatency(); + + const normalizedMetrics = metrics.map((metric, index) => { + if (typeof metric === 'string') { + if (metric.startsWith('sum:')) { + return { key: metric, type: 'sum', field: metric.slice(4), source: metric }; + } + if (metric.startsWith('avg:')) { + return { key: metric, type: 'avg', field: metric.slice(4), source: metric }; + } + return { key: metric, type: metric, field: '', source: metric }; + } + + const definition = metric || {}; + return { + key: definition.id || definition.key || `${definition.type || 'count'}:${definition.field || index}`, + type: definition.type || 'count', + field: definition.field || '', + source: definition + }; + }); + + const states = new Map( + normalizedMetrics.map((metric) => [ + metric.key, + metric.type === 'countBy' ? {} : 0 + ]) + ); + + for (const row of rows) { + for (const metric of normalizedMetrics) { + if (metric.type === 'count') { + states.set(metric.key, states.get(metric.key) + 1); + continue; + } + + if (metric.type === 'sum' || metric.type === 'avg') { + const value = Number(getValueByPath(row, metric.field)) || 0; + states.set(metric.key, states.get(metric.key) + value); + continue; + } + + if (metric.type === 'countBy') { + const bucket = states.get(metric.key); + const value = getValueByPath(row, metric.field) ?? 'unknown'; + bucket[value] = (bucket[value] || 0) + 1; + } + } + } + + const values = {}; + const items = normalizedMetrics.map((metric) => { + let value = states.get(metric.key); + + if (metric.type === 'avg') { + value = rows.length > 0 ? value / rows.length : 0; + } + + values[metric.key] = value; + + return typeof metric.source === 'string' + ? { + key: metric.key, + type: metric.type, + field: metric.field, + value + } + : { + ...metric.source, + value + }; + }); + + return { + query: normalized, + total: rows.length, + values, + items + }; + } + + async getRecord(id) { + const idField = this.getIdField(); + const record = this.rows.find((row) => String(row?.[idField]) === String(id)); + return record ? clone(record) : null; + } + + async createRecord(values = {}) { + const idField = this.getIdField(); + const nextRow = { + ...clone(values), + [idField]: values?.[idField] ?? this.nextId++ + }; + + this.rows.unshift(nextRow); + this.notifyChange({ type: 'create', row: clone(nextRow) }); + return clone(nextRow); + } + + async updateRecord(id, patch = {}) { + const idField = this.getIdField(); + const rowIndex = this.rows.findIndex((row) => String(row?.[idField]) === String(id)); + + if (rowIndex < 0) { + throw new Error(`Record not found: ${id}`); + } + + this.rows[rowIndex] = { + ...this.rows[rowIndex], + ...clone(patch), + [idField]: this.rows[rowIndex][idField] + }; + + this.notifyChange({ type: 'update', row: clone(this.rows[rowIndex]) }); + return clone(this.rows[rowIndex]); + } + + async deleteRecord(id) { + const idField = this.getIdField(); + const beforeLength = this.rows.length; + this.rows = this.rows.filter((row) => String(row?.[idField]) !== String(id)); + + if (this.rows.length === beforeLength) { + throw new Error(`Record not found: ${id}`); + } + + this.notifyChange({ type: 'delete', id }); + return true; + } +} + +export default RecordsModel; diff --git a/src/data/index.js b/src/data/index.js index 73f9160..3a8a1cf 100644 --- a/src/data/index.js +++ b/src/data/index.js @@ -1,2 +1 @@ -export { DataModel, default as DataModelDefault } from './DataModel.js'; -export { InMemoryDataModel, default as InMemoryDataModelDefault } from './InMemoryDataModel.js'; +export { RecordsModel, default as RecordsModelDefault } from './RecordsModel.js'; diff --git a/src/data/utils.js b/src/data/utils.js new file mode 100644 index 0000000..2fe16f5 --- /dev/null +++ b/src/data/utils.js @@ -0,0 +1,72 @@ +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 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); +} diff --git a/src/ui/components/DirView.jsx b/src/ui/components/DirView.jsx index d216d71..b7bd9f9 100644 --- a/src/ui/components/DirView.jsx +++ b/src/ui/components/DirView.jsx @@ -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) { diff --git a/src/ui/components/MenuItemButton.jsx b/src/ui/components/MenuItemButton.jsx index 4e38237..5c7f604 100644 --- a/src/ui/components/MenuItemButton.jsx +++ b/src/ui/components/MenuItemButton.jsx @@ -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 && ( - + {typeof IconComponent === 'string' ? ( {IconComponent} ) : IconComponent ? ( ) : null} + {showNotificationBadge ? ( + + + {notificationCount > 9 ? '9+' : '!'} + + + ) : null} )} @@ -466,12 +509,32 @@ export function MenuItemButton({ > {/* Icon */} {showIcon && ( - + {typeof IconComponent === 'string' ? ( {IconComponent} ) : IconComponent ? ( ) : null} + {showNotificationBadge ? ( + + + {notificationCount > 9 ? '9+' : '!'} + + + ) : null} )} diff --git a/src/ui/components/Shell.jsx b/src/ui/components/Shell.jsx index 560ef34..77e6c8a 100644 --- a/src/ui/components/Shell.jsx +++ b/src/ui/components/Shell.jsx @@ -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 ( {children} + ); } @@ -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 ( + + {Icon ? ( + + + + ) : null} + + {record.title ? ( + + {record.title} + + ) : null} + {record.message ? ( + + {record.message} + + ) : null} + {record.timestamp ? ( + + {new Date(record.timestamp).toLocaleString()} + + ) : null} + {record.action_label ? ( + + {record.action_label} + + ) : null} + +