@oclif/table 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2018 Salesforce.com
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,23 @@
1
+ <img src="https://user-images.githubusercontent.com/449385/38243295-e0a47d58-372e-11e8-9bc0-8c02a6f4d2ac.png" width="260" height="73">
2
+
3
+ [![Version](https://img.shields.io/npm/v/@oclif/table.svg)](https://npmjs.org/package/@oclif/table)
4
+ [![Downloads/week](https://img.shields.io/npm/dw/@oclif/table.svg)](https://npmjs.org/package/@oclif/table)
5
+ [![License](https://img.shields.io/npm/l/@oclif/table.svg)](https://github.com/oclif/table/blob/main/LICENSE)
6
+
7
+ # Description
8
+
9
+ Print tables to the terminal using [ink](https://www.npmjs.com/package/ink)
10
+
11
+ # Examples
12
+
13
+ You can see examples of how to use it in the [examples](./examples/) directory.
14
+
15
+ You can run any of these with the following:
16
+
17
+ ```
18
+ tsx examples/basic.ts
19
+ ```
20
+
21
+ # Contributing
22
+
23
+ See the [contributing guide](./CONRTIBUTING.md).
package/lib/index.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { makeTable, makeTables } from './table.js';
2
+ export type { TableProps } from './types.js';
package/lib/index.js ADDED
@@ -0,0 +1 @@
1
+ export { makeTable, makeTables } from './table.js';
@@ -0,0 +1,17 @@
1
+ export declare const BORDER_STYLES: readonly ["all", "headers-only-with-outline", "headers-only-with-underline", "headers-only", "horizontal-with-outline", "horizontal", "none", "outline", "vertical-with-outline", "vertical"];
2
+ export type BorderStyle = (typeof BORDER_STYLES)[number];
3
+ type Skeleton = {
4
+ cross: string;
5
+ left: string;
6
+ line: string;
7
+ right: string;
8
+ };
9
+ export declare const BORDER_SKELETONS: Record<BorderStyle, {
10
+ data: Skeleton;
11
+ footer: Skeleton;
12
+ header: Skeleton;
13
+ heading: Skeleton;
14
+ separator: Skeleton;
15
+ headerFooter?: Skeleton;
16
+ }>;
17
+ export {};
@@ -0,0 +1,358 @@
1
+ export const BORDER_STYLES = [
2
+ 'all',
3
+ 'headers-only-with-outline',
4
+ 'headers-only-with-underline',
5
+ 'headers-only',
6
+ 'horizontal-with-outline',
7
+ 'horizontal',
8
+ 'none',
9
+ 'outline',
10
+ 'vertical-with-outline',
11
+ 'vertical',
12
+ ];
13
+ export const BORDER_SKELETONS = {
14
+ all: {
15
+ data: {
16
+ cross: '│',
17
+ left: '│',
18
+ line: ' ',
19
+ right: '│',
20
+ },
21
+ footer: {
22
+ cross: '┴',
23
+ left: '└',
24
+ line: '─',
25
+ right: '┘',
26
+ },
27
+ header: {
28
+ cross: '┬',
29
+ left: '┌',
30
+ line: '─',
31
+ right: '┐',
32
+ },
33
+ heading: {
34
+ cross: '│',
35
+ left: '│',
36
+ line: ' ',
37
+ right: '│',
38
+ },
39
+ separator: {
40
+ cross: '┼',
41
+ left: '├',
42
+ line: '─',
43
+ right: '┤',
44
+ },
45
+ },
46
+ 'headers-only': {
47
+ data: {
48
+ cross: ' ',
49
+ left: ' ',
50
+ line: ' ',
51
+ right: ' ',
52
+ },
53
+ footer: {
54
+ cross: '',
55
+ left: '',
56
+ line: '',
57
+ right: '',
58
+ },
59
+ header: {
60
+ cross: '─',
61
+ left: '┌',
62
+ line: '─',
63
+ right: '┐',
64
+ },
65
+ headerFooter: {
66
+ cross: '─',
67
+ left: '└',
68
+ line: '─',
69
+ right: '┘',
70
+ },
71
+ heading: {
72
+ cross: ' ',
73
+ left: '│',
74
+ line: ' ',
75
+ right: '│',
76
+ },
77
+ separator: {
78
+ cross: '',
79
+ left: '',
80
+ line: '',
81
+ right: '',
82
+ },
83
+ },
84
+ 'headers-only-with-outline': {
85
+ data: {
86
+ cross: ' ',
87
+ left: '│',
88
+ line: ' ',
89
+ right: '│',
90
+ },
91
+ footer: {
92
+ cross: '─',
93
+ left: '└',
94
+ line: '─',
95
+ right: '┘',
96
+ },
97
+ header: {
98
+ cross: '─',
99
+ left: '┌',
100
+ line: '─',
101
+ right: '┐',
102
+ },
103
+ headerFooter: {
104
+ cross: '─',
105
+ left: '├',
106
+ line: '─',
107
+ right: '┤',
108
+ },
109
+ heading: {
110
+ cross: ' ',
111
+ left: '│',
112
+ line: ' ',
113
+ right: '│',
114
+ },
115
+ separator: {
116
+ cross: '',
117
+ left: '',
118
+ line: '',
119
+ right: '',
120
+ },
121
+ },
122
+ 'headers-only-with-underline': {
123
+ data: {
124
+ cross: ' ',
125
+ left: ' ',
126
+ line: ' ',
127
+ right: ' ',
128
+ },
129
+ footer: {
130
+ cross: '',
131
+ left: '',
132
+ line: '',
133
+ right: '',
134
+ },
135
+ header: {
136
+ cross: '',
137
+ left: '',
138
+ line: '',
139
+ right: '',
140
+ },
141
+ headerFooter: {
142
+ cross: '─',
143
+ left: ' ',
144
+ line: '─',
145
+ right: ' ',
146
+ },
147
+ heading: {
148
+ cross: ' ',
149
+ left: ' ',
150
+ line: ' ',
151
+ right: ' ',
152
+ },
153
+ separator: {
154
+ cross: '',
155
+ left: '',
156
+ line: '',
157
+ right: '',
158
+ },
159
+ },
160
+ horizontal: {
161
+ data: {
162
+ cross: ' ',
163
+ left: ' ',
164
+ line: ' ',
165
+ right: ' ',
166
+ },
167
+ footer: {
168
+ cross: '─',
169
+ left: '─',
170
+ line: '─',
171
+ right: '─',
172
+ },
173
+ header: {
174
+ cross: ' ',
175
+ left: ' ',
176
+ line: ' ',
177
+ right: ' ',
178
+ },
179
+ heading: {
180
+ cross: ' ',
181
+ left: ' ',
182
+ line: ' ',
183
+ right: ' ',
184
+ },
185
+ separator: {
186
+ cross: '─',
187
+ left: '─',
188
+ line: '─',
189
+ right: '─',
190
+ },
191
+ },
192
+ 'horizontal-with-outline': {
193
+ data: {
194
+ cross: ' ',
195
+ left: '│',
196
+ line: ' ',
197
+ right: '│',
198
+ },
199
+ footer: {
200
+ cross: '─',
201
+ left: '└',
202
+ line: '─',
203
+ right: '┘',
204
+ },
205
+ header: {
206
+ cross: '─',
207
+ left: '┌',
208
+ line: '─',
209
+ right: '┐',
210
+ },
211
+ heading: {
212
+ cross: ' ',
213
+ left: '│',
214
+ line: ' ',
215
+ right: '│',
216
+ },
217
+ separator: {
218
+ cross: '─',
219
+ left: '├',
220
+ line: '─',
221
+ right: '┤',
222
+ },
223
+ },
224
+ none: {
225
+ data: {
226
+ cross: ' ',
227
+ left: ' ',
228
+ line: ' ',
229
+ right: ' ',
230
+ },
231
+ footer: {
232
+ cross: ' ',
233
+ left: ' ',
234
+ line: ' ',
235
+ right: ' ',
236
+ },
237
+ header: {
238
+ cross: ' ',
239
+ left: ' ',
240
+ line: ' ',
241
+ right: ' ',
242
+ },
243
+ heading: {
244
+ cross: ' ',
245
+ left: ' ',
246
+ line: ' ',
247
+ right: ' ',
248
+ },
249
+ separator: {
250
+ cross: '',
251
+ left: '',
252
+ line: '',
253
+ right: '',
254
+ },
255
+ },
256
+ outline: {
257
+ data: {
258
+ cross: ' ',
259
+ left: '│',
260
+ line: ' ',
261
+ right: '│',
262
+ },
263
+ footer: {
264
+ cross: '─',
265
+ left: '└',
266
+ line: '─',
267
+ right: '┘',
268
+ },
269
+ header: {
270
+ cross: '─',
271
+ left: '┌',
272
+ line: '─',
273
+ right: '┐',
274
+ },
275
+ heading: {
276
+ cross: ' ',
277
+ left: '│',
278
+ line: ' ',
279
+ right: '│',
280
+ },
281
+ separator: {
282
+ cross: '',
283
+ left: '',
284
+ line: '',
285
+ right: '',
286
+ },
287
+ },
288
+ vertical: {
289
+ data: {
290
+ cross: '│',
291
+ left: '│',
292
+ line: ' ',
293
+ right: '│',
294
+ },
295
+ footer: {
296
+ cross: '',
297
+ left: '',
298
+ line: '',
299
+ right: '',
300
+ },
301
+ header: {
302
+ cross: ' ',
303
+ left: ' ',
304
+ line: ' ',
305
+ right: ' ',
306
+ },
307
+ heading: {
308
+ cross: '│',
309
+ left: '│',
310
+ line: ' ',
311
+ right: '│',
312
+ },
313
+ separator: {
314
+ cross: '',
315
+ left: '',
316
+ line: '',
317
+ right: '',
318
+ },
319
+ },
320
+ 'vertical-with-outline': {
321
+ data: {
322
+ cross: '│',
323
+ left: '│',
324
+ line: ' ',
325
+ right: '│',
326
+ },
327
+ footer: {
328
+ cross: '┴',
329
+ left: '└',
330
+ line: '─',
331
+ right: '┘',
332
+ },
333
+ header: {
334
+ cross: '┬',
335
+ left: '┌',
336
+ line: '─',
337
+ right: '┐',
338
+ },
339
+ headerFooter: {
340
+ cross: '┼',
341
+ left: '├',
342
+ line: '─',
343
+ right: '┤',
344
+ },
345
+ heading: {
346
+ cross: '│',
347
+ left: '│',
348
+ line: ' ',
349
+ right: '│',
350
+ },
351
+ separator: {
352
+ cross: '',
353
+ left: '',
354
+ line: '',
355
+ right: '',
356
+ },
357
+ },
358
+ };
package/lib/table.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ import React from 'react';
2
+ import { CellProps, ContainerProps, ScalarDict, TableProps } from './types.js';
3
+ export declare function Table<T extends ScalarDict>(props: TableProps<T>): React.JSX.Element;
4
+ /**
5
+ * Renders the header of a table.
6
+ */
7
+ export declare function Header(props: React.PropsWithChildren): React.JSX.Element;
8
+ /**
9
+ * Renders a cell in the table.
10
+ */
11
+ export declare function Cell(props: CellProps): React.JSX.Element;
12
+ /**
13
+ * Renders the scaffold of the table.
14
+ */
15
+ export declare function Skeleton(props: React.PropsWithChildren & {
16
+ readonly height?: number;
17
+ }): React.JSX.Element;
18
+ /**
19
+ * Renders a table with the given data.
20
+ * @param options see {@link TableProps}
21
+ */
22
+ export declare function makeTable<T extends ScalarDict>(options: TableProps<T>): void;
23
+ export declare function makeTables<T extends ScalarDict[]>(tables: {
24
+ [P in keyof T]: TableProps<T[P]>;
25
+ }, options?: Omit<ContainerProps, 'children'>): void;
package/lib/table.js ADDED
@@ -0,0 +1,238 @@
1
+ /* eslint-disable react/prop-types */
2
+ import cliTruncate from 'cli-truncate';
3
+ import { Box, Text, render } from 'ink';
4
+ import { sha1 } from 'object-hash';
5
+ import React from 'react';
6
+ import stripAnsi from 'strip-ansi';
7
+ import wrapAnsi from 'wrap-ansi';
8
+ import { BORDER_SKELETONS } from './skeletons.js';
9
+ import { allKeysInCollection, getColumns, getHeadings, intersperse, maybeStripAnsi, sortData } from './utils.js';
10
+ /**
11
+ * Determines the configured width based on the provided width value.
12
+ * If no width is provided, it returns the width of the current terminal.
13
+ * If the provided width is a percentage, it calculates the width based on the percentage of the terminal width.
14
+ * If the provided width is a number, it returns the provided width.
15
+ * If the calculated width is greater than the terminal width, it returns the terminal width.
16
+ *
17
+ * @param providedWidth - The width value provided.
18
+ * @returns The determined configured width.
19
+ */
20
+ function determineConfiguredWidth(providedWidth, columns = process.stdout.columns) {
21
+ if (!providedWidth)
22
+ return columns;
23
+ const num = typeof providedWidth === 'string' && providedWidth.endsWith('%')
24
+ ? Math.floor((Number.parseInt(providedWidth, 10) / 100) * columns)
25
+ : typeof providedWidth === 'string'
26
+ ? Number.parseInt(providedWidth, 10)
27
+ : providedWidth;
28
+ if (num > columns) {
29
+ return columns;
30
+ }
31
+ return num;
32
+ }
33
+ /**
34
+ * Determine the width to use for the table.
35
+ *
36
+ * This allows us to use the minimum width required to display the table if the configured width is too small.
37
+ */
38
+ function determineWidthToUse(columns, configuredWidth) {
39
+ const tableWidth = columns.map((c) => c.width).reduce((a, b) => a + b) + columns.length + 1;
40
+ return tableWidth < configuredWidth ? configuredWidth : tableWidth;
41
+ }
42
+ export function Table(props) {
43
+ const { data, filter, horizontalAlignment = 'left', maxWidth, noStyle = false, orientation = 'horizontal', overflow = 'truncate', padding = 1, sort, title, verticalAlignment = 'top', } = props;
44
+ const headerOptions = noStyle ? {} : { bold: true, color: 'blue', ...props.headerOptions };
45
+ const borderStyle = noStyle ? 'none' : (props.borderStyle ?? 'all');
46
+ const borderColor = noStyle ? undefined : props.borderColor;
47
+ const borderProps = { color: borderColor };
48
+ const titleOptions = noStyle ? {} : props.titleOptions;
49
+ const processedData = maybeStripAnsi(sortData(filter ? data.filter((row) => filter(row)) : data, sort), noStyle);
50
+ const config = {
51
+ borderStyle,
52
+ columns: props.columns ?? allKeysInCollection(data),
53
+ data: processedData,
54
+ headerOptions,
55
+ horizontalAlignment,
56
+ maxWidth: determineConfiguredWidth(maxWidth),
57
+ overflow,
58
+ padding,
59
+ verticalAlignment,
60
+ };
61
+ const headings = getHeadings(config);
62
+ const columns = getColumns(config, headings);
63
+ const dataComponent = row({
64
+ borderProps,
65
+ cell: Cell,
66
+ skeleton: BORDER_SKELETONS[config.borderStyle].data,
67
+ });
68
+ const footerComponent = row({
69
+ borderProps,
70
+ cell: Skeleton,
71
+ props: borderProps,
72
+ skeleton: BORDER_SKELETONS[config.borderStyle].footer,
73
+ });
74
+ const headerComponent = row({
75
+ borderProps,
76
+ cell: Skeleton,
77
+ props: borderProps,
78
+ skeleton: BORDER_SKELETONS[config.borderStyle].header,
79
+ });
80
+ const { headerFooter } = BORDER_SKELETONS[config.borderStyle];
81
+ const headerFooterComponent = headerFooter
82
+ ? row({
83
+ borderProps,
84
+ cell: Skeleton,
85
+ props: borderProps,
86
+ skeleton: headerFooter,
87
+ })
88
+ : () => false;
89
+ const headingComponent = row({
90
+ borderProps,
91
+ cell: Header,
92
+ props: config.headerOptions,
93
+ skeleton: BORDER_SKELETONS[config.borderStyle].heading,
94
+ });
95
+ const separatorComponent = row({
96
+ borderProps,
97
+ cell: Skeleton,
98
+ props: borderProps,
99
+ skeleton: BORDER_SKELETONS[config.borderStyle].separator,
100
+ });
101
+ if (orientation === 'vertical') {
102
+ return (React.createElement(Box, { flexDirection: "column", width: determineWidthToUse(columns, config.maxWidth), paddingBottom: 1 },
103
+ title && React.createElement(Text, { ...titleOptions }, title),
104
+ processedData.map((row, index) => {
105
+ // Calculate the hash of the row based on its value and position
106
+ const key = `row-${sha1(row)}-${index}`;
107
+ const maxKeyLength = Math.max(...Object.values(headings).map((c) => c.length));
108
+ // Construct a row.
109
+ return (React.createElement(Box, { key: key, borderTop: true, borderBottom: false, borderLeft: false, borderRight: false, flexDirection: "column", borderStyle: noStyle ? undefined : 'single', borderColor: borderColor }, columns.map((column) => {
110
+ const value = (row[column.column] ?? '').toString();
111
+ const keyName = (headings[column.key] ?? column.key).toString();
112
+ const keyPadding = ' '.repeat(maxKeyLength - keyName.length + padding);
113
+ return (React.createElement(Box, { key: `${key}-cell-${column.key}`, flexWrap: "wrap" },
114
+ React.createElement(Text, { ...config.headerOptions },
115
+ keyName,
116
+ keyPadding),
117
+ React.createElement(Text, { wrap: overflow }, value)));
118
+ })));
119
+ })));
120
+ }
121
+ return (React.createElement(Box, { flexDirection: "column", width: determineWidthToUse(columns, config.maxWidth) },
122
+ title && React.createElement(Text, { ...titleOptions }, title),
123
+ headerComponent({ columns, data: {}, key: 'header' }),
124
+ headingComponent({ columns, data: headings, key: 'heading' }),
125
+ headerFooterComponent({ columns, data: {}, key: 'footer' }),
126
+ processedData.map((row, index) => {
127
+ // Calculate the hash of the row based on its value and position
128
+ const key = `row-${sha1(row)}-${index}`;
129
+ // Construct a row.
130
+ return (React.createElement(Box, { key: key, flexDirection: "column" },
131
+ separatorComponent({ columns, data: {}, key: `separator-${key}` }),
132
+ dataComponent({ columns, data: row, key: `data-${key}` })));
133
+ }),
134
+ footerComponent({ columns, data: {}, key: 'footer' })));
135
+ }
136
+ /**
137
+ * Constructs a Row element from the configuration.
138
+ */
139
+ function row(config) {
140
+ // This is a component builder. We return a function.
141
+ const { borderProps, skeleton } = config;
142
+ return (props) => {
143
+ const data = props.columns.map((column, colI) => {
144
+ const { horizontalAlignment, overflow, padding, verticalAlignment, width } = column;
145
+ const value = props.data[column.column];
146
+ if (value === undefined || value === null) {
147
+ const key = `${props.key}-empty-${column.key}`;
148
+ return (React.createElement(config.cell, { key: key, column: colI, ...config.props }, skeleton.line.repeat(width)));
149
+ }
150
+ const key = `${props.key}-cell-${column.key}`;
151
+ // Some terminals don't play nicely with zero-width characters, so we replace them with spaces.
152
+ // https://github.com/sindresorhus/terminal-link/issues/18
153
+ // https://github.com/Shopify/cli/pull/995
154
+ const valueWithNoZeroWidthChars = String(value).replaceAll('​', ' ');
155
+ const spaceForText = width - padding * 2;
156
+ const v =
157
+ // if the visible length of the value is greater than the column width, truncate or wrap
158
+ stripAnsi(valueWithNoZeroWidthChars).length >= spaceForText
159
+ ? overflow === 'wrap'
160
+ ? wrapAnsi(valueWithNoZeroWidthChars, spaceForText, { hard: true, trim: true, wordWrap: false }).replaceAll('\n', `${' '.repeat(padding)}\n${' '.repeat(padding)}`)
161
+ : cliTruncate(valueWithNoZeroWidthChars, spaceForText)
162
+ : valueWithNoZeroWidthChars;
163
+ const spaces = overflow === 'wrap' ? width - stripAnsi(v).split('\n')[0].trim().length : width - stripAnsi(v).length;
164
+ let marginLeft;
165
+ let marginRight;
166
+ if (horizontalAlignment === 'left') {
167
+ marginLeft = padding;
168
+ marginRight = spaces - marginLeft;
169
+ }
170
+ else if (horizontalAlignment === 'center') {
171
+ marginLeft = Math.floor(spaces / 2);
172
+ marginRight = Math.ceil(spaces / 2);
173
+ }
174
+ else {
175
+ marginRight = padding;
176
+ marginLeft = spaces - marginRight;
177
+ }
178
+ const alignItems = verticalAlignment === 'top' ? 'flex-start' : verticalAlignment === 'center' ? 'center' : 'flex-end';
179
+ return (React.createElement(config.cell, { key: key, column: colI, alignItems, ...config.props }, `${skeleton.line.repeat(marginLeft)}${v}${skeleton.line.repeat(marginRight)}`));
180
+ });
181
+ const height = data.map((d) => d.props.children.split('\n').length).reduce((a, b) => Math.max(a, b), 0);
182
+ const elements = intersperse((i) => {
183
+ const key = `${props.key}-hseparator-${i}`;
184
+ // The horizontal separator.
185
+ return (React.createElement(Skeleton, { key: key, height: height, ...borderProps }, skeleton.cross));
186
+ }, data);
187
+ return (React.createElement(Box, { flexDirection: "row" },
188
+ React.createElement(Skeleton, { height: height, ...borderProps }, skeleton.left),
189
+ ...elements,
190
+ React.createElement(Skeleton, { height: height, ...borderProps }, skeleton.right)));
191
+ };
192
+ }
193
+ /**
194
+ * Renders the header of a table.
195
+ */
196
+ export function Header(props) {
197
+ const { children, ...rest } = props;
198
+ return React.createElement(Text, { ...rest }, children);
199
+ }
200
+ /**
201
+ * Renders a cell in the table.
202
+ */
203
+ export function Cell(props) {
204
+ return (React.createElement(Box, { ...props },
205
+ React.createElement(Text, null, props.children)));
206
+ }
207
+ /**
208
+ * Renders the scaffold of the table.
209
+ */
210
+ export function Skeleton(props) {
211
+ const { children, ...rest } = props;
212
+ // repeat Text component height times
213
+ const texts = Array.from({ length: props.height ?? 1 }, (_, i) => (React.createElement(Text, { key: i, ...rest }, children)));
214
+ return React.createElement(Box, { flexDirection: "column" }, texts);
215
+ }
216
+ /**
217
+ * Renders a table with the given data.
218
+ * @param options see {@link TableProps}
219
+ */
220
+ export function makeTable(options) {
221
+ const instance = render(React.createElement(Table, { ...options }));
222
+ instance.unmount();
223
+ }
224
+ function Container(props) {
225
+ return (React.createElement(Box, { flexWrap: "wrap", flexDirection: props.direction ?? 'row', ...props }, props.children));
226
+ }
227
+ export function makeTables(tables, options) {
228
+ const leftMargin = options?.marginLeft ?? options?.margin ?? 0;
229
+ const rightMargin = options?.marginRight ?? options?.margin ?? 0;
230
+ const columns = process.stdout.columns - (leftMargin + rightMargin);
231
+ const processed = tables.map((table) => ({
232
+ ...table,
233
+ // adjust maxWidth to account for margin
234
+ maxWidth: determineConfiguredWidth(table.maxWidth, columns),
235
+ }));
236
+ const instance = render(React.createElement(Container, { ...options }, processed.map((table) => (React.createElement(Table, { key: sha1(table), ...table })))));
237
+ instance.unmount();
238
+ }
package/lib/types.d.ts ADDED
@@ -0,0 +1,237 @@
1
+ import { BorderStyle } from './skeletons.js';
2
+ export type Scalar = string | number | boolean | null | undefined;
3
+ export type ScalarDict = {
4
+ [key: string]: Scalar;
5
+ };
6
+ export type CellProps = React.PropsWithChildren<{
7
+ readonly column: number;
8
+ }>;
9
+ export type HorizontalAlignment = 'left' | 'right' | 'center';
10
+ export type VerticalAlignment = 'top' | 'center' | 'bottom';
11
+ export type ColumnProps<T> = {
12
+ /**
13
+ * Horizontal alignment of cell content. Overrides the horizontal alignment set in the table.
14
+ */
15
+ horizontalAlignment?: HorizontalAlignment;
16
+ key: T;
17
+ /**
18
+ * Name of the column. If not provided, it will default to the key.
19
+ */
20
+ name?: string;
21
+ /**
22
+ * Overflow behavior for cells. Overrides the overflow set in the table.
23
+ */
24
+ overflow?: Overflow;
25
+ /**
26
+ * Padding for the column. Overrides the padding set in the table.
27
+ */
28
+ padding?: number;
29
+ /**
30
+ * Vertical alignment of cell content. Overrides the vertical alignment set in the table.
31
+ */
32
+ verticalAlignment?: VerticalAlignment;
33
+ };
34
+ export type AllColumnProps<T> = {
35
+ [K in keyof T]: ColumnProps<K>;
36
+ }[keyof T];
37
+ export type Percentage = `${number}%`;
38
+ type TextOptions = {
39
+ color?: SupportedColor;
40
+ backgroundColor?: SupportedColor;
41
+ bold?: boolean;
42
+ dimColor?: boolean;
43
+ italic?: boolean;
44
+ underline?: boolean;
45
+ strikethrough?: boolean;
46
+ inverse?: boolean;
47
+ };
48
+ export type HeaderFormatter = ((header: string) => string) | 'camelCase' | 'capitalCase' | 'constantCase' | 'kebabCase' | 'pascalCase' | 'sentenceCase' | 'snakeCase';
49
+ export type SupportedColor = 'black' | 'blackBright' | 'blue' | 'blueBright' | 'cyan' | 'cyanBright' | 'gray' | 'green' | 'greenBright' | 'grey' | 'magenta' | 'magentaBright' | 'red' | 'redBright' | 'reset' | 'white' | 'whiteBright' | 'yellow' | 'yellowBright' | `#${string}` | `rgb(${number},${number},${number})`;
50
+ export type HeaderOptions = TextOptions & {
51
+ /**
52
+ * Column header formatter. Can either be a function or a method name on the `change-case` library.
53
+ *
54
+ * See https://www.npmjs.com/package/change-case for more information.
55
+ */
56
+ formatter?: HeaderFormatter;
57
+ };
58
+ type Overflow = 'wrap' | 'truncate';
59
+ type SortOrder<T> = 'asc' | 'desc' | ((valueA: T, valueB: T) => number);
60
+ export type Sort<T> = {
61
+ [K in keyof T]?: SortOrder<T[K]>;
62
+ };
63
+ export type TableProps<T extends ScalarDict> = {
64
+ /**
65
+ * List of values (rows).
66
+ */
67
+ data: T[];
68
+ /**
69
+ * Columns that we should display in the table.
70
+ */
71
+ columns?: (keyof T | AllColumnProps<T>)[];
72
+ /**
73
+ * Cell padding.
74
+ */
75
+ padding?: number;
76
+ /**
77
+ * Width of the table. Can be a number (e.g. 80) or a percentage (e.g. '80%').
78
+ *
79
+ * If not provided, it will default to the width of the terminal (determined by `process.stdout.columns`).
80
+ *
81
+ * If you provide a number or percentage that is larger than the terminal width, it will default to the terminal width.
82
+ *
83
+ * If you provide a number or percentage that is too small to fit the table, it will default to the width of the table.
84
+ */
85
+ maxWidth?: Percentage | number;
86
+ /**
87
+ * Overflow behavior for cells. Defaults to 'truncate'.
88
+ */
89
+ overflow?: Overflow;
90
+ /**
91
+ * Styling options for the column headers
92
+ */
93
+ headerOptions?: HeaderOptions;
94
+ /**
95
+ * Border style for the table. Defaults to 'all'. Only applies to horizontal orientation.
96
+ */
97
+ borderStyle?: BorderStyle;
98
+ /**
99
+ * Color of the table border. Defaults to 'white' in dark terminals and 'black' in light terminals.
100
+ */
101
+ borderColor?: SupportedColor;
102
+ /**
103
+ * Align data in columns. Defaults to 'left'. Only applies to horizontal orientation.
104
+ */
105
+ horizontalAlignment?: HorizontalAlignment;
106
+ /**
107
+ * Apply a filter to each row in the table.
108
+ */
109
+ filter?: (row: T) => boolean;
110
+ /**
111
+ * Sort the data in the table.
112
+ *
113
+ * Each key in the object should correspond to a column in the table. The value can be 'asc', 'desc', or a custom sort function.
114
+ *
115
+ * The order of the keys determines the order of the sorting. The first key is the primary sort key, the second key is the secondary sort key, and so on.
116
+ *
117
+ * @example
118
+ * ```js
119
+ * const data = [
120
+ * {name: 'Alice', age: 30},
121
+ * {name: 'Bob', age: 25},
122
+ * {name: 'Charlie', age: 35},
123
+ * ]
124
+ *
125
+ * // sort the name column in ascending order
126
+ * makeTable({data, sort: {name: 'asc'}})
127
+ *
128
+ * // sort the name column in descending order
129
+ * makeTable({data, sort: {name: 'desc'}})
130
+ *
131
+ * // sort by name in ascending order and age in descending order
132
+ * makeTable({data, sort: {name: 'asc', age: 'desc'}})
133
+ *
134
+ * // sort by name in ascending order and age in descending order using a custom sort function
135
+ * makeTable({data, sort: {name: 'asc', age: (a, b) => b - a}})
136
+ * ```
137
+ */
138
+ sort?: Sort<T>;
139
+ /**
140
+ * The orientation of the table. Defaults to 'horizontal'.
141
+ *
142
+ * If 'vertical', individual records will be displayed vertically in key:value pairs.
143
+ *
144
+ * @example
145
+ * ```
146
+ * ─────────────
147
+ * Name Alice
148
+ * Id 36329
149
+ * Age 20
150
+ * ─────────────
151
+ * Name Bob
152
+ * Id 49032
153
+ * Age 21
154
+ * ─────────────
155
+ * ```
156
+ */
157
+ orientation?: 'horizontal' | 'vertical';
158
+ /**
159
+ * Vertical alignment of cell content. Defaults to 'top'. Only applies to horizontal orientation.
160
+ */
161
+ verticalAlignment?: VerticalAlignment;
162
+ /**
163
+ * Title of the table. Displayed above the table.
164
+ */
165
+ title?: string;
166
+ /**
167
+ * Styling options for the title of the table.
168
+ */
169
+ titleOptions?: TextOptions;
170
+ /**
171
+ * Disable all styling for the table.
172
+ */
173
+ noStyle?: boolean;
174
+ };
175
+ export type Config<T> = {
176
+ columns: (keyof T | AllColumnProps<T>)[];
177
+ data: T[];
178
+ padding: number;
179
+ maxWidth: number;
180
+ overflow: Overflow;
181
+ headerOptions: HeaderOptions;
182
+ borderStyle: BorderStyle;
183
+ horizontalAlignment: HorizontalAlignment;
184
+ verticalAlignment: VerticalAlignment;
185
+ };
186
+ export type RowConfig = {
187
+ /**
188
+ * Component used to render cells.
189
+ */
190
+ cell: (props: CellProps) => React.ReactNode;
191
+ /**
192
+ * Component used to render skeleton in the row.
193
+ */
194
+ skeleton: {
195
+ /**
196
+ * Characters used in skeleton.
197
+ * | |
198
+ * (left)-(line)-(cross)-(line)-(right)
199
+ * | |
200
+ */
201
+ left: string;
202
+ right: string;
203
+ cross: string;
204
+ line: string;
205
+ };
206
+ props?: Record<string, unknown>;
207
+ borderProps: {
208
+ color: SupportedColor | undefined;
209
+ };
210
+ };
211
+ export type RowProps<T extends ScalarDict> = {
212
+ readonly key: string;
213
+ readonly data: Partial<T>;
214
+ readonly columns: Column<T>[];
215
+ };
216
+ export type Column<T> = {
217
+ key: string;
218
+ column: keyof T;
219
+ width: number;
220
+ padding: number;
221
+ horizontalAlignment: HorizontalAlignment;
222
+ verticalAlignment: VerticalAlignment;
223
+ overflow: Overflow;
224
+ };
225
+ export type ContainerProps = {
226
+ readonly alignItems?: 'flex-start' | 'flex-end' | 'center';
227
+ readonly children: React.ReactNode;
228
+ readonly columnGap?: number;
229
+ readonly direction?: 'row' | 'column';
230
+ readonly margin?: number;
231
+ readonly marginLeft?: number;
232
+ readonly marginRight?: number;
233
+ readonly marginTop?: number;
234
+ readonly marginBottom?: number;
235
+ readonly rowGap?: number;
236
+ };
237
+ export {};
package/lib/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/lib/utils.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ import { Column, Config, ScalarDict, Sort } from './types.js';
2
+ /**
3
+ * Intersperses a list of elements with another element.
4
+ *
5
+ * @example
6
+ * ```js
7
+ * intersperse(() => 'foo', [1, 2, 3]) // => [1, 'foo', 2, 'foo', 3]
8
+ * ```
9
+ */
10
+ export declare function intersperse<T, I>(intersperser: (index: number) => I, elements: T[]): (T | I)[];
11
+ export declare function sortData<T extends ScalarDict>(data: T[], sort?: Sort<T> | undefined): T[];
12
+ export declare function allKeysInCollection<T extends ScalarDict>(data: T[]): (keyof T)[];
13
+ export declare function getColumns<T extends ScalarDict>(config: Config<T>, headings: Partial<T>): Column<T>[];
14
+ export declare function getHeadings<T extends ScalarDict>(config: Config<T>): Partial<T>;
15
+ export declare function maybeStripAnsi<T extends ScalarDict[]>(data: T, noStyle: boolean): T;
package/lib/utils.js ADDED
@@ -0,0 +1,140 @@
1
+ import { camelCase, capitalCase, constantCase, kebabCase, pascalCase, sentenceCase, snakeCase } from 'change-case';
2
+ import { orderBy } from 'natural-orderby';
3
+ import stripAnsi from 'strip-ansi';
4
+ /**
5
+ * Intersperses a list of elements with another element.
6
+ *
7
+ * @example
8
+ * ```js
9
+ * intersperse(() => 'foo', [1, 2, 3]) // => [1, 'foo', 2, 'foo', 3]
10
+ * ```
11
+ */
12
+ export function intersperse(intersperser, elements) {
13
+ // Intersperse by reducing from left.
14
+ const interspersed = elements.reduce((acc, element, index) => {
15
+ // Only add element if it's the first one.
16
+ if (acc.length === 0)
17
+ return [element];
18
+ // Add the intersperser as well otherwise.
19
+ return [...acc, intersperser(index), element];
20
+ }, []);
21
+ return interspersed;
22
+ }
23
+ export function sortData(data, sort) {
24
+ if (!sort)
25
+ return data;
26
+ const identifiers = Object.keys(sort);
27
+ const orders = Object.values(sort);
28
+ return orderBy(data, identifiers, orders);
29
+ }
30
+ export function allKeysInCollection(data) {
31
+ const keys = new Set();
32
+ for (const row of data) {
33
+ for (const key in row) {
34
+ if (key in row)
35
+ keys.add(key);
36
+ }
37
+ }
38
+ return [...keys];
39
+ }
40
+ export function getColumns(config, headings) {
41
+ const { columns, horizontalAlignment, maxWidth, overflow, verticalAlignment } = config;
42
+ const widths = columns.map((propsOrKey) => {
43
+ const props = typeof propsOrKey === 'object' ? propsOrKey : { key: propsOrKey };
44
+ const { key } = props;
45
+ const padding = props.padding ?? config.padding;
46
+ // Get the width of each cell in the column
47
+ const data = config.data.map((data) => {
48
+ const value = data[key];
49
+ if (value === undefined || value === null)
50
+ return 0;
51
+ return stripAnsi(String(value).replaceAll('​', ' ')).length;
52
+ });
53
+ const header = String(headings[key]).length;
54
+ const width = Math.max(...data, header) + padding * 2;
55
+ return {
56
+ column: key,
57
+ horizontalAlignment: props.horizontalAlignment ?? horizontalAlignment,
58
+ key: String(key),
59
+ overflow: props.overflow ?? overflow,
60
+ padding,
61
+ verticalAlignment: props.verticalAlignment ?? verticalAlignment,
62
+ width,
63
+ };
64
+ });
65
+ const numberOfBorders = widths.length + 1;
66
+ const calculateTableWidth = (widths) => widths.map((w) => w.width).reduce((a, b) => a + b) + numberOfBorders;
67
+ // If the table is too wide, reduce the width of the largest column as little as possible to fit the table.
68
+ // At most, it will reduce the width to the length of the column's header plus padding.
69
+ // If the table is still too wide, it will reduce the width of the next largest column and so on
70
+ let tableWidth = calculateTableWidth(widths);
71
+ const seen = new Set();
72
+ while (tableWidth > maxWidth) {
73
+ const largestColumn = widths.reduce((a, b) => (a.width > b.width ? a : b));
74
+ const header = String(headings[largestColumn.key]).length;
75
+ // The minimum width of a column is the width of the header plus padding on both sides
76
+ const minWidth = header + largestColumn.padding * 2;
77
+ const difference = tableWidth - maxWidth;
78
+ const newWidth = largestColumn.width - difference < minWidth ? minWidth : largestColumn.width - difference;
79
+ largestColumn.width = newWidth;
80
+ tableWidth = calculateTableWidth(widths);
81
+ if (seen.has(largestColumn.key))
82
+ break;
83
+ seen.add(largestColumn.key);
84
+ }
85
+ return widths;
86
+ }
87
+ export function getHeadings(config) {
88
+ const { columns, headerOptions: { formatter }, } = config;
89
+ const format = (header) => {
90
+ if (typeof header !== 'string')
91
+ return header;
92
+ if (!formatter)
93
+ return header;
94
+ if (typeof formatter === 'function')
95
+ return formatter(header);
96
+ switch (formatter) {
97
+ case 'pascalCase': {
98
+ return pascalCase(header);
99
+ }
100
+ case 'capitalCase': {
101
+ return capitalCase(header);
102
+ }
103
+ case 'camelCase': {
104
+ return camelCase(header);
105
+ }
106
+ case 'snakeCase': {
107
+ return snakeCase(header);
108
+ }
109
+ case 'kebabCase': {
110
+ return kebabCase(header);
111
+ }
112
+ case 'constantCase': {
113
+ return constantCase(header);
114
+ }
115
+ case 'sentenceCase': {
116
+ return sentenceCase(header);
117
+ }
118
+ default: {
119
+ return header;
120
+ }
121
+ }
122
+ };
123
+ return Object.fromEntries(columns.map((c) => {
124
+ const key = typeof c === 'object' ? c.key : c;
125
+ const name = typeof c === 'object' ? (c.name ?? format(key)) : format(c);
126
+ return [key, name];
127
+ }));
128
+ }
129
+ export function maybeStripAnsi(data, noStyle) {
130
+ if (!noStyle)
131
+ return data;
132
+ const newData = [];
133
+ for (const row in data) {
134
+ if (row in data) {
135
+ const newRow = Object.fromEntries(Object.entries(data[row]).map(([key, value]) => [key, typeof value === 'string' ? stripAnsi(value) : value]));
136
+ newData.push(newRow);
137
+ }
138
+ }
139
+ return newData;
140
+ }
package/package.json ADDED
@@ -0,0 +1,81 @@
1
+ {
2
+ "name": "@oclif/table",
3
+ "description": "Display table in terminal",
4
+ "version": "0.1.0",
5
+ "author": "Salesforce",
6
+ "bugs": "https://github.com/oclif/multi-stage-output/issues",
7
+ "dependencies": {
8
+ "@oclif/core": "^4",
9
+ "@types/react": "^18.3.3",
10
+ "change-case": "^5.4.4",
11
+ "cli-truncate": "^4.0.0",
12
+ "ink": "^5.0.1",
13
+ "natural-orderby": "^3.0.2",
14
+ "object-hash": "^3.0.0",
15
+ "react": "^18.3.1",
16
+ "strip-ansi": "^7.1.0",
17
+ "wrap-ansi": "^9.0.0"
18
+ },
19
+ "devDependencies": {
20
+ "@commitlint/config-conventional": "^19",
21
+ "@oclif/prettier-config": "^0.2.1",
22
+ "@types/chai": "^4.3.16",
23
+ "@types/mocha": "^10.0.7",
24
+ "@types/node": "^18",
25
+ "@types/object-hash": "^3.0.6",
26
+ "@types/sinon": "^17.0.3",
27
+ "ansis": "^3.3.2",
28
+ "chai": "^4.5.0",
29
+ "commitlint": "^19",
30
+ "eslint": "^8.57.0",
31
+ "eslint-config-oclif": "^5.2.0",
32
+ "eslint-config-oclif-typescript": "^3.1.8",
33
+ "eslint-config-prettier": "^9.1.0",
34
+ "eslint-config-xo": "^0.45.0",
35
+ "eslint-config-xo-react": "^0.27.0",
36
+ "eslint-plugin-react": "^7.34.3",
37
+ "eslint-plugin-react-hooks": "^4.6.2",
38
+ "husky": "^9.1.3",
39
+ "ink-testing-library": "^4.0.0",
40
+ "lint-staged": "^15",
41
+ "mocha": "^10.7.3",
42
+ "prettier": "^3.3.3",
43
+ "shx": "^0.3.4",
44
+ "sinon": "^18",
45
+ "terminal-link": "^3.0.0",
46
+ "ts-node": "^10.9.2",
47
+ "typescript": "^5"
48
+ },
49
+ "engines": {
50
+ "node": ">=18.0.0"
51
+ },
52
+ "files": [
53
+ "/lib"
54
+ ],
55
+ "homepage": "https://github.com/oclif/core",
56
+ "keywords": [
57
+ "oclif",
58
+ "cli",
59
+ "stages"
60
+ ],
61
+ "license": "MIT",
62
+ "exports": {
63
+ ".": "./lib/index.js"
64
+ },
65
+ "repository": "oclif/core",
66
+ "publishConfig": {
67
+ "access": "public"
68
+ },
69
+ "scripts": {
70
+ "build": "shx rm -rf lib && tsc",
71
+ "compile": "tsc",
72
+ "format": "prettier --write \"+(src|test)/**/*.+(ts|js|json)\"",
73
+ "lint": "eslint . --ext .ts",
74
+ "posttest": "yarn lint",
75
+ "prepack": "yarn run build",
76
+ "prepare": "husky",
77
+ "test": "mocha --forbid-only \"test/**/*.test.+(ts|tsx)\" --parallel"
78
+ },
79
+ "types": "lib/index.d.ts",
80
+ "type": "module"
81
+ }