From 2d746d8c90f4e4af75b12be5781e3963027020c2 Mon Sep 17 00:00:00 2001 From: Ross Stenersen Date: Tue, 28 Jun 2022 12:32:03 -0500 Subject: [PATCH] feat: update table output --- .changeset/odd-fireants-act.md | 12 + package-lock.json | 160 +++++-- packages/cli/doc/configuration.md | 6 +- packages/cli/package.json | 2 - .../lib/commands/apps/apps-util.test.ts | 14 +- .../lib/commands/devices/devices-util.test.ts | 4 +- packages/cli/src/commands/deviceprofiles.ts | 3 +- .../cli/src/commands/virtualdevices/events.ts | 4 +- packages/lib/package.json | 5 +- .../src/__tests__/smartthings-command.test.ts | 29 +- .../lib/src/__tests__/table-generator.test.ts | 435 ++++++++++-------- packages/lib/src/output-builder.ts | 8 +- packages/lib/src/smartthings-command.ts | 6 +- packages/lib/src/table-generator.ts | 146 ++++-- 14 files changed, 518 insertions(+), 316 deletions(-) create mode 100644 .changeset/odd-fireants-act.md diff --git a/.changeset/odd-fireants-act.md b/.changeset/odd-fireants-act.md new file mode 100644 index 000000000..142a50454 --- /dev/null +++ b/.changeset/odd-fireants-act.md @@ -0,0 +1,12 @@ +--- +"@smartthings/cli": patch +"@smartthings/cli-lib": patch +--- + +Update table output: + - switch to table package which handles international characters properly + - removed compact / expanded command line options + - removed compactTableOutput configuration option + - added group-rows and no-group-rows command line options + - added groupTableOutputRows configuration option + - (lib) completely isolated use of dependency to table-generator.ts diff --git a/package-lock.json b/package-lock.json index eaec30970..ebb1eaea2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3818,12 +3818,6 @@ "@types/node": "*" } }, - "node_modules/@types/cli-table": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@types/cli-table/-/cli-table-0.3.0.tgz", - "integrity": "sha512-QnZUISJJXyhyD6L1e5QwXDV/A5i2W1/gl6D6YMc8u0ncPepbv/B4w3S+izVvtAg60m6h+JP09+Y/0zF2mojlFQ==", - "dev": true - }, "node_modules/@types/connect": { "version": "3.4.35", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", @@ -4698,6 +4692,14 @@ "node": ">=0.10.0" } }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "engines": { + "node": ">=8" + } + }, "node_modules/async": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz", @@ -5599,6 +5601,7 @@ "version": "0.3.11", "resolved": "https://registry.npmjs.org/cli-table/-/cli-table-0.3.11.tgz", "integrity": "sha512-IqLQi4lO0nIB4tcdTpN4LCB9FI3uqrJZK7RC515EnhZ6qBaglkIgICb1wjeAqpdoOabm1+SuQtkXIPdYC93jhQ==", + "dev": true, "dependencies": { "colors": "1.0.3" }, @@ -5778,6 +5781,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz", "integrity": "sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs=", + "dev": true, "engines": { "node": ">=0.1.90" } @@ -8336,8 +8340,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-glob": { "version": "3.2.11", @@ -12034,6 +12037,11 @@ "integrity": "sha1-lDbjTtJgk+1/+uGTYUQ1CRXZrdg=", "dev": true }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -14822,7 +14830,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -15299,6 +15306,22 @@ "node": ">=8" } }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -16133,6 +16156,41 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true }, + "node_modules/table": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.8.0.tgz", + "integrity": "sha512-s/fitrbVeEyHKFa7mFdkuQMWlH1Wgw/yEXMt5xACT4ZpzWFluehAxRtUUQKPuWhaLAWhFcVx6w3oC8VKaUfPGA==", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/table/node_modules/ajv": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", + "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/table/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, "node_modules/taketalk": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/taketalk/-/taketalk-1.0.0.tgz", @@ -16967,7 +17025,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, "dependencies": { "punycode": "^2.1.0" } @@ -16976,7 +17033,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "dev": true, "engines": { "node": ">=6" } @@ -18360,7 +18416,6 @@ "@smartthings/core-sdk": "^5.0.0", "@smartthings/plugin-cli-edge": "^1.14.1", "aws-sdk": "^2.1144.0", - "cli-table": "^0.3.11", "inquirer": "^8.2.4", "js-yaml": "^4.1.0", "log4js": "6.3.0" @@ -18370,7 +18425,6 @@ }, "devDependencies": { "@smartthings/cli-testlib": "^1.0.0-beta.4", - "@types/cli-table": "^0.3.0", "@types/inquirer": "^8.2.1", "@types/jest": "^28.1.3", "@types/js-yaml": "^4.0.5", @@ -18406,7 +18460,6 @@ "@types/eventsource": "^1.1.8", "axios": "^0.21.4", "chalk": "^4.1.2", - "cli-table": "^0.3.11", "eventsource": "^2.0.2", "express": "^4.18.1", "get-port": "^5.1.1", @@ -18415,10 +18468,10 @@ "lodash.at": "^4.6.0", "open": "^8.4.0", "os-locale": "^5.0.0", - "qs": "^6.10.3" + "qs": "^6.10.3", + "table": "^6.8.0" }, "devDependencies": { - "@types/cli-table": "^0.3.0", "@types/express": "^4.17.13", "@types/inquirer": "^8.2.1", "@types/jest": "^28.1.3", @@ -21466,7 +21519,6 @@ "@smartthings/cli-testlib": "^1.0.0-beta.4", "@smartthings/core-sdk": "^5.0.0", "@smartthings/plugin-cli-edge": "^1.14.1", - "@types/cli-table": "^0.3.0", "@types/inquirer": "^8.2.1", "@types/jest": "^28.1.3", "@types/js-yaml": "^4.0.5", @@ -21474,7 +21526,6 @@ "@typescript-eslint/eslint-plugin": "^5.28.0", "@typescript-eslint/parser": "^5.28.0", "aws-sdk": "^2.1144.0", - "cli-table": "^0.3.11", "eslint": "^8.17.0", "eslint-plugin-eslint-comments": "^3.2.0", "eslint-plugin-import": "^2.26.0", @@ -21498,7 +21549,6 @@ "@log4js-node/log4js-api": "^1.0.2", "@oclif/core": "^1.9.0", "@smartthings/core-sdk": "^5.0.0", - "@types/cli-table": "^0.3.0", "@types/eventsource": "^1.1.8", "@types/express": "^4.17.13", "@types/inquirer": "^8.2.1", @@ -21511,7 +21561,6 @@ "@typescript-eslint/parser": "^5.28.0", "axios": "^0.21.4", "chalk": "^4.1.2", - "cli-table": "^0.3.11", "eslint": "^8.17.0", "eslint-plugin-eslint-comments": "^3.2.0", "eslint-plugin-import": "^2.26.0", @@ -21532,6 +21581,7 @@ "os-locale": "^5.0.0", "qs": "^6.10.3", "rimraf": "^3.0.2", + "table": "^6.8.0", "ts-jest": "^28.0.5", "ts-node": "^10.8.1", "typescript": "^4.5.4" @@ -21687,12 +21737,6 @@ "@types/node": "*" } }, - "@types/cli-table": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@types/cli-table/-/cli-table-0.3.0.tgz", - "integrity": "sha512-QnZUISJJXyhyD6L1e5QwXDV/A5i2W1/gl6D6YMc8u0ncPepbv/B4w3S+izVvtAg60m6h+JP09+Y/0zF2mojlFQ==", - "dev": true - }, "@types/connect": { "version": "3.4.35", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", @@ -22388,6 +22432,11 @@ "integrity": "sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=", "dev": true }, + "astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==" + }, "async": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz", @@ -23074,6 +23123,7 @@ "version": "0.3.11", "resolved": "https://registry.npmjs.org/cli-table/-/cli-table-0.3.11.tgz", "integrity": "sha512-IqLQi4lO0nIB4tcdTpN4LCB9FI3uqrJZK7RC515EnhZ6qBaglkIgICb1wjeAqpdoOabm1+SuQtkXIPdYC93jhQ==", + "dev": true, "requires": { "colors": "1.0.3" } @@ -23219,7 +23269,8 @@ "colors": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz", - "integrity": "sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs=" + "integrity": "sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs=", + "dev": true }, "combined-stream": { "version": "1.0.8", @@ -25204,8 +25255,7 @@ "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "fast-glob": { "version": "3.2.11", @@ -28070,6 +28120,11 @@ "integrity": "sha1-lDbjTtJgk+1/+uGTYUQ1CRXZrdg=", "dev": true }, + "lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==" + }, "log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -30197,8 +30252,7 @@ "require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" }, "require-main-filename": { "version": "2.0.0", @@ -30543,6 +30597,16 @@ "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==" }, + "slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "requires": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + } + }, "smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -31235,6 +31299,36 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true }, + "table": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.8.0.tgz", + "integrity": "sha512-s/fitrbVeEyHKFa7mFdkuQMWlH1Wgw/yEXMt5xACT4ZpzWFluehAxRtUUQKPuWhaLAWhFcVx6w3oC8VKaUfPGA==", + "requires": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "dependencies": { + "ajv": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", + "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "requires": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + } + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + } + } + }, "taketalk": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/taketalk/-/taketalk-1.0.0.tgz", @@ -31866,7 +31960,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, "requires": { "punycode": "^2.1.0" }, @@ -31874,8 +31967,7 @@ "punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "dev": true + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" } } }, diff --git a/packages/cli/doc/configuration.md b/packages/cli/doc/configuration.md index 036ffb1eb..6939b6842 100644 --- a/packages/cli/doc/configuration.md +++ b/packages/cli/doc/configuration.md @@ -31,7 +31,7 @@ The following per-profile config options are supported: | Option | Default Value | Description | | -- | -- | -- | | indent | 2 | Indent level for JSON or YAML output. | -| compactTableOutput | true | Compact table output without lines between rows. | +| groupTableOutputRows | true | Separate groups of four rows by a line to make long rows easier to follow across the screen. | | organization | none | UUID of the organization to use in applicable CLI commands. | | token | none | Use a bearer token (such as a PAT) for authentication instead of the default login flow. | @@ -40,11 +40,11 @@ The following per-profile config options are supported: ```yaml default: indent: 4 - compactTableOutput: false + groupTableOutputRows: false tight: indent: 1 - compactTableOutput: true + groupTableOutputRows: true ``` ## On the Command Line diff --git a/packages/cli/package.json b/packages/cli/package.json index bad2c06b7..f19445ca7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -75,14 +75,12 @@ "@smartthings/core-sdk": "^5.0.0", "@smartthings/plugin-cli-edge": "^1.14.1", "aws-sdk": "^2.1144.0", - "cli-table": "^0.3.11", "inquirer": "^8.2.4", "js-yaml": "^4.1.0", "log4js": "6.3.0" }, "devDependencies": { "@smartthings/cli-testlib": "^1.0.0-beta.4", - "@types/cli-table": "^0.3.0", "@types/inquirer": "^8.2.1", "@types/jest": "^28.1.3", "@types/js-yaml": "^4.0.5", diff --git a/packages/cli/src/__tests__/lib/commands/apps/apps-util.test.ts b/packages/cli/src/__tests__/lib/commands/apps/apps-util.test.ts index 457302272..4ca568747 100644 --- a/packages/cli/src/__tests__/lib/commands/apps/apps-util.test.ts +++ b/packages/cli/src/__tests__/lib/commands/apps/apps-util.test.ts @@ -4,11 +4,10 @@ import { AppsEndpoint } from '@smartthings/core-sdk' import { APICommand, ChooseOptions, chooseOptionsDefaults, chooseOptionsWithDefaults, - selectFromList, stringTranslateToId, + selectFromList, stringTranslateToId, Table, } from '@smartthings/cli-lib' import { buildTableOutput, chooseApp } from '../../../../lib/commands/apps/apps-util' -import Table from 'cli-table' describe('chooseApp', () => { @@ -126,13 +125,18 @@ describe('buildTableOutput', () => { }) it('creates new table with correct options and adds settings', () => { - const newTable = new Table() + const pushMock = jest.fn() + const toStringMock = jest.fn().mockReturnValue('table output') + const newTable = { push: pushMock, toString: toStringMock } as Table mockNewOutputTable.mockReturnValueOnce(newTable) - expect(buildTableOutput(mockTableGenerator, { settings: { setting: 'setting' } })).toStrictEqual(newTable.toString()) + expect(buildTableOutput(mockTableGenerator, { settings: { setting: 'setting value' } })).toEqual('table output') expect(mockNewOutputTable).toBeCalledWith( expect.objectContaining({ head: ['Key', 'Value'] }), ) - expect(newTable).toContainValue(['setting', 'setting']) + expect(pushMock).toHaveBeenCalledTimes(1) + expect(pushMock).toHaveBeenCalledWith(['setting', 'setting value']) + expect(toStringMock).toHaveBeenCalledTimes(1) + expect(toStringMock).toHaveBeenCalledWith() }) }) diff --git a/packages/cli/src/__tests__/lib/commands/devices/devices-util.test.ts b/packages/cli/src/__tests__/lib/commands/devices/devices-util.test.ts index 48ea1a932..0e784463b 100644 --- a/packages/cli/src/__tests__/lib/commands/devices/devices-util.test.ts +++ b/packages/cli/src/__tests__/lib/commands/devices/devices-util.test.ts @@ -1,8 +1,6 @@ -import Table from 'cli-table' - import { Device } from '@smartthings/core-sdk' -import { summarizedText, TableGenerator } from '@smartthings/cli-lib' +import { summarizedText, Table, TableGenerator } from '@smartthings/cli-lib' import { buildTableOutput } from '../../../../lib/commands/devices/devices-util' diff --git a/packages/cli/src/commands/deviceprofiles.ts b/packages/cli/src/commands/deviceprofiles.ts index ee1d52d2b..01bf27ec2 100644 --- a/packages/cli/src/commands/deviceprofiles.ts +++ b/packages/cli/src/commands/deviceprofiles.ts @@ -1,11 +1,10 @@ import { Flags } from '@oclif/core' -import Table from 'cli-table' import { DeviceProfile, LocaleReference } from '@smartthings/core-sdk' import { APIOrganizationCommand, ChooseOptions, WithOrganization, allOrganizationsFlags, chooseOptionsWithDefaults, outputListing, forAllOrganizations, selectFromList, - stringTranslateToId, summarizedText, TableFieldDefinition, TableGenerator } from '@smartthings/cli-lib' + stringTranslateToId, summarizedText, Table, TableFieldDefinition, TableGenerator } from '@smartthings/cli-lib' export function buildTableOutput(tableGenerator: TableGenerator, data: DeviceProfile, diff --git a/packages/cli/src/commands/virtualdevices/events.ts b/packages/cli/src/commands/virtualdevices/events.ts index dd402ff14..17b43a6bf 100644 --- a/packages/cli/src/commands/virtualdevices/events.ts +++ b/packages/cli/src/commands/virtualdevices/events.ts @@ -7,6 +7,7 @@ import { inputAndOutputItem, inputProcessor, selectFromList, + stringFromUnknown, TableGenerator, } from '@smartthings/cli-lib' import { VirtualDeviceEventsResponse } from '@smartthings/core-sdk/dist/endpoint/virtualdevices' @@ -26,7 +27,8 @@ function buildTableOutput(tableGenerator: TableGenerator, data: EventInputOutput for (const index in input) { const event = input[index] const isStateChange = parseInt(index) < stateChanges.length ? stateChanges[index] : 'undefined' - table.push([event.component, event.capability, event.attribute, event.value, isStateChange]) + const value = stringFromUnknown(event.value) + table.push([event.component, event.capability, event.attribute, value, isStateChange]) } return table.toString() } diff --git a/packages/lib/package.json b/packages/lib/package.json index 8f5bc7037..b8ef6fa7a 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -34,7 +34,6 @@ "@types/eventsource": "^1.1.8", "axios": "^0.21.4", "chalk": "^4.1.2", - "cli-table": "^0.3.11", "eventsource": "^2.0.2", "express": "^4.18.1", "get-port": "^5.1.1", @@ -43,10 +42,10 @@ "lodash.at": "^4.6.0", "open": "^8.4.0", "os-locale": "^5.0.0", - "qs": "^6.10.3" + "qs": "^6.10.3", + "table": "^6.8.0" }, "devDependencies": { - "@types/cli-table": "^0.3.0", "@types/express": "^4.17.13", "@types/inquirer": "^8.2.1", "@types/jest": "^28.1.3", diff --git a/packages/lib/src/__tests__/smartthings-command.test.ts b/packages/lib/src/__tests__/smartthings-command.test.ts index 8d4971071..0bcaabc54 100644 --- a/packages/lib/src/__tests__/smartthings-command.test.ts +++ b/packages/lib/src/__tests__/smartthings-command.test.ts @@ -60,17 +60,16 @@ describe('SmartThingsCommand', () => { expect(smartThingsCommand.profileName).toBe(profileName) }) - it('should set tableGenerator to compact by default during setup', async () => { + it('should set tableGenerator to group rows by default during setup', async () => { await smartThingsCommand.init() expect(smartThingsCommand.tableGenerator).toBeInstanceOf(DefaultTableGenerator) expect(DefaultTableGenerator).toBeCalledWith(true) }) - it('should override default table compact when passed via profile during setup', async () => { - const compact = false + it('should override default table group rows when passed via profile during setup', async () => { const profile: Profile = { - compactTableOutput: compact, + groupTableOutputRows: false, } loadConfigMock.mockResolvedValueOnce({ profile } as CLIConfig) @@ -78,38 +77,36 @@ describe('SmartThingsCommand', () => { await smartThingsCommand.init() expect(smartThingsCommand.tableGenerator).toBeInstanceOf(DefaultTableGenerator) - expect(DefaultTableGenerator).toBeCalledWith(compact) + expect(DefaultTableGenerator).toBeCalledWith(false) }) - it('should override table compact when --expanded flag passed during setup', async () => { - const compact = true + it('should override groupTableOutputRows when --no-group-rows flag passed during setup', async () => { const profile: Profile = { - compactTableOutput: compact, + groupTableOutputRows: true, } loadConfigMock.mockResolvedValueOnce({ profile } as CLIConfig) - const expanded = true - parseSpy.mockResolvedValueOnce({ args: {}, flags: { expanded } } as parserOutputType) + parseSpy.mockResolvedValueOnce({ args: {}, flags: { 'no-group-rows': true } } as parserOutputType) await smartThingsCommand.init() expect(smartThingsCommand.tableGenerator).toBeInstanceOf(DefaultTableGenerator) - expect(DefaultTableGenerator).toBeCalledWith(!compact) + expect(DefaultTableGenerator).toBeCalledWith(false) }) - it('should override table compact when --compact flag passed during setup', async () => { + it('should override table row grouping when --group-rows flag passed during setup', async () => { const profile: Profile = { - compactTableOutput: false, + groupTableOutputRows: false, } loadConfigMock.mockResolvedValueOnce({ profile } as CLIConfig) - const compact = true - parseSpy.mockResolvedValueOnce({ args: {}, flags: { compact } } as parserOutputType) + const groupRows = true + parseSpy.mockResolvedValueOnce({ args: {}, flags: { 'group-rows': groupRows } } as parserOutputType) await smartThingsCommand.init() expect(smartThingsCommand.tableGenerator).toBeInstanceOf(DefaultTableGenerator) - expect(DefaultTableGenerator).toBeCalledWith(compact) + expect(DefaultTableGenerator).toBeCalledWith(groupRows) }) it('should abort command with message and successful exit', () => { diff --git a/packages/lib/src/__tests__/table-generator.test.ts b/packages/lib/src/__tests__/table-generator.test.ts index 2fd22f97f..e890af01f 100644 --- a/packages/lib/src/__tests__/table-generator.test.ts +++ b/packages/lib/src/__tests__/table-generator.test.ts @@ -1,7 +1,7 @@ import at from 'lodash.at' import { URL } from 'url' import log4js from '@log4js-node/log4js-api' -import { DefaultTableGenerator, TableFieldDefinition, TableGenerator } from '../table-generator' +import { DefaultTableGenerator, stringFromUnknown, TableFieldDefinition, TableGenerator } from '../table-generator' const mockDebug = jest.fn() @@ -49,25 +49,20 @@ function stripEscapeSequences(str: string): string { return retVal } -function rowCount(table: string): number { - return table.split('\n').length - 2 -} +/** + * Clean up string created using backticks. Lines are stripped of ` +|` at the beginning of each + * line (allowing indentation) and `|` at the end (allowing for spaces at the end of the line + * that are not automatically removed by tooling). If there is a new-line at the beginning of the + * string, it is also removed. + */ +const fixIndent = (input: string): string => input.replace(/^\s*\|/gm, '').replace(/\|$/gm, '').replace(/^\n/, '') -// These are the characters the used by the cli-table library: -// \u250c ┌ -// \u252c ┬ -// \u2510 ┐ -// \u2502 │ -// \u2500 ─ -// \u2514 └ -// \u2534 ┴ -// \u2518 ┘ -// https://en.wikipedia.org/wiki/Box_Drawing_(Unicode_block) +const lineCount = (input: string): number =>(input.match(/\n/g) ?? []).length expect.extend({ toHaveLabel(received: string, label: string) { const stripped = stripEscapeSequences(received) - const regex = new RegExp(`\u2502 +${quoteForRegex(label)} +\u2502 +[^\u2502]*? +\u2502`, 'g') + const regex = new RegExp(` +${quoteForRegex(label)} +`, 'g') return { message: (): string => `expected ${stripped} to have label ${label}`, pass: regex.test(stripped), @@ -75,7 +70,7 @@ expect.extend({ }, toHaveValue(received: string, value: string) { const stripped = stripEscapeSequences(received) - const regex = new RegExp(`\u2502 +[^\u2502]*? +\u2502 +${quoteForRegex(value)} +\u2502`, 'g') + const regex = new RegExp(` +${quoteForRegex(value)} +`, 'g') return { message: (): string => `expected ${stripped} to have value ${value}`, pass: regex.test(stripped), @@ -83,7 +78,7 @@ expect.extend({ }, toHaveLabelAndValue(received: string, label: string, value: string) { const stripped = stripEscapeSequences(received) - const regex = new RegExp(`\u2502 +${quoteForRegex(label)} +\u2502 +${quoteForRegex(value)} +\u2502`, 'g') + const regex = new RegExp(` +${quoteForRegex(label)} +${quoteForRegex(value)} +`, 'g') return { message: (): string => `expected ${stripped} to have label ${label} and value ${value}`, pass: regex.test(stripped), @@ -91,8 +86,8 @@ expect.extend({ }, toHaveItemValues(received: string, values: string[]) { const stripped = stripEscapeSequences(received) - const valuesRegex = values.map(value => quoteForRegex(value)).join(' +\u2502 +') - const regex = new RegExp(`\u2502 +${valuesRegex} +\u2502`, 'g') + const valuesRegex = values.map(value => quoteForRegex(value)).join(' +') + const regex = new RegExp(` +${valuesRegex} +`, 'g') return { message: (): string => `expected ${stripped} to have values ${JSON.stringify(values)}`, pass: regex.test(stripped), @@ -149,188 +144,248 @@ const basicFieldDefinitions: TableFieldDefinition[] = [ 'oauthClientId', ] -describe('tableGenerator', () => { +describe('table-generator', () => { const mockAt = jest.mocked(at) const mockGetLogger = jest.mocked(log4js.getLogger) let tableGenerator: TableGenerator beforeEach(() => { - tableGenerator = new DefaultTableGenerator(true) - }) - - it('buildTableFromItem converts simple column labels properly', function() { - const output = tableGenerator.buildTableFromItem(basicData, basicFieldDefinitions) - - expect(output).toHaveLabel('Id') - expect(output).toHaveLabel('Simple Field') - expect(output).toHaveLabel('Really Long Field Name') - expect(output).toHaveLabel('Request URI') - // "URL" string should be made all caps - expect(output).toHaveLabel('Target URL') - expect(output).toHaveLabel('Sub Field') - // "url" in "Burley" should NOT be all caps. - expect(output).toHaveLabel('Burley') - // "is" should be dropped for boolean-style fields - expect(output).not.toHaveLabel('Is Burley') - // "uri" string in "centuries" should not be made all caps - expect(output).toHaveLabel('Age In Centuries') - expect(output).toHaveLabel('Lambda ARN EU') - expect(output).toHaveLabel('Red Barn Shade') - expect(output).toHaveLabel('OAuth Token URL') - expect(output).toHaveLabel('OAuth Client Id') - }) - - it('buildTableFromItem converts simple column values properly', function() { - const output = tableGenerator.buildTableFromItem(basicData, basicFieldDefinitions) - - expect(output).toHaveValue('uuid-here') - expect(output).toHaveValue('value') - expect(output).toHaveValue('7.2') - expect(output).toHaveLabelAndValue('Really Long Field Name', '7.2') - expect(output).toHaveLabelAndValue('Request URI', 'a request URI') - expect(output).toHaveLabelAndValue('Target URL', 'https://www.google.com/') - expect(output).toHaveLabelAndValue('Sub Field', 'sub-field value') - }) - - it('buildTableFromItem uses specified column headings', function() { - const basicFieldDefinitions = [{ - prop: 'reallyLongFieldName', - label: 'Shorter Name', - }] - const output = tableGenerator.buildTableFromItem(basicData, basicFieldDefinitions) - - expect(output).not.toHaveLabel('Really Long Field Name') - expect(output).toHaveLabel('Shorter Name') - }) - - it('buildTableFromItem uses calculated value', function() { - const fieldDefinitions = [{ - prop: 'reallyLongFieldName', - value: (item: SimpleData): string => `${item.reallyLongFieldName} ms`, - }] - const output = tableGenerator.buildTableFromItem(basicData, fieldDefinitions) - - expect(output).not.toHaveValue('7.2') - expect(output).toHaveValue('7.2 ms') - }) - - it('buildTableFromItem uses empty string for undefined calculated value', function() { - const fieldDefinitions = [{ - prop: 'reallyLongFieldName', - value: (): string | undefined => undefined, - }] - const output = tableGenerator.buildTableFromItem(basicData, fieldDefinitions) - - expect(output).toHaveValue('') - }) - - it('buildTableFromItem skips rows when include returns false', function() { - const fieldDefinitions = ['id', { - prop: 'someNumber', - include: (item: SimpleData): boolean => item.someNumber < 5, - }] - const output = tableGenerator.buildTableFromItem(basicData, fieldDefinitions) - - expect(output).toHaveLabelAndValue('Id', 'uuid-here') - expect(output).not.toHaveLabel('Some Number') - expect(rowCount(output)).toBe(1) - }) - - it('buildTableFromItem remembers to include rows when include returns true', function() { - const fieldDefinitions = ['id', { - prop: 'someNumber', - include: (item: SimpleData): boolean => item.someNumber > 5, - }] - const output = tableGenerator.buildTableFromItem(basicData, fieldDefinitions) - - expect(output).toHaveLabelAndValue('Id', 'uuid-here') - expect(output).toHaveLabelAndValue('Some Number', '14.4') - expect(rowCount(output)).toBe(2) + tableGenerator = new DefaultTableGenerator(false) }) - it('buildTableFromItem skips falsy values with skipEmpty', function() { - const fieldDefinitions = ['id', { prop: 'mightBeNull', skipEmpty: true }] - const output = tableGenerator.buildTableFromItem(basicData, fieldDefinitions) - - expect(output).toHaveLabelAndValue('Id', 'uuid-here') - expect(output).not.toHaveLabel('Might Be Null') - expect(rowCount(output)).toBe(1) + describe('stringFromUnknown', () => { + it.each` + input | result + ${'string'} | ${'string'} + ${undefined} | ${''} + ${() => 5} | ${''} + ${1} | ${'1'} + ${true} | ${'true'} + ${BigInt(5)} | ${'5'} + ${Symbol('symbol')} | ${'Symbol(symbol)'} + ${{ toString: () => 'toString' }} | ${'toString'} + ${{ simple: 'object' }} | ${'{"simple":"object"}'} + `('converts $input to $result', ({ input, result }) => { + expect(stringFromUnknown(input)).toBe(result) + }) }) - it('buildTableFromItem include truthy values with skipEmpty', function() { - const fieldDefinitions = ['id', { prop: 'mightBeNull', skipEmpty: true }] - const output = tableGenerator.buildTableFromItem({ ...basicData, mightBeNull: 'not null' }, fieldDefinitions) - - expect(output).toHaveLabelAndValue('Id', 'uuid-here') - expect(output).toHaveLabelAndValue('Might Be Null', 'not null') - expect(rowCount(output)).toBe(2) - }) - - it('buildTableFromList generates the correct number of rows', function() { - let output = tableGenerator.buildTableFromList([basicData], ['id', 'someNumber']) - - expect(rowCount(output)).toBe(3) // header, header separator and data row - - output = tableGenerator.buildTableFromList([basicData, basicData], ['id', 'someNumber']) - expect(rowCount(output)).toBe(4) // header, header separator and two data rows - }) - - it('buildTableFromList generates the correct header', function() { - const output = tableGenerator.buildTableFromList([basicData], ['id', 'someNumber']) - - expect(output).toHaveItemValues(['Id', 'Some Number']) - }) - - it('buildTableFromList generates the correct data values', function() { - const output = tableGenerator.buildTableFromList([basicData], ['id', 'someNumber']) - - expect(output).toHaveItemValues(['uuid-here', '14.4']) - }) - - it('throws exception if value missing with no prop', () => { - expect(() => tableGenerator.buildTableFromItem(basicData, [{ label: 'Label' }])) - .toThrow('both label and value are required if prop is not specified') - }) - - it('throws exception if label missing with no prop', () => { - expect(() => tableGenerator.buildTableFromItem(basicData, [{ value: () => 'some value' }])) - .toThrow('both label and value are required if prop is not specified') - }) - - it('uses empty string for no match', () => { - mockAt.mockReturnValue([]) - - const output = tableGenerator.buildTableFromList([{}], ['fieldName']) - - expect(output).toHaveItemValues(['']) - expect(mockGetLogger).toHaveBeenCalledTimes(1) - expect(mockGetLogger).toHaveBeenCalledWith('table-manager') - expect(mockAt).toHaveBeenCalledTimes(1) - expect(mockAt).toHaveBeenCalledWith({}, 'fieldName') - expect(mockDebug).toHaveBeenCalledTimes(1) - expect(mockDebug).toHaveBeenCalledWith('did not find match for fieldName in {}') - }) - - it('combines data on multiple matches', () => { - mockAt.mockReturnValue(['one', 'two']) - - const output = tableGenerator.buildTableFromList([{}], ['fieldName']) - - expect(output).toHaveItemValues(['one, two']) - expect(mockGetLogger).toHaveBeenCalledTimes(1) - expect(mockGetLogger).toHaveBeenCalledWith('table-manager') - expect(mockAt).toHaveBeenCalledTimes(1) - expect(mockAt).toHaveBeenCalledWith({}, 'fieldName') - expect(mockWarn).toHaveBeenCalledTimes(1) - expect(mockWarn).toHaveBeenCalledWith('found more than one match for fieldName in {}') + describe('buildTableFromItem', () => { + it('converts simple column labels properly', function() { + const output = tableGenerator.buildTableFromItem(basicData, basicFieldDefinitions) + + expect(output).toHaveLabel('Id') + expect(output).toHaveLabel('Simple Field') + expect(output).toHaveLabel('Really Long Field Name') + expect(output).toHaveLabel('Request URI') + // "URL" string should be made all caps + expect(output).toHaveLabel('Target URL') + expect(output).toHaveLabel('Sub Field') + // "url" in "Burley" should NOT be all caps. + expect(output).toHaveLabel('Burley') + // "is" should be dropped for boolean-style fields + expect(output).not.toHaveLabel('Is Burley') + // "uri" string in "centuries" should not be made all caps + expect(output).toHaveLabel('Age In Centuries') + expect(output).toHaveLabel('Lambda ARN EU') + expect(output).toHaveLabel('Red Barn Shade') + expect(output).toHaveLabel('OAuth Token URL') + expect(output).toHaveLabel('OAuth Client Id') + }) + + it('converts simple column values properly', function() { + const output = tableGenerator.buildTableFromItem(basicData, basicFieldDefinitions) + + expect(output).toHaveValue('uuid-here') + expect(output).toHaveValue('value') + expect(output).toHaveValue('7.2') + expect(output).toHaveLabelAndValue('Really Long Field Name', '7.2') + expect(output).toHaveLabelAndValue('Request URI', 'a request URI') + expect(output).toHaveLabelAndValue('Target URL', 'https://www.google.com/') + expect(output).toHaveLabelAndValue('Sub Field', 'sub-field value') + }) + + it('uses specified column headings', function() { + const basicFieldDefinitions = [{ + prop: 'reallyLongFieldName', + label: 'Shorter Name', + }] + const output = tableGenerator.buildTableFromItem(basicData, basicFieldDefinitions) + + expect(output).not.toHaveLabel('Really Long Field Name') + expect(output).toHaveLabel('Shorter Name') + }) + + it('uses calculated value', function() { + const fieldDefinitions = [{ + prop: 'reallyLongFieldName', + value: (item: SimpleData): string => `${item.reallyLongFieldName} ms`, + }] + const output = tableGenerator.buildTableFromItem(basicData, fieldDefinitions) + + expect(output).toHaveValue('7.2 ms') + }) + + it('uses empty string for undefined calculated value', function() { + const fieldDefinitions = [{ + prop: 'reallyLongFieldName', + value: (): string | undefined => undefined, + }] + const output = tableGenerator.buildTableFromItem(basicData, fieldDefinitions) + + expect(output).toBe(fixIndent(` + |──────────────────────────| + | Really Long Field Name | + |──────────────────────────| + |`)) + }) + + it('skips rows when include returns false', function() { + const fieldDefinitions = ['id', { + prop: 'someNumber', + include: (item: SimpleData): boolean => item.someNumber < 5, + }] + const output = tableGenerator.buildTableFromItem(basicData, fieldDefinitions) + + expect(output).toHaveLabelAndValue('Id', 'uuid-here') + expect(output).not.toHaveLabel('Some Number') + expect(lineCount(output)).toBe(3) + }) + + it('remembers to include rows when include returns true', function() { + const fieldDefinitions = ['id', { + prop: 'someNumber', + include: (item: SimpleData): boolean => item.someNumber > 5, + }] + const output = tableGenerator.buildTableFromItem(basicData, fieldDefinitions) + + expect(output).toHaveLabelAndValue('Id', 'uuid-here') + expect(output).toHaveLabelAndValue('Some Number', '14.4') + expect(lineCount(output)).toBe(4) + }) + + it('skips falsy values with skipEmpty', function() { + const fieldDefinitions = ['id', { prop: 'mightBeNull', skipEmpty: true }] + const output = tableGenerator.buildTableFromItem(basicData, fieldDefinitions) + + expect(output).toHaveLabelAndValue('Id', 'uuid-here') + expect(output).not.toHaveLabel('Might Be Null') + expect(lineCount(output)).toBe(3) + }) + + it('include truthy values with skipEmpty', function() { + const fieldDefinitions = ['id', { prop: 'mightBeNull', skipEmpty: true }] + const output = tableGenerator.buildTableFromItem({ ...basicData, mightBeNull: 'not null' }, fieldDefinitions) + + expect(output).toHaveLabelAndValue('Id', 'uuid-here') + expect(output).toHaveLabelAndValue('Might Be Null', 'not null') + expect(lineCount(output)).toBe(4) + }) + + it('throws exception if value missing with no prop', () => { + expect(() => tableGenerator.buildTableFromItem(basicData, [{ label: 'Label' }])) + .toThrow('both label and value are required if prop is not specified') + }) + + it('throws exception if label missing with no prop', () => { + expect(() => tableGenerator.buildTableFromItem(basicData, [{ value: () => 'some value' }])) + .toThrow('both label and value are required if prop is not specified') + }) + + it('handles all possible TableCellData types', () => { + const item = { + stringField: 'string', + numberField: 'number', + booleanField: true, + undefinedField: undefined, + } + const output = tableGenerator.buildTableFromItem(item, + ['stringField', 'numberField', 'booleanField', 'undefinedField']) + + expect(output).toBe(fixIndent(` + |─────────────────────────| + | String Field string | + | Number Field number | + | Boolean Field true | + | Undefined Field | + |─────────────────────────| + |`)) + }) }) - it('gets logger only once', () => { - tableGenerator.buildTableFromList([{}], ['fieldName']) - expect(mockGetLogger).toHaveBeenCalledTimes(1) - expect(mockGetLogger).toHaveBeenCalledWith('table-manager') - tableGenerator.buildTableFromList([{}], ['fieldName']) - expect(mockGetLogger).toHaveBeenCalledTimes(1) + describe('buildTableFromList', () => { + it('generates the correct number of rows', function() { + let output = tableGenerator.buildTableFromList([basicData], ['id', 'someNumber']) + + expect(lineCount(output)).toBe(5) // top and bottom, header, header separator and data row + + output = tableGenerator.buildTableFromList([basicData, basicData], ['id', 'someNumber']) + expect(lineCount(output)).toBe(6) // top and bottom, header, header separator and two data rows + }) + + it('generates the correct header', function() { + const output = tableGenerator.buildTableFromList([basicData], ['id', 'someNumber']) + + expect(output).toHaveItemValues(['Id', 'Some Number']) + }) + + it('generates the correct data values', function() { + const output = tableGenerator.buildTableFromList([basicData], ['id', 'someNumber']) + + expect(output).toHaveItemValues(['uuid-here', '14.4']) + }) + + it('uses empty string for no match', () => { + mockAt.mockReturnValue([]) + + const output = tableGenerator.buildTableFromList([{}], ['fieldName']) + + expect(output).toHaveItemValues(['']) + expect(mockGetLogger).toHaveBeenCalledTimes(1) + expect(mockGetLogger).toHaveBeenCalledWith('table-manager') + expect(mockAt).toHaveBeenCalledTimes(1) + expect(mockAt).toHaveBeenCalledWith({}, 'fieldName') + expect(mockDebug).toHaveBeenCalledTimes(1) + expect(mockDebug).toHaveBeenCalledWith('did not find match for fieldName in {}') + }) + + it('combines data on multiple matches', () => { + mockAt.mockReturnValue(['one', 'two']) + + const output = tableGenerator.buildTableFromList([{}], ['fieldName']) + + expect(output).toHaveItemValues(['one, two']) + expect(mockGetLogger).toHaveBeenCalledTimes(1) + expect(mockGetLogger).toHaveBeenCalledWith('table-manager') + expect(mockAt).toHaveBeenCalledTimes(1) + expect(mockAt).toHaveBeenCalledWith({}, 'fieldName') + expect(mockWarn).toHaveBeenCalledTimes(1) + expect(mockWarn).toHaveBeenCalledWith('found more than one match for fieldName in {}') + }) + + it('gets logger only once', () => { + tableGenerator.buildTableFromList([{}], ['fieldName']) + expect(mockGetLogger).toHaveBeenCalledTimes(1) + expect(mockGetLogger).toHaveBeenCalledWith('table-manager') + tableGenerator.buildTableFromList([{}], ['fieldName']) + expect(mockGetLogger).toHaveBeenCalledTimes(1) + }) + + it('includes separators every 4 rows with grouping on', () => { + const longList: SimpleData[] = [ + { id: 'uno', someNumber: 10 }, + { id: 'dos', someNumber: 9 }, + { id: 'tres', someNumber: 8 }, + { id: 'cuatro', someNumber: 7.2 }, + { id: 'cinco', someNumber: 6 }, + { id: 'seis', someNumber: 5 }, + { id: 'siete', someNumber: 4 }, + { id: 'ocho', someNumber: 3 }, + ] + + const output = tableGenerator.buildTableFromList(longList, ['id', 'someNumber']) + expect(lineCount(output)).toBe(12) + }) }) }) diff --git a/packages/lib/src/output-builder.ts b/packages/lib/src/output-builder.ts index 4252ea0a9..b2daf74fe 100644 --- a/packages/lib/src/output-builder.ts +++ b/packages/lib/src/output-builder.ts @@ -25,12 +25,12 @@ export const outputFlags = { char: 'o', description: 'specify output file', }), - compact: Flags.boolean({ - description: 'use compact table format with no lines between body rows', + 'group-rows': Flags.boolean({ + description: 'separate groups of four rows by a line to make long rows easier to follow across the screen', hidden: true, }), - expanded: Flags.boolean({ - description: 'use expanded table format with a line between each body row', + 'no-group-rows': Flags.boolean({ + description: 'do not separate groups of four rows by a line to make long rows easier to follow across the screen', hidden: true, }), } diff --git a/packages/lib/src/smartthings-command.ts b/packages/lib/src/smartthings-command.ts index 1735cf1f9..6cf353b20 100644 --- a/packages/lib/src/smartthings-command.ts +++ b/packages/lib/src/smartthings-command.ts @@ -197,11 +197,11 @@ export abstract class SmartThingsCommand extends Command i this._profile = this.cliConfig.profile - const compact = this.flags.expanded + const groupRows = this.flags['no-group-rows'] ? false - : (this.flags.compact ? true : this.booleanConfigValue('compactTableOutput', true)) + : (this.flags['group-rows'] ? true : this.booleanConfigValue('groupTableOutputRows', true)) - this._tableGenerator = new DefaultTableGenerator(compact) + this._tableGenerator = new DefaultTableGenerator(groupRows) } /** diff --git a/packages/lib/src/table-generator.ts b/packages/lib/src/table-generator.ts index 4614cb517..b39ee8199 100644 --- a/packages/lib/src/table-generator.ts +++ b/packages/lib/src/table-generator.ts @@ -1,53 +1,12 @@ +import log4js from '@log4js-node/log4js-api' import at from 'lodash.at' -import Table from 'cli-table' +import { table } from 'table' import { Logger } from '@smartthings/core-sdk' -import log4js from '@log4js-node/log4js-api' - export const summarizedText = '(Information is summarized, for full details use YAML, -y, or JSON flag, -j.)' -/** - * This code is copied from the DefinitelyTyped source code because it is not - * exported there. - * - * https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/cli-table/index.d.ts - * - * TODO: open a pull request to export this in DefinitelyTyped - */ -export interface TableOptions { - chars: Partial> - truncate: string - colors: boolean - colWidths: number[] - colAligns: Array<'left' | 'middle' | 'right'> - style: Partial<{ - 'padding-left': number - 'padding-right': number - head: string[] - border: string[] - compact: boolean - }> - head: string[] -} - /** * Used to define a field in an output table. * @@ -129,8 +88,95 @@ export interface TableGenerator { buildTableFromList(items: T[], tableFieldDefinitions: TableFieldDefinition[]): string } +export interface TableOptions { + /** + * Separate groups of four rows by a line to make long rows easier to follow across the screen. + */ + groupRows: boolean + head: string[] + isList?: boolean +} + +export const stringFromUnknown = (input: unknown): string => { + if (typeof input === 'string') { + return input + } + if (input == undefined) { + return '' + } + if (typeof input === 'function') { + return '' + } + if (typeof input === 'number' || typeof input == 'boolean' || typeof input === 'bigint' || + typeof input === 'symbol') { + return input.toString() + } + if (typeof input === 'object') { + // For object, only use the toString if it's not the default + if (input.toString !== Object.prototype.toString) { + return input.toString() + } + } + return JSON.stringify(input) +} + +export type TableCellData = string | number | boolean | undefined +export interface Table { + push: (row: TableCellData[]) => void + + toString: () => string +} + +class TableAdapter implements Table { + private data: string[][] = [] + private hasHeaderRow: boolean + + push(row: TableCellData[]): void { + this.data.push(row.map(cell => cell?.toString() ?? '')) + } + + constructor(private options: Partial) { + this.hasHeaderRow = options.head != undefined + if (options.head) { + this.data.push(options.head) + } + } + + toString(): string { + const border = { + topBody: '─', + topJoin: '', + topLeft: '', + topRight: '', + + bottomBody: '─', + bottomJoin: '', + bottomLeft: '', + bottomRight: '', + + bodyLeft: '', + bodyRight: '', + bodyJoin: '', + + joinBody: '─', + joinLeft: '', + joinRight: '', + joinJoin: '', + } + + const listDrawHorizontalLine = this.options.groupRows + ? (index: number) => index === 0 || index === this.data.length || (index - 1) % 5 === 0 + : (index: number) => index === 0 || index === this.data.length || index === 1 + const drawHorizontalLine = this.options.isList + ? listDrawHorizontalLine + : (index: number) => index === 0 || index === this.data.length + const config = { drawHorizontalLine, border } + return table(this.data, config) + } +} + export class DefaultTableGenerator implements TableGenerator { - constructor(private compact: boolean) {} + constructor(private groupRows: boolean) {} private _logger?: Logger protected get logger(): Logger { @@ -169,9 +215,9 @@ export class DefaultTableGenerator implements TableGenerator { return this.convertToLabel(definition.prop) } - private getDisplayValueFor(item: T, definition: TableFieldDefinition): string { + private getDisplayValueFor(item: T, definition: TableFieldDefinition): string | undefined { if (!(typeof definition === 'string') && definition.value) { - return definition.value(item) ?? '' + return definition.value(item) } const propertyName = typeof definition === 'string' ? definition : definition.prop @@ -196,12 +242,12 @@ export class DefaultTableGenerator implements TableGenerator { } newOutputTable(options?: Partial): Table { - const configuredOptions = { style: { compact: this.compact } } + const configuredOptions = { groupRows: this.groupRows } if (options) { - return new Table({ ...configuredOptions, ...options }) + return new TableAdapter({ ...configuredOptions, ...options }) } - return new Table(configuredOptions) + return new TableAdapter(configuredOptions) } buildTableFromItem(item: T, definitions: TableFieldDefinition[]): string { @@ -223,7 +269,7 @@ export class DefaultTableGenerator implements TableGenerator { buildTableFromList(items: T[], definitions: TableFieldDefinition[]): string { const headingLabels = definitions.map(def => this.getLabelFor(def)) - const table = this.newOutputTable({ head: headingLabels }) + const table = this.newOutputTable({ isList: true, head: headingLabels }) for (const item of items) { table.push(definitions.map(def => this.getDisplayValueFor(item, def))) }