diff --git a/ivette/.eslintrc.js b/ivette/.eslintrc.js index 163e36aee57deb7cfda15a74345bb71c0f2eb006..ebdbbb656e9a0007b96ff7f378c22fd3c3401f78 100644 --- a/ivette/.eslintrc.js +++ b/ivette/.eslintrc.js @@ -67,6 +67,8 @@ module.exports = { "no-return-assign": ["error", "except-parens" ], // Allow single line expressions in react "react/jsx-one-expression-per-line": "off", + // Allow property spreading since with aim at using TSC + "react/jsx-props-no-spreading": "off", // Allow all sorts of linebreaking for operators "operator-linebreak": "off", // Force curly brackets on newline if some item is diff --git a/ivette/package.json b/ivette/package.json index 847f6c4cd4a65f95f1356a03c94d6e0be4f2702e..d77d420e12005ff11011b27dd7bca04d6531db44 100644 --- a/ivette/package.json +++ b/ivette/package.json @@ -26,6 +26,7 @@ "@types/node": "12.12.21", "@types/react": "^16.9.17", "@types/react-dom": "^16.9.6", + "@types/react-virtualized": "^9.21.10", "@typescript-eslint/eslint-plugin": "^2.28.0", "@typescript-eslint/parser": "^2.28.0", "babel-loader": "^8.0.6", @@ -43,6 +44,7 @@ "eslint-plugin-react-hooks": "^3.0.0", "html-loader": "1.0.0-alpha.0", "jsdoc": "^3.6.3", + "react-fast-compare": "^3.2.0", "react-hot-loader": "^4.12.20", "serve": "^11.3.0", "typedoc": "^0.17.6", diff --git a/ivette/src/dome/src/main/dome.js b/ivette/src/dome/src/main/dome.js index c1f1452eb93ee84409a60bc9e49bb4f25a1f868a..1385eb6bc5e50f273d46a85201ae79d68e74e093 100644 --- a/ivette/src/dome/src/main/dome.js +++ b/ivette/src/dome/src/main/dome.js @@ -196,6 +196,7 @@ function navigateURL( event , url ) { // -------------------------------------------------------------------------- const windowsHandle = {} ; // Prevent live windows to be garbage collected +const windowsReload = {} ; // Reloaded windows function createBrowserWindow( config, isMain=true ) { @@ -226,7 +227,8 @@ function createBrowserWindow( config, isMain=true ) } const theWindow = new BrowserWindow( options ); - + const wid = theWindow.id; + // Load the index.html of the app. if (DEVEL || LOCAL) process.env['ELECTRON_DISABLE_SECURITY_WARNINGS'] = 'true'; @@ -249,7 +251,14 @@ function createBrowserWindow( config, isMain=true ) // URL Navigation theWindow.webContents.on('will-navigate', navigateURL ); theWindow.webContents.on('did-navigate-in-page', navigateURL ); - theWindow.webContents.on('did-finish-load', () => broadcast('dome.ipc.reload')); + theWindow.webContents.on('did-finish-load', () => { + const isLoaded = windowsReload[wid]; + if (!isLoaded) { + windowsReload[wid] = true; + } else { + broadcast('dome.ipc.reload'); + } + }); // Emitted when the window want's to close. theWindow.on('close', (evt) => { @@ -270,7 +279,6 @@ function createBrowserWindow( config, isMain=true ) } // Keep the window reference to prevent destruction - const wid = theWindow.id ; windowsHandle[ wid ] = theWindow ; // Emitted when the window is closed. diff --git a/ivette/src/dome/src/renderer/data/compare.ts b/ivette/src/dome/src/renderer/data/compare.ts new file mode 100644 index 0000000000000000000000000000000000000000..6dcbec1d018875f4d3eceff7f1eafb345d04b700 --- /dev/null +++ b/ivette/src/dome/src/renderer/data/compare.ts @@ -0,0 +1,371 @@ +// -------------------------------------------------------------------------- +// --- Comparison Utilities +// -------------------------------------------------------------------------- + +/** + Data comparisons. + @packageDocumentation + @module dome/data/compare +*/ + +/** + Interface for comparison functions. + These function shall fullfill the following contract: + - `compare(x,y) == 0` shall be an equivalence relation + (reflexive, symmetric, transitive) + - `compare(x,y) <= 0` shall be a complete order + (reflexive, antisymetric, transitive) + - `compare(x,y) < 0` shall be a complete strict order + (anti-reflexive, asymetric, transitive) +*/ +export interface Order<A> { + (x: A, y: A): number; +} + +export function equal(_x: any, _y: any): 0 { return 0; } + +export type bignum = bigint | number; + +/** Non-NaN numbers and big-ints */ +export function isBigNum(x: any): x is bignum { + return typeof (x) === 'bigint' || (typeof (x) === 'number' && !Number.isNaN(x)); +} + +/** + Primitive comparison. + Can only compare arguments that have + comparable primitive type. + + This includes symbols, boolean, non-NaN numbers, bigints and strings. + Numbers and big-ints can also be compared with each others. +*/ +export function primitive(x: symbol, y: symbol): number; +export function primitive(x: boolean, y: boolean): number; +export function primitive(x: bignum, y: bignum): number; +export function primitive(x: string, y: string): number; +export function primitive(x: any, y: any) { + if (x < y) return -1; + if (x > y) return 1; + return 0; +} + +/** + Primitive comparison for numbers (NaN included). + */ +export function float(x: number, y: number) { + const nx = Number.isNaN(x); + const ny = Number.isNaN(y); + if (nx && ny) return 0; + if (nx && !ny) return -1; + if (!nx && ny) return 1; + if (x < y) return -1; + if (x > y) return 1; + return 0; +} + +/** + Alphabetic comparison for strings. + Handles case differently than `byString` comparison. +*/ +export function alpha(x: string, y: string) { + const cmp = primitive(x.toLowerCase(), y.toLowerCase()); + return cmp != 0 ? cmp : primitive(x, y); +} + +/** Combine comparison orders in sequence. */ +export function sequence<A>(...orders: (Order<A> | undefined)[]): Order<A> { + return (x: A, y: A) => { + if (x === y) return 0; + for (const order of orders) { + if (order) { + const cmp = order(x, y); + if (cmp != 0) return cmp; + } + } + return 0; + }; +} + +/** Compare optional values. Undefined values come first. */ +export function option<A>(order: Order<A>): Order<undefined | A> { + return (x?: A, y?: A) => { + if (x == undefined && y == undefined) return 0; + if (x == undefined) return -1; + if (y == undefined) return 1; + return order(x, y); + }; +} + +/** Compare optional values. Undefined values come last. */ +export function defined<A>(order: Order<A>): Order<undefined | A> { + return (x?: A, y?: A) => { + if (x == undefined && y == undefined) return 0; + if (x == undefined) return 1; + if (y == undefined) return -1; + return order(x, y); + }; +} + +/** Lexicographic comparison of array elements. */ +export function array<A>(order: Order<A>): Order<A[]> { + return (x: A[], y: A[]) => { + if (x === y) return 0; + const p = x.length; + const q = y.length; + const m = p < q ? p : q; + for (let k = 0; k < m; k++) { + const cmp = order(x[k], y[k]); + if (cmp != 0) return cmp; + } + return p - q; + }; +} + +/** Order string enumeration constants. + `enums(v1,...,vN)` will order constant following the order of arguments. + Non-listed constants appear at the end, or at the rank specified by `'*'`. */ +export function byRank(...args: string[]): Order<string> { + const ranks: { [index: string]: number } = {}; + args.forEach((C, k) => ranks[C] = k); + const wildcard = ranks['*'] ?? ranks.length; + return (x: string, y: string) => { + if (x === y) return 0; + const rx = ranks[x] ?? wildcard; + const ry = ranks[y] ?? wildcard; + if (rx == wildcard && ry == wildcard) + return primitive(x, y); + else + return rx - ry; + }; +} + +/** Direct or reverse direction. */ +export function direction<A>(order: Order<A>, reverse = false): Order<A> { + return (x, y) => (x === y ? 0 : reverse ? order(y, x) : order(x, y)); +} + +/** By projection. */ +export function lift<A, B>(fn: (x: A) => B, order: Order<B>): Order<A> { + return (x: A, y: A) => (x === y ? 0 : order(fn(x), fn(y))); +} + +/** Return own property names of its object argument. */ +export function getKeys<T>(a: T): (keyof T)[] { + return Object.getOwnPropertyNames(a) as (keyof T)[]; +} + +/** + Maps each field of `A` to some _optional_ comparison of the associated type. + Hence, `ByFields<{…, f: T, …}>` is `{…, f?: Order<T>, …}`. + See [[fields]] comparison function. + */ +export type ByFields<A> = { + [P in keyof A]?: Order<A[P]>; +} + +/** + Maps each field of `A` to some comparison of the associated type. + Hence, `ByAllFields<{…, f: T, …}>` is `{…, f: Order<T>, …}`. + See [[fieldsComplete]] comparison function. +*/ +export type ByAllFields<A> = { + [P in keyof A]: Order<A[P]>; +} + +/** Object comparison by (some) fields. + + Compare objects field by field, using the comparison orders provided by the + `order` argument. Order of field comparison is taken from the `order` + argument, not from the compared values. + + You may not compare _all_ fields of the compared values. For optional + fields, you shall provide a comparison function compatible with type + `undefined`. + + It might be difficult for Typescript to typecheck `byFields(…)` expressions + when dealing with optional types. In such cases, you shall use `byFields<A>(…)` + and explicitly mention the type of compared values. + + Example: + + type foo = { id: number, name?: string, descr?: string } + const compare = fields<foo>({ id: number, name: option(alpha) }); + +*/ +export function byFields<A>(order: ByFields<A>): Order<A> { + return (x: A, y: A) => { + if (x === y) return 0; + for (const fd of getKeys(order)) { + const byFd = order[fd]; + if (byFd !== undefined) { + const cmp = byFd(x[fd], y[fd]); + if (cmp != 0) return cmp; + } + } + return 0; + }; +} + +/** Complete object comparison. + This is similar to `byFields()` comparison, but an ordering function must be + provided for _any_ field (optional or not) of the compared values. +*/ +export function byAllFields<A>(order: ByAllFields<A>): Order<A> { + return (x: A, y: A) => { + if (x === y) return 0; + for (const fd of getKeys<ByFields<A>>(order)) { + const byFd = order[fd]; + const cmp = byFd(x[fd], y[fd]); + if (cmp != 0) return cmp; + } + return 0; + }; +} + +/** Pair comparison. */ +export function pair<A, B>(ordA: Order<A>, ordB: Order<B>): Order<[A, B]> { + return (u, v) => { + if (u === v) return 0; + const [x1, y1] = u; + const [x2, y2] = v; + const cmp = ordA(x1, x2); + return cmp != 0 ? cmp : ordB(y1, y2); + }; +} + +/** Triple comparison. */ +export function triple<A, B, C>( + ordA: Order<A>, + ordB: Order<B>, + ordC: Order<C>, +): Order<[A, B, C]> { + return (u, v) => { + if (u === v) return 0; + const [x1, y1, z1] = u; + const [x2, y2, z2] = v; + const cmp1 = ordA(x1, x2); + if (cmp1 != 0) return cmp1; + const cmp2 = ordB(y1, y2); + if (cmp2 != 0) return cmp2; + return ordC(z1, z2); + }; +} + +/** 4-Tuple comparison. */ +export function tuple4<A, B, C, D>( + ordA: Order<A>, + ordB: Order<B>, + ordC: Order<C>, + ordD: Order<D>, +): Order<[A, B, C, D]> { + return (u, v) => { + if (u === v) return 0; + const [x1, y1, z1, t1] = u; + const [x2, y2, z2, t2] = v; + const cmp1 = ordA(x1, x2); + if (cmp1 != 0) return cmp1; + const cmp2 = ordB(y1, y2); + if (cmp2 != 0) return cmp2; + const cmp3 = ordC(z1, z2); + if (cmp3 != 0) return cmp3; + return ordD(t1, t2); + }; +} + +/** 5-Tuple comparison. */ +export function tuple5<A, B, C, D, E>( + ordA: Order<A>, + ordB: Order<B>, + ordC: Order<C>, + ordD: Order<D>, + ordE: Order<E>, +): Order<[A, B, C, D, E]> { + return (u, v) => { + if (u === v) return 0; + const [x1, y1, z1, t1, w1] = u; + const [x2, y2, z2, t2, w2] = v; + const cmp1 = ordA(x1, x2); + if (cmp1 != 0) return cmp1; + const cmp2 = ordB(y1, y2); + if (cmp2 != 0) return cmp2; + const cmp3 = ordC(z1, z2); + if (cmp3 != 0) return cmp3; + const cmp4 = ordD(t1, t2); + if (cmp4 != 0) return cmp4; + return ordE(w1, w2); + }; +} + +// -------------------------------------------------------------------------- +// --- Structural Comparison +// -------------------------------------------------------------------------- + +/** @internal */ +enum RANK { UNDEFINED, BOOLEAN, SYMBOL, NAN, BIGNUM, STRING, ARRAY, OBJECT, FUNCTION }; + +/** @internal */ +function rank(x: any): RANK { + let t = typeof x; + switch (t) { + case 'undefined': return RANK.UNDEFINED; + case 'boolean': return RANK.BOOLEAN; + case 'symbol': return RANK.SYMBOL; + case 'number': + return Number.isNaN(x) ? RANK.NAN : RANK.BIGNUM; + case 'bigint': + return RANK.BIGNUM; + case 'string': return RANK.STRING; + case 'object': return Array.isArray(x) ? RANK.ARRAY : RANK.OBJECT; + case 'function': return RANK.FUNCTION; + } +} + +/** + Universal structural comparison. + Values are ordered by _rank_, each being associated with some type of values: + 1. undefined values; + 2. booleans; + 3. symbols; + 4. NaN numbers; + 5. non-NaN numbers and bigints; + 6. arrays; + 7. objects; + 8. functions; + + For values of same primitive type, primitive ordering is performed. + + For array values, lexicographic ordering is performed. + + For object values, lexicographic ordering is performed over their properties: + properties are ordered by name, and recursive structural ordering is performed + on property values. + + All functions are compared equal. + */ +export function structural(x: any, y: any): number { + if (x === y) return 0; + if (typeof x === 'symbol' && typeof y === 'symbol') return primitive(x, y); + if (typeof x === 'boolean' && typeof y === 'boolean') return primitive(x, y); + if (typeof x === 'string' && typeof y === 'string') return primitive(x, y); + if (isBigNum(x) && isBigNum(y)) return primitive(x, y); + if (Array.isArray(x) && Array.isArray(y)) return array(structural)(x, y); + if (typeof x === 'object' && typeof y === 'object') { + const fs = Object.getOwnPropertyNames(x).sort(); + const gs = Object.getOwnPropertyNames(y).sort(); + const p = fs.length; + const q = gs.length; + for (let i = 0, j = 0; i < p && j < q;) { + let a = undefined, b = undefined; + const f = fs[i]; + const g = gs[j]; + if (f <= g) { a = x[f]; i++; } + if (g <= f) { b = y[g]; j++; } + const cmp = structural(a, b); + if (cmp != 0) return cmp; + } + return p - q; + } + return rank(x) - rank(y); +}; + +// -------------------------------------------------------------------------- diff --git a/ivette/src/dome/src/renderer/data.js b/ivette/src/dome/src/renderer/data/library.js similarity index 99% rename from ivette/src/dome/src/renderer/data.js rename to ivette/src/dome/src/renderer/data/library.js index 2105a9bfafdc20127753ecc59635233daf6c1b6f..e175217eceddafcbbd811d0a53d7ac15e6e7c637 100644 --- a/ivette/src/dome/src/renderer/data.js +++ b/ivette/src/dome/src/renderer/data/library.js @@ -4,7 +4,7 @@ /** @packageDocumentation - @module dome/data + @module dome/data/library @description This module allows to integrate data definitions within React elements. diff --git a/ivette/src/dome/src/renderer/dome.js b/ivette/src/dome/src/renderer/dome.js index e3986b692c9baf6c17c16a3ffe96029ab76bb1e4..6fb25c3702fd9c8e80c8ae9939650783ee9abc8a 100644 --- a/ivette/src/dome/src/renderer/dome.js +++ b/ivette/src/dome/src/renderer/dome.js @@ -77,6 +77,10 @@ export function off(evt,job) { emitter.off(evt,job); } /** Same as `emitter.emit` */ export function emit(evt,...args) { emitter.emit(evt,...args); } +{ + emitter.setMaxListeners(250); +} + // -------------------------------------------------------------------------- // --- Application Events // -------------------------------------------------------------------------- @@ -476,7 +480,7 @@ ipcRenderer.on('dome.ipc.settings.update',(sender,patches) => { /** @summary Get value from local window (persistent) settings. - @param {string} key User's Setting Key (`'dome.*'` are reserved keys) + @param {string} [key] - User's Setting Key (`'dome.*'` are reserved keys) @param {any} [defaultValue] - default value if the key is not present @return {any} associated value of object or `undefined`. @description diff --git a/ivette/src/dome/src/renderer/table/arrays.js b/ivette/src/dome/src/renderer/table/arrays.js deleted file mode 100644 index 19f0fd189fc614060c975186da2660c2ba2c47f1..0000000000000000000000000000000000000000 --- a/ivette/src/dome/src/renderer/table/arrays.js +++ /dev/null @@ -1,524 +0,0 @@ -// -------------------------------------------------------------------------- -// --- Table Models -// -------------------------------------------------------------------------- - -/** - @packageDocumentation - @module dome/table/arrays -*/ - -import _ from 'lodash' ; -import React from 'react' ; -import { Model, ASC, DESC } from 'dome/table/models' ; - -// -------------------------------------------------------------------------- -// --- Ordering -// -------------------------------------------------------------------------- - -// Compute the value of an item -const getValueWith = - ( item , value ) => - ( item === undefined ? undefined : - typeof(value) === 'function' ? value(item) : - item[value] ); - -// Compute the primary ordering function -const comparisonWith = (sorting) => { - switch(typeof(sorting)) { - - case 'function': - return sorting ; - - case 'string': - return (a,b) => { - if ( a === b ) return 0 ; - if ( a === undefined ) return 1 ; - if ( b === undefined ) return -1 ; - const va = a[sorting]; - const vb = b[sorting]; - if ( va === vb ) return 0; - if ( va === undefined ) return 1 ; - if ( vb === undefined ) return -1 ; - if ( va < vb ) return -1; - if ( va > vb ) return 1; - return 0; - }; - - case 'object': - const { sortBy, sortDirection=ASC } = sorting ; - return (a,b) => { - if ( a === b ) return 0 ; - if ( a === undefined ) return 1 ; - if ( b === undefined ) return -1 ; - const isFun = typeof(sortBy)==='function' ; - const va = isFun ? sortBy(a) : a[sortBy] ; - const vb = isFun ? sortBy(b) : b[sortBy] ; - if ( va === vb ) return 0; - if ( va === undefined ) return 1 ; - if ( vb === undefined ) return -1 ; - switch(sortDirection) { - case ASC: - if (va < vb) return -1; - if (va > vb) return 1; - break; - case DESC: - if (va < vb) return 1; - if (va > vb) return -1; - break; - } - return 0; - }; - - default: - return () => 0; - - } -}; - -// Make a chainable order -const chainableOrder = ( order ) => { - - const compare = (a,b) => { - for (var k = 0; k < order.length ; k++) { - const cmp = (order[k])(a,b); - if (cmp !==0 ) return cmp; - } - return 0; - }; - - compare.thenWith = (sorting) => - sorting ? - chainableOrder( order.slice().push(comparisonWith(sorting)) ) - : compare ; - - return compare ; -}; - -/** - @summary Comparison helper. - @method - @param {any} sorting - the sorting properties - @return {function} the corresponding comparison function - @description - -This function is a helper for comparing items, by comparing -values extracted from them with chaining. It returns -a comparison function that you can use, for instance, -in `Array.sort` fort sorting arrays. - -##### Comparison - -The comparison order is defined according the `sorting` parameter -provided to the helper: - -If `sorting` is a function, it is used as the primary comparison function. -When items compare equal, chained comparisons are used to refine the -ordering (see Chaining below). - -If `sorting` is a property name (a string), items `a` and `b` are ordered -by comparing their respective values `a[sorting]` and `b[sorting]` for -this property. - -If `sorting` as an object, it shall provides `{ sortBy, sortDirection }` -properties and the comparison function is defined as follows: -- `sortBy` can be a function or a property name. -If a function is given, items `a` and `b` are ordered by comparing -values `sortBy(a)` and `sortBy(b)`. Otherwize, they are ordered by -comparing `a[sortBy]` and `b[sortBy]`. -- `sortDirection` can be either `ASC` for normal comparison or `DESC` -for reversing the comparison. -If no direction is given (or any other value) `ASC` is assumed. - -When computing comparison, `undefined` values are _always_ rejected to the -end of the ordering, whatever the specified direction. - -##### Chaining - -The returned comparison function can be chained with a secondary -comparison function, which will be used when two items compare equal. - -You can chain as many comparison functions you want by using -`.thenWith(sorting)` like in the example below. Each call to `.thenWith` returns -a different comparison function that can be safely forked with subsequent calls -to `.thenWith` as illustrated in the second example. - -Remark than `compareWith` and `.thenWith` also accept undefined or null values, which -are considered neutral. - -@example -// Chaining Comparison -items.sort( - compareWith({sortBy:'name',sortDirection: ASC}) - .thenWith( (a,b) => a.priority - b.priority ) - .thenWith({sortBy:'age',sortDirection: DESC}) -); - -@example -// Forking Comparison -const primary = compareWith( (a,b) => a.priority - b.prioroty ); -const byName = primary.thenWith('name'); -const byAge = primary.thenWith('age'); - -*/ -export const compareWith = - ( sorting ) => - ( sorting ? chainableOrder([ comparisonWith(sorting) ]) : () => 0 ); - -// -------------------------------------------------------------------------- -// --- Comparison Ring -// -------------------------------------------------------------------------- - -/** - @class - @summary Helper for column comparison. - @description -A comparison ring can be used to implement column ordering, where each -column selection _refines_ the previous ordering. - -Hence, the ring holds the current comparison order and it is well suited -for being used in conjunction with [[Table]] -components. - -A comparison specification can be a property name or a function. -See [[compareWith]] for more details. -By default, the ring uses the column identifier as a property name for comparing. - -Initially, the ordering is _natural_. -*/ -export class ComparisonRing { - - /** - @param {id|function} [natural] - default (natural) order - */ - - constructor(natural) { - this.columns = {} ; - this.natural = natural && comparisonWith(natural) ; - this.compare = this.compare.bind(this); - this.ring = [] ; - this.sort = undefined ; - } - - /** - @summary Current order comparison. - @description - Returns the comparison function corresponding to the current order. - The comparison function is _chainable_, see - [[compareWith]] for more details. - */ - compareWith() - { - if (!this.sort) { - const ordering = this.ring.map( - ({sortBy,sortDirection}) => - comparisonWith({ - sortBy: this.columns[sortBy] || sortBy , - sortDirection - }) - ); - if (this.natural) ordering.push(this.natural); - this.sort = chainableOrder(ordering); - } - return this.sort; - } - - /** - @summary Refine current order comparison. - @description - Short cut for `this.compareWith().thenWith(sorting)` - */ - thenWith(sorting) { - return this.compareWith().thenWith(sorting); - } - - /** - @summary Get value for ordering a column. - @param {any} item - the item to compare with - @param {string} column - the column identifier - @return {any} item's value for the column - Returns the value used to order an item within a given column. - */ - getValue(item,column) { - return getValueWith(item,this.columns[column] || column); - } - - /** - @summary Set value to order a column. - @param {string} column - column identifier - @param {string|function} [value] - value accessor (defaults to `column`) - @description - Set the sorting specification (property name or function) for the given column. - */ - setValue( column , value ) { - this.columns[column] = value ; - } - - /** Sets natural ordering (property name or value accessor) */ - setNatural( natural ) { - this.natural = natural ; - } - - /** @summary Compare two items with respect to the current ordering. - @description - You can use `this.compare` as a closure to the current comparison function - (no need for `this.compare.bind(this)`). - */ - compare(a,b) { - return this.order()(a,b); - } - - /** @summary Return current ordering. - @return {object} ordering specification - @description - Return the last `{ sortBy, sortDirection }` ordering. - Shall be used for the Table view. */ - getOrdering() { - return this.ring[0] ; - } - - /** - @summary Specify current ordering. - @param {object} sorting - the new ordering - @description - Use the specified `{ sortBy, sortDirection }` ordering, and refine it with - the previous one. - - If `sorting` is undefined, reset the ring to the natural ordering. - */ - setOrdering( sorting ) { - if (sorting) { - const key = sorting.sortBy ; - this.ring = this.ring.filter( (s) => s.sortBy !== key ); - this.ring.unshift( sorting ); - } else { - this.ring = [] ; - } - this.sort = undefined ; - } - -} - -// -------------------------------------------------------------------------- -// --- Unsorted Model -// -------------------------------------------------------------------------- - -/** - @summary A table Model for unsorted datasets. - @description - - This class implements a simple [[Model]] - where item's are identified by their index. Such a model is not adapted to - re-ordering and filtering, because table views will have no way to synchronize - the selected index before and after re-ordering, hence the name. - -*/ - -export class UnsortedModel extends Model { - - /** - @param {number} [count] - the initial size (default `0`) - */ - constructor(count=0) { - super(); - this.count = count < 0 ? 0 : count ; - } - - /** - @summary Set the number of items. - @param {number} n - the number of items - @param {boolean} [reload] - force reloading (false by default) - @description - Triggers a reload if the item count has changed, - unless you force it. - */ - setItemCount(n,reload=false) { - if (n<0) n=0; - if ( reload || n != this.count ) { - this.count = n ; - this.reload(); - } - } - - getItemCount() { return this.count; } - - /** Identity, or `undefined` when out of range. */ - getItemAt(k) { - return 0 <= k && k < this.count ? k : undefined ; - } - - /** Identity, or `-1` when out of range. */ - getIndexOf(k) { - return 0 <= k && k < this.count ? k : -1 ; - } - -} - -// -------------------------------------------------------------------------- -// --- Array Model -// -------------------------------------------------------------------------- - -/** - @summary A table Model based on array. - @extends Model - @description - - This class implements a simple [[Model]] - implementation where item's are stored in an array. The model supports built-in - ordering thanks with a - [[ComparisonRing]] with additional filtering capabilities. - - The model keep items in sync with their ordered & filtered index by - injecting an `index` property in them each time the collection is re-ordered. - You shall not use `index` property for your own needs. - - Item objects can be modified in place, but you shall call `model.updateItem(item)` - or `model.reload()` to re-renderer the associated (visible) cells. - */ -export class ArrayModel extends Model { - - /** Initially empty model */ - constructor() { - super(); - this.ring = new ComparisonRing('index'); // Used for stable sorting - this.data = []; // Array of item elements - } - - /** Remove all items (and reload) */ - clear() { - this.data = [] ; - this.reload(); - } - - /** Add one or more items (and reload) */ - add( ...items ) { - this.data.push(...items); - this.reload(); - } - - /** - @summary Model items array. - @description -Returns the internal item array holding _all_ the items in the model. - -This array is _not_ sortered and filtered. You can obtain the current index of an item in -table views by accessing its `item.index` property, which is `undefined` if the item has been -filtered out. - -If you modify the internal item array, don't forget to call `reload()` after modifications -in order to keep views in sync. - */ - getData() { return this.data; } - - /** Replace the entire collection of items */ - setData(data) { - this.data = data || [] ; - this.reload(); - } - - _order() { - if (!this.order) { - const filter = this.filtering ; - const compare = this.ring.compareWith(); - const ordered = this.data.filter((item,index) => { - const ok = filter ? filter(item) : true ; - // Index inside initial collection is used for stable sort - item.index = ok ? index : undefined ; - return ok ; - }).sort(compare); - // Now set index in filtered & sorted collection - ordered.forEach((item,index) => item.index = index); - this.order = ordered ; - } - return this.order ; - } - - /** Return a _copy_ of the visible items. */ - getItems() { return this._order().slice(); } - - // MODEL Interface - getItemCount() { return this._order().length; } - - // MODEL Interface - getItemAt(index) { return this._order()[index] ; } - - // MODEL Interface - getIndexOf(item) { return item && item.index ; } - - /** @summary Current filtering function. - @return {function} `undefined` means no filtering - */ - getFiltering() { return this.filtering ; } - - /** @summary Set the filtering function. - @param {function} [filter] - The filter function - @description - The filtering function is used to filter out items to be displayed. - It is invoked as `filter(item)` and shall return a truthly value when `item` - must be displayed. - */ - setFiltering(filter) { - this.filtering = filter ; - this.reload(); - } - - // MODEL Interface - getOrdering() { return this.ring.getOrdering(); } - - // MODEL Interface - setOrdering(order) { - if (order === undefined || order.sortBy !== 'index') - { - this.ring.setOrdering(order); - this.reload(); - } - } - - // MODEL Interface - getValue( item , column ) { - return this.ring.getValue( item , column ); - } - - /** Set the value-getter for the given column */ - setValue( column , value ) { - this.ring.setValue(column,value); - this.reload(); - } - - // MODEL Interface - reload() { - this.order = undefined ; - super.reload(); - } -} - -// -------------------------------------------------------------------------- -// --- Model Hook -// -------------------------------------------------------------------------- - -/** - @summary Uses a new array model (Custom React Hook). - @param {Collection} [items] - the array items - @description - This hook is a convenient way to have a local array model with full featured - sorting and filtering fonctionnalities, which is automatically updated - with the provided items. - - The array model is created once and updated at each render. - Items can be specified with a lodash collection. - - @example // Array Model - const MyView = () => { - Dome.useUpdate( MyUpdateEvent ); - const model = useArrayModel(getMyItems()); - return (<Table model={model} … >…</Table>) ; - }; - -*/ -export function useArrayModel( items ) -{ - const model = React.useMemo( () => new ArrayModel() , [] ); - model.setData( _.toArray(items) ); - return model; -} - -// -------------------------------------------------------------------------- diff --git a/ivette/src/dome/src/renderer/table/arrays.ts b/ivette/src/dome/src/renderer/table/arrays.ts new file mode 100644 index 0000000000000000000000000000000000000000..cade711487035d5799102b860ddec55d7c29dc77 --- /dev/null +++ b/ivette/src/dome/src/renderer/table/arrays.ts @@ -0,0 +1,448 @@ +// -------------------------------------------------------------------------- +// --- Array Models +// -------------------------------------------------------------------------- + +/** + @packageDocumentation + @module dome/table/arrays +*/ + +import * as Compare from 'dome/data/compare'; +import type { ByFields, Order } from 'dome/data/compare'; +import { + SortingInfo, Sorting, Filter, Filtering, Model, Collection, forEach +} from './models'; + +// -------------------------------------------------------------------------- +// --- Sorting Utilities +// -------------------------------------------------------------------------- + +export type ByColumns<Row> = { [dataKey: string]: Compare.Order<Row> }; + +interface PACK<Key, Row> { + index: number | undefined; + key: Key; + row: Row; +}; + +type SORT<K, R> = Order<PACK<K, R>>; + +function orderBy<K, R>( + columns: ByColumns<R>, + ord: SortingInfo, +): SORT<K, R> { + const dataKey = ord.sortBy; + const byData = columns[dataKey] ?? Compare.equal; + const rv = ord.sortDirection === 'DESC'; + type D = PACK<K, R>; + const byEntry = (x: D, y: D) => byData(x.row, y.row); + const byIndex = (x: D, y: D) => (x.index ?? 0) - (y.index ?? 0); + return Compare.direction(Compare.sequence(byEntry, byIndex), rv); +} + +function orderByRing<K, R>( + natural: undefined | Order<R>, + columns: undefined | ByColumns<R>, + ring: SortingInfo[], +): SORT<K, R> { + type D = PACK<K, R>; + const byRing = columns ? ring.map((ord) => orderBy(columns, ord)) : []; + const byData = natural ? ((x: D, y: D) => natural(x.row, y.row)) : undefined; + return Compare.sequence(...byRing, byData); +} + +// -------------------------------------------------------------------------- +// --- Filtering Utilities +// -------------------------------------------------------------------------- + +type INDEX<K, R> = Map<K, PACK<K, R>>; +type TABLE<K, R> = PACK<K, R>[]; + +// -------------------------------------------------------------------------- +// --- Array Model +// -------------------------------------------------------------------------- + +export class MapModel<Key, Row> + extends Model<Key, Row> + implements Sorting, Filtering<Key, Row> +{ + + // Hold raw data (unsorted, unfiltered) + private index: INDEX<Key, Row> = new Map(); + + // Hold filtered & sorted data (computed on demand) + private table?: TABLE<Key, Row>; + + // Filtering function + private filter?: Filter<Key, Row>; + + // Natural ordering (if any) + private natural?: Order<Row>; + + // Sortable columns and associated ordering (if any) + private columns?: ByColumns<Row>; + + // Comparison Ring + private ring: SortingInfo[] = []; + + // Consolidated order (computed on demand) + private order?: SORT<Key, Row>; + + // Lazily compute order + protected sorter(): SORT<Key, Row> { + let current = this.order; + if (current) return current; + current = this.order = orderByRing(this.natural, this.columns, this.ring); + return current; + } + + // Lazily compute table + protected rebuild(): TABLE<Key, Row> { + const current = this.table; + if (current !== undefined) return current; + let table: TABLE<Key, Row> = []; + try { + this.index.forEach((packed) => { + packed.index = undefined; + const phi = this.filter; + if (!phi || phi(packed.row, packed.key)) + table.push(packed); + }); + table.sort(this.sorter()); + } catch (err) { + console.warn('[Dome] error when rebuilding table:', err); + } + table.forEach((pack, index) => pack.index = index); + this.table = table; + return table; + } + + // -------------------------------------------------------------------------- + // --- Proxy + // -------------------------------------------------------------------------- + + getRowCount() { return this.rebuild().length; } + + getRowAt(k: number) { return this.rebuild()[k]?.row; } + + getKeyAt(k: number) { + const current = this.table; + return current ? current[k]?.key : undefined; + } + + getKeyFor(k: number, _: Row) { return this.getKeyAt(k); } + + getIndexOf(key: Key) { + const pack = this.index.get(key); + if (!pack) return undefined; + const k = pack.index; + if (k === undefined || k < 0) return undefined; + const current = this.table; + return (current && k < current.length) ? k : undefined; + } + + // -------------------------------------------------------------------------- + // --- Ordering + // -------------------------------------------------------------------------- + + /** Sets comparison functions for the specified columns. Previous + comparison for un-specified columns are kept unchanged, if any. + This will be used to refine + [[setNaturalOrder]] in response to user column selection with + [[setSortBy]] provided you enable by-column sorting from the table view. + Finally triggers a reload. */ + setColumnOrder(columns?: ByColumns<Row>) { + this.columns = { ...this.columns, ...columns }; + this.reload(); + } + + /** Sets natural ordering of the rows. + It defines in which order the entries are rendered in the table. This + primary ordering can be refined in response to user column selection with + [[setSortBy]] provided you enable by-column sorting from the table view. + Finally triggers a reload. */ + setNaturalOrder(order?: Order<Row>) { + this.natural = order; + this.reload(); + } + + /** + Sets both natural ordering and column ordering with the provided + orders by fields. This is a combination of [[setColumnOrder]] and + [[setNaturalOrder]] with [[Compare.byFields]]. + */ + setOrderingByFields(byfields: ByFields<Row>) { + this.natural = Compare.byFields(byfields); + const columns = this.columns ?? {}; + for (let k of Object.keys(byfields)) { + const dataKey = k as (string & keyof Row); + const fn = byfields[dataKey]; + if (fn) columns[dataKey] = (x: Row, y: Row) => { + const dx = x[dataKey]; + const dy = y[dataKey]; + if (dx === dy) return 0; + if (dx === undefined) return 1; + if (dy === undefined) return -1; + return fn(dx, dy); + }; + } + this.columns = columns; + this.reload(); + } + + /** + Remove the sorting function for the provided column. + */ + deleteColumnOrder(dataKey: string) { + const columns = this.columns; + if (columns) delete columns[dataKey]; + this.ring = this.ring.filter(ord => ord.sortBy !== dataKey); + this.reload(); + } + + /** Reorder rows with the provided column and direction. + Previous ordering is kept and refined by the new one. + Use `undefined` or `null` to reset the natural ordering. */ + setSorting(ord?: undefined | null | SortingInfo) { + if (ord) { + const ring = this.ring; + const cur = ring[0]; + const fd = ord.sortBy; + if ( + !cur || + cur.sortBy !== fd || + cur.sortDirection !== ord.sortDirection + ) { + const newRing = ring.filter((o) => o.sortBy !== fd); + newRing.unshift(ord); + this.ring = newRing; + this.reload(); + } + } else { + if (this.ring.length > 0) { + this.ring = []; + this.reload(); + } + } + } + + canSortBy(column: string) { + const columns = this.columns as any; + return columns[column] !== undefined; + } + + getSorting(): SortingInfo | undefined { + return this.ring[0]; + } + + // -------------------------------------------------------------------------- + // --- Filtering + // -------------------------------------------------------------------------- + + setFilter(fn?: Filter<Key, Row>) { + const phi = this.filter; + if (phi !== fn) { + this.filter = fn; + this.reload(); + } + } + + // -------------------------------------------------------------------------- + // --- Full Updates + // -------------------------------------------------------------------------- + + /** Trigger a complete reload of the table. */ + reload() { + if (this.table || this.order) { + this.table = undefined; + this.order = undefined; + super.reload(); + } + } + + /** Remove all data and reload. */ + clear() { + this.index.clear(); + this.reload(); + } + + // -------------------------------------------------------------------------- + // --- Checks for Reload vs. Update + // -------------------------------------------------------------------------- + + private needReloadForUpdate(pack: PACK<Key, Row>): boolean { + // Case where reload is already triggered + const current = this.table; + if (!current) return false; + // Case where filtering of key has changed + const k = pack.index ?? -1; + const n = current ? current.length : 0; + const phi = this.filter; + const old_ok = 0 <= k && k < n; + const now_ok = phi ? phi(pack.row, pack.key) : true; + if (old_ok !== now_ok) return true; + // Case where element was not displayed and will still not be + if (!old_ok) return false; + // Detecting if ordering is preserved + const order = this.sorter(); + const prev = k - 1; + if (0 <= prev && order(pack, current[prev]) < 0) return true; + const next = k + 1; + if (next < n && order(current[next], pack) < 0) return true; + super.updateIndex(k); + return false; + } + + private needReloadForInsert(pack: PACK<Key, Row>): boolean { + // Case where reload is already triggered + const current = this.table; + if (!current) return false; + // Case where inserted element is filtered out + const phi = this.filter; + return phi ? phi(pack.row, pack.key) : true; + } + + private needReloadForRemoval(pack: PACK<Key, Row>): boolean { + // Case where reload is already triggered + const current = this.table; + if (!current) return false; + // Case where inserted element is filtered out + const k = pack.index ?? -1; + return 0 <= k && k < current.length; + } + + // -------------------------------------------------------------------------- + // --- Update item and optimized reload + // -------------------------------------------------------------------------- + + /** + Update a data entry and signal the views only if needed. + Use `undefined` to keep value unchanged, `null` for removal, + or the new row data for update. This triggers a full + reload if ordering or filtering if modified by the updated value, + a update index if the row data is only modified and visible. + Otherwise, no rendering is triggered since the modification + is not visible. + @param key - the entry identifier + @param row - new value of `null` for removal + */ + update(key: Key, row?: undefined | null | Row) { + if (row === undefined) return; + const pack = this.index.get(key); + let doReload = false; + if (pack) { + if (row === null) { + // Removal + this.index.delete(key); + doReload = this.needReloadForRemoval(pack); + } else { + // Updated + pack.row = row; + doReload = this.needReloadForUpdate(pack); + } + } else { + if (row === null) { + // Nop + return; + } else { + const newPack = { key, row, index: undefined }; + this.index.set(key, newPack); + doReload = this.needReloadForInsert(newPack); + } + } + if (doReload) this.reload(); + } + + // -------------------------------------------------------------------------- + // --- Batched Updates + // -------------------------------------------------------------------------- + + /** + Silently removes the entry. + Modification will be only visible after a final [[reload]]. + Useful for a large number of batched updates. + */ + removeAllData() { + this.index.clear(); + } + + /** + Silently removes the entry. + Modification will be only visible after a final [[reload]]. + Useful for a large number of batched updates. + @param key - the removed entry. + */ + removeData(key: Key) { + this.index.delete(key); + } + + /** + Silently updates the entry. + Modification will be only visible after a final [[reload]]. + Useful for a large number of batched updates. + @param key - the entry to update. + @param row - the new row data or `null` for removal. + */ + setData(key: Key, row: null | Row) { + if (row !== null) { + this.index.set(key, { key, row, index: undefined }); + } else { + this.index.delete(key); + } + } + + /** Returns the data associated with a key (if any). */ + getData(key: Key): Row | undefined { + return this.index.get(key)?.row; + } + +} + +// -------------------------------------------------------------------------- +// --- Compact Array Model +// -------------------------------------------------------------------------- + +/** + @template Row - object data that also contains « key » +*/ +export class ArrayModel<Row> extends MapModel<string, Row> { + + private key: keyof Row + + /** @param key - the key property of `Row` holding an entry identifier. */ + constructor(key: keyof Row) { + super(); + this.key = key; + } + + /** Optimized, see [[getKey]]. */ + getKeyFor(_: number, data: Row) { return this.getKey(data); } + + /** Returns the key of data. */ + getKey(data: Row): string { return (data as any)[this.key]; } + + /** Adds a collection of data. Finally triggers a reload. */ + add(data: Collection<Row>) { + forEach(data, (row: Row) => this.setData(this.getKey(row), row)); + this.reload(); + } + + /** Replaces all previous entries with new ones. Finally triggers a reload. */ + replace(data: Collection<Row>) { + this.removeAllData(); + this.add(data); + } + + /** Removes a colllection of data, identified by keys or (key of) rows. + Finally triggers a reload. */ + remove(data: Collection<string | Row>) { + forEach(data, e => { + const k = typeof e === 'string' ? e : this.getKey(e); + this.removeData(k); + }); + this.reload(); + } + +} + +// -------------------------------------------------------------------------- diff --git a/ivette/src/dome/src/renderer/table/models.js b/ivette/src/dome/src/renderer/table/models.js deleted file mode 100644 index 8a1dc224ce856ce67825653891a2fe2ba8ae1acd..0000000000000000000000000000000000000000 --- a/ivette/src/dome/src/renderer/table/models.js +++ /dev/null @@ -1,248 +0,0 @@ -// -------------------------------------------------------------------------- -// --- Models -// -------------------------------------------------------------------------- - -/** - @packageDocumentation - @module dome/table/models -*/ - -import _ from 'lodash' ; -import { SortDirection } from 'react-virtualized' ; - -// -------------------------------------------------------------------------- -// --- Sorting -// -------------------------------------------------------------------------- - -/** Ascending Order (SortDirection) */ -export const ASC = SortDirection.ASC ; - -/** Descending Order (SortDirection) */ -export const DESC = SortDirection.DESC ; - -// -------------------------------------------------------------------------- -// --- Collection Model -// -------------------------------------------------------------------------- - -/** - @class - @summary Data Listener. - @description - - A Model is responsible for keeping the tables and lists views in sync - with their associated data sets. The model listens for updates, retrieves - items from their index, and re-render the views when necessary. - - Several tables may connect to the same table model, but they will share the - same number and ordering of items. However, each connected table will only - render its own range of items and will re-renderer only when impacted by - individual updates. - - - A Model instance may not hold the data directly. A table or list component - uses the model only as a _proxy_ reponsible for fetching the items with respect to - some current filtering and ordering selection. The model also serves as - a proxy for triggering table re-rendering when table data is updated. - - - To design your data model, you shall extends the base `Model` class and - override the public methods to fit your needs. - - - `getItemCount() -> number` (the number of items) - - `getItemAt(number) -> item` (the item at the given index) - - `getIndexOf(item) -> number` (the index of an item in the current order) - - `getValue(item,column) -> any` (the value associated to some item in a column) - - - To implement sorting, you shall also override the following methods: - - - `getOrdering() -> { sortBy, sortDirection }` - - `setOrdering({ sortBy, sortDirection }) -> ()` - - - Whenever data is added, removed, updated or re-ordered, the `Model` shall be - informed by calling one of the following methods: - - - `updateItem(item);` when an individual item shall be re-rendered (if ever visible) - - `updateIndex(index[,index]);` when an item or a range of items shall be re-rendered - - `reload();` for all other modifications of the collection, including filtering and re-ordering - - - Items count and items indices shall be consistent with the current filter(s) - and order(s) selected by the user. Tables are equipped with - callbacks on table headers that can be used to trigger re-ordering of your data, but - you can implement your own controls or use menus to do that. - - Items can be represented by any javascript values (string, integers, objects...). - Default table cell renderers expect items to be object with one property per column, but you - can override those default. The default `getValue` implementation simply returns the - item property corresponding to the column identifier. - - When some data is updated, selection and scrolling of the views will be - preserved based on item's value. Table and List views will - keep each rendered item in sync with their index thanks to methods `getItemAt` - and `getIndexOf` that you provide with the Model. - - Hence, items implementation shall contains enough information to uniquely identify them, - whatever their current index. - - ##### Model Helpers - - The module [[dome/table/arrays]] provides you with - usefull helpers to implement Models with filtering and ordering features. - -*/ -export class Model { - - constructor() { - this._dome_clients = {} ; - this._dome_clientId = 0 ; - } - - /** - @summary Items count. - @abstract - @return {number} number of items - @description - Shall return the number of items to be displayed by the table. - Negative values are considered as zero. - Default implementation returns zero. - */ - getItemCount() { return 0; } - - /** - @summary Item at given index. - @abstract - @param {number} index - item's ordering index - @return {any} the item's value - @description - Shall return the item at a given index in the table with respect to - current filtering and ordering (if appropriate). - <p> - Default implementation returns `undefined`. - */ - getItemAt() { return undefined; } - - /** - @summary Index of an item. - @abstract - @param {any} item - the item - @return {number} index of the item in the filtered and ordered collection - @description - Shall return the index of a given item inside the table with respect to - current filtering and ordering, or `undefined` if no such item exists. - <p> - Default implementation returns `undefined`. - */ - getIndexOf() { return undefined; } - - /** - @summary Item value in a column. - @param {any} item - an item - @param {string} column - a column identifier - @return {any} the value associated to the item for the given column. - @description - Defaults to accessing the column property of the item (ie. `item[column]`). - This method can be overriden by custom models and also table columns. - */ - getValue(item,column) { return item[column]; } - - /** - @summary Re-render an item. - @param {any} item - the updated item - @description - Signal that a given item has been updated and need to be re-rendered if visible. - */ - updateItem(item) { - const k = this.getIndexOf(item); - if ( 0 <= k ) this.updateIndex(k); - } - - /** - @summary Re-render a range of items. - @param {number} first - the first updated item index - @param {number} [last] - the last updated item index (defaults to `first`) - @description - Signal that a range of items have been updated and need to be re-rendered if visible. - */ - updateIndex(a,b=a) { - _.forOwn(this._dome_clients,({ lower,upper,trigger }) => { - if ( a <= upper && lower <= b ) trigger(); - }); - } - - /** Re-render all items */ - reload() { _.forOwn(this._dome_clients,({trigger})=> trigger()); } - - /** - @summary Current ordering. - @return {object} current sorting order - @description - Shall return the current ordering `{ sortBy, sortDirection }` - for user feedback in table headers, and `undefined` for natural ordering - or no ordering at all. - <p> - Default implementation returns `undefined`. - */ - getOrdering() { return undefined; } - - /** - @summary Change ordering of the model. - @param {object} [sort] - sorting order - @description - Callback to user clicks on table headers. This method receives - the new `{ sortBy, sortDirection }` ordering requested by the user action, - of `undefined` to reset initial, natural ordering of items. - You can also invoke this method on your own, away from any table view. - <p> - The method shall eventually reorder the items internally, and finally - signal completion with a call to `Model.reload()` in order to sync the views. - If re-ordering can take a while, this shall be performed asynchronously. - <p> - Default implementation does nothing. - */ - setOrdering() { } - - /** - @summary Connect a trigger to the model. - @protected - @param {Function} trigger - callback to force table update - @return {ClientID} client identifier - @description - Returns a _client_ identifier for removing and watching. - The trigger function is called for each update watched by the _client_. - Initially, the watching range is empty. - */ - _bind(trigger) { - const client = "#" + this._dome_clientId++ ; - this._dome_clients[client] = { lower:0, upper:0, trigger }; - return client; - } - - /** - @summary Disconnect the _client_ from the model. - @protected - @param {ClientID} client - the identifier of the client to disconnect - */ - _remove(client) { - delete this._dome_clients[client]; - } - - /** - @summary Set the current range of items watched by the _client_. - @protected - @param {ClientID} client - the identifier of the client to disconnect - @param {number} first - first index of the range - @param {number} last - last index of the range - @description - Data updates tha fall outside this range will _not_ trigger - re-rendering of the client. - */ - _watch(client,a,b) { - const listener = this._dome_clients[client]; - if (listener) { listener.lower = a ; listener.upper = b; } - } - -} - -// -------------------------------------------------------------------------- diff --git a/ivette/src/dome/src/renderer/table/models.ts b/ivette/src/dome/src/renderer/table/models.ts new file mode 100644 index 0000000000000000000000000000000000000000..78bad99218c8ede24ff5dde5fc13e1bd1b292245 --- /dev/null +++ b/ivette/src/dome/src/renderer/table/models.ts @@ -0,0 +1,221 @@ +// -------------------------------------------------------------------------- +// --- Models +// -------------------------------------------------------------------------- + +/** + @packageDocumentation + @module dome/table/models +*/ + +import { SortDirectionType } from 'react-virtualized'; + +// -------------------------------------------------------------------------- +// --- Listeners +// -------------------------------------------------------------------------- + +/** Update callback. */ +export type Trigger = () => void; + +/** Client Views. */ +export interface Client { + /** Set the update callback of the client. */ + onUpdate(trigger?: Trigger): void; + /** Set the reload callback of the client. */ + onReload(trigger?: Trigger): void; + /** Set the watching range of the client. */ + watch(lower: number, upper: number): void; + /** Unlink the client. */ + unlink(): void; +} + +/** @internal */ +interface Watcher { + lower: number; + upper: number; + update: undefined | Trigger; + reload: undefined | Trigger; +} + +// -------------------------------------------------------------------------- +// --- Sorting +// -------------------------------------------------------------------------- + +/** Sorting Info. */ +export interface SortingInfo { + /** The column identifier that triggers some sorting. */ + sortBy: string; + /** The requested sorting direction (`'ASC'` or `'DESC'`). */ + sortDirection: SortDirectionType; +} + +/** Sorting proxy. + Can be provided along with Models or in a separate class or object. */ +export interface Sorting { + /** Whether the model can be sorted from the `dataKey` column identifier. */ + canSortBy(dataKey: string): boolean; + /** Callback to respond to sorting requests from columns. */ + setSorting(order?: SortingInfo): void; + /** Current sorting information. */ + getSorting(): SortingInfo | undefined; +} + +// -------------------------------------------------------------------------- +// --- Filtering +// -------------------------------------------------------------------------- + +export interface Filter<Key, Row> { + (row: Row, key: Key): boolean; +} + +export interface Filtering<Key, Row> { + setFilter(fn?: Filter<Key, Row>): void; +} + +// -------------------------------------------------------------------------- +// --- Collection +// -------------------------------------------------------------------------- + +/** Convenient type for a collection of items. */ +export type Collection<A> = undefined | null | A | Collection<A>[]; + +/** Iterator over collection. */ +export function forEach<A>(data: Collection<A>, fn: (elt: A) => void) { + if (Array.isArray(data)) data.forEach((e) => forEach(e, fn)); + else if (data !== undefined && data !== null) fn(data); +} + +// -------------------------------------------------------------------------- +// --- Abstract Model +// -------------------------------------------------------------------------- + +/** + A Model is responsible for keeping the tables and lists views in sync + with their associated data sets. The model listens for updates, retrieves + items from their index, and re-render the views when necessary. + + Several tables may connect to the same table model, but they will share the + same number and ordering of items. However, each connected table will only + render its own range of items and will re-render only when impacted by + individual updates. + + The model might not hold the entire collection of data at the same time, but + serves as a proxy for fetching data on demand. A model makes a distinction between: + - `Key`: a key identifies a given entry in the table at any time; + - `Row`: the row data associated to some `Key` at a given time; + + When row data change over time, the table views associated to the model + use `Key` information to keep scrolling and selection states in sync. + + The model is responsible for: + - providing row data to the views; + - informing views of data updates; + - compute row ordering with respect to ordering and/or filtering; + - lookup key index with respect to ordering and/or filtering; + + When your data change over time, you shall invoke the following methods + of the model to keep views in sync: + - [[update]] or [[updateIndex]] when single or contiguous row data changes over time; + - [[reload]] when the number of rows, their ordering, or (many) row data has been changed. + + It is always safe to use `reload` instead of `update` although it might be less performant. + + @template Key - identification of some entry + @template Row - dynamic row data associated to some key +*/ +export abstract class Model<Key, Row> { + + private clients = new Map<number, Watcher>(); + private clientsId = 0; + + /** + Shall return the number of rows to be currently displayed in the table. + Negative values are considered as zero. + */ + abstract getRowCount(): number; + + /** + Shall return the current row data at a given index in the table, with respect to + current filtering and ordering (if any). + Might return `undefined` if the index is invalid or not (yet) available. + */ + abstract getRowAt(index: number): undefined | Row; + + /** + Shall return the key at the given index. The specified index and data + are those of the last corresponding call to [[getRowAt]]. + Might return `undefined` if the index is invalid. + */ + abstract getKeyAt(index: number): undefined | Key; + + /** + Shall return the key of the given entry. The specified index and data + are those of the last corresponding call to [[getRowAt]]. + Might return `undefined` if the index is invalid. + */ + abstract getKeyFor(index: number, data: Row): undefined | Key; + + /** + Shall return the index of a given entry in the table, identified by its key, with + respect to current filtering and ordering (if any). + Shall return `undefined` if the specified key no longer belong to the table or + when it is currently filtered out. + Out-of-range indices would be treated as `undefined`. + */ + abstract getIndexOf(key: Key): undefined | number; + + /** + Signal an item update. + Default implementation uses [[getIndexOf]] to retrieve the index and then + delegates to [[updateIndex]]. + All views that might be rendering the specified item will be updated. + */ + update(key: Key) { + const k = this.getIndexOf(key); + if (k !== undefined && 0 <= k) this.updateIndex(k); + } + + /** + Signal a range of updates. + @param first - the first updated item index + @param last - the last updated item index (defaults to `first`) + */ + updateIndex(first: number, last = first) { + if (first <= last) { + this.clients.forEach(({ lower, upper, update }) => { + if (update && first <= upper && lower <= last) update(); + }); + } + } + + /** Re-render all views. */ + reload() { this.clients.forEach(({ reload }) => reload && reload()); } + + /** + Connect a client view to the model. + The initial watching range is empty with no trigger. + You normally never call this method directly. + It is automatically called by table views. + */ + link(): Client { + const id = this.clientsId++; + const m = this.clients; + const w: Watcher & Client = { + lower: 0, + upper: 0, + update: undefined, + reload: undefined, + onUpdate(s?: Trigger) { w.update = s; }, + onReload(s?: Trigger) { w.reload = s; }, + unlink() { m.delete(id); }, + watch(lower: number, upper: number) { + w.lower = lower; + w.upper = upper; + } + }; + m.set(id, w); + return w; + } + +} + +// -------------------------------------------------------------------------- diff --git a/ivette/src/dome/src/renderer/table/style.css b/ivette/src/dome/src/renderer/table/style.css index bf732426f386057343a6d9a1cf89ca777e3b2f10..aefbe542e53a14750f730b645303516c8a51ecb5 100644 --- a/ivette/src/dome/src/renderer/table/style.css +++ b/ivette/src/dome/src/renderer/table/style.css @@ -66,6 +66,10 @@ margin-right: 2px ; } +.dome-xTable-renderer { + overflow: hidden; +} + .dome-xTable-resizer { position: absolute ; cursor: col-resize ; @@ -96,6 +100,14 @@ vertical-align: baseline ; } +.dome-window-active .dome-xTable-selected { + background: #8ce0fb ; +} + +.dome-window-inactive .dome-xTable-selected { + background: #ccc ; +} + .dome-xTable-odd { background: #fdfdfd ; } @@ -119,3 +131,17 @@ } /* -------------------------------------------------------------------------- */ +/* --- Dragging Columns --- */ +/* -------------------------------------------------------------------------- */ + +.dome-xTable .ReactVirtualized__Table__headerColumn, +.dome-xTable .ReactVirtualized__Table__rowColumn +{ + transition: flex-basis linear 50ms ; +} + +.dome-xTable-resizer { + transition: left linear 50ms ; +} + +/* -------------------------------------------------------------------------- */ diff --git a/ivette/src/dome/src/renderer/table/views.js b/ivette/src/dome/src/renderer/table/views.js deleted file mode 100644 index dc7923748ce90485095863132c57818cb7823f00..0000000000000000000000000000000000000000 --- a/ivette/src/dome/src/renderer/table/views.js +++ /dev/null @@ -1,667 +0,0 @@ -// -------------------------------------------------------------------------- -// --- Tables -// -------------------------------------------------------------------------- - -/** - @packageDocumentation - @module dome/table/views -*/ - -import _ from 'lodash' ; -import React from 'react' ; -import * as Dome from 'dome' ; -import { DraggableCore } from 'react-draggable'; -import { SVG } from 'dome/controls/icons' ; -import { - AutoSizer, - SortDirection, - Table as VTable, - Column as VColumn -} from 'react-virtualized' ; - -import './style.css' ; - -// -------------------------------------------------------------------------- -// --- Header Renderer -// -------------------------------------------------------------------------- - -const headerRowRenderer = - (contextMenu) => - // Borrowed from react-virtualized Table.defaultHeaderRowRenderer - ({ - className, - columns, - style - }) => ( - <div role="row" - className={className} - style={style} - onContextMenu={contextMenu} > - {columns} - </div> - ); - -const headerIcon = (icon) => ( - icon && <div className='dome-xTable-header-icon'><SVG id={icon}/></div> -); - -const headerLabel = (label) => ( - label && - (<label className='dome-xTable-header-label dome-text-label'> - {label} - </label>) -); - -const makeSorter = (id) => ( - <div className='dome-xTable-header-sorter'> - <SVG id={id} size={8}/> - </div> -); - -const headerSorter = {} ; -headerSorter[ SortDirection.ASC ] = makeSorter('ANGLE.UP'); -headerSorter[ SortDirection.DESC ] = makeSorter('ANGLE.DOWN'); - -const headerRenderer = - ({ - columnData: { label, icon, title, headerRef }, - dataKey, - sortBy, - sortDirection - }) => { - const tooltip = title || label ; - const onRef = (elt) => headerRef(dataKey,elt) ; - const sorter = dataKey === sortBy ? headerSorter[sortDirection] : undefined ; - return ( - <div className='dome-xTable-header' title={tooltip} ref={onRef} > - { headerIcon(icon) } - { headerLabel(label) } - { sorter } - </div> - ); - }; - -// -------------------------------------------------------------------------- -// --- Cell Renderer -// -------------------------------------------------------------------------- - -const cellDataGetter = - (getValue) => - ({ rowData:{ model , item }, dataKey:id }) => - ( item == undefined ? undefined : - getValue ? getValue(item) : - model.getValue(item,id) - ); - -const cellRenderer = - (renderValue) => - ({ cellData: data }) => - ( - data === undefined ? undefined : - renderValue ? renderValue(data) : - (<div className='dome-xTable-cell dome-text-data'>{data}</div>) - ); - -// -------------------------------------------------------------------------- -// --- Column Resizer -// -------------------------------------------------------------------------- - -const DRAGGING = 'dome-xTable-resizer dome-color-dragging' ; -const DRAGZONE = 'dome-xTable-resizer dome-color-dragzone' ; - -const Resizer = (props) => ( - <DraggableCore - onStart={props.onStart} - onStop={props.onStop} - onDrag={(_elt,data)=> props.onDrag(props.left,props.right,data.x - props.offset)} - > - <div - className={ props.id === props.dragging ? DRAGGING : DRAGZONE } - style={{ left: props.offset-2 }} - /> - </DraggableCore> -); - -const computeWidth = (elt) => { - const parent = elt && elt.parentElement ; - return parent && parent.getBoundingClientRect().width ; -}; - -// -------------------------------------------------------------------------- -// --- Table Columns -// -------------------------------------------------------------------------- - -/** - @summary Table Column. - @property {string} id - Column unique identifier (required) - @property {string} [icon] - Header icon - @property {string} [label] - Header label - @property {string} [title] - Header tooltip - @property {string} [align] - Column alignment (`'left'`, `'center'`, `'right'`) - @property {number} [width] - Column base width (in pixels, default `60`) - @property {boolean} [fill] - Extensible column (not by default) - @property {boolean} [fixed] - Non-resizable column (not by default) - @property {boolean} [disableSort] - Do not trigger sorting callback for this column - @property {boolean|string} [visible] - Default column visibility - @property {function} [getValue] - Obtain an item's value for this column - @property {function} [renderValue] - Render item's value in each table cell - @description - - Each column displays a specific value derived from the item displayed in a - row. Column properties enforce a separation between how to extract the value - from an item and how to render it in the cell. - - - `getValue(item) : any` shall returns the value to render for the _item_ - - `renderValue(any) : Element` shall returns the (React) element to display the item - - - By default, values are obtained from the underlying model by invoking - [[Model.getValue]] with the column identifier. - - This separation of concerns allows for defining - Column types, where for instance the renderer is already defined and you only need to - know how to extract the expected value of items. - See [[DefineColumn]] - for more informations and examples. - - A table should have at least one extensible column to occupy the available width. - If no column in the table is explicitely declared to be extensible, the last - one would be implicitely set to fill. - - Default visiblity can be set to a boolean value ; alternatively, you may specify - `visible='never'` to make the column invisible to the user, or `visible='always'` - to force the column to be visible. - -*/ -export const Column = (props) => null; -// Fake component only used to store props. -// Virtualized column is rendered with function vColumn (see below) - -const vColumn = ({ - headerRef, - columnResize,hasFill,lastElt, - contextMenu -}) => (elt) => { - const defaults = elt.type._DOME_COLUMN_DEFAULTS || {} ; - const forcers = !hasFill && elt == lastElt ? { fill:true } : {} ; - const { id,label,title,icon,align,width,fill,disableSort,getValue,renderValue } - = Object.assign( {}, defaults , elt.props , forcers ) ; - return ( - <VColumn - key={id} - displayName='React-Virtualized-Column' - width={columnResize[id] || width || 60} - flexGrow={ fill ? 1 : 0 } - dataKey={id} - columnData={{label,title,icon,headerRef}} - headerRenderer={headerRenderer} - cellRenderer={cellRenderer(renderValue)} - cellDataGetter={cellDataGetter(getValue)} - headerStyle={{ textAlign: align }} - disableSort={disableSort} - style={{ textAlign: align }} - /> - ); -}; - -const defaultVisible = (visible) => { - switch(visible) { - case 'always': - case undefined: - return true; - case 'never': - case null: - return false; - default: - return visible; - } -}; - -// -------------------------------------------------------------------------- -// --- Specific Columns -// -------------------------------------------------------------------------- - -/** - @summary Define specific Column instances. - @param {Object} properties - default Column properties - @return {Column} a new Column class of Component - @description - - Allow to define specialized instances of [[Column]]. - - @example // Example of column type - import { DefineColumn } from 'dome/table/views' ; - export const ColumnCheck = DefineColumn({ - align: 'center', - renderValue: (ok) => <Icon id={ok ? 'CHECK' : 'CROSS'}/> - }); - - @example // Usage in a Table - <Table ...> - <Column id='name' label='Name' fill /> - <ColumnCheck id='check' label='Checked' /> - </Table> - - */ -export const DefineColumn = (DEFAULTS) => { - function Component() { return null; }; - Component._DOME_COLUMN_DEFAULTS = DEFAULTS ; - return Component ; -}; - -// -------------------------------------------------------------------------- -// --- Table Rows -// -------------------------------------------------------------------------- - -const rowClassName = - (multipleSelection,selected) => - ({index}) => - (multipleSelection - ? 0 <= _.sortedIndexOf( selected , index ) - : (index === selected)) - ? 'dome-color-selected' : - index & 1 ? 'dome-xTable-even' : 'dome-xTable-odd' ; - -// -------------------------------------------------------------------------- -// --- Table View -// -------------------------------------------------------------------------- - -// Must be kept in sync with table.css -const CSS_HEADER_HEIGHT = 22 ; -const CSS_ROW_HEIGHT = 20 ; -const DEFAULT_STATE = { width:{}, resize:{}, visible:{} }; - -/** - @class - @summary Table View. - @property {Model} model - table data proxy (required) - @property {Column[]} children - one or more table columns (required) - @property {string} [settings] - window settings for column size & visibility (optional) - @property {any} [selection] - current selection (depends on `multipleSelection`) - @property {function} [onSelection] - callback to selection changes (depends on `multipleSelection`) - @property {boolean} [multipleSelection] - select single or multiple selection - @property {any} [scrollToItem] - ensures the item is visible - @property {function} [renderEmpty] - callback to render an empty table - @description - - This component is base on [React-Virtualized - Tables](https://bvaughn.github.io/react-virtualized/#/components/Table), - offering a lazy, super-optimized rendering process that scales on huge - datasets. - - A table shall be connected to an instance of - [[Model]] class to retrieve the data and - get informed of data updates. - - The table columns shall be instances of - [[Column]] class. - - Clicking on table headers trigger re-ordering callback on the model with the - expected column and direction, unless disabled _via_ the column - specification. However, actual sorting (and corresponding feedback on table - headers) would only take place if the model supports re-ordering and - eventually triggers a reload. - - Right-clicking the table headers displays a popup-menu with actions to reset natural ordering, - reset column widths and select column visibility. - - Tables do not control item selection state. Instead, you shall supply the selection - state and callback _via_ properties, like any other controlled React components. - - Item selection can be based either on single-row or multiple-row. In case of - single-row selection (`multipleSelection:false`, the default), selection state - must be a single item or `undefined`, and the `onSelection` callback is called - with the same type of values. - - In case of multiple-row selection (`multipleSelection:true`), the selection state - shall be an _array_ of items, and `onSelection` callback also. Single items are _not_ - accepted, but `undefined` selection can be used in place of an empty array. - - Clicking on a row triggers the `onSelection` callback with the updated selection. - In single-selection mode, the clicked item is sent to the callback. In - multiple-selection mode, key modifiers are taken into account for determining the new - slection. By default, the new selection only contains the clicked item. If the `Shift` - modifier has been pressed, the current selection is extended with a range of items - from the last selected one, to the newly selected one. If the `CtrlOrCmd` modifier - has been pressed, the selection is extended with the newly clicked item. - Clicking an already selected item with the `CtrlOrCmd` modifier removes it from - the current selection. - - */ -export class Table extends React.Component { - - constructor(props) - { - super(props); - - // Table Reload - this.tableRef = undefined ; - this.setTableRef = (ref) => this.tableRef = ref ; - this.reloadTable = () => { - this.reloaded = true ; - setImmediate(() => { - const ref = this.tableRef ; - if (ref) { - this.forceUpdate(); - ref.forceUpdateGrid(); - } - }); - }; - - // Model Watching - this.watchModel = ({startIndex,stopIndex}) => { - this.props.model._watch( this.client , startIndex , stopIndex ); - }; - - // Default Context Menu - this.resetOrdering = () => this.props.model.setOrdering() ; - - // Column States - this.state = Object.assign( - DEFAULT_STATE, - Dome.getWindowSetting( this.props.settings ) - ); - this.restoreDefaults = () => this.setState( DEFAULT_STATE ); - - // Header Reset Resizing - this.resetResizing = () => this.setState({ width:{}, resize:{} }); - - // Header Column References - this.headerRef = (id,elt) => { - const old = this.state.width[id] ; - const current = computeWidth(elt); - if (elt && old !== current) { - const columns = Object.assign( {}, this.state.width ); - columns[id] = current ; - this.setState({ width: columns }); - } - }; - - // Column Resizing - this.resizeColumns = (lcol,rcol,delta) => { - const columnSize = this.state.width ; - const wl = columnSize[lcol] + delta ; - const wr = columnSize[rcol] - delta ; - if (wl > 40 && wr > 40) { - const resize = Object.assign( {}, this.state.resize ); - resize[lcol] = wl ; - resize[rcol] = wr ; - this.setState({ resize }); - } - }; - - // Column Visibility - this.isVisible = (visible) => (elt) => { - const props = elt.props ; - const v = visible[props.id] ; - if (v !== undefined) return v; - const p = props.visible ; - switch( p ) { - case 'never': - case null: - return false; - case 'always': - case undefined: - return true; - default: - return p; - } - }; - - // Selection - this.selectRow = this.selectRow.bind(this); - this.contextMenu = this.contextMenu.bind(this); - - } - - // --- Life Cycle (binding to model) - - componentDidMount() - { - Dome.on('dome.defaults',this.restoreDefaults ); - this.client = this.props.model._bind(this.reloadTable); - } - - componentWillUnmont() - { - Dome.off('dome.defaults',this.restoreDefaults ); - this.props.model._remove(this.client); - this.tableRef = undefined ; - } - - componentDidUpdate() - { - Dome.setWindowSetting( this.props.settings, this.state ); - } - - // --- Column Resizers - - computeResizers(columns) { - // Insert a resizer on each side of non-fixed columns, - // provided there also exists some non-fixed column on both side. - if (columns.length < 2) return null; - const resizing = columns.map( ({props:{id,fixed}}) => ({id,fixed}) ); - var k, cid ; - for (cid = undefined, k = 0; k < columns.length; k++) { - const r = resizing[k]; - r.left = cid ; - if (!r.fixed) cid = r.id ; - } - for (cid = undefined, k = columns.length-1; 0 <= k ; k--) { - const r = resizing[k]; - r.right = cid ; - if (!r.fixed) cid = r.id ; - } - var offset = 0 , resizers = [] ; - const columnSize = this.state.width ; - for (k = 0; k < columns.length - 1 ; k++) { - const width = columnSize[resizing[k].id] ; - if (!width) return null; - offset += width ; - const a = resizing[k]; - const b = resizing[k+1]; - if ((!a.fixed || !b.fixed) && a.right && b.left) { - const id = k ; - const onStart = () => { this.dragging = id ; this.forceUpdate(); }; - const onStop = () => { this.dragging = undefined ; this.forceUpdate(); }; - const resizer = ( - <Resizer key={id} - id={id} - dragging={this.dragging} - onStart={onStart} - onStop={onStop} - onDrag={this.resizeColumns} - offset={offset} - left={b.left} - right={a.right} /> - ); - resizers.push(resizer); - } - } - return resizers ; - } - - // --- Context Menu - - contextMenu() { - var has_order ; - var has_width ; - var has_default ; - const children = this.props.children ; - React.Children.forEach(children, (elt) => { - if (elt) { - const { fixed, disableSort, visible } = elt.props ; - if (!disableSort) has_order = true ; - if (!fixed) has_width = true ; - if (visible!=='always' && visible!=='never') - has_default = true ; - } - }); - const items = [ - { label: 'Reset Ordering', - display:has_order, onClick:this.resetOrdering }, - { label: 'Reset Column widths', - display:has_width, onClick:this.resetResizing }, - { label: 'Restore Columns defaults', - display:has_default, onClick:this.restoreDefaults }, - 'separator' - ]; - const visible = Object.assign( {}, this.state.visible ); - React.Children.forEach(children, (elt) => { - if (elt) { - switch(elt.props.visible) { - case 'never': - case 'always': - break; - default: - const { id, label, title } = elt.props ; - const checked = this.isVisible(visible)(elt); - const onClick = () => { - visible[id] = !checked ; - this.setState({ visible }); - }; - items.push({ label: label || title, checked, onClick }); - } - } - }); - Dome.popupMenu(items); - } - - // --- Row Selection - - selectRow({event, index, rowData:{item}}) { - this.focus = item ; - if (item) { - const { model, multipleSelection , selection, onSelection } = this.props ; - if (multipleSelection) { - const selectedItems = - selection === undefined ? [] : - Array.isArray(selection) ? selection : - [selection] ; - if (event.metaKey || event.ctrlKey) { - var s, a ; - const isClicked = (e) => model.getIndexOf(e) === index ; - if (_.find( selectedItems , isClicked )) { - s = _.filter( selectedItems, (e) => model.getIndexOf(e) !== index ); - } else { - s = selectedItems.slice(); - s.push(item); - a = index ; - } - this.anchor = a ; - this.anchored = undefined ; - onSelection(s); - } - else if (event.shiftKey && this.anchor) { - var old = this.anchored || (this.anchored = selection) ; - var updated = old.slice(); - var anchor = this.anchor ; - var k ; - if (anchor < index) - for (k = anchor ; k <= index ; k++) { - updated.push(model.getItemAt(k)); - } - else - for (k = anchor ; index <= k ; k--) { - updated.push(model.getItemAt(k)); - } - // No anchor modification - onSelection(_.uniqBy(updated, model.getIndexOf.bind(model))); - } - else { - this.anchor = index ; - this.anchored = undefined ; - onSelection([item]); - } - } else { - onSelection(item); - } - } - } - - // --- Rendering - - render() { - - const { - model, renderEmpty, - multipleSelection, selection, onSelection, - scrollToItem - } = this.props ; - - const itemCount = model.getItemCount(); - const ordering = model.getOrdering(); - var selected = undefined ; - if (selection) - if (multipleSelection && Array.isArray(selection)) { - selected = selection.map((elt) => { - var k = model.getIndexOf(elt); - return Number.isInteger(k) ? k : -1 ; - }).sort((a,b) => a-b); - } else - selected = model.getIndexOf(selection); - - const rowGetter = ({index}) => ({ - model , item: (index < itemCount ? model.getItemAt(index) : undefined) - }) ; - - const isVisible = this.isVisible(this.state.visible); - const columns = React.Children.toArray(this.props.children).filter(isVisible); - var hasFill = false ; - var lastElt = undefined ; - React.Children.forEach(columns,(elt) => { - if (elt.props.fill) hasFill = true ; else lastElt = elt ; - }); - const SizedTable = ({ height, width }) => { - const tableHeight = CSS_HEADER_HEIGHT + CSS_ROW_HEIGHT * itemCount ; - const smallHeight = itemCount > 0 && tableHeight < height ; - const rowCount = ( smallHeight ? itemCount + 1 : itemCount) ; - const reloaded = this.reloaded ; - if (reloaded) this.reloaded = false ; - const scrollToIndex = - scrollToItem ? model.getIndexOf(scrollToItem) : - reloaded && this.focus ? model.getIndexOf(this.focus) : undefined ; - const resizers = this.computeResizers(columns); - const renderColumn = vColumn({ - headerRef: this.headerRef, - hasFill, lastElt, - columnResize:this.state.resize - }); - return ( - <React.Fragment> - <VTable - ref={this.setTableRef} - key='table' - displayName='React-Virtualized-Table' - width={width} - height={height} - rowCount={rowCount} - noRowsRenderer={renderEmpty} - rowGetter={rowGetter} - rowClassName={rowClassName(multipleSelection,selected)} - rowHeight={CSS_ROW_HEIGHT} - headerHeight={CSS_HEADER_HEIGHT} - headerRowRenderer={headerRowRenderer(this.contextMenu)} - onRowsRendered={this.watchModel} - onRowClick={onSelection && this.selectRow} - sortBy={ordering && ordering.sortBy} - sortDirection={ordering && ordering.sortDirection} - sort={model.setOrdering.bind(model)} - scrollToIndex={ scrollToIndex } - scrollToAlignment='auto' - > - {React.Children.map(columns,renderColumn)} - </VTable> - {resizers} - </React.Fragment> - ); - }; - return ( - <div className='dome-xTable'> - <AutoSizer key='table'>{SizedTable}</AutoSizer> - </div> - ); - } -} - -// -------------------------------------------------------------------------- diff --git a/ivette/src/dome/src/renderer/table/views.tsx b/ivette/src/dome/src/renderer/table/views.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cfb3ce310a03b6f7e3bc17275e99f364379d987d --- /dev/null +++ b/ivette/src/dome/src/renderer/table/views.tsx @@ -0,0 +1,1023 @@ +// -------------------------------------------------------------------------- +// --- Tables +// -------------------------------------------------------------------------- + +/** + @packageDocumentation + @module dome/table/views + */ + +import React, { ReactNode } from 'react'; +import { forEach, debounce } from 'lodash'; +import isEqual from 'react-fast-compare'; +import * as Dome from 'dome'; +import { DraggableCore } from 'react-draggable'; +import { + AutoSizer, Size, + SortDirection, SortDirectionType, + Index, IndexRange, + Table as VTable, + Column as VColumn, + TableHeaderRowProps, + TableHeaderProps, + TableCellDataGetter, + TableCellRenderer, + RowMouseEventHandlerParams, +} from 'react-virtualized'; + +import { SVG as SVGraw } from 'dome/controls/icons'; +import { Trigger, Client, Sorting, SortingInfo, Model } from './models'; + +import './style.css'; + +const SVG = SVGraw as (props: { id: string, size?: number }) => JSX.Element; + +// -------------------------------------------------------------------------- +// --- Rendering Interfaces +// -------------------------------------------------------------------------- + +/** Cell data renderer. */ +export type Renderer<Cell> = (data?: Cell) => null | JSX.Element; + +/** + Associates, for each field `{ fd: Cell }` in `Row`, a renderer + for type `Cell`. + */ +export type RenderByFields<Row> = { + [fd in keyof Row]?: Renderer<Row[fd]>; +}; + +// -------------------------------------------------------------------------- +// --- Table Columns +// -------------------------------------------------------------------------- + +/** + @template Row - table row data of some table entries + @template Cell - type of cell data to render in this column + */ +export interface ColumnProps<Row, Cell> { + /** Column identifier. */ + id: string; + /** Header icon. */ + icon?: string; + /** Header label. */ + label?: string; + /** Header title. */ + title?: string; + /** + Column position. + By default, column will appear according to their mounting order. + */ + index?: number; + /** CSS vertical alignment on cells. */ + align?: 'left' | 'center' | 'right'; + /** Column base width in pixels (default 60px). */ + width?: number; + /** Extensible column (not by default). */ + fill?: boolean; + /** Fixed width column (not by default). */ + fixed?: boolean; + /** + Data Key for this column. Defaults to `id`. It is used for: + - triggering ordering events to the model, if enabled. + - using by-fields table renderers, if provided. + */ + dataKey?: string; + /** + Disable model sorting, even if enabled by the model + for this column `dataKey`. Not by default. + */ + disableSort?: boolean; + /** + Default column visibility. With `'never'` or `'always'` the column + visibility is forced and can not be modified by the user. Otherwize, + the user can change visibility through the column header context menu. + */ + visible?: boolean | 'never' | 'always'; + /** + Data getter for this column. + */ + getter?: (row: Row, dataKey: string) => Cell; + /** + Override table by-fields cell renderers. + */ + render?: Renderer<Cell>; + /** + Override table right-click callback. + */ + onContextMenu?: (row: Row, index: number, dataKey: string) => void; +} + +// -------------------------------------------------------------------------- +// --- Table Properties +// -------------------------------------------------------------------------- + +/** + @template Key - unique identifiers of table entries + @template Row - data associated to each key in the table entries + */ +export interface TableProps<Key, Row> { + /** Data proxy. */ + model: Model<Key, Row>; + /** Sorting Proxy. */ + sorting?: Sorting; + /** Rendering by Fields. */ + rendering?: RenderByFields<Row>; + /** Window settings to store the size and visibility of columns. */ + settings?: string; + /** Selected row (identified by key). */ + selection?: Key; + /** Selection callback. */ + onSelection?: (row: Row, key: Key, index: number) => void; + /** Context menu callback. */ + onContextMenu?: (row: Row, index: number) => void; + /** Fallback for rendering an empty table. */ + renderEmpty?: () => null | JSX.Element; + /** Shall only contains `<Column<Row> … />` elements. */ + children?: any; +} + +// -------------------------------------------------------------------------- +// --- React-Virtualized Interfaces +// -------------------------------------------------------------------------- + +type divRef = React.RefObject<HTMLDivElement>; +type tableRef = React.RefObject<VTable>; + +interface ColumnData { + icon?: string; + label?: string; + title?: string; + headerMenu: () => void; + headerRef: divRef; +}; + +interface PopupItem { + label: string; + checked?: boolean; + enabled?: boolean; + display?: boolean; + onClick?: Trigger; +}; + +type PopupMenu = ('separator' | PopupItem)[]; + +type Cmap<A> = Map<string, A> +type Cprops = ColProps<any>; +type ColProps<R> = ColumnProps<R, any>; + +// -------------------------------------------------------------------------- +// --- Column Utilities +// -------------------------------------------------------------------------- + +const isVisible = (visible: Cmap<boolean>, col: Cprops) => { + const defaultVisible = col.visible ?? true; + switch (defaultVisible) { + case 'never': return false; + case 'always': return false; + default: + return visible.get(col.id) ?? defaultVisible; + } +}; + +const defaultGetter = (row: any, dataKey: string) => { + if (typeof row === 'object') return row[dataKey]; + return undefined; +}; + +const defaultRenderer = (d: any) => ( + <div className="dome-xTable-renderer dome-text-label"> + {new String(d)} + </div> +); + +function makeRowGetter<Key, Row>(model?: Model<Key, Row>) { + return ({ index }: Index) => model && model.getRowAt(index); +}; + +function makeDataGetter( + getter: ((row: any, dataKey: string) => any) = defaultGetter, + dataKey: string, +): TableCellDataGetter { + return (({ rowData }) => { + try { + if (rowData !== undefined) return getter(rowData, dataKey); + } catch (err) { + console.error( + '[Dome.table] custom getter error', + 'rowData:', rowData, + 'dataKey:', dataKey, + err, + ); + } + return undefined; + }); +} + +function makeDataRenderer( + render: ((data: any) => ReactNode) = defaultRenderer, + onContextMenu?: (row: any, index: number, dataKey: string) => void, +): TableCellRenderer { + return (props => { + const cellData = props.cellData; + try { + const contents = cellData ? render(cellData) : null; + if (onContextMenu) { + const callback = (evt: React.MouseEvent) => { + evt.stopPropagation(); + onContextMenu(props.rowData, props.rowIndex, props.dataKey); + }; + return (<div onContextMenu={callback}>{contents}</div>); + } + return contents; + } catch (err) { + console.error( + '[Dome.table] custom renderer error', + 'dataKey:', props.dataKey, + 'cellData:', cellData, + err, + ); + return null; + } + }); +} + +// -------------------------------------------------------------------------- +// --- Table Settings +// -------------------------------------------------------------------------- + +type ColSettings<A> = { [id: string]: undefined | null | A }; + +type TableSettings = { + resize?: ColSettings<number>; + visible?: ColSettings<boolean>; +} + +// -------------------------------------------------------------------------- +// --- Table State +// -------------------------------------------------------------------------- + +class TableState<Key, Row> { + + settings?: string; // User settings + signal?: Trigger; // Full reload + width?: number; // Current table width + offset?: number; // Current resizing offset + resizing?: number; // Currently dragging resizer + resize: Cmap<number> = new Map(); // Current resizing wrt. dragging + visible: Cmap<boolean> = new Map(); // Current + headerRef: Cmap<divRef> = new Map(); // Once, build on demand + columnWith: Cmap<number> = new Map(); // DOM column element width without dragging + tableRef: tableRef = React.createRef(); // Once, global + getter: Cmap<TableCellDataGetter> = new Map(); // Computed from registry + render: Cmap<TableCellRenderer> = new Map(); // Computed from registry and getterFields + rowGetter: (info: Index) => Row | undefined; // Computed from last fetching + rendering?: RenderByFields<Row>; // Last user props used for computing renderers + model?: Model<Key, Row>; // Last user proxy used for computing getter + sorting?: Sorting; // Last user proxy used for sorting + client?: Client; // Client of last fetching + columns: ColProps<Row>[] = []; // Currently known columns + scrolledKey?: Key; // Lastly scrolled key + selectedIndex?: number; // Current selected index + sortBy?: string; // last sorting dataKey + sortDirection?: SortDirectionType; // last sorting direction + onContextMenu?: (row: Row, index: number) => void; // context menu callback + range?: IndexRange; + rowCount = 0; + + constructor() { + this.unwind = this.unwind.bind(this); + this.forceUpdate = this.forceUpdate.bind(this); + this.fullReload = this.fullReload.bind(this); + this.updateGrid = this.updateGrid.bind(this); + this.onRowsRendered = this.onRowsRendered.bind(this); + this.rowClassName = this.rowClassName.bind(this); + this.onHeaderMenu = this.onHeaderMenu.bind(this); + this.onRowClick = this.onRowClick.bind(this); + this.onRowRightClick = this.onRowRightClick.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + this.onSorting = this.onSorting.bind(this); + this.clearSettings = this.clearSettings.bind(this); + this.rebuild = debounce(this.rebuild.bind(this), 5); + this.rowGetter = makeRowGetter(); + } + + // --- Static Callbacks + + unwind() { + this.signal = undefined; + this.onSelection = undefined; + this.onContextMenu = undefined; + } + + forceUpdate() { + const s = this.signal; + if (s) { this.signal = undefined; s(); } + } + + updateGrid() { + this.tableRef.current?.forceUpdateGrid(); + } + + fullReload() { + this.scrolledKey = undefined; + this.forceUpdate(); + this.updateGrid(); + } + + getRef(id: string) { + const href = this.headerRef.get(id); + if (href) return href; + const nref: divRef = React.createRef(); + this.headerRef.set(id, nref); + return nref; + } + + // --- Computing Column Size + + computeWidth(id: string): number | undefined { + const cwidth = this.columnWith; + if (this.resizing !== undefined) return cwidth.get(id); + const elt = this.headerRef.get(id)?.current?.parentElement; + const cw = elt?.getBoundingClientRect()?.width; + if (cw) cwidth.set(id, cw); + return cw; + } + + startResizing(idx: number) { + this.resizing = idx; + this.offset = 0; + this.forceUpdate(); + } + + stopResizing() { + this.resizing = undefined; + this.offset = undefined; + this.columnWith.clear(); + this.updateSettings(); + } + + // Debounced + setResizeOffset(lcol: string, rcol: string, offset: number) { + const colws = this.columnWith; + const cwl = colws.get(lcol); + const cwr = colws.get(rcol); + const wl = cwl ? cwl + offset : 0; + const wr = cwr ? cwr - offset : 0; + if (wl > 40 && wr > 40) { + const resize = this.resize; + resize.set(lcol, wl); + resize.set(rcol, wr); + this.offset = offset; + this.forceUpdate(); // no settings yet, onStop only + } + } + + // --- Table settings + + clearSettings() { + this.resize.clear(); + this.visible.clear(); + this.forceUpdate(); + } + + updateSettings() { + const userSettings = this.settings; + if (userSettings) { + const cws: ColSettings<number> = {}; + const cvs: ColSettings<boolean> = {}; + const resize = this.resize; + const visible = this.visible; + this.columns.forEach(({ id }) => { + const cw = resize.get(id); + const cv = visible.get(id); + cws[id] = cw === undefined ? null : cw; + cvs[id] = cv === undefined ? null : cv; + }); + const theSettings: TableSettings = { resize: cws, visible: cvs }; + Dome.setWindowSetting(userSettings, theSettings); + } + this.forceUpdate(); + } + + importSettings(settings?: string) { + if (settings !== this.settings) { + this.settings = settings; + const resize = this.resize; + const visible = this.visible; + resize.clear(); + visible.clear(); + const theSettings: undefined | TableSettings = + Dome.getWindowSetting(settings); + if (theSettings) { + forEach(theSettings.resize, (cw, cid) => { + if (typeof cw === 'number') this.resize.set(cid, cw); + }); + forEach(theSettings.visible, (cv, cid) => { + if (typeof cv === 'boolean') this.visible.set(cid, cv); + }); + this.forceUpdate(); + } + } + } + + // --- User Table properties + + setSorting(sorting?: Sorting) { + const info = sorting?.getSorting(); + this.sortBy = info?.sortBy; + this.sortDirection = info?.sortDirection; + if (sorting !== this.sorting) { + this.sorting = sorting; + this.fullReload(); + } + } + + setModel(model?: Model<Key, Row>) { + if (model !== this.model) { + this.client?.unlink(); + this.model = model; + if (model) { + const client = model.link(); + client.onReload(this.fullReload); + client.onUpdate(this.updateGrid); + this.client = client; + } else { + this.client = undefined; + } + this.rowGetter = makeRowGetter(model); + this.fullReload(); + } + } + + setRendering(rendering?: RenderByFields<Row>) { + if (rendering !== this.rendering) { + this.rendering = rendering; + this.render.clear(); + this.fullReload(); + } + } + + // ---- Selection Management + + onSelection?: (data: Row, key: Key, index: number) => void; + + onRowClick(info: RowMouseEventHandlerParams) { + const index = info.index; + const data = info.rowData as (Row | undefined); + const model = this.model; + const key = (data !== undefined) ? model?.getKeyFor(index, data) : undefined; + const onSelection = this.onSelection; + if (key !== undefined && data !== undefined && onSelection) + onSelection(data, key, index); + } + + onRowsRendered(info: IndexRange) { + this.range = info; + this.client?.watch(info.startIndex, info.stopIndex); + } + + rowClassName({ index }: Index): string { + if (this.selectedIndex === index) return 'dome-xTable-selected'; + return (index & 1 ? 'dome-xTable-even' : 'dome-xTable-odd'); + } + + keyStepper(index: number) { + const onSelection = this.onSelection; + const key = this.model?.getKeyAt(index); + const data = this.model?.getRowAt(index); + if (key !== undefined && data !== undefined && onSelection) { + onSelection(data, key, index); + } + } + + scrollToIndex(selection: Key | undefined): number | undefined { + const index = selection && this.model?.getIndexOf(selection); + this.selectedIndex = index; + if (this.scrolledKey !== selection) { + this.scrolledKey = selection; + if (selection) return index; + } + return undefined; + } + + onSorting(ord?: SortingInfo) { + const sorting = this.sorting; + if (sorting) { + sorting.setSorting(ord); + this.sortBy = ord?.sortBy; + this.sortDirection = ord?.sortDirection; + this.forceUpdate(); + } + } + + // ---- Row Events + + onRowRightClick({ event, rowData, index }: RowMouseEventHandlerParams) { + const callback = this.onContextMenu; + if (callback) { + event.stopPropagation(); + callback(rowData, index); + } + } + + onKeyDown(evt: React.KeyboardEvent) { + const index = this.selectedIndex; + switch (evt.key) { + case 'ArrowUp': + if (index !== undefined) { + this.keyStepper(index - 1); + evt.preventDefault(); + } + break; + case 'ArrowDown': + if (index !== undefined) { + this.keyStepper(index + 1); + evt.preventDefault(); + } + break; + } + } + + // ---- Header Context Menu + + onHeaderMenu() { + let has_order = false; + let has_resize = false; + let has_visible = false; + const visible = this.visible; + const columns = this.columns; + columns.forEach(col => { + if (!col.disableSort) has_order = true; + if (!col.fixed) has_resize = true; + if (col.visible !== 'never' && col.visible !== 'always') + has_visible = true; + }); + const resetSizing = () => { + this.resize.clear(); + this.updateSettings(); + }; + const resetColumns = () => { + this.visible.clear(); + this.resize.clear(); + this.updateSettings(); + }; + const items: PopupMenu = [ + { + label: 'Reset ordering', + display: has_order && this.sorting, + onClick: this.onSorting, + }, + { + label: 'Reset column widths', + display: has_resize, + onClick: resetSizing, + }, + { + label: 'Restore column defaults', + display: has_visible, + onClick: resetColumns, + }, + 'separator', + ]; + columns.forEach(col => { + switch (col.visible) { + case 'never': + case 'always': + break; + default: + const { id, label, title } = col; + const checked = isVisible(visible, col); + const onClick = () => { + visible.set(id, !checked); + this.updateSettings(); + }; + items.push({ label: label || title || id, checked, onClick }); + } + }); + Dome.popupMenu(items); + } + + // --- Getter & Setters + + computeGetter(id: string, dataKey: string, props: Cprops) { + const current = this.getter.get(id); + if (current) return current; + const dataGetter = makeDataGetter(props.getter, dataKey); + this.getter.set(id, dataGetter); + return dataGetter; + } + + computeRender(id: string, dataKey: string, props: Cprops) { + const current = this.render.get(id); + if (current) return current; + let renderer = props.render; + if (!renderer) { + const rdr = this.rendering; + if (rdr) renderer = (rdr as any)[dataKey]; + } + const cellRenderer = makeDataRenderer(renderer, props.onContextMenu); + this.render.set(id, cellRenderer); + return cellRenderer; + } + + // --- User Column Registry + + private registry = new Map<string, null | ColProps<Row>>(); + + setRegistry(id: string, props: null | ColProps<Row>) { + this.registry.set(id, props); + this.rebuild(); + } + + useColumn(props: ColProps<Row>): Trigger { + const id = props.id; + this.setRegistry(id, props); + return () => this.setRegistry(id, null); + } + + rebuild() { + const current = this.columns; + const cols: ColProps<Row>[] = []; + this.registry.forEach((col) => col && cols.push(col)); + if (!isEqual(current, cols)) { + this.getter.clear(); + this.render.clear(); + this.columns = cols; + this.fullReload(); + } + } +} + +// -------------------------------------------------------------------------- +// --- Columns Components +// -------------------------------------------------------------------------- + +const ColumnContext = + React.createContext<undefined | TableState<any, any>>(undefined); + +/** + Table Column. + @template Row - table row data of some table entries + @template Cell - type of cell data to render in this column + */ +export function Column<Row, Cell>(props: ColumnProps<Row, Cell>) { + const context = React.useContext(ColumnContext); + React.useEffect(() => context && context.useColumn(props)); + return null; +} + +// -------------------------------------------------------------------------- +// --- Virtualized Column +// -------------------------------------------------------------------------- + +function makeColumn<Key, Row>( + state: TableState<Key, Row>, + props: ColProps<Row>, + fill: boolean, +) { + const { id } = props; + const align = { textAlign: props.align }; + const dataKey = props.dataKey ?? id; + const columnData: ColumnData = { + icon: props.icon, + label: props.label, + title: props.title, + headerMenu: state.onHeaderMenu, + headerRef: state.getRef(id), + }; + const width = state.resize.get(id) || props.width || 60; + const flexGrow = fill ? 1 : 0; + const sorting = state.sorting; + const disableSort = + props.disableSort || !sorting || !sorting.canSortBy(dataKey); + const getter = state.computeGetter(id, dataKey, props); + const render = state.computeRender(id, dataKey, props); + return ( + <VColumn + key={id} + width={width} + flexGrow={flexGrow} + dataKey={dataKey} + columnData={columnData} + headerRenderer={headerRenderer} + cellDataGetter={getter} + cellRenderer={render} + headerStyle={align} + disableSort={disableSort} + style={align} + /> + ); +}; + +function makeCprops<Key, Row>(state: TableState<Key, Row>) { + const cols: Cprops[] = []; + state.columns.forEach((col) => { + if (col && isVisible(state.visible, col)) { + cols.push(col); + } + }); + cols.sort((a, b) => (a.index ?? 0) - (b.index ?? 0)); + return cols; +} + +function makeColumns<Key, Row>(state: TableState<Key, Row>, cols: Cprops[]) { + let fill: undefined | Cprops; + let lastExt: undefined | Cprops; + cols.forEach((col) => { + if (col.fill && !fill) fill = col; + if (!col.fixed) lastExt = col; + }); + const n = cols.length; + if (0 < n && !fill) fill = lastExt || cols[n - 1]; + return cols.map((col) => makeColumn(state, col, col === fill)); +} + +// -------------------------------------------------------------------------- +// --- Table Utility Renderers +// -------------------------------------------------------------------------- + +const headerIcon = (icon?: string) => ( + icon && + (<div className='dome-xTable-header-icon'> + <SVG id={icon} /> + </div>) +); + +const headerLabel = (label?: string) => ( + label && + (<label className='dome-xTable-header-label dome-text-label'> + {label} + </label>) +); + +const makeSorter = (id: string) => ( + <div className='dome-xTable-header-sorter'> + <SVG id={id} size={8} /> + </div> +); + +const sorterASC = makeSorter('ANGLE.UP'); +const sorterDESC = makeSorter('ANGLE.DOWN'); + +function headerRowRenderer(props: TableHeaderRowProps) { + return ( + <div + role="row" + className={props.className} + style={props.style} + > + {props.columns} + </div> + ); +} + +function headerRenderer(props: TableHeaderProps) { + const data: ColumnData = props.columnData; + const { sortBy, sortDirection, dataKey } = props; + const { icon, label, title, headerRef, headerMenu } = data; + const sorter = + dataKey === sortBy + ? (sortDirection === SortDirection.ASC ? sorterASC : sorterDESC) + : undefined; + return ( + <div + className='dome-xTable-header' + title={title} + ref={headerRef} + onContextMenu={headerMenu} + > + {headerIcon(icon)} + {headerLabel(label)} + {sorter} + </div> + ); +} + +// -------------------------------------------------------------------------- +// --- Column Resizer +// -------------------------------------------------------------------------- + +const DRAGGING = 'dome-xTable-resizer dome-color-dragging'; +const DRAGZONE = 'dome-xTable-resizer dome-color-dragzone'; + +interface ResizerProps { + dragging: boolean; // Currently dragging + position: number; // drag-start offset + offset: number; // current offset + onStart: Trigger; + onStop: Trigger; + onDrag: (offset: number) => void; +} + +const Resizer = (props: ResizerProps) => ( + <DraggableCore + onStart={props.onStart} + onStop={props.onStop} + onDrag={(_elt, data) => props.onDrag(data.x - props.position)} + > + <div + className={props.dragging ? DRAGGING : DRAGZONE} + style={{ left: props.position + props.offset - 2 }} + /> + </DraggableCore> +); + +type ResizeInfo = { id: string, fixed: boolean, left?: string, right?: string }; + +function makeResizers( + state: TableState<any, any>, + columns: Cprops[], +): null | JSX.Element[] { + if (columns.length < 2) return null; + const resizing: ResizeInfo[] = columns.map(({ id, fixed = false }) => ({ id, fixed })); + var k: number, cid; // last non-fixed from left/right + for (cid = undefined, k = 0; k < columns.length; k++) { + const r = resizing[k]; + r.left = cid; + if (!r.fixed) cid = r.id; + } + for (cid = undefined, k = columns.length - 1; 0 <= k; k--) { + const r = resizing[k]; + r.right = cid; + if (!r.fixed) cid = r.id; + } + const cwidth = columns.map(col => state.computeWidth(col.id)); + var position = 0, resizers = []; + for (k = 0; k < columns.length - 1; k++) { + const width = cwidth[k]; + if (!width) return null; + position += width; + const a = resizing[k]; + const b = resizing[k + 1]; + if ((!a.fixed || !b.fixed) && a.right && b.left) { + const index = k; // Otherwize use dynamic value of k + const rcol = a.right; + const lcol = b.left; + const offset = state.offset ?? 0; + const dragging = state.resizing === index; + const onStart = () => state.startResizing(index); + const onStop = () => state.stopResizing(); + const onDrag = (ofs: number) => state.setResizeOffset(lcol, rcol, ofs); + const resizer = ( + <Resizer + key={index} + dragging={dragging} + position={position} + offset={offset} + onStart={onStart} + onStop={onStop} + onDrag={onDrag} + /> + ); + resizers.push(resizer); + } + } + return resizers; +} + +// -------------------------------------------------------------------------- +// --- Virtualized Table View +// -------------------------------------------------------------------------- + +// Must be kept in sync with table.css +const CSS_HEADER_HEIGHT = 22; +const CSS_ROW_HEIGHT = 20; + +function makeTable<Key, Row>( + props: TableProps<Key, Row>, + state: TableState<Key, Row>, + size: Size, +) { + + const { width, height } = size; + const model = props.model; + const itemCount = model.getRowCount(); + const tableHeight = CSS_HEADER_HEIGHT + CSS_ROW_HEIGHT * itemCount; + const smallHeight = itemCount > 0 && tableHeight < height; + const rowCount = (smallHeight ? itemCount + 1 : itemCount); + const scrollTo = state.scrollToIndex(props.selection); + const cprops = makeCprops(state); + const columns = makeColumns(state, cprops); + const resizers = makeResizers(state, cprops); + + state.rowCount = rowCount; + if (state.width !== width) { + state.width = width; + setImmediate(state.forceUpdate); + } + + return ( + <div onKeyDown={state.onKeyDown}> + <VTable + ref={state.tableRef} + key="table" + displayName="React-Virtualized-Table" + width={width} + height={height} + rowCount={rowCount} + noRowsRenderer={props.renderEmpty} + rowGetter={state.rowGetter} + rowClassName={state.rowClassName} + rowHeight={CSS_ROW_HEIGHT} + headerHeight={CSS_HEADER_HEIGHT} + headerRowRenderer={headerRowRenderer} + onRowsRendered={state.onRowsRendered} + onRowClick={state.onRowClick} + onRowRightClick={state.onRowRightClick} + sortBy={state.sortBy} + sortDirection={state.sortDirection} + sort={state.onSorting} + scrollToIndex={scrollTo} + scrollToAlignment="auto" + > + {columns} + </VTable> + {resizers} + </div > + ); +}; + +// -------------------------------------------------------------------------- +// --- Table View +// -------------------------------------------------------------------------- + +/** Table View. + + This component is base on [React-Virtualized + Tables](https://bvaughn.github.io/react-virtualized/#/components/Table), + offering a lazy, super-optimized rendering process that scales on huge + datasets. + + A table shall be connected to an instance of [[Model]] class to retrieve the + data and get informed of data updates. + + The table children shall be instances of [[Column]] class, and can be grouped + into arbitrary level of React fragments or custom components. + + Clicking on table headers trigger re-ordering callback on the model with the + expected column and direction, unless disabled _via_ the column x + specification. However, actual sorting (and corresponding feedback on table + headers) would only take place if the model supports re-ordering and + eventually triggers a reload. + + Right-clicking the table headers displays a popup-menu with actions to reset + natural ordering, reset column widths and select column visibility. + + Tables do not control item selection state. Instead, you shall supply the + selection state and callback _via_ properties, like any other controlled + React components. + + Item selection can be based either on single-row or multiple-row. In case of + single-row selection (`multipleSelection:false`, the default), selection + state must be a single item or `undefined`, and the `onSelection` callback is + called with the same type of values. + + In case of multiple-row selection (`multipleSelection:true`), the selection + state shall be an _array_ of items, and `onSelection` callback also. Single + items are _not_ accepted, but `undefined` selection can be used in place of + an empty array. + + Clicking on a row triggers the `onSelection` callback with the updated + selection. In single-selection mode, the clicked item is sent to the + callback. In multiple-selection mode, key modifiers are taken into account + for determining the new slection. By default, the new selection only contains + the clicked item. If the `Shift` modifier has been pressed, the current + selection is extended with a range of items from the last selected one, to + the newly selected one. If the `CtrlOrCmd` modifier has been pressed, the + selection is extended with the newly clicked item. Clicking an already + selected item with the `CtrlOrCmd` modifier removes it from the current + selection. + + @template Key - unique identifiers of table entries @template Row - data + associated to each key in the table entries */ + +export function Table<Key, Row>(props: TableProps<Key, Row>) { + + const state = React.useMemo(() => new TableState<Key, Row>(), []); + const [age, setAge] = React.useState(0); + React.useEffect(() => { + state.signal = () => setAge(age + 1); + state.importSettings(props.settings); + state.setModel(props.model); + state.setSorting(props.sorting); + state.setRendering(props.rendering); + state.onSelection = props.onSelection; + state.onContextMenu = props.onContextMenu; + return state.unwind; + }); + Dome.useEvent('dome.defaults', state.clearSettings); + + return ( + <div className='dome-xTable'> + <ColumnContext.Provider value={state}> + {props.children} + </ColumnContext.Provider> + <AutoSizer key='table'> + {(size: Size) => makeTable(props, state, size)} + </AutoSizer> + </div> + ); +} + +// -------------------------------------------------------------------------- diff --git a/ivette/src/frama-c/states.ts b/ivette/src/frama-c/states.ts index 94ccba06ede83ea5138782453bd45370a351962d..e0515681e67ad61d75bc699cc26c539f0f19abc6 100644 --- a/ivette/src/frama-c/states.ts +++ b/ivette/src/frama-c/states.ts @@ -178,7 +178,7 @@ export function useRequest(rq: string, params: any, options: any = {}) { const footprint = project ? JSON.stringify([project, rq, params]) : undefined; async function trigger() { - if (project && rq && params) { + if (project && rq && params !== undefined) { try { const r = await Server.GET({ endpoint: rq, params }); setResponse(r); diff --git a/ivette/src/renderer/Controller.tsx b/ivette/src/renderer/Controller.tsx index 4fed24326a1c57f67b6f1f58c37ba84bea6c666c..f14fdadc91f84ee433a1d4ea56d33158d7395d3d 100644 --- a/ivette/src/renderer/Controller.tsx +++ b/ivette/src/renderer/Controller.tsx @@ -76,6 +76,10 @@ function buildServerConfig(argv: string[], cwd?: string) { }; } +function buildServerCommand(cmd: string) { + return buildServerConfig(cmd.trim().split(/[ \t\n]+/)); +} + function insertConfig(hs: string[], cfg: Server.Configuration) { const cmd = dumpServerConfig(cfg).trim(); const newhs = @@ -90,8 +94,20 @@ function insertConfig(hs: string[], cfg: Server.Configuration) { // --- Start Server on Command // -------------------------------------------------------------------------- +let reloadCommand: string | undefined; + +Dome.onReload(() => { + const hst = Dome.getWindowSetting('Controller.history'); + reloadCommand = Array.isArray(hst) && hst[0]; +}); + Dome.onCommand((argv: string[], cwd: string) => { - const cfg = buildServerConfig(argv, cwd); + let cfg; + if (reloadCommand) { + cfg = buildServerCommand(reloadCommand); + } else { + cfg = buildServerConfig(argv, cwd); + } Server.setConfig(cfg); Server.start(); }); @@ -189,9 +205,7 @@ const RenderConsole = () => { }; const doExec = () => { - const cmd = editor.getValue().trim(); - const argv = cmd.split(/[ \t\n]+/); - const cfg = buildServerConfig(argv); + const cfg = buildServerCommand(editor.getValue()); const hst = insertConfig(history, cfg); setHistory(hst); setCursor(-1); diff --git a/ivette/src/renderer/Properties.tsx b/ivette/src/renderer/Properties.tsx index c17bfe7f81d295327cbd6fd30da2ab7f6b5e5f3f..3061204a39bbad89fbd9a1b5213c57263769514e 100644 --- a/ivette/src/renderer/Properties.tsx +++ b/ivette/src/renderer/Properties.tsx @@ -5,68 +5,134 @@ import _ from 'lodash'; import React from 'react'; import * as States from 'frama-c/states'; +import * as Compare from 'dome/data/compare'; import { Label, Code } from 'dome/controls/labels'; import { ArrayModel } from 'dome/table/arrays'; -import { Table, DefineColumn } from 'dome/table/views'; +import { Table, Column, ColumnProps, Renderer } from 'dome/table/views'; import { Component } from 'frama-c/LabViews'; // -------------------------------------------------------------------------- // --- Property Columns // -------------------------------------------------------------------------- -const ColumnCode: any = - DefineColumn({ renderValue: (text: string) => <Code>{text}</Code> }); +export const renderCode: Renderer<string> = + (text?: string) => (text ? <Code>{text}</Code> : null); -const ColumnTag: any = - DefineColumn({ - renderValue: (l: { label: string; descr: string }) => ( - <Label label={l.label} title={l.descr} /> - ), - }); +function ColumnCode<Row>(props: ColumnProps<Row, string>) { + return <Column render={renderCode} {...props} />; +} + +interface Tag { name: string; label: string; descr: string } + +export const renderTag: Renderer<Tag> = + (d?: Tag) => (d ? <Label label={d.label} title={d.descr} /> : null); + +function ColumnTag<Row>(props: ColumnProps<Row, Tag>) { + return <Column render={renderTag} {...props} />; +} // -------------------------------------------------------------------------- // --- Properties Table // ------------------------------------------------------------------------- +interface SourceLoc { + file: string; + line: number; +} + +interface Property { + key: string; + descr: string; + kind: string; + status: string; + function?: string; + kinstr: string; + source: SourceLoc; +} + +const bySource = + Compare.byFields<SourceLoc>({ file: Compare.alpha, line: Compare.primitive }); + +const byStatus = + Compare.byRank( + 'inconsistent', + 'invalid', + 'invalid_under_hyp', + 'unknown', + 'valid_under_hyp', + 'valid', + 'invalid_but_dead', + 'unknown_but_dead', + 'valid_but_dead', + 'never_tried', + 'considered_valid', + ); + +const byProperty: Compare.ByFields<Property> = { + status: byStatus, + function: Compare.defined(Compare.alpha), + source: bySource, + kind: Compare.primitive, + key: Compare.primitive, + kinstr: Compare.primitive, +}; + +class PropertyModel extends ArrayModel<Property> { + constructor() { + super('key'); + this.setOrderingByFields(byProperty); + } +} + const RenderTable = () => { // Hooks - const model = React.useMemo(() => new ArrayModel(), []); - const items = States.useSyncArray('kernel.properties'); - const status = States.useDictionary('kernel.dictionary.propstatus'); - const [select, setSelect] = States.useSelection(); + const model = React.useMemo(() => new PropertyModel(), []); + const items: { [key: string]: Property } = + States.useSyncArray('kernel.properties'); + const statusDict: { [status: string]: Tag } = + States.useDictionary('kernel.dictionary.propstatus'); + const [select, setSelect] = + States.useSelection(); + React.useEffect(() => { - model.setData(_.toArray(items)); + const data = _.toArray(items); + model.replace(data); }, [model, items]); // Callbacks - const getStatus = ({ status: st }: any) => status[st] || { label: st }; - const selection = select ? items[select.marker] : undefined; - const onSelection = (item: any) => item && setSelect({ - marker: item.key, - function: item.function, - }); + const getStatus = React.useCallback( + ({ status: st }: Property) => (statusDict[st] ?? { label: st }), + [statusDict], + ); + + const onSelection = React.useCallback( + ({ key, function: fct }: Property) => { + setSelect({ marker: key, function: fct }); + }, [setSelect], + ); + + const selection = select?.marker; // Rendering return ( - <> - <Table - model={model} - selection={selection} - onSelection={onSelection} - scrollToItem={selection} - > - <ColumnCode id="function" label="Function" width={120} /> - <ColumnCode id="descr" label="Description" fill /> - <ColumnTag - id="status" - label="Status" - fixed - width={80} - align="center" - getValue={getStatus} - /> - </Table> - </> + <Table<string, Property> + model={model} + sorting={model} + selection={selection} + onSelection={onSelection} + settings="ivette.properties.table" + > + <ColumnCode id="function" label="Function" width={120} /> + <ColumnCode id="descr" label="Description" fill /> + <ColumnTag + id="status" + label="Status" + fixed + width={80} + align="center" + getter={getStatus} + /> + </Table> ); }; diff --git a/ivette/yarn.lock b/ivette/yarn.lock index 26425e68bbe7e3614e4d8c1ebfc3315a0ab914f1..7be39fad855443cb9b03db8b55b67c6a0b192ecf 100644 --- a/ivette/yarn.lock +++ b/ivette/yarn.lock @@ -1081,6 +1081,14 @@ dependencies: "@types/react" "*" +"@types/react-virtualized@^9.21.10": + version "9.21.10" + resolved "https://registry.yarnpkg.com/@types/react-virtualized/-/react-virtualized-9.21.10.tgz#cd072dc9c889291ace2c4c9de8e8c050da8738b7" + integrity sha512-f5Ti3A7gGdLkPPFNHTrvKblpsPNBiQoSorOEOD+JPx72g/Ng2lOt4MYfhvQFQNgyIrAro+Z643jbcKafsMW2ag== + dependencies: + "@types/prop-types" "*" + "@types/react" "*" + "@types/react@*", "@types/react@^16.9.17": version "16.9.32" resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.32.tgz#f6368625b224604148d1ddf5920e4fefbd98d383" @@ -7093,6 +7101,11 @@ react-draggable@^4.2.0: classnames "^2.2.5" prop-types "^15.6.0" +react-fast-compare@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb" + integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA== + react-hot-loader@^4.12.20: version "4.12.20" resolved "https://registry.yarnpkg.com/react-hot-loader/-/react-hot-loader-4.12.20.tgz#c2c42362a7578e5c30357a5ff7afa680aa0bef8a"