From a37ed6d6edfd02650fcb08424c307e9b7dc842c5 Mon Sep 17 00:00:00 2001 From: Sara Bee <855595+doeg@users.noreply.github.com> Date: Thu, 11 Feb 2021 16:06:33 -0500 Subject: [PATCH] Add DataTable component with URL pagination Signed-off-by: Sara Bee <855595+doeg@users.noreply.github.com> --- web/vtadmin/package-lock.json | 191 ++++++++++++++---- web/vtadmin/package.json | 7 +- .../src/components/dataTable/DataTable.tsx | 75 +++++++ .../dataTable/PaginationNav.module.scss | 51 +++++ .../dataTable/PaginationNav.test.tsx | 99 +++++++++ .../components/dataTable/PaginationNav.tsx | 119 +++++++++++ .../src/components/routes/Clusters.tsx | 27 +-- web/vtadmin/src/components/routes/Gates.tsx | 27 +-- .../src/components/routes/Keyspaces.tsx | 27 +-- web/vtadmin/src/components/routes/Schemas.tsx | 31 ++- web/vtadmin/src/components/routes/Tablets.tsx | 47 ++--- .../src/hooks/useURLPagination.test.tsx | 99 +++++++++ web/vtadmin/src/hooks/useURLPagination.ts | 65 ++++++ web/vtadmin/src/hooks/useURLQuery.test.tsx | 63 ++++++ web/vtadmin/src/hooks/useURLQuery.ts | 38 ++++ web/vtadmin/src/index.css | 13 +- web/vtadmin/tsconfig.json | 1 + 17 files changed, 844 insertions(+), 136 deletions(-) create mode 100644 web/vtadmin/src/components/dataTable/DataTable.tsx create mode 100644 web/vtadmin/src/components/dataTable/PaginationNav.module.scss create mode 100644 web/vtadmin/src/components/dataTable/PaginationNav.test.tsx create mode 100644 web/vtadmin/src/components/dataTable/PaginationNav.tsx create mode 100644 web/vtadmin/src/hooks/useURLPagination.test.tsx create mode 100644 web/vtadmin/src/hooks/useURLPagination.ts create mode 100644 web/vtadmin/src/hooks/useURLQuery.test.tsx create mode 100644 web/vtadmin/src/hooks/useURLQuery.ts diff --git a/web/vtadmin/package-lock.json b/web/vtadmin/package-lock.json index bca6bead1d4..cc66d480c86 100644 --- a/web/vtadmin/package-lock.json +++ b/web/vtadmin/package-lock.json @@ -2080,9 +2080,10 @@ } }, "@testing-library/dom": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-7.29.0.tgz", - "integrity": "sha512-0hhuJSmw/zLc6ewR9cVm84TehuTd7tbqBX9pRNSp8znJ9gTmSgesdbiGZtt8R6dL+2rgaPFp9Yjr7IU1HWm49w==", + "version": "7.29.4", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-7.29.4.tgz", + "integrity": "sha512-CtrJRiSYEfbtNGtEsd78mk1n1v2TUbeABlNIcOCJdDfkN5/JTOwQEbbQpoSRxGqzcWPgStMvJ4mNolSuBRv1NA==", + "dev": true, "requires": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -2098,6 +2099,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "requires": { "color-convert": "^2.0.1" } @@ -2106,6 +2108,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, "requires": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -2115,6 +2118,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "requires": { "color-name": "~1.1.4" } @@ -2122,17 +2126,20 @@ "color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true }, "supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "requires": { "has-flag": "^4.0.0" } @@ -2140,9 +2147,10 @@ } }, "@testing-library/jest-dom": { - "version": "5.11.6", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.11.6.tgz", - "integrity": "sha512-cVZyUNRWwUKI0++yepYpYX7uhrP398I+tGz4zOlLVlUYnZS+Svuxv4fwLeCIy7TnBYKXUaOlQr3vopxL8ZfEnA==", + "version": "5.11.9", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.11.9.tgz", + "integrity": "sha512-Mn2gnA9d1wStlAIT2NU8J15LNob0YFBVjs2aEQ3j8rsfRQo+lAs7/ui1i2TGaJjapLmuNPLTsrm+nPjmZDwpcQ==", + "dev": true, "requires": { "@babel/runtime": "^7.9.2", "@types/testing-library__jest-dom": "^5.9.1", @@ -2158,6 +2166,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "requires": { "color-convert": "^2.0.1" } @@ -2166,6 +2175,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, "requires": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -2175,6 +2185,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "requires": { "color-name": "~1.1.4" } @@ -2182,12 +2193,14 @@ "color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "css": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/css/-/css-3.0.0.tgz", "integrity": "sha512-DG9pFfwOrzc+hawpmqX/dHYHJG+Bsdb0klhyi1sDneOgGOXy9wQIC8hzyVp1e4NRYDBdxcylvywPkkXCHAzTyQ==", + "dev": true, "requires": { "inherits": "^2.0.4", "source-map": "^0.6.1", @@ -2197,17 +2210,20 @@ "has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true }, "source-map-resolve": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.6.0.tgz", "integrity": "sha512-KXBr9d/fO/bWo97NXsPIAW1bFSBOuCnjbNTBMO7N59hsv5i9yzRDfcYwwt0l04+VqnKC+EwzvJZIP/qkuMgR/w==", + "dev": true, "requires": { "atob": "^2.1.2", "decode-uri-component": "^0.2.0" @@ -2217,6 +2233,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "requires": { "has-flag": "^4.0.0" } @@ -2224,14 +2241,29 @@ } }, "@testing-library/react": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-11.2.2.tgz", - "integrity": "sha512-jaxm0hwUjv+hzC+UFEywic7buDC9JQ1q3cDsrWVSDAPmLotfA6E6kUHlYm/zOeGCac6g48DR36tFHxl7Zb+N5A==", + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-11.2.5.tgz", + "integrity": "sha512-yEx7oIa/UWLe2F2dqK0FtMF9sJWNXD+2PPtp39BvE0Kh9MJ9Kl0HrZAgEuhUJR+Lx8Di6Xz+rKwSdEPY2UV8ZQ==", + "dev": true, "requires": { "@babel/runtime": "^7.12.5", "@testing-library/dom": "^7.28.1" } }, + "@testing-library/react-hooks": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-5.0.3.tgz", + "integrity": "sha512-UrnnRc5II7LMH14xsYNm/WRch/67cBafmrSQcyFh0v+UUmSf1uzfB7zn5jQXSettGwOSxJwdQUN7PgkT0w22Lg==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5", + "@types/react": ">=16.9.0", + "@types/react-dom": ">=16.9.0", + "@types/react-test-renderer": ">=16.9.0", + "filter-console": "^0.1.1", + "react-error-boundary": "^3.1.0" + } + }, "@testing-library/user-event": { "version": "12.6.0", "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-12.6.0.tgz", @@ -2246,9 +2278,10 @@ "integrity": "sha512-/+CRPXpBDpo2RK9C68N3b2cOvO0Cf5B9aPijHsoDQTHivnGSObdOF2BRQOYjojWTDy6nQvMjmqRXIxH55VjxxA==" }, "@types/aria-query": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.0.tgz", - "integrity": "sha512-iIgQNzCm0v7QMhhe4Jjn9uRh+I6GoPmt03CbEtwx3ao8/EfoQcmgtqH4vQ5Db/lxiIGaWDv6nwvunuh0RyX0+A==" + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.1.tgz", + "integrity": "sha512-S6oPal772qJZHoRZLFc/XoZW2gFvwXusYUmXPXkgxJLuEk2vOt7jc4Yo6z/vtI0EBkbPBVrJJ0B+prLIKiWqHg==", + "dev": true }, "@types/babel__core": { "version": "7.1.12", @@ -2486,6 +2519,15 @@ "@types/react-router": "*" } }, + "@types/react-test-renderer": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-17.0.0.tgz", + "integrity": "sha512-nvw+F81OmyzpyIE1S0xWpLonLUZCMewslPuA8BtjSKc5XEbn8zEQBXS7KuOLHTNnSOEM2Pum50gHOoZ62tqTRg==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/resolve": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-0.0.8.tgz", @@ -2513,6 +2555,7 @@ "version": "5.9.5", "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.9.5.tgz", "integrity": "sha512-ggn3ws+yRbOHog9GxnXiEZ/35Mow6YtPZpd7Z5mKDeZS/o7zx3yAle0ov/wjhVB5QT4N2Dt+GNoGCdqkBGCajQ==", + "dev": true, "requires": { "@types/jest": "*" } @@ -4866,7 +4909,8 @@ "css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s=" + "integrity": "sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s=", + "dev": true }, "cssdb": { "version": "4.4.0", @@ -5381,7 +5425,8 @@ "dom-accessibility-api": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.4.tgz", - "integrity": "sha512-TvrjBckDy2c6v6RLxPv5QXOnU+SmF9nBII5621Ve5fu6Z/BDrENurBEvlC1f44lKEUVqOpK4w9E5Idc5/EgkLQ==" + "integrity": "sha512-TvrjBckDy2c6v6RLxPv5QXOnU+SmF9nBII5621Ve5fu6Z/BDrENurBEvlC1f44lKEUVqOpK4w9E5Idc5/EgkLQ==", + "dev": true }, "dom-converter": { "version": "0.2.0", @@ -6765,6 +6810,17 @@ "to-regex-range": "^5.0.1" } }, + "filter-console": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/filter-console/-/filter-console-0.1.1.tgz", + "integrity": "sha512-zrXoV1Uaz52DqPs+qEwNJWJFAWZpYJ47UNmpN9q4j+/EYsz85uV0DC9k8tRND5kYmoVzL0W+Y75q4Rg8sRJCdg==", + "dev": true + }, + "filter-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", + "integrity": "sha1-mzERErxsYSehbgFsbF1/GeCAXFs=" + }, "finalhandler": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", @@ -7531,16 +7587,11 @@ "integrity": "sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==" }, "history": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", - "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/history/-/history-5.0.0.tgz", + "integrity": "sha512-3NyRMKIiFSJmIPdq7FxkNMJkQ7ZEtVblOQ38VtKaA0zZMW1Eo6Q6W8oDKEflr1kNNTItSnk4JMCO1deeSgbLLg==", "requires": { - "@babel/runtime": "^7.1.2", - "loose-envify": "^1.2.0", - "resolve-pathname": "^3.0.0", - "tiny-invariant": "^1.0.2", - "tiny-warning": "^1.0.0", - "value-equal": "^1.0.1" + "@babel/runtime": "^7.7.6" } }, "hmac-drbg": { @@ -10390,7 +10441,8 @@ "lz-string": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz", - "integrity": "sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=" + "integrity": "sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=", + "dev": true }, "magic-string": { "version": "0.25.7", @@ -10752,7 +10804,8 @@ "min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==" + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true }, "mini-create-react-context": { "version": "0.4.1", @@ -11490,6 +11543,22 @@ "prepend-http": "^1.0.0", "query-string": "^4.1.0", "sort-keys": "^1.0.0" + }, + "dependencies": { + "query-string": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-4.3.4.tgz", + "integrity": "sha1-u7aTucqRXCMlFbIosaArYJBD2+s=", + "requires": { + "object-assign": "^4.1.0", + "strict-uri-encode": "^1.0.0" + } + }, + "strict-uri-encode": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz", + "integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM=" + } } }, "npm-run-path": { @@ -13444,12 +13513,14 @@ "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" }, "query-string": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/query-string/-/query-string-4.3.4.tgz", - "integrity": "sha1-u7aTucqRXCMlFbIosaArYJBD2+s=", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-6.14.0.tgz", + "integrity": "sha512-In3o+lUxlgejoVJgwEdYtdxrmlL0cQWJXj0+kkI7RWVo7hg5AhFtybeKlC9Dpgbr8eOC4ydpEh8017WwyfzqVQ==", "requires": { - "object-assign": "^4.1.0", - "strict-uri-encode": "^1.0.0" + "decode-uri-component": "^0.2.0", + "filter-obj": "^1.1.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" } }, "querystring": { @@ -13646,6 +13717,15 @@ "scheduler": "^0.20.1" } }, + "react-error-boundary": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.0.tgz", + "integrity": "sha512-lmPrdi5SLRJR+AeJkqdkGlW/CRkAUvZnETahK58J4xb5wpbfDngasEGu+w0T1iXEhVrYBJZeW+c4V1hILCnMWQ==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5" + } + }, "react-error-overlay": { "version": "6.0.8", "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.8.tgz", @@ -13687,6 +13767,19 @@ "tiny-warning": "^1.0.0" }, "dependencies": { + "history": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", + "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", + "requires": { + "@babel/runtime": "^7.1.2", + "loose-envify": "^1.2.0", + "resolve-pathname": "^3.0.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0", + "value-equal": "^1.0.1" + } + }, "isarray": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", @@ -13714,6 +13807,21 @@ "react-router": "5.2.0", "tiny-invariant": "^1.0.2", "tiny-warning": "^1.0.0" + }, + "dependencies": { + "history": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", + "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", + "requires": { + "@babel/runtime": "^7.1.2", + "loose-envify": "^1.2.0", + "resolve-pathname": "^3.0.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0", + "value-equal": "^1.0.1" + } + } } }, "react-scripts": { @@ -13891,6 +13999,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, "requires": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" @@ -15386,6 +15495,11 @@ "integrity": "sha512-1klA3Gi5PD1Wv9Q0wUoOQN1IWAuPu0D1U03ThXTr0cJ20+/iq2tHSDnK7Kk/0LXJ1ztUB2/1Os0wKmfyNgUQfg==", "dev": true }, + "split-on-first": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==" + }, "split-string": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", @@ -15593,9 +15707,9 @@ "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==" }, "strict-uri-encode": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz", - "integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM=" + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha1-ucczDHBChi9rFC3CdLvMWGbONUY=" }, "string-length": { "version": "4.0.1", @@ -15721,6 +15835,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, "requires": { "min-indent": "^1.0.0" } diff --git a/web/vtadmin/package.json b/web/vtadmin/package.json index 9493a41d4b4..8982ee2aac9 100644 --- a/web/vtadmin/package.json +++ b/web/vtadmin/package.json @@ -7,8 +7,6 @@ "npm": ">=6.14.9" }, "dependencies": { - "@testing-library/jest-dom": "^5.11.6", - "@testing-library/react": "^11.2.2", "@testing-library/user-event": "^12.6.0", "@types/classnames": "^2.2.11", "@types/jest": "^26.0.19", @@ -17,8 +15,10 @@ "@types/react-dom": "^16.9.10", "@types/react-router-dom": "^5.1.7", "classnames": "^2.2.6", + "history": "^5.0.0", "lodash-es": "^4.17.20", "node-sass": "^4.14.1", + "query-string": "^6.14.0", "react": "^17.0.1", "react-dom": "^17.0.1", "react-query": "^3.5.9", @@ -60,6 +60,9 @@ ] }, "devDependencies": { + "@testing-library/jest-dom": "^5.11.9", + "@testing-library/react": "^11.2.5", + "@testing-library/react-hooks": "^5.0.3", "@types/lodash-es": "^4.17.4", "msw": "^0.24.4", "prettier": "^2.2.1", diff --git a/web/vtadmin/src/components/dataTable/DataTable.tsx b/web/vtadmin/src/components/dataTable/DataTable.tsx new file mode 100644 index 00000000000..3cca7131052 --- /dev/null +++ b/web/vtadmin/src/components/dataTable/DataTable.tsx @@ -0,0 +1,75 @@ +/** + * Copyright 2021 The Vitess Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import qs from 'query-string'; +import * as React from 'react'; +import { useLocation } from 'react-router-dom'; + +import { useURLPagination } from '../../hooks/useURLPagination'; +import { useURLQuery } from '../../hooks/useURLQuery'; +import { PaginationNav } from './PaginationNav'; + +interface Props { + columns: string[]; + data: T[]; + pageSize?: number; + renderRows: (rows: T[]) => JSX.Element[]; +} + +// Generally, page sizes of ~100 rows are fine in terms of performance, +// but anything over ~50 feels unwieldy in terms of UX. +const DEFAULT_PAGE_SIZE = 50; + +export const DataTable = ({ columns, data, pageSize = DEFAULT_PAGE_SIZE, renderRows }: Props) => { + const { pathname } = useLocation(); + const urlQuery = useURLQuery(); + + const totalPages = Math.ceil(data.length / pageSize); + const { page } = useURLPagination({ totalPages }); + + const startIndex = (page - 1) * pageSize; + const endIndex = startIndex + pageSize; + const dataPage = data.slice(startIndex, endIndex); + + const startRow = startIndex + 1; + const lastRow = Math.min(data.length, startIndex + pageSize); + + const formatPageLink = (p: number) => ({ + pathname, + search: qs.stringify({ ...urlQuery, page: p === 1 ? undefined : p }), + }); + + return ( +
+ + + + {columns.map((col, cdx) => ( + + ))} + + + {renderRows(dataPage)} +
{col}
+ + + {!!data.length && ( +

+ Showing {startRow} {lastRow > startRow ? `- ${lastRow}` : null} of {data.length} +

+ )} +
+ ); +}; diff --git a/web/vtadmin/src/components/dataTable/PaginationNav.module.scss b/web/vtadmin/src/components/dataTable/PaginationNav.module.scss new file mode 100644 index 00000000000..68231d7dcc0 --- /dev/null +++ b/web/vtadmin/src/components/dataTable/PaginationNav.module.scss @@ -0,0 +1,51 @@ +/** + * Copyright 2021 The Vitess Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +.links { + display: flex; + list-style-type: none; + margin: 0; + padding: 0; +} + +.placeholder, +a.link { + border: solid 1px var(--backgroundPrimaryHighlight); + border-radius: 6px; + color: var(--textColorSecondary); + display: block; + line-height: 36px; + margin-right: 8px; + text-align: center; + width: 36px; +} + +a.link { + cursor: pointer; + text-decoration: none; + + &:hover { + border-color: var(--colorPrimary); + } + + &.activeLink { + border-color: var(--colorPrimary); + color: var(--colorPrimary); + } +} + +.placeholder::before { + content: '...'; +} diff --git a/web/vtadmin/src/components/dataTable/PaginationNav.test.tsx b/web/vtadmin/src/components/dataTable/PaginationNav.test.tsx new file mode 100644 index 00000000000..3092c4f1617 --- /dev/null +++ b/web/vtadmin/src/components/dataTable/PaginationNav.test.tsx @@ -0,0 +1,99 @@ +/** + * Copyright 2021 The Vitess Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { render, screen, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { PaginationNav, Props } from './PaginationNav'; + +const formatLink = (page: number) => ({ + pathname: '/test', + search: `?hello=world&page=${page}`, +}); + +describe('PaginationNav', () => { + const tests: { + name: string; + props: Props; + expected: null | Array; + }[] = [ + { + name: 'renders without breaks', + props: { currentPage: 1, formatLink, maxVisible: 3, totalPages: 2 }, + expected: [1, 2], + }, + { + name: 'renders breaks on the right', + props: { currentPage: 1, formatLink, maxVisible: 5, totalPages: 11 }, + expected: [1, 2, 3, null, 11], + }, + { + name: 'renders breaks on the left', + props: { currentPage: 11, formatLink, maxVisible: 5, totalPages: 11 }, + expected: [1, null, 9, 10, 11], + }, + { + name: 'renders breaks in the middle', + props: { currentPage: 6, formatLink, maxVisible: 5, totalPages: 11 }, + expected: [1, null, 6, null, 11], + }, + { + name: 'renders widths according to the minWidth prop', + props: { currentPage: 6, formatLink, maxVisible: 9, minWidth: 2, totalPages: 100 }, + expected: [1, 2, null, 5, 6, 7, null, 99, 100], + }, + { + name: 'does not render if totalPages == 0', + props: { currentPage: 1, formatLink, totalPages: 0 }, + expected: null, + }, + { + name: 'renders even if page > totalPages', + props: { currentPage: 100000, formatLink, maxVisible: 5, totalPages: 11 }, + expected: [1, null, 9, 10, 11], + }, + ]; + + test.each(tests.map(Object.values))('%s', (name: string, props: Props, expected: Array) => { + render(, { wrapper: MemoryRouter }); + + const nav = screen.queryByRole('navigation'); + if (expected === null) { + expect(nav).toBeNull(); + return; + } + + const lis = screen.getAllByRole('listitem'); + expect(lis).toHaveLength(expected.length); + + lis.forEach((li, idx) => { + const e = expected[idx]; + const link = within(li).queryByRole('link'); + + if (e === null) { + // Placeholders don't render links + expect(link).toBeNull(); + } else { + expect(link).toHaveAttribute('href', `/test?hello=world&page=${e}`); + expect(link).toHaveTextContent(`${e}`); + + if (e === props.currentPage) { + expect(link).toHaveClass('activeLink'); + } else { + expect(link).not.toHaveClass('activeLink'); + } + } + }); + }); +}); diff --git a/web/vtadmin/src/components/dataTable/PaginationNav.tsx b/web/vtadmin/src/components/dataTable/PaginationNav.tsx new file mode 100644 index 00000000000..a96857d225f --- /dev/null +++ b/web/vtadmin/src/components/dataTable/PaginationNav.tsx @@ -0,0 +1,119 @@ +/** + * Copyright 2021 The Vitess Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import cx from 'classnames'; +import * as React from 'react'; +import { Link, LinkProps } from 'react-router-dom'; + +import style from './PaginationNav.module.scss'; + +export interface Props { + currentPage: number; + formatLink: (page: number) => LinkProps['to']; + // The maximum number of pagination elements to show. Note that this includes any placeholders. + // It's recommended for this value to be >= 5 to handle the case where there are + // breaks on either side of the list. + maxVisible?: number; + // The minimum number of pagination elements to show at the beginning/end of a sequence, + // adjacent to any sequence breaks. + minWidth?: number; + // The total number of pages + totalPages: number; +} + +const DEFAULT_MAX_VISIBLE = 8; +const DEFAULT_MIN_WIDTH = 1; + +// This assumes we always want to 1-index our pages, where "page 1" is the first page. +// If we find a need for zero-indexed pagination, we can make this configurable. +const FIRST_PAGE = 1; + +// PageSpecifiers with a numeric value are links. `null` is used +// to signify a break in the sequence. +type PageSpecifier = number | null; + +export const PaginationNav = ({ + currentPage, + formatLink, + maxVisible = DEFAULT_MAX_VISIBLE, + minWidth = DEFAULT_MIN_WIDTH, + totalPages, +}: Props) => { + if (totalPages <= 1) { + return null; + } + + // This rather magical solution is borrowed, with gratitude, from StackOverflow + // https://stackoverflow.com/a/46385144 + const leftWidth = (maxVisible - minWidth * 2 - 3) >> 1; + const rightWidth = (maxVisible - minWidth * 2 - 2) >> 1; + + let numbers: PageSpecifier[] = []; + if (totalPages <= maxVisible) { + // No breaks in list + numbers = range(FIRST_PAGE, totalPages); + } else if (currentPage <= maxVisible - minWidth - 1 - rightWidth) { + // No break on left side of page + numbers = range(FIRST_PAGE, maxVisible - minWidth - 1).concat( + null, + range(totalPages - minWidth + 1, totalPages) + ); + } else if (currentPage >= totalPages - minWidth - 1 - rightWidth) { + // No break on right of page + numbers = range(FIRST_PAGE, minWidth).concat( + null, + range(totalPages - minWidth - 1 - rightWidth - leftWidth, totalPages) + ); + } else { + // Breaks on both sides + numbers = range(FIRST_PAGE, minWidth).concat( + null, + range(currentPage - leftWidth, currentPage + rightWidth), + null, + range(totalPages - minWidth + 1, totalPages) + ); + } + + return ( + + ); +}; + +// lodash-es has a `range` function but it doesn't play nice +// with the PageSpecifier[] return type (since it's a mixed array +// of numbers and nulls). +const range = (start: number, end: number): PageSpecifier[] => { + if (isNaN(start) || isNaN(end)) return []; + return Array.from(Array(end - start + 1), (_, i) => i + start); +}; diff --git a/web/vtadmin/src/components/routes/Clusters.tsx b/web/vtadmin/src/components/routes/Clusters.tsx index c00f364c01b..514f846722e 100644 --- a/web/vtadmin/src/components/routes/Clusters.tsx +++ b/web/vtadmin/src/components/routes/Clusters.tsx @@ -17,6 +17,8 @@ import { orderBy } from 'lodash-es'; import * as React from 'react'; import { useClusters } from '../../hooks/api'; import { useDocumentTitle } from '../../hooks/useDocumentTitle'; +import { DataTable } from '../dataTable/DataTable'; +import { vtadmin as pb } from '../../proto/vtadmin'; export const Clusters = () => { useDocumentTitle('Clusters'); @@ -26,25 +28,18 @@ export const Clusters = () => { return orderBy(data, ['name']); }, [data]); + const renderRows = (rows: pb.Cluster[]) => + rows.map((cluster, idx) => ( + + {cluster.name} + {cluster.id} + + )); + return (

Clusters

- - - - - - - - - {rows.map((cluster, idx) => ( - - - - - ))} - -
NameId
{cluster.name}{cluster.id}
+
); }; diff --git a/web/vtadmin/src/components/routes/Gates.tsx b/web/vtadmin/src/components/routes/Gates.tsx index 7ca63182696..1e889ed132f 100644 --- a/web/vtadmin/src/components/routes/Gates.tsx +++ b/web/vtadmin/src/components/routes/Gates.tsx @@ -17,6 +17,8 @@ import { orderBy } from 'lodash-es'; import * as React from 'react'; import { useGates } from '../../hooks/api'; import { useDocumentTitle } from '../../hooks/useDocumentTitle'; +import { vtadmin as pb } from '../../proto/vtadmin'; +import { DataTable } from '../dataTable/DataTable'; export const Gates = () => { useDocumentTitle('Gates'); @@ -26,25 +28,18 @@ export const Gates = () => { return orderBy(data, ['cluster.name', 'hostname']); }, [data]); + const renderRows = (gates: pb.VTGate[]) => + gates.map((gate, idx) => ( + + {gate.cluster?.name} + {gate.hostname} + + )); + return (

Gates

- - - - - - - - - {rows.map((gate, idx) => ( - - - - - ))} - -
ClusterHostname
{gate.cluster?.name}{gate.hostname}
+
); }; diff --git a/web/vtadmin/src/components/routes/Keyspaces.tsx b/web/vtadmin/src/components/routes/Keyspaces.tsx index ca2ab6f3cdf..b4e49a971b5 100644 --- a/web/vtadmin/src/components/routes/Keyspaces.tsx +++ b/web/vtadmin/src/components/routes/Keyspaces.tsx @@ -17,6 +17,8 @@ import { orderBy } from 'lodash-es'; import * as React from 'react'; import { useKeyspaces } from '../../hooks/api'; import { useDocumentTitle } from '../../hooks/useDocumentTitle'; +import { vtadmin as pb } from '../../proto/vtadmin'; +import { DataTable } from '../dataTable/DataTable'; export const Keyspaces = () => { useDocumentTitle('Keyspaces'); @@ -26,25 +28,18 @@ export const Keyspaces = () => { return orderBy(data, ['cluster.name', 'keyspace.name']); }, [data]); + const renderRows = (rows: pb.Keyspace[]) => + rows.map((keyspace, idx) => ( + + {keyspace.cluster?.name} + {keyspace.keyspace?.name} + + )); + return (

Keyspaces

- - - - - - - - - {rows.map((keyspace, idx) => ( - - - - - ))} - -
ClusterKeyspace
{keyspace.cluster?.name}{keyspace.keyspace?.name}
+
); }; diff --git a/web/vtadmin/src/components/routes/Schemas.tsx b/web/vtadmin/src/components/routes/Schemas.tsx index a78c7b1ceeb..abd34c8a9cf 100644 --- a/web/vtadmin/src/components/routes/Schemas.tsx +++ b/web/vtadmin/src/components/routes/Schemas.tsx @@ -15,8 +15,9 @@ */ import { orderBy } from 'lodash-es'; import * as React from 'react'; -import { useTableDefinitions } from '../../hooks/api'; +import { TableDefinition, useTableDefinitions } from '../../hooks/api'; import { useDocumentTitle } from '../../hooks/useDocumentTitle'; +import { DataTable } from '../dataTable/DataTable'; export const Schemas = () => { useDocumentTitle('Schemas'); @@ -26,27 +27,19 @@ export const Schemas = () => { return orderBy(data, ['cluster.name', 'keyspace', 'tableDefinition.name']); }, [data]); + const renderRows = (rows: TableDefinition[]) => + rows.map((row, idx) => ( + + {row.cluster?.name} + {row.keyspace} + {row.tableDefinition?.name} + + )); + return (

Schemas

- - - - - - - - - - {rows.map((row, idx) => ( - - - - - - ))} - -
ClusterKeyspaceTable
{row.cluster?.name}{row.keyspace}{row.tableDefinition?.name}
+
); }; diff --git a/web/vtadmin/src/components/routes/Tablets.tsx b/web/vtadmin/src/components/routes/Tablets.tsx index 5575933bc0b..1d46185376a 100644 --- a/web/vtadmin/src/components/routes/Tablets.tsx +++ b/web/vtadmin/src/components/routes/Tablets.tsx @@ -19,6 +19,7 @@ import { useTablets } from '../../hooks/api'; import { vtadmin as pb, topodata } from '../../proto/vtadmin'; import { orderBy } from 'lodash-es'; import { useDocumentTitle } from '../../hooks/useDocumentTitle'; +import { DataTable } from '../dataTable/DataTable'; export const Tablets = () => { useDocumentTitle('Tablets'); @@ -28,36 +29,28 @@ export const Tablets = () => { return orderBy(data, ['cluster.name', 'tablet.keyspace', 'tablet.shard', 'tablet.type']); }, [data]); + const renderRows = React.useCallback((rows: pb.Tablet[]) => { + return rows.map((t, tdx) => ( + + {t.cluster?.name} + {t.tablet?.keyspace} + {t.tablet?.shard} + {formatAlias(t)} + {t.tablet?.hostname} + {formatType(t)} + {formatState(t)} + + )); + }, []); + return (

Tablets

- - - - - - - - - - - - - - - {rows.map((t, tdx) => ( - - - - - - - - - - ))} - -
ClusterKeyspaceShardAliasHostnameTypeState
{t.cluster?.name}{t.tablet?.keyspace}{t.tablet?.shard}{formatAlias(t)}{t.tablet?.hostname}{formatType(t)}{formatState(t)}
+
); }; diff --git a/web/vtadmin/src/hooks/useURLPagination.test.tsx b/web/vtadmin/src/hooks/useURLPagination.test.tsx new file mode 100644 index 00000000000..a6dd87e85a7 --- /dev/null +++ b/web/vtadmin/src/hooks/useURLPagination.test.tsx @@ -0,0 +1,99 @@ +/** + * Copyright 2021 The Vitess Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { renderHook } from '@testing-library/react-hooks'; +import { createMemoryHistory, To } from 'history'; +import { Router } from 'react-router-dom'; +import { PaginationOpts, PaginationParams, useURLPagination } from './useURLPagination'; + +describe('useURLPagination', () => { + const tests: { + name: string; + url: string; + opts: PaginationOpts; + // URL query parameters after any redirects have taken place + expected: PaginationParams; + // If defined, checks whether history.replace was called with the given parameters. + // If null, checks that history.replace was not called. (Unfortunately, we can't + // make this an optional param, else Jest times out because the length of + // callback args must be consistent between tests.) + redirectParams: To | null; + }[] = [ + { + name: 'returns pagination parameters in the URL', + url: '/test?page=1&foo=bar', + opts: { totalPages: 10 }, + expected: { page: 1 }, + redirectParams: null, + }, + { + name: 'assumes an undefined page parameter is the first page', + url: '/test?foo=bar', + opts: { totalPages: 10 }, + expected: { page: 1 }, + redirectParams: null, + }, + { + name: 'redirects to the first page if current page > total pages', + url: '/test?page=100&foo=bar', + opts: { totalPages: 10 }, + expected: { page: 1 }, + redirectParams: { pathname: '/test', search: '?foo=bar&page=1' }, + }, + { + name: 'redirects to the first page if current page is a negative number', + url: '/test?page=-123&foo=bar', + opts: { totalPages: 10 }, + expected: { page: 1 }, + redirectParams: { pathname: '/test', search: '?foo=bar&page=1' }, + }, + { + name: 'redirects to the first page if current page is not a number', + url: '/test?page=abc&foo=bar', + opts: { totalPages: 10 }, + expected: { page: 1 }, + redirectParams: { pathname: '/test', search: '?foo=bar&page=1' }, + }, + { + name: 'does not redirect if totalPages is 0', + url: '/test?page=100&foo=bar', + opts: { totalPages: 0 }, + expected: { page: 100 }, + redirectParams: null, + }, + ]; + + test.concurrent.each(tests.map(Object.values))( + '%s', + (name: string, url: string, opts: PaginationOpts, expected: PaginationParams, redirectParams: To | null) => { + const history = createMemoryHistory({ initialEntries: [url] }); + jest.spyOn(history, 'replace'); + + const { result } = renderHook(() => useURLPagination(opts), { + wrapper: ({ children }) => { + return {children}; + }, + }); + + expect(result.current).toEqual(expected); + + if (redirectParams) { + expect(history.replace).toHaveBeenCalledWith(redirectParams); + } else { + expect(history.replace).not.toHaveBeenCalled(); + } + } + ); +}); diff --git a/web/vtadmin/src/hooks/useURLPagination.ts b/web/vtadmin/src/hooks/useURLPagination.ts new file mode 100644 index 00000000000..bfa5f63668e --- /dev/null +++ b/web/vtadmin/src/hooks/useURLPagination.ts @@ -0,0 +1,65 @@ +/** + * Copyright 2021 The Vitess Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { useEffect } from 'react'; + +import qs from 'query-string'; +import { useHistory, useLocation } from 'react-router-dom'; +import { useURLQuery } from './useURLQuery'; + +export interface PaginationOpts { + totalPages: number; +} + +export interface PaginationParams { + page: number; +} + +// This assumes we always want to 1-index our pages, where "page 1" is the first page. +// If we find a need for zero-indexed pagination, we can make this configurable. +const FIRST_PAGE = 1; + +/** + * useURLPagination is a hook for components that: + * - use pagination in some way + * - encode pagination state in the URL (e.g., /some/route?page=123) + */ +export const useURLPagination = ({ totalPages }: PaginationOpts): PaginationParams => { + const history = useHistory(); + const location = useLocation(); + const query = useURLQuery(); + + // A slight nuance here -- if `page` is not in the URL at all, then we can assume + // it's the first page. This makes for slightly nicer URLs for the first/default page: + // "/foo" instead of "/foo?page=1". No redirect required. + // + // However, if the value in the URL *is* defined but is negative, non-numeric, + // too big, or otherwise Weird, then we *do* want to redirect to the first page. + const page = !('page' in query) || query.page === null ? FIRST_PAGE : query.page; + + useEffect(() => { + const isPageTooBig = totalPages > 0 && page > totalPages; + const isPageTooSmall = page < FIRST_PAGE; + + if (isPageTooBig || isPageTooSmall || typeof page !== 'number') { + const nextQuery = qs.stringify({ ...query, page: FIRST_PAGE }); + history.replace({ pathname: location.pathname, search: `?${nextQuery}` }); + } + }, [page, totalPages, history, location.pathname, query]); + + return { + page, + } as PaginationParams; +}; diff --git a/web/vtadmin/src/hooks/useURLQuery.test.tsx b/web/vtadmin/src/hooks/useURLQuery.test.tsx new file mode 100644 index 00000000000..e1420fe8b48 --- /dev/null +++ b/web/vtadmin/src/hooks/useURLQuery.test.tsx @@ -0,0 +1,63 @@ +/** + * Copyright 2021 The Vitess Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { renderHook } from '@testing-library/react-hooks'; +import { createMemoryHistory } from 'history'; +import { Router } from 'react-router-dom'; + +import { useURLQuery } from './useURLQuery'; + +describe('useURLQuery', () => { + const tests: { + name: string; + url: string; + expected: ReturnType; + }[] = [ + { + name: 'parses numbers', + url: '/test?page=1', + expected: { page: 1 }, + }, + { + name: 'parses booleans', + url: '/test?foo=true&bar=false', + expected: { foo: true, bar: false }, + }, + { + name: 'parses arrays by duplicate keys', + url: '/test?list=1&list=2&list=3', + expected: { list: [1, 2, 3] }, + }, + { + name: 'parses complex URLs', + url: '/test?page=1&isTrue=true&isFalse=false&list=one&list=two&list=three&foo=bar', + expected: { page: 1, isTrue: true, isFalse: false, list: ['one', 'two', 'three'], foo: 'bar' }, + }, + ]; + + test.each(tests.map(Object.values))('%s', (name: string, url: string, expected: ReturnType) => { + const history = createMemoryHistory({ + initialEntries: [url], + }); + + const { result } = renderHook(() => useURLQuery(), { + wrapper: ({ children }) => { + return {children}; + }, + }); + + expect(result.current).toEqual(expected); + }); +}); diff --git a/web/vtadmin/src/hooks/useURLQuery.ts b/web/vtadmin/src/hooks/useURLQuery.ts new file mode 100644 index 00000000000..2baa8ec9dc1 --- /dev/null +++ b/web/vtadmin/src/hooks/useURLQuery.ts @@ -0,0 +1,38 @@ +/** + * Copyright 2021 The Vitess Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import qs from 'query-string'; +import { useLocation } from 'react-router-dom'; + +/** + * useURLQuery is a hook for parsing query parameters from the current URL + * into a map, where "query parameters" are those appearing after the "?". + * + * So, given a URL like: https://test.com/some/route?foo=bar&count=123 + * ^^^^^^^^^^^^^^^^^ + * ... useURLQuery() would return `{ foo: "bar", count: 123 }` + */ +export const useURLQuery = () => { + const { search } = useLocation(); + + // For full options, see: https://github.com/sindresorhus/query-string + return qs.parse(search, { + // Parse arrays with elements using duplicate keys + // 'foo=1&foo=2&foo=3' => { foo: [1, 2, 3] } + arrayFormat: 'none', + parseBooleans: true, + parseNumbers: true, + }); +}; diff --git a/web/vtadmin/src/index.css b/web/vtadmin/src/index.css index cc063bbd499..44f67001e86 100644 --- a/web/vtadmin/src/index.css +++ b/web/vtadmin/src/index.css @@ -68,6 +68,9 @@ --inputHeightMedium: 3.6rem; --inputHeightLarge: 4.8rem; + /* Tables */ + --tableCellPadding: 1.6rem; + /* Layout variables, set to light theme by default */ --backgroundPrimary: #fff; --backgroundPrimaryHighlight: rgba(61, 90, 254, 0.1); @@ -158,16 +161,22 @@ code { /* Tables */ table { border-collapse: collapse; + margin-bottom: var(--tableCellPadding); width: 100%; } table th { font-size: var(--fontSizeSmall); - padding: 8px 12px; + padding: 8px var(--tableCellPadding); text-align: left; } table tbody td { border: solid 1px var(--tableBorderColor); - padding: 12px; + padding: var(--tableCellPadding); +} + +/* Utilities */ +.text-color-secondary { + color: var(--textColorSecondary); } diff --git a/web/vtadmin/tsconfig.json b/web/vtadmin/tsconfig.json index a273b0cfc0e..2c59c156903 100644 --- a/web/vtadmin/tsconfig.json +++ b/web/vtadmin/tsconfig.json @@ -10,6 +10,7 @@ "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, + "downlevelIteration": true, "strict": true, "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true,