@rokkit/data 1.0.0-next.100

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/filter.js ADDED
@@ -0,0 +1,57 @@
1
+ import { filterOperations } from './constants'
2
+
3
+ /**
4
+ * Filters an array of objects based on a specified condition defined in the options.
5
+ * If a column is provided, it filters based on the value of that column. Otherwise, it filters
6
+ * based on a value present in any of the object's properties.
7
+ *
8
+ * @param {Object[]} data - The array of objects to filter.
9
+ * @param {Object} options - The filtering options that define the condition.
10
+ * @param {string} [options.column] - The property name in the objects to filter by. If omitted, all properties are considered.
11
+ * @param {string|number} [options.value] - The value to compare against when filtering.
12
+ * @param {string} [options.operator] - The operator to use for comparison, should match a key in the filterOperations object.
13
+ * @returns {Object[]} - The filtered array of objects.
14
+ */
15
+ export function filterObjectArray(data, options) {
16
+ const { column, value, operator } = options
17
+ // If no operator or value is given, return the data unchanged.
18
+ if (!operator || !value) return data
19
+ // If a column is specified, perform the filtering on that specific column.
20
+ if (column) return data.filter((row) => filterOperations[operator](row[column], value))
21
+
22
+ return data.filter((row) => findMatchingRow(row, value, operator))
23
+ }
24
+
25
+ /**
26
+ * Checks if a row matches the given value based on the specified operator.
27
+ * Supports both standard and negated operators by checking the operator string
28
+ * for a leading '!' character, which indicates negation.
29
+ *
30
+ * @param {Object} row - The row object in which to search for the value match.
31
+ * @param {any} value - The value to match against the row's properties.
32
+ * @param {string} operator - The operator used to compare the row's properties to the value.
33
+ * When operator starts with '!', it negates the result of the comparison.
34
+ * @returns {boolean} - Returns true if the row matches the condition; otherwise, returns false.
35
+ */
36
+ function findMatchingRow(row, value, operator) {
37
+ const op = operator.startsWith('!') ? operator.slice(1) : operator
38
+ const matched = Object.keys(row).some((key) => filterOperations[op](row[key], value))
39
+
40
+ return operator.startsWith('!') ? !matched : matched
41
+ }
42
+
43
+ /**
44
+ * Applies multiple filtering conditions to an array of objects. This function iterates through each filter option,
45
+ * successively narrowing down the data.
46
+ *
47
+ * @param {Object[]} data - The array of objects to filter.
48
+ * @param {Object[]} filters - An array of filtering options to apply to the data.
49
+ * @returns {Object[]} - The filtered array of objects that meet all conditions specified by the filters.
50
+ */
51
+ export function filterData(data, filters) {
52
+ let filteredData = data
53
+ filters.forEach((filter) => {
54
+ filteredData = filterObjectArray(filteredData, filter)
55
+ })
56
+ return filteredData
57
+ }
@@ -0,0 +1,56 @@
1
+ import { identity } from 'ramda'
2
+
3
+ const formatterMap = {
4
+ currency: getCurrencyFormatter,
5
+ integer: (language) => (val) => val.toLocaleString(language, { maximumFractionDigits: 0 }),
6
+ number: (language, decimalPlaces) => (val) =>
7
+ val.toLocaleString(language, {
8
+ minimumFractionDigits: decimalPlaces,
9
+ maximumFractionDigits: decimalPlaces
10
+ }),
11
+ date: (language) => (val) => val.toLocaleDateString(language),
12
+ time: (language) => (val) => val.toLocaleTimeString(language),
13
+ object: () => JSON.stringify,
14
+ array: () => JSON.stringify, // Combined formatter for 'object' and 'array'
15
+ ellipsis: () => () => '...' // Static formatter, no need for parameters
16
+ }
17
+
18
+ /**
19
+ * Returns a currency formatter function that formats a value as a currency string.
20
+ * @param {string} language - The IETF language tag used for locale-specific formatting.
21
+ * @param {number} decimalPlaces - The number of decimal places to show with currency formatting.
22
+ * @returns {function} - A currency formatter function that takes a value and returns a formatted string.
23
+ */
24
+ function getCurrencyFormatter(language, decimalPlaces) {
25
+ /**
26
+ * Formats a value as a currency string with the desired decimal places.
27
+ *
28
+ * @param {number} val - The value to format as a currency string.
29
+ * @param {string} [currency='USD'] - The currency code to use for formatting.
30
+ * @returns {string} - The formatted currency string.
31
+ */
32
+ const formatter = (val, currency = 'USD') =>
33
+ val.toLocaleString(language, {
34
+ style: 'currency',
35
+ currency,
36
+ minimumFractionDigits: decimalPlaces,
37
+ maximumFractionDigits: decimalPlaces
38
+ })
39
+ return formatter
40
+ }
41
+
42
+ /**
43
+ * Creates a formatter function that formats a value according to the specified type and optional locale settings.
44
+ * Supported types include 'default' (no formatting), 'integer', 'number', 'date', 'time',, 'object', 'array', 'currency' & 'ellipsis''
45
+ *
46
+ * @param {string} type - The type of the formatter to use (e.g., 'integer', 'number', 'date', 'time', 'currency').
47
+ * @param {string} [language='en-US'] - Optional IETF language tag used for locale-specific formatting.
48
+ * @param {number} [decimalPlaces=2] - Optional number of decimal places to show with number and currency formatting.
49
+ * @returns {*} - A format function that takes a value and returns a formatted string.
50
+ */
51
+ export function createFormatter(type, language = 'en-US', decimalPlaces = 2) {
52
+ const formatter = formatterMap[type]
53
+ if (formatter) return formatter(language, decimalPlaces)
54
+
55
+ return identity
56
+ }
@@ -0,0 +1,136 @@
1
+ import { defaultViewOptions } from './constants'
2
+ /**
3
+ * Removes objects from an array where the parent attribute is not null.
4
+ * @param {Array<Object>} arr - The array of objects.
5
+ */
6
+ export function removeChildren(arr) {
7
+ for (let i = arr.length - 1; i >= 0; i--) {
8
+ if (arr[i].parent) arr.splice(i, 1)
9
+ }
10
+ }
11
+ /**
12
+ * Flatten nested children arrays within an array of objects.
13
+ * @param {Array<Object>} arr - The array of objects.
14
+ */
15
+ export function flattenNestedChildren(arr) {
16
+ let index = 0
17
+ while (index < arr.length) {
18
+ if (Array.isArray(arr[index].children) && arr[index].children.length > 0) {
19
+ const children = arr[index].children
20
+ arr.splice(index + 1, 0, ...children)
21
+ }
22
+ index++
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Recursively updates parent items' flags based on the child's flags.
28
+ * @param {Object} item - The current object.
29
+ */
30
+ function updateParentFlags(item) {
31
+ if (!item.parent) return
32
+ if (item.parent.excluded) {
33
+ item.parent.excluded = false
34
+ item.parent.retainedByChild = true
35
+
36
+ updateParentFlags(item.parent)
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Applies a hierarchical filter to an array of objects.
42
+ * @param {Array<Object>} arr - The array of objects.
43
+ * @param {Function} filterFn - The filter function to apply on each object.
44
+ */
45
+ export function hierarchicalFilter(arr, filterFn) {
46
+ arr.forEach((item) => {
47
+ item.excluded = !filterFn(item)
48
+ })
49
+
50
+ arr.forEach((item) => {
51
+ if (!item.excluded) updateParentFlags(item)
52
+ })
53
+ }
54
+
55
+ /**
56
+ * Derives the hierarchy from the data.
57
+ *
58
+ * @param {Array} data - The data to derive the hierarchy from.
59
+ * @param {string} path - The column name to be used as hierarchical path.
60
+ * @param {string} separator - The separator to be used in the path.
61
+ * @returns {Array<import('./types').Hierarchy>} - The derived hierarchy.
62
+ */
63
+ export function deriveHierarchy(data, options) {
64
+ const { expanded, path, separator } = { ...defaultViewOptions, ...options }
65
+ if (!path) return data.map((row) => ({ depth: 0, row }))
66
+
67
+ const hierarchy = data.map((row) => {
68
+ const parts = row[path].split(separator).filter((part) => part.length > 0)
69
+ const depth = parts.length
70
+ const value = depth > 0 ? parts[depth - 1] : ''
71
+ return { depth, value, path: row[path], row }
72
+ })
73
+
74
+ hierarchy.forEach((row) => {
75
+ row.children = hierarchy.filter(
76
+ (child) => child.path.startsWith(row.path) && row.depth === child.depth - 1
77
+ )
78
+
79
+ row.isParent = row.children.length > 0
80
+ if (row.isParent) {
81
+ row.isExpanded = expanded
82
+ row.isHidden = !row.parent ? false : row.parent.isHidden || !row.parent.isExpanded
83
+ }
84
+ row.children.forEach((child) => {
85
+ child.parent = row
86
+ child.isHidden = row.isHidden || !row.isExpanded
87
+ })
88
+ })
89
+ return hierarchy
90
+ }
91
+
92
+ /**
93
+ * Recursively sets the visibility of child nodes based on the hidden and expanded state
94
+ * of their parent node.
95
+ *
96
+ * @param {Object} node - The parent node whose children's visibility will be changed.
97
+ * The node is expected to have a 'children' property which is an
98
+ * array of child nodes, a 'isHidden' property indicating if the
99
+ * node is hidden, and a 'isExpanded' property indicating if the
100
+ * node is expanded.
101
+ */
102
+ function changeVisibilityForChildren(node) {
103
+ if (node.children) {
104
+ node.children.forEach((child) => {
105
+ child.isHidden = node.isHidden || !node.isExpanded
106
+ changeVisibilityForChildren(child)
107
+ })
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Toggles the expansion state of a node and updates the visibility of its children accordingly.
113
+ * If the node is a parent (has child nodes), it will invert its 'isExpanded' property and call
114
+ * 'changeVisibilityForChildren' to reflect changes to its child nodes.
115
+ *
116
+ * @param {Object} node - The node to toggle expansion for. The node is expected to have an
117
+ * 'isParent' property indicating if the node has children, an
118
+ * 'isExpanded' property indicating if the node is expanded, and an array
119
+ * of child nodes under the 'children' property if it is a parent.
120
+ */
121
+ export function toggleExpansion(node) {
122
+ if (node.isParent) {
123
+ node.isExpanded = !node.isExpanded
124
+ changeVisibilityForChildren(node)
125
+ }
126
+ }
127
+
128
+ // function hierarchy(data, options){
129
+ // const nodes = deriveHierarchy(data, options)
130
+
131
+ // return {
132
+ // nodes,
133
+ // filter: (fn) => hierarchicalFilter(nodes, fn),
134
+ // selection:
135
+ // }
136
+ // }
package/src/index.js ADDED
@@ -0,0 +1,7 @@
1
+ // skipcq: JS-E1004 - Needed for exposing all types
2
+ export * from './types'
3
+ export { typeOf } from './utils'
4
+ export { renamer } from './renamer'
5
+ export { model } from './model'
6
+ export { dataset } from './dataset'
7
+ export { dataview } from './view'
package/src/infer.js ADDED
@@ -0,0 +1,263 @@
1
+ import { ascending, descending } from 'd3-array'
2
+ import { compact } from '@rokkit/core'
3
+ import { omit } from 'ramda'
4
+ import { createFormatter } from './formatter'
5
+ import { defaultViewOptions, defaultActionOrder } from './constants'
6
+ import { typeOf } from './utils'
7
+ import { deriveColumnProperties } from './metadata'
8
+
9
+ /**
10
+ * Derives the sorting information for a column based on the type of the input value.
11
+ *
12
+ * @param {import('./types').SortableColumn} value - The value that determines the sorting behavior of the column.
13
+ * @returns {import('./types').ColumnSorter} An object containing the 'name' of the column and a 'sorter' function.
14
+ * @throws {Error} Throws an error if the value type is not supported or the value format is invalid.
15
+ */
16
+ export function deriveSortableColumn(value) {
17
+ const type = typeOf(value)
18
+
19
+ const configGenerators = {
20
+ array: arrayConfigGenerator,
21
+ object: objectConfigGenerator,
22
+ string: stringConfigGenerator
23
+ }
24
+
25
+ const configGenerator = configGenerators[type]
26
+ if (!configGenerator) {
27
+ throw new Error(`Invalid value type: ${type}`)
28
+ }
29
+
30
+ return configGenerator(value)
31
+ }
32
+
33
+ /**
34
+ * Generates a sorting configuration for an array value.
35
+ *
36
+ * @param {Array} value - The array from which to derive a sorting configuration.
37
+ * @returns {Object} A validated sorting configuration based on the array.
38
+ */
39
+ function arrayConfigGenerator(value) {
40
+ arrayValidator(value)
41
+ return { name: value[0], sorter: value[1] ? ascending : descending }
42
+ }
43
+
44
+ /**
45
+ * Generates a sorting configuration for an object value.
46
+ *
47
+ * @param {Object} value - The object from which to derive a sorting configuration.
48
+ * @returns {Object} A validated sorting configuration based on the object.
49
+ */
50
+ function objectConfigGenerator(value) {
51
+ objectValidator(value)
52
+ return { name: value.name, sorter: value.sorter ?? ascending }
53
+ }
54
+
55
+ /**
56
+ * Generates a sorting configuration for a string value.
57
+ *
58
+ * @param {string} value - The string from which to derive a sorting configuration.
59
+ * @returns {Object} A sorting configuration using the string name and ascending sort function.
60
+ */
61
+ function stringConfigGenerator(value) {
62
+ return { name: value, sorter: ascending }
63
+ }
64
+
65
+ /**
66
+ * Validates an array to ensure it can be used to create a sorting configuration.
67
+ *
68
+ * @param {Array} value - The array to validate.
69
+ * @throws {Error} Throws an error if the array does not correspond to a [string, boolean] pair.
70
+ */
71
+ function arrayValidator(value) {
72
+ if (value.length !== 2 || typeof value[0] !== 'string' || typeof value[1] !== 'boolean') {
73
+ throw new Error('Array should be a pair of [string, boolean]')
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Validates an object to ensure it has the necessary properties for creating a sorting configuration.
79
+ *
80
+ * @param {Object} value - The object to validate.
81
+ * @throws {Error} Throws an error if the object does not have a 'name' property or has an invalid 'sorter'.
82
+ */
83
+ function objectValidator(value) {
84
+ if (typeof value.name !== 'string') {
85
+ throw new Error('The property "name" is required and must be a string')
86
+ }
87
+ if (value.sorter !== undefined && typeof value.sorter !== 'function') {
88
+ throw new Error('Sorter should be a function')
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Creates a deep scan sample object that contains a union of all keys from all
94
+ * objects in the array, pulling the values from the last item that contains the key.
95
+ *
96
+ * @param {Array<Object>} data - An array of objects to deep scan and collect sample values.
97
+ * @returns {Object} A sample object consolidating all unique keys with their latest non-null values.
98
+ */
99
+ export function getDeepScanSample(data) {
100
+ return data.reduce(
101
+ (acc, cur) => ({
102
+ ...acc,
103
+ ...compact(omit(Object.keys(acc), cur))
104
+ }),
105
+ {}
106
+ )
107
+ }
108
+ /**
109
+ * Derives a unique list of column names from an array of data objects.
110
+ * Each data object represents a row and this function compiles a list of all unique property keys
111
+ * present across these objects.
112
+ *
113
+ * @param {Object[]} data - The array of data objects to analyze for column names.
114
+ * @returns {string[]} - An array of strings representing the unique column names found across all data objects.
115
+ */
116
+ export function deriveColumns(data, options) {
117
+ if (data.length === 0) return []
118
+
119
+ const { scanMode } = { ...defaultViewOptions, ...options }
120
+ const sample = scanMode === 'fast' ? data[0] : getDeepScanSample(data)
121
+
122
+ return deriveColumnProperties(sample, options)
123
+ }
124
+
125
+ /**
126
+ * Derives the data types of columns based on the data provided in an array of objects.
127
+ * The function assumes that all values of a single field (column) are of the same type. It uses
128
+ * the first object in the data array as a reference for the field names. Each field is categorized
129
+ * as either 'string' or 'number' based on whether any of the field values in the data set are non-numeric.
130
+ *
131
+ * @param {Object[]} data - The array of data objects to assess for column data types.
132
+ * @returns {Object} - An object with keys being the data types ('string' & 'number') and values
133
+ * being arrays of field names that correspond to that data type.
134
+ */
135
+ export function deriveDataTypes(data) {
136
+ const dataTypes = Object.keys(data[0])
137
+ .map((field) => ({
138
+ field,
139
+ type: data.map((d) => d[field]).some(isNaN) ? 'string' : 'number'
140
+ }))
141
+ .reduce((acc, cur) => ({ ...acc, [cur.type]: [...acc[cur.type], cur.field] }), {
142
+ string: [],
143
+ number: []
144
+ })
145
+ return dataTypes
146
+ }
147
+
148
+ /**
149
+ * Infers the data type of a set of values by examining the values in the array.
150
+ * Assumes all values in the array should have the same type, unless they are 'null'.
151
+ * If multiple types are detected (excluding 'null'), the function will return 'mixed'.
152
+ * If all values are 'null', it returns 'null', and it returns 'undefined' for an empty array.
153
+ *
154
+ * @param {Array} values - An array of values for which the data type is to be inferred.
155
+ * @returns {string} - A string representing the inferred data type. Possible values are
156
+ * 'undefined', 'null', 'mixed', or any data type returned by the getType function.
157
+ */
158
+ export function inferDataType(values) {
159
+ if (values.length === 0) {
160
+ return 'undefined'
161
+ }
162
+
163
+ const nonNullValues = values.filter((value) => value !== null)
164
+
165
+ if (nonNullValues.length === 0) {
166
+ return 'null'
167
+ }
168
+
169
+ let type = typeOf(nonNullValues[0])
170
+
171
+ for (let i = 1; i < nonNullValues.length; i++) {
172
+ if (typeOf(nonNullValues[i]) !== type) {
173
+ type = 'mixed'
174
+ break
175
+ }
176
+ }
177
+
178
+ return type
179
+ }
180
+
181
+ /**
182
+ * Derives column metadata from the data to be used in a tabular component.
183
+ *
184
+ * @param {Array} data - The data to derive column metadata from.
185
+ * @returns {Array<import('./types').ColumnMetadata>} - The derived column metadata.
186
+ */
187
+ export function deriveMetadata(dataArray, options = {}) {
188
+ let { columns = [] } = options
189
+ if (dataArray.length === 0) return columns
190
+ const { actions, language } = { ...defaultViewOptions, ...options }
191
+
192
+ if (columns.length === 0) {
193
+ columns = deriveColumns(dataArray, options)
194
+ } else {
195
+ columns.forEach((col) => {
196
+ // if (col.sortable && !col.order) col.order = 'none'
197
+ if (!col.fields) col.fields = { text: col.name }
198
+ })
199
+ }
200
+ columns = addFormatters(columns, language)
201
+ if (actions.length > 0) columns = deriveActions(columns, actions)
202
+
203
+ return columns
204
+ }
205
+
206
+ /**
207
+ * Adds a formatter function to each column in the array based on the column type. If a formatter
208
+ * function is already present, it will not be overwritten. The formatter is created based on the
209
+ * override formatter if present, or the column type and language.
210
+ *
211
+ * @param {Array<import('./types').ColumnMetadata>} columns - The array of column metadata to add formatters to.
212
+ * @param {string} language - The language to use for formatting.
213
+ * @returns {Array<import('./types').ColumnMetadata>} - The array of column metadata with formatters added.
214
+ */
215
+ export function addFormatters(columns, language) {
216
+ return columns.map((column) => {
217
+ const formatter = column.formatter ?? column.type
218
+ if (typeof formatter !== 'function') {
219
+ const digits = column.digits ?? (['number', 'currency'].includes(column.type) ? 2 : 0)
220
+ column.formatter = createFormatter(formatter, language, digits)
221
+ }
222
+ return column
223
+ })
224
+ }
225
+
226
+ /**
227
+ * Converts an array of action names to an array of action objects.
228
+ *
229
+ * @param {Array<string|import('./types').Action>} input - The input to convert to actions.
230
+ * @returns {Array<import('./types').Action>} - The converted actions.
231
+ */
232
+ export function convertToActions(input) {
233
+ if (!Array.isArray(input)) throw new Error('Actions must be an array')
234
+ const actions = input
235
+ .map((action) => {
236
+ if (typeof action === 'string') {
237
+ return { name: action, label: action, order: defaultActionOrder[action] }
238
+ }
239
+ return { order: defaultActionOrder[action.name], ...action }
240
+ })
241
+ .sort((a, b) => a.order - b.order)
242
+ return actions
243
+ }
244
+ /**
245
+ * Adds actions to the column metadata. This function updates the columns array in place.
246
+ *
247
+ * @param {Array<import('./types').ColumnMetadata>} columns - The column metadata to update.
248
+ * @param {Array<string|import('./types').Action>} input - The actions to add to the column metadata.
249
+ * @returns {Array<import('./types').ColumnMetadata>} - The updated column metadata.
250
+ */
251
+ export function deriveActions(columns, input) {
252
+ // convert array of strings to array of objects
253
+ const actions = convertToActions(input)
254
+ const existing = columns.filter(({ action }) => action).map(({ action }) => action)
255
+
256
+ const actionColumns = actions
257
+ .filter(({ name }) => !existing.includes(name))
258
+ .map(({ name, label }) => ({
259
+ label,
260
+ action: name
261
+ }))
262
+ return [...actionColumns, ...columns]
263
+ }