Unify records model and add notification center
This commit is contained in:
@@ -162,7 +162,7 @@ Security and data types are re-exported from the root entry; there are no separa
|
|||||||
### Other areas
|
### Other areas
|
||||||
|
|
||||||
- **`security/`** — policies, models, login and account pages, route guards, `securityService`
|
- **`security/`** — policies, models, login and account pages, route guards, `securityService`
|
||||||
- **`data/`** — `DataModel`, `InMemoryDataModel`
|
- **`data/`** — `RecordsModel`
|
||||||
|
|
||||||
### Application profile and `initEnv`
|
### Application profile and `initEnv`
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
||||||
+1
-2
@@ -1,2 +1 @@
|
|||||||
export { DataModel, default as DataModelDefault } from './DataModel.js';
|
export { RecordsModel, default as RecordsModelDefault } from './RecordsModel.js';
|
||||||
export { InMemoryDataModel, default as InMemoryDataModelDefault } from './InMemoryDataModel.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);
|
||||||
|
}
|
||||||
@@ -150,7 +150,7 @@ export function DirView({
|
|||||||
onRowPress = null,
|
onRowPress = null,
|
||||||
onRefresh = null,
|
onRefresh = null,
|
||||||
showHeader = true,
|
showHeader = true,
|
||||||
showSummary = true,
|
showSummary = false,
|
||||||
density = 'comfortable',
|
density = 'comfortable',
|
||||||
striped = false
|
striped = false
|
||||||
}) {
|
}) {
|
||||||
@@ -199,12 +199,14 @@ export function DirView({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
|
const offset = (currentPage - 1) * pageSize;
|
||||||
|
const sortBy = orderBy ? [{ field: orderBy, direction: order }] : [];
|
||||||
|
const filterBy = effectiveSearchTerm ? { search: effectiveSearchTerm } : {};
|
||||||
const query = {
|
const query = {
|
||||||
page: currentPage,
|
offset,
|
||||||
pageSize,
|
page_size: pageSize,
|
||||||
search: effectiveSearchTerm,
|
sort_by: sortBy,
|
||||||
orderBy,
|
filter_by: filterBy
|
||||||
order
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const [recordResult, summaryResult] = await Promise.all([
|
const [recordResult, summaryResult] = await Promise.all([
|
||||||
@@ -213,8 +215,8 @@ export function DirView({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setRecords(recordResult.records || []);
|
setRecords(recordResult.rows || []);
|
||||||
setTotalRecords(recordResult.totalRecords || 0);
|
setTotalRecords(recordResult.total || 0);
|
||||||
setSummary(summaryResult || null);
|
setSummary(summaryResult || null);
|
||||||
}
|
}
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { Button, XStack, YStack, Text } from 'tamagui';
|
import { Button, XStack, YStack, Text } from 'tamagui';
|
||||||
import { getIcon } from './IconMapper.jsx';
|
import { getIcon } from './IconMapper.jsx';
|
||||||
|
import { NotificationManager } from './Shell.jsx';
|
||||||
import {
|
import {
|
||||||
getBounds,
|
getBounds,
|
||||||
addDocumentEventListener,
|
addDocumentEventListener,
|
||||||
@@ -46,14 +47,14 @@ export function MenuItemButton({
|
|||||||
selected = false,
|
selected = false,
|
||||||
hovered = false,
|
hovered = false,
|
||||||
width,
|
width,
|
||||||
size = '$4',
|
size = '$5',
|
||||||
onClick,
|
onClick,
|
||||||
onExpand,
|
onExpand,
|
||||||
expanded: controlledExpanded,
|
expanded: controlledExpanded,
|
||||||
defaultExpanded = false,
|
defaultExpanded = false,
|
||||||
collapsed = false,
|
collapsed = false,
|
||||||
displayStyle,
|
displayStyle,
|
||||||
padding = '$2',
|
padding = '$1',
|
||||||
style,
|
style,
|
||||||
testID,
|
testID,
|
||||||
stateVersion,
|
stateVersion,
|
||||||
@@ -99,6 +100,12 @@ export function MenuItemButton({
|
|||||||
const [internalExpanded, setInternalExpanded] = useState(() => getMenuItemExpandedPreference(menuItem.path, defaultExpanded));
|
const [internalExpanded, setInternalExpanded] = useState(() => getMenuItemExpandedPreference(menuItem.path, defaultExpanded));
|
||||||
const [popupOpen, setPopupOpen] = useState(false);
|
const [popupOpen, setPopupOpen] = useState(false);
|
||||||
const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0, alignRight: false, alignRightSide: false, alignBottom: 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 popupRef = useRef(null);
|
||||||
const buttonRef = useRef(null);
|
const buttonRef = useRef(null);
|
||||||
const isExpanded = controlledExpanded !== undefined ? controlledExpanded : internalExpanded;
|
const isExpanded = controlledExpanded !== undefined ? controlledExpanded : internalExpanded;
|
||||||
@@ -114,6 +121,21 @@ export function MenuItemButton({
|
|||||||
setInternalExpanded(getMenuItemExpandedPreference(menuItem.path, defaultExpanded));
|
setInternalExpanded(getMenuItemExpandedPreference(menuItem.path, defaultExpanded));
|
||||||
}, [controlledExpanded, defaultExpanded, menuItem.path, stateVersion]);
|
}, [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)
|
// Close popup when clicking outside (popup mode only)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (effectiveExpandMode === 'popup' && popupOpen) {
|
if (effectiveExpandMode === 'popup' && popupOpen) {
|
||||||
@@ -283,8 +305,8 @@ export function MenuItemButton({
|
|||||||
return '$textMuted';
|
return '$textMuted';
|
||||||
};
|
};
|
||||||
|
|
||||||
const ICON_SIZE = 'sm';
|
const ICON_SIZE = 'md';
|
||||||
const CHEVRON_SIZE = 'sm';
|
const CHEVRON_SIZE = 'md';
|
||||||
|
|
||||||
// Determine display style (both, label_only, icon_only)
|
// Determine display style (both, label_only, icon_only)
|
||||||
// Use displayStyle prop if provided, otherwise fall back to menuItem.style
|
// 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;
|
const showIcon = (effectiveDisplayStyle === 'both' || effectiveDisplayStyle === 'icon_only') && IconComponent;
|
||||||
// Hide label when collapsed (unless it's icon_only style which never shows label)
|
// 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 showLabel = !collapsed && (effectiveDisplayStyle === 'both' || effectiveDisplayStyle === 'label_only') && menuItem.label;
|
||||||
|
const showNotificationBadge = menuItem.id === 'notifications' && notificationCount > 0;
|
||||||
|
|
||||||
// Lucide chevron icons for groups
|
// Lucide chevron icons for groups
|
||||||
// For popup mode in vertical orientation, show chevron right (>)
|
// For popup mode in vertical orientation, show chevron right (>)
|
||||||
@@ -337,12 +360,32 @@ export function MenuItemButton({
|
|||||||
>
|
>
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
{showIcon && (
|
{showIcon && (
|
||||||
<XStack marginRight={showLabel ? '$2' : 0} alignItems="center" justifyContent="center">
|
<XStack marginRight={showLabel ? '$2' : 0} alignItems="center" justifyContent="center" position="relative">
|
||||||
{typeof IconComponent === 'string' ? (
|
{typeof IconComponent === 'string' ? (
|
||||||
<Text fontSize={size} lineHeight={size}>{IconComponent}</Text>
|
<Text fontSize={size} lineHeight={size}>{IconComponent}</Text>
|
||||||
) : IconComponent ? (
|
) : IconComponent ? (
|
||||||
<IconComponent size={ICON_SIZE} color={getIconColor()} />
|
<IconComponent size={ICON_SIZE} color={getIconColor()} />
|
||||||
) : null}
|
) : 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>
|
</XStack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -466,12 +509,32 @@ export function MenuItemButton({
|
|||||||
>
|
>
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
{showIcon && (
|
{showIcon && (
|
||||||
<XStack marginRight={showLabel ? '$2' : 0} alignItems="center" justifyContent="center">
|
<XStack marginRight={showLabel ? '$2' : 0} alignItems="center" justifyContent="center" position="relative">
|
||||||
{typeof IconComponent === 'string' ? (
|
{typeof IconComponent === 'string' ? (
|
||||||
<Text fontSize={size} lineHeight={size}>{IconComponent}</Text>
|
<Text fontSize={size} lineHeight={size}>{IconComponent}</Text>
|
||||||
) : IconComponent ? (
|
) : IconComponent ? (
|
||||||
<IconComponent size={ICON_SIZE} color={getIconColor()} />
|
<IconComponent size={ICON_SIZE} color={getIconColor()} />
|
||||||
) : null}
|
) : 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>
|
</XStack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
+409
-26
@@ -6,7 +6,9 @@
|
|||||||
|
|
||||||
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
|
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
|
||||||
import { XStack, YStack, Text, Button } from 'tamagui';
|
import { XStack, YStack, Text, Button } from 'tamagui';
|
||||||
|
import RecordsModel from '../../data/RecordsModel.js';
|
||||||
import { getIcon } from './IconMapper.jsx';
|
import { getIcon } from './IconMapper.jsx';
|
||||||
|
import { SidePanelShell } from './SidePanelShell.jsx';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Shell Context
|
// Shell Context
|
||||||
@@ -198,7 +200,8 @@ class ToastManager {
|
|||||||
show(title, message = '', options = {}) {
|
show(title, message = '', options = {}) {
|
||||||
const {
|
const {
|
||||||
type = 'info',
|
type = 'info',
|
||||||
duration = this._defaultDuration
|
duration = this._defaultDuration,
|
||||||
|
persistToNotifications = true
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
@@ -208,6 +211,7 @@ class ToastManager {
|
|||||||
message,
|
message,
|
||||||
type,
|
type,
|
||||||
duration,
|
duration,
|
||||||
|
persistToNotifications,
|
||||||
timestamp: startTime,
|
timestamp: startTime,
|
||||||
startTime: startTime // Store start time for pause/resume calculations
|
startTime: startTime // Store start time for pause/resume calculations
|
||||||
};
|
};
|
||||||
@@ -226,6 +230,9 @@ class ToastManager {
|
|||||||
// Auto-dismiss after duration
|
// Auto-dismiss after duration
|
||||||
if (duration > 0) {
|
if (duration > 0) {
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
|
if (toast.persistToNotifications) {
|
||||||
|
notificationCenterManager.addFromToast(toast);
|
||||||
|
}
|
||||||
this.hide(toast.id);
|
this.hide(toast.id);
|
||||||
this._timeouts.delete(toast.id);
|
this._timeouts.delete(toast.id);
|
||||||
}, duration);
|
}, duration);
|
||||||
@@ -355,11 +362,241 @@ class ToastManager {
|
|||||||
getToasts() {
|
getToasts() {
|
||||||
return [...this._toasts];
|
return [...this._toasts];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getNotificationModel() {
|
||||||
|
return notificationCenterManager.getModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
setNotificationModel(model) {
|
||||||
|
notificationCenterManager.setModel(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
openNotifications() {
|
||||||
|
notificationCenterManager.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
closeNotifications() {
|
||||||
|
notificationCenterManager.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleNotifications() {
|
||||||
|
notificationCenterManager.toggle();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create singleton instance
|
// Create singleton instance
|
||||||
const toastManager = new ToastManager();
|
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
|
// Shell Provider
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -389,6 +626,7 @@ export function ShellProvider({
|
|||||||
|
|
||||||
// Toast state
|
// Toast state
|
||||||
const [toasts, setToasts] = useState([]);
|
const [toasts, setToasts] = useState([]);
|
||||||
|
const [notificationsOpen, setNotificationsOpen] = useState(notificationCenterManager.isOpen());
|
||||||
|
|
||||||
// Initialize shell manager with setters
|
// Initialize shell manager with setters
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -405,6 +643,10 @@ export function ShellProvider({
|
|||||||
toastManager._init(setToasts);
|
toastManager._init(setToasts);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
notificationCenterManager._init(setNotificationsOpen);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Update shell manager state when it changes
|
// Update shell manager state when it changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
shellManager._updateState({
|
shellManager._updateState({
|
||||||
@@ -469,6 +711,7 @@ export function ShellProvider({
|
|||||||
return (
|
return (
|
||||||
<ShellContext.Provider value={value}>
|
<ShellContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
|
<NotificationCenterPanel open={notificationsOpen} />
|
||||||
</ShellContext.Provider>
|
</ShellContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -518,30 +761,7 @@ function Toast({ toast, onClose, onPause, onResume }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Type-specific styling
|
// Type-specific styling
|
||||||
const typeStyles = {
|
const style = getNotificationTypeStyle(toast.type);
|
||||||
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 Icon = getIcon(style.icon);
|
const Icon = getIcon(style.icon);
|
||||||
|
|
||||||
return (
|
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
|
* ToastViewport Component
|
||||||
* Container for toast notifications (fixed bottom-right)
|
* Container for toast notifications (fixed bottom-right)
|
||||||
@@ -677,11 +1059,12 @@ export function ShellPlacement({ placement = 'mainContent', children }) {
|
|||||||
// Attach static properties
|
// Attach static properties
|
||||||
ShellProvider.Manager = shellManager;
|
ShellProvider.Manager = shellManager;
|
||||||
ShellProvider.ToastManager = toastManager;
|
ShellProvider.ToastManager = toastManager;
|
||||||
|
ShellProvider.NotificationManager = notificationCenterManager;
|
||||||
ShellProvider.Context = ShellContext;
|
ShellProvider.Context = ShellContext;
|
||||||
ShellProvider.Placement = ShellPlacement;
|
ShellProvider.Placement = ShellPlacement;
|
||||||
|
|
||||||
export default ShellProvider;
|
export default ShellProvider;
|
||||||
export { shellManager as ShellManager, toastManager as ToastManager };
|
export { shellManager as ShellManager, toastManager as ToastManager, notificationCenterManager as NotificationManager };
|
||||||
export { ShellContext };
|
export { ShellContext };
|
||||||
// ShellPlacement is already exported as a function declaration above
|
// ShellPlacement is already exported as a function declaration above
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { GridViewContext } from './context.js';
|
import { GridViewContext } from './context.js';
|
||||||
import { GridSegmentsLayout } from './layout.jsx';
|
import { GridSegmentsLayout } from './layout.jsx';
|
||||||
|
import { normalizeColumnDefinition } from '../../../data/utils.js';
|
||||||
import {
|
import {
|
||||||
areSortEntriesEqual,
|
areSortEntriesEqual,
|
||||||
normalizeColumnDefinition,
|
|
||||||
normalizeColumnDefinitionsInput,
|
normalizeColumnDefinitionsInput,
|
||||||
resolveVisibleColumns
|
resolveVisibleColumns
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ They are intentionally related, and their data-facing APIs are aligned where pra
|
|||||||
|
|
||||||
## Exports
|
## Exports
|
||||||
|
|
||||||
- `GridDataModel`
|
|
||||||
- `GridView`
|
- `GridView`
|
||||||
- `GridSegmentsLayout`
|
- `GridSegmentsLayout`
|
||||||
- `PanelHeader`
|
- `PanelHeader`
|
||||||
@@ -73,15 +72,15 @@ for cell-level custom rendering.
|
|||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
import {
|
import {
|
||||||
GridDataModel,
|
|
||||||
GridView,
|
GridView,
|
||||||
PanelHeader,
|
PanelHeader,
|
||||||
PanelBodyView,
|
PanelBodyView,
|
||||||
PanelFooter,
|
PanelFooter,
|
||||||
createPanelGridViewProps
|
createPanelGridViewProps
|
||||||
} from '@reliancy/bface/ui/components';
|
} from '@reliancy/bface/ui/components';
|
||||||
|
import { RecordsModel } from '@reliancy/bface';
|
||||||
|
|
||||||
const model = new GridDataModel({
|
const model = new RecordsModel({
|
||||||
rows: [
|
rows: [
|
||||||
{ id: 1, customer: 'Northwind', total: 1200 },
|
{ id: 1, customer: 'Northwind', total: 1200 },
|
||||||
{ id: 2, customer: 'Blue Harbor', total: 980 }
|
{ id: 2, customer: 'Blue Harbor', total: 980 }
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
export { GridView, default as GridViewDefault } from './GridView.jsx';
|
export { GridView, default as GridViewDefault } from './GridView.jsx';
|
||||||
export { GridDataModel } from './model.js';
|
|
||||||
export { useGridView } from './context.js';
|
export { useGridView } from './context.js';
|
||||||
export { GridSegmentsLayout } from './layout.jsx';
|
export { GridSegmentsLayout } from './layout.jsx';
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ function SegmentContainer({ direction, segmentKey, size, children }) {
|
|||||||
return (
|
return (
|
||||||
<YStack
|
<YStack
|
||||||
key={segmentKey}
|
key={segmentKey}
|
||||||
overflow="hidden"
|
overflow={segmentKey === 'body' ? 'hidden' : 'visible'}
|
||||||
{...resolveSegmentLayout(direction, size, { isFlexible: segmentKey === 'body' })}
|
{...resolveSegmentLayout(direction, size, { isFlexible: segmentKey === 'body' })}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -42,17 +42,36 @@ function renderToolbarItem(item) {
|
|||||||
if (item.kind === 'search') {
|
if (item.kind === 'search') {
|
||||||
const SearchIcon = getIcon('search');
|
const SearchIcon = getIcon('search');
|
||||||
return (
|
return (
|
||||||
<XStack key={item.key || item.placeholder || 'search'} alignItems="center" gap="$2">
|
<XStack
|
||||||
{SearchIcon ? <SearchIcon size="sm" color="$textMuted" /> : null}
|
key={item.key || item.placeholder || 'search'}
|
||||||
|
alignItems="center"
|
||||||
|
position="relative"
|
||||||
|
width={item.width || 240}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
width={item.width || 240}
|
width="100%"
|
||||||
value={item.value}
|
value={item.value}
|
||||||
placeholder={item.placeholder || 'Search'}
|
placeholder={item.placeholder || 'Search'}
|
||||||
onChangeText={(value) => item.onChange?.(value)}
|
onChangeText={(value) => item.onChange?.(value)}
|
||||||
backgroundColor="$bgPanel"
|
backgroundColor="$bgPanel"
|
||||||
borderColor="$lineSubtle"
|
borderColor="$lineSubtle"
|
||||||
focusStyle={{ borderColor: '$accent' }}
|
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>
|
</XStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = {}) {
|
export function normalizeColumnDefinitionsInput(input = {}) {
|
||||||
if (Array.isArray(input)) {
|
if (Array.isArray(input)) {
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
@@ -114,35 +70,6 @@ export function normalizeColumnsArray(input = []) {
|
|||||||
return [];
|
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) {
|
export function resolveCellValue(row, column) {
|
||||||
return row?.[column.field];
|
return row?.[column.field];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
export { EmptyShell, default as EmptyShellDefault } from './EmptyShell.jsx';
|
export { EmptyShell, default as EmptyShellDefault } from './EmptyShell.jsx';
|
||||||
export { LandingShell, LandingShell as TopBarShell, default as LandingShellDefault } from './LandingShell.jsx';
|
export { LandingShell, LandingShell as TopBarShell, default as LandingShellDefault } from './LandingShell.jsx';
|
||||||
export { DashboardShell, default as DashboardShellDefault } from './DashboardShell.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 { AppInfo, default as AppInfoDefault } from './AppInfo.jsx';
|
||||||
export { TopBar, default as TopBarDefault } from './TopBar.jsx';
|
export { TopBar, default as TopBarDefault } from './TopBar.jsx';
|
||||||
export { SideBar, default as SideBarDefault } from './SideBar.jsx';
|
export { SideBar, default as SideBarDefault } from './SideBar.jsx';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, test } from 'node:test';
|
import { describe, test } from 'node:test';
|
||||||
import assert from 'node:assert';
|
import assert from 'node:assert';
|
||||||
import { GridDataModel } from '../src/ui/components/grid/model.js';
|
import { RecordsModel } from '../src/data/index.js';
|
||||||
|
|
||||||
const rows = [
|
const rows = [
|
||||||
{ id: 1, name: 'Northwind', status: 'open', total: 1200 },
|
{ id: 1, name: 'Northwind', status: 'open', total: 1200 },
|
||||||
@@ -9,9 +9,9 @@ const rows = [
|
|||||||
{ id: 4, name: 'Lattice', status: 'closed', total: 400 }
|
{ id: 4, name: 'Lattice', status: 'closed', total: 400 }
|
||||||
];
|
];
|
||||||
|
|
||||||
describe('GridDataModel', () => {
|
describe('RecordsModel', () => {
|
||||||
test('queryStructure infers columns from row data', async () => {
|
test('queryStructure infers columns from row data', async () => {
|
||||||
const model = new GridDataModel({ rows });
|
const model = new RecordsModel({ rows });
|
||||||
const result = await model.queryStructure();
|
const result = await model.queryStructure();
|
||||||
|
|
||||||
assert.ok(result.columns.name);
|
assert.ok(result.columns.name);
|
||||||
@@ -21,7 +21,7 @@ describe('GridDataModel', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('queryRecords filters, sorts, and paginates', async () => {
|
test('queryRecords filters, sorts, and paginates', async () => {
|
||||||
const model = new GridDataModel({ rows });
|
const model = new RecordsModel({ rows });
|
||||||
const result = await model.queryRecords({
|
const result = await model.queryRecords({
|
||||||
filter_by: { status: 'open' },
|
filter_by: { status: 'open' },
|
||||||
sort_by: [{ field: 'total', direction: 'desc' }],
|
sort_by: [{ field: 'total', direction: 'desc' }],
|
||||||
@@ -35,7 +35,7 @@ describe('GridDataModel', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('queryRecords supports text search through search filter', async () => {
|
test('queryRecords supports text search through search filter', async () => {
|
||||||
const model = new GridDataModel({ rows });
|
const model = new RecordsModel({ rows });
|
||||||
const result = await model.queryRecords({
|
const result = await model.queryRecords({
|
||||||
filter_by: { search: 'harbor' }
|
filter_by: { search: 'harbor' }
|
||||||
});
|
});
|
||||||
@@ -44,14 +44,14 @@ describe('GridDataModel', () => {
|
|||||||
assert.strictEqual(result.rows[0].name, 'Blue Harbor');
|
assert.strictEqual(result.rows[0].name, 'Blue Harbor');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('queryAggregates supports count and sum metrics', async () => {
|
test('querySummary supports count and sum metrics', async () => {
|
||||||
const model = new GridDataModel({ rows });
|
const model = new RecordsModel({ rows });
|
||||||
const result = await model.queryAggregates({
|
const result = await model.querySummary(
|
||||||
metrics: ['count', 'sum:total'],
|
{ filter_by: { status: 'open' } },
|
||||||
filter_by: { status: 'open' }
|
['count', 'sum:total']
|
||||||
});
|
);
|
||||||
|
|
||||||
assert.strictEqual(result.count, 2);
|
assert.strictEqual(result.values.count, 2);
|
||||||
assert.strictEqual(result['sum:total'], 3600);
|
assert.strictEqual(result.values['sum:total'], 3600);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user