diff --git a/docs/docs/widgets/kanban.md b/docs/docs/widgets/kanban.md new file mode 100644 index 0000000000..a3b56fd173 --- /dev/null +++ b/docs/docs/widgets/kanban.md @@ -0,0 +1,100 @@ +--- +id: kanban +title: Kanban +--- + +# Kanban + +Kanban widget allows you to visually organize and prioritize your tasks with a transparent workflow. You can set the number of columns to display, enable/disable the add cards button, and bind data to the cards. + +
+ +![ToolJet - Kanban widget](/img/widgets/kanban/kanban.png) + +
+ +## Events + +To add an event, click on the widget handle to open the widget properties on the right sidebar. Go to the **Events** section and click on **Add handler**. + +- [Card added](#card-added) +- [Card removed](#card-removed) +- [Card moved](#card-moved) +- [Card selected](#card-selected) +- [Card updated](#card-updated) + +Just like any other event on ToolJet, you can set multiple handlers for any of the above mentioned events. + +
+ +![ToolJet - Kanban widget](/img/widgets/kanban/kanban-events.png) + +
+ +## Properties + +
+ +:::caution +Please keep in mind that you need to provide an `id` for each card in the `Card data` field
+and this `id` must be of type string. +::: + +![ToolJet - Kanban widget](/img/widgets/kanban/properties.png) + +
+ +| Properties | description | Expected value | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Columns | Enter the columns data - `id` and `title` in the form of array of objects or from a query that returns an array of objects. | `{{[{ "id": "1", "title": "to do" },{ "id": "2", "title": "in progress" },{ "id": "2", "title": "Completed" }]}}` or `{{queries.xyz.data}}` | +| Card data | Enter the cards data - `id`, `title` and `columnId` in the form of array of objects or from a query that returns an array of objects. | `{{[{ id: "01", title: "one", columnId: "1" },{ id: "02", title: "two", columnId: "1" },{ id: "03", title: "three", columnId: "2" }]}}` or `{{queries.abc.data}}` | +| Enable Add Card | This property allows you to show or hide the `Add Cards` button at the bottom of every column. | By deafult its enabled, you can programmatically set `{{true}}` or `{{false}}` enable/disable button by clicking on the `Fx` next to it | + +## General + +Tooltip: Set a tooltip text to specify the information about the data/kanban when the user moves the mouse pointer over the widget. + +## Layout + +
+ +![ToolJet - Kanban widget](/img/widgets/kanban/layout.png) + +
+ +| Layout | description | Expected value | +| --------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| Show on desktop | Toggle on or off to display the widget in desktop view. | You can programmatically set the value by clicking on `Fx` to set the value `{{true}}` or `{{false}}` | +| Show on mobile | Toggle on or off to display the widget in mobile view. | You can programmatically set the value by clicking on `Fx` to set the value `{{true}}` or `{{false}}` | + +## Styles + +
+ +![ToolJet - List view widget](/img/widgets/kanban/styles.png) + +
+ +| Style | Description | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Disable | If disabled or set to `{{false}}` the widget will be locked and becomes non-functional. By default, its disabled i.e. its value is set to `{{true}}` . | +| Visibility | This is to control the visibility of the widget. If `{{false}}`/disabled the widget will not visible after the app is deployed. By default, it's enabled (set to `{{true}}`). | +| Width | This property sets the width of the column. | +| Accent color | You can change the accent color of the column title by entering the Hex color code or choosing a color of your choice from the color picker. | + +## Exposed variables + +
+ +![ToolJet - List view widget](/img/widgets/kanban/variables.png) + +
+ +| Variable | Description | +| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| columns | The `columns` variable is an array of objects that includes the columns data in the respective objects. Since the columns variable is an array you'll need to specify the index of the object in the array to get the data within that object. Each object within a column has two keys - `id` and `title` and an array `cards` which is again an array of objects. Example: If you want to get the title of second card then you'll use `{{components.kanbanboard1.columns[1].title}}` - here we have specified the array index as `[1]` and then key which is the `title`. Similary you can get the card details using `{{components.kanbanboard1.columns[0].cards[1].title}}` | +| lastAddedCard | The variable `lastAddedCard` holds the properties of the card that has been added lastly. It holds the following data - `id`, `title`, and `columnId` of the last addded card. You can get the values using `{{components.kanbanboard1.lastAddedCard.title}}` | +| lastRemovedCard | The variable `lastRemovedCard` holds the properties of the card that has been recently deleted from the kanban. It holds the following data - `id`, `title`, and `columnId` of the recently deleted card. You can get the values using `{{components.kanbanboard1.lastRemovedCard.title}}` | +| lastCardMovement | The variable `lastCardMovement` holds the properties of the card that has been recently moved from its original position. It holds the following data - `originColumnId`, `destinationColumnId`, `originCardIndex`, `destinationCardIndex` and an object `cardDetails` which includes `title`. You can get the values using `{{components.kanbanboard1.lastCardMovement.cardDetails.title}}` or `{{components.kanbanboard1.lastCardMovement.destinationCardIndex}}` | +| lastUpdatedCard | The variable `lastUpdatedCard` holds `id`, `title`, and `columnId` of the latest modified card. You can get the values using `{{components.kanbanboard1.lastUpdatedCard.columnId}}` | +| selectedCard | The variable `selectedCard` holds `id`, `title`, `columnId`, and `description` of the selected card in the kanban. You can get the values using `{{components.kanbanboard1.selectedCard.description}}` | diff --git a/docs/sidebars.js b/docs/sidebars.js index fa0bbec2c1..0a13bac335 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -115,6 +115,7 @@ const sidebars = { 'widgets/file-picker', 'widgets/iframe', 'widgets/image', + 'widgets/kanban', 'widgets/listview', 'widgets/map', 'widgets/modal', diff --git a/docs/static/img/widgets/kanban/kanban-events.png b/docs/static/img/widgets/kanban/kanban-events.png new file mode 100644 index 0000000000..37f96ad0f8 Binary files /dev/null and b/docs/static/img/widgets/kanban/kanban-events.png differ diff --git a/docs/static/img/widgets/kanban/kanban.png b/docs/static/img/widgets/kanban/kanban.png new file mode 100644 index 0000000000..00d7be5f29 Binary files /dev/null and b/docs/static/img/widgets/kanban/kanban.png differ diff --git a/docs/static/img/widgets/kanban/layout.png b/docs/static/img/widgets/kanban/layout.png new file mode 100644 index 0000000000..c16fcd0450 Binary files /dev/null and b/docs/static/img/widgets/kanban/layout.png differ diff --git a/docs/static/img/widgets/kanban/properties.png b/docs/static/img/widgets/kanban/properties.png new file mode 100644 index 0000000000..1288ab5541 Binary files /dev/null and b/docs/static/img/widgets/kanban/properties.png differ diff --git a/docs/static/img/widgets/kanban/styles.png b/docs/static/img/widgets/kanban/styles.png new file mode 100644 index 0000000000..01c5ffced2 Binary files /dev/null and b/docs/static/img/widgets/kanban/styles.png differ diff --git a/docs/static/img/widgets/kanban/variables.png b/docs/static/img/widgets/kanban/variables.png new file mode 100644 index 0000000000..eefe9b47ed Binary files /dev/null and b/docs/static/img/widgets/kanban/variables.png differ diff --git a/frontend/assets/images/icons/editor/edit.svg b/frontend/assets/images/icons/editor/edit.svg new file mode 100644 index 0000000000..8a90d97d5b --- /dev/null +++ b/frontend/assets/images/icons/editor/edit.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/assets/images/icons/widgets/kanbanboard.svg b/frontend/assets/images/icons/widgets/kanbanboard.svg new file mode 100644 index 0000000000..b826e73a7a --- /dev/null +++ b/frontend/assets/images/icons/widgets/kanbanboard.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4efa2f30a7..87712f01e2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -52,6 +52,7 @@ "query-string": "^6.13.6", "rc-slider": "^9.7.5", "react": "^16.14.0", + "react-beautiful-dnd": "^13.1.0", "react-big-calendar": "^0.38.0", "react-bootstrap": "^1.5.2", "react-circular-progressbar": "^2.0.4", @@ -17237,6 +17238,15 @@ "@types/node": "*" } }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", + "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", + "dependencies": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "node_modules/@types/html-minifier-terser": { "version": "5.1.2", "integrity": "sha512-h4lTMgMJctJybDp8CQrxTUiiYmedihHWkjnF/8Pxseu2S6Nlfcy8kwboQ8yejh456rP2yWoEVm1sS/FVsfM48w==" @@ -17337,6 +17347,17 @@ "@types/react": "*" } }, + "node_modules/@types/react-redux": { + "version": "7.1.24", + "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.24.tgz", + "integrity": "sha512-7FkurKcS1k0FHZEtdbbgN8Oc6b+stGSfZYjQGicofJ0j4U0qIn/jaSvnP2pLwZKiai3/17xqqxkkrxTgN8UNbQ==", + "dependencies": { + "@types/hoist-non-react-statics": "^3.3.0", + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0", + "redux": "^4.0.0" + } + }, "node_modules/@types/react-transition-group": { "version": "4.4.1", "integrity": "sha512-vIo69qKKcYoJ8wKCJjwSgCTM+z3chw3g18dkrDfVX665tMH7tmbDxEAnPdey4gTlwZz5QuHGzd+hul0OVZDqqQ==", @@ -19386,6 +19407,14 @@ "urix": "^0.1.0" } }, + "node_modules/css-box-model": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", + "integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==", + "dependencies": { + "tiny-invariant": "^1.0.6" + } + }, "node_modules/css-loader": { "version": "6.5.1", "integrity": "sha512-gEy2w9AnJNnD9Kuo4XAP9VflW/ujKoS9c/syO+uWMlm5igc7LysKzPXaDoR2vroROkSwsTS2tGr1yGGEbZOYZQ==", @@ -28359,6 +28388,11 @@ "performance-now": "^2.1.0" } }, + "node_modules/raf-schd": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", + "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==" + }, "node_modules/randombytes": { "version": "2.1.0", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", @@ -28519,6 +28553,24 @@ "pure-color": "^1.2.0" } }, + "node_modules/react-beautiful-dnd": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.1.0.tgz", + "integrity": "sha512-aGvblPZTJowOWUNiwd6tNfEpgkX5OxmpqxHKNW/4VmvZTNTbeiq7bA3bn5T+QSF2uibXB0D1DmJsb1aC/+3cUA==", + "dependencies": { + "@babel/runtime": "^7.9.2", + "css-box-model": "^1.2.0", + "memoize-one": "^5.1.1", + "raf-schd": "^4.0.2", + "react-redux": "^7.2.0", + "redux": "^4.0.4", + "use-memo-one": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8.5 || ^17.0.0", + "react-dom": "^16.8.5 || ^17.0.0" + } + }, "node_modules/react-big-calendar": { "version": "0.38.0", "integrity": "sha512-eoVkt9gTo+f1HBL09+o7dYLxp6QxHv52fcn50P5PfaWp3S98uGLQqoqsvghT85koMKvGfDVa5V0+J7yHcaF07Q==", @@ -29079,6 +29131,46 @@ "react-dom": "~16" } }, + "node_modules/react-redux": { + "version": "7.2.8", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.8.tgz", + "integrity": "sha512-6+uDjhs3PSIclqoCk0kd6iX74gzrGc3W5zcAjbrFgEdIjRSQObdIwfx80unTkVUYvbQ95Y8Av3OvFHq1w5EOUw==", + "dependencies": { + "@babel/runtime": "^7.15.4", + "@types/react-redux": "^7.1.20", + "hoist-non-react-statics": "^3.3.2", + "loose-envify": "^1.4.0", + "prop-types": "^15.7.2", + "react-is": "^17.0.2" + }, + "peerDependencies": { + "react": "^16.8.3 || ^17 || ^18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/react-redux/node_modules/@babel/runtime": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.17.9.tgz", + "integrity": "sha512-lSiBBvodq29uShpWGNbgFdKYNiFDo5/HIYsaCEY9ff4sb10x9jizo2+pRrSyF4jKZCXqgzuqBOQKbUm90gQwJg==", + "dependencies": { + "regenerator-runtime": "^0.13.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/react-redux/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + }, "node_modules/react-rnd": { "version": "10.3.0", "integrity": "sha512-v+0TRPIaRWY25TYv02vLQHYpACbkX+4xKvsyIrUEy4bMpq0bP1oEiaxTorp0Xn72IVv0QZV1vOnZimgTEB/skw==", @@ -31033,6 +31125,14 @@ } } }, + "node_modules/use-memo-one": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.2.tgz", + "integrity": "sha512-u2qFKtxLsia/r8qG0ZKkbytbztzRb317XCkT7yP8wxL0tZ/CzK2G+WWie5vWvpyeP7+YoPIwbJoIHJ4Ba4k0oQ==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0" + } + }, "node_modules/util": { "version": "0.10.3", "integrity": "sha1-evsa/lCAUkZInj23/g7TeTNqwPk=", @@ -45089,6 +45189,15 @@ "@types/node": "*" } }, + "@types/hoist-non-react-statics": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", + "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", + "requires": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "@types/html-minifier-terser": { "version": "5.1.2", "integrity": "sha512-h4lTMgMJctJybDp8CQrxTUiiYmedihHWkjnF/8Pxseu2S6Nlfcy8kwboQ8yejh456rP2yWoEVm1sS/FVsfM48w==" @@ -45189,6 +45298,17 @@ "@types/react": "*" } }, + "@types/react-redux": { + "version": "7.1.24", + "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.24.tgz", + "integrity": "sha512-7FkurKcS1k0FHZEtdbbgN8Oc6b+stGSfZYjQGicofJ0j4U0qIn/jaSvnP2pLwZKiai3/17xqqxkkrxTgN8UNbQ==", + "requires": { + "@types/hoist-non-react-statics": "^3.3.0", + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0", + "redux": "^4.0.0" + } + }, "@types/react-transition-group": { "version": "4.4.1", "integrity": "sha512-vIo69qKKcYoJ8wKCJjwSgCTM+z3chw3g18dkrDfVX665tMH7tmbDxEAnPdey4gTlwZz5QuHGzd+hul0OVZDqqQ==", @@ -46747,6 +46867,14 @@ "urix": "^0.1.0" } }, + "css-box-model": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", + "integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==", + "requires": { + "tiny-invariant": "^1.0.6" + } + }, "css-loader": { "version": "6.5.1", "integrity": "sha512-gEy2w9AnJNnD9Kuo4XAP9VflW/ujKoS9c/syO+uWMlm5igc7LysKzPXaDoR2vroROkSwsTS2tGr1yGGEbZOYZQ==", @@ -53354,6 +53482,11 @@ "performance-now": "^2.1.0" } }, + "raf-schd": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", + "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==" + }, "randombytes": { "version": "2.1.0", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", @@ -53470,6 +53603,20 @@ "pure-color": "^1.2.0" } }, + "react-beautiful-dnd": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.1.0.tgz", + "integrity": "sha512-aGvblPZTJowOWUNiwd6tNfEpgkX5OxmpqxHKNW/4VmvZTNTbeiq7bA3bn5T+QSF2uibXB0D1DmJsb1aC/+3cUA==", + "requires": { + "@babel/runtime": "^7.9.2", + "css-box-model": "^1.2.0", + "memoize-one": "^5.1.1", + "raf-schd": "^4.0.2", + "react-redux": "^7.2.0", + "redux": "^4.0.4", + "use-memo-one": "^1.1.1" + } + }, "react-big-calendar": { "version": "0.38.0", "integrity": "sha512-eoVkt9gTo+f1HBL09+o7dYLxp6QxHv52fcn50P5PfaWp3S98uGLQqoqsvghT85koMKvGfDVa5V0+J7yHcaF07Q==", @@ -53884,6 +54031,34 @@ "webrtc-adapter": "^7.2.1" } }, + "react-redux": { + "version": "7.2.8", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.8.tgz", + "integrity": "sha512-6+uDjhs3PSIclqoCk0kd6iX74gzrGc3W5zcAjbrFgEdIjRSQObdIwfx80unTkVUYvbQ95Y8Av3OvFHq1w5EOUw==", + "requires": { + "@babel/runtime": "^7.15.4", + "@types/react-redux": "^7.1.20", + "hoist-non-react-statics": "^3.3.2", + "loose-envify": "^1.4.0", + "prop-types": "^15.7.2", + "react-is": "^17.0.2" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.17.9.tgz", + "integrity": "sha512-lSiBBvodq29uShpWGNbgFdKYNiFDo5/HIYsaCEY9ff4sb10x9jizo2+pRrSyF4jKZCXqgzuqBOQKbUm90gQwJg==", + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, + "react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + } + } + }, "react-rnd": { "version": "10.3.0", "integrity": "sha512-v+0TRPIaRWY25TYv02vLQHYpACbkX+4xKvsyIrUEy4bMpq0bP1oEiaxTorp0Xn72IVv0QZV1vOnZimgTEB/skw==", @@ -55350,6 +55525,12 @@ "use-isomorphic-layout-effect": "^1.0.0" } }, + "use-memo-one": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.2.tgz", + "integrity": "sha512-u2qFKtxLsia/r8qG0ZKkbytbztzRb317XCkT7yP8wxL0tZ/CzK2G+WWie5vWvpyeP7+YoPIwbJoIHJ4Ba4k0oQ==", + "requires": {} + }, "util": { "version": "0.10.3", "integrity": "sha1-evsa/lCAUkZInj23/g7TeTNqwPk=", diff --git a/frontend/package.json b/frontend/package.json index 01b39179d1..0b111ce953 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -48,6 +48,7 @@ "query-string": "^6.13.6", "rc-slider": "^9.7.5", "react": "^16.14.0", + "react-beautiful-dnd": "^13.1.0", "react-big-calendar": "^0.38.0", "react-bootstrap": "^1.5.2", "react-circular-progressbar": "^2.0.4", diff --git a/frontend/src/Editor/Box.jsx b/frontend/src/Editor/Box.jsx index 957398c9a3..5b9a9d6474 100644 --- a/frontend/src/Editor/Box.jsx +++ b/frontend/src/Editor/Box.jsx @@ -42,6 +42,7 @@ import { ButtonGroup } from './Components/ButtonGroup'; import { CustomComponent } from './Components/CustomComponent/CustomComponent'; import { VerticalDivider } from './Components/verticalDivider'; import { PDF } from './Components/PDF'; +import { KanbanBoard } from './Components/KanbanBoard/KanbanBoard'; import { Steps } from './Components/Steps'; import OverlayTrigger from 'react-bootstrap/OverlayTrigger'; import '@/_styles/custom.scss'; @@ -91,6 +92,7 @@ const AllComponents = { CustomComponent, VerticalDivider, PDF, + KanbanBoard, Steps, }; diff --git a/frontend/src/Editor/Components/KanbanBoard/Board.jsx b/frontend/src/Editor/Components/KanbanBoard/Board.jsx new file mode 100644 index 0000000000..e1557cf194 --- /dev/null +++ b/frontend/src/Editor/Components/KanbanBoard/Board.jsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { DragDropContext } from 'react-beautiful-dnd'; +import { v4 as uuidv4 } from 'uuid'; +import Column from './Column'; +import { reorderCards, moveCards } from './utils'; + +const grid = 8; + +const getItemStyle = (isDragging, draggableStyle) => { + const _draggableStyle = isDragging + ? { ...draggableStyle, left: draggableStyle.left - 100, top: draggableStyle.top - 100 } + : draggableStyle; + + return { + ..._draggableStyle, + userSelect: 'none', + padding: grid * 2, + margin: `0 0 ${grid}px 0`, + background: isDragging ? '#c2cfff' : '#fefefe', + }; +}; + +function Board({ height, state, colStyles, setState, fireEvent, setExposedVariable }) { + const addNewItem = (state, keyIndex) => { + const newItem = { + id: uuidv4(), + title: 'New card', + columnId: state[keyIndex].id, + }; + const newState = [...state]; + if (!newState[keyIndex]['cards']) [(newState[keyIndex]['cards'] = [])]; + newState[keyIndex]['cards'].push(newItem); + setState(newState); + setExposedVariable('lastAddedCard', newItem).then(() => fireEvent('onCardAdded')); + }; + + function onDragEnd(result) { + const { source, destination } = result; + + // dropped outside the list + if (destination && destination !== null) { + const sInd = +source.droppableId; + const dInd = +destination.droppableId; + const originColumnId = state[sInd].id; + const destinationColumnId = state[dInd].id; + + const card = state[sInd]['cards'][source.index]; + const cardDetails = { + title: card.title, + }; + + if (sInd === dInd) { + const items = reorderCards(state[sInd]['cards'], source.index, destination.index); + const newState = [...state]; + newState[sInd]['cards'] = items; + setState(newState); + } else { + const result = moveCards(state[sInd]['cards'], state[dInd].cards, source, destination); + const newState = [...state]; + newState[sInd]['cards'] = result[sInd]; + newState[dInd]['cards'] = result[dInd]; + newState[dInd]['cards'][destination.index].columnId = newState[dInd].id; + + setState(newState); + } + + const movementDetails = { + originColumnId, + destinationColumnId, + originCardIndex: sInd, + destinationCardIndex: dInd, + cardDetails, + }; + setExposedVariable('lastCardMovement', movementDetails).then(() => fireEvent('onCardMoved')); + } + } + + const getListStyle = (isDraggingOver) => ({ + ...colStyles, + padding: grid, + borderColor: isDraggingOver && '#c0ccf8', + }); + + const updateCardProperty = (columnIndex, cardIndex, property, newValue) => { + const columnOfCardToBeUpdated = state[columnIndex]; + const cardSetOfTheCardToBeUpdated = columnOfCardToBeUpdated.cards; + const cardToBeUpdated = cardSetOfTheCardToBeUpdated[cardIndex]; + const updatedCard = { ...cardToBeUpdated, [property]: newValue }; + const updatedCardSet = cardSetOfTheCardToBeUpdated.map((card, index) => (index === cardIndex ? updatedCard : card)); + const updatedColumn = { ...columnOfCardToBeUpdated, cards: updatedCardSet }; + const newState = state.map((column, index) => (index === columnIndex ? updatedColumn : column)); + setState(newState); + + setExposedVariable('lastUpdatedCard', updatedCard).then(() => fireEvent('onCardUpdated')); + }; + + return ( +
e.stopPropagation()} + className="container d-flex" + > + + {state.map((col, ind) => ( + + ))} + +
+ ); +} + +export default Board; diff --git a/frontend/src/Editor/Components/KanbanBoard/Card.jsx b/frontend/src/Editor/Components/KanbanBoard/Card.jsx new file mode 100644 index 0000000000..b28d4801cc --- /dev/null +++ b/frontend/src/Editor/Components/KanbanBoard/Card.jsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { Draggable } from 'react-beautiful-dnd'; +import { BoardContext } from './KanbanBoard'; +import { v4 as uuidv4 } from 'uuid'; +import { CardEventPopover } from './CardPopover'; +import { ReactPortal } from '@/_components/Portal/ReactPortal'; +import _ from 'lodash'; + +export const Card = ({ + item, + index, + state, + updateCb, + getItemStyle, + keyIndex, + fireEvent, + setExposedVariable, + updateCardProperty, +}) => { + const [isHovered, setIsHovered] = React.useState(false); + + const [eventPopoverOptions, setEventPopoverOptions] = React.useState({ show: false }); + + function popoverClosed() { + setEventPopoverOptions({ + ...eventPopoverOptions, + show: false, + }); + } + + const { id, darkMode } = React.useContext(BoardContext); + + const removeCardHandler = (colIndex, cardIndex) => { + const newState = [...state]; + const removedCard = newState[colIndex]['cards'].splice(cardIndex, 1)[0]; + updateCb(newState); + setExposedVariable('lastRemovedCard', removedCard).then(() => fireEvent('onCardRemoved')); + }; + + const draggableId = item.id ?? uuidv4(); + + const handleEventPopoverOptions = (e) => { + setEventPopoverOptions({ + ...eventPopoverOptions, + show: true, + offset: { + left: e.target.getBoundingClientRect().x, + top: e.target.getBoundingClientRect().y, + width: e.target.getBoundingClientRect().width, + height: e.target.getBoundingClientRect().height, + }, + }); + }; + + const handleCardClick = (event) => { + handleEventPopoverOptions(event); + setExposedVariable('selectedCard', item).then(() => fireEvent('onCardSelected')); + }; + + const target = React.useRef(null); + const el = document.getElementById(id); + + return ( + + {(dndProps, dndState) => ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + className={`dnd-card card card-sm ${darkMode && 'card-dark'}`} + ref={dndProps.innerRef} + {...dndProps.draggableProps} + {...dndProps.dragHandleProps} + style={{ ...getItemStyle(dndState.isDragging, dndProps.draggableProps.style) }} + > +
+ + {item.title} + + {isHovered && !item.isEditing && ( + removeCardHandler(keyIndex, index)} + > + + + )} + {eventPopoverOptions.show && ( + + + + )} +
+
+ )} +
+ ); +}; diff --git a/frontend/src/Editor/Components/KanbanBoard/CardPopover.jsx b/frontend/src/Editor/Components/KanbanBoard/CardPopover.jsx new file mode 100644 index 0000000000..a017dfb70f --- /dev/null +++ b/frontend/src/Editor/Components/KanbanBoard/CardPopover.jsx @@ -0,0 +1,157 @@ +import React, { useEffect, useRef, useState } from 'react'; + +export const CardEventPopover = function ({ + show, + offset, + kanbanCardWidgetId, + popoverClosed, + card, + updateCardProperty, + index, + keyIndex, +}) { + const parentRef = useRef(null); + const [showPopover, setShow] = useState(show); + const [top, setTop] = useState(0); + const [left, setLeft] = useState(0); + + const [titleInputBoxValue, setTitleInputBoxValue] = useState(card.title ?? ''); + const [descriptionTextAreaValue, setDescriptionTextAreaValue] = useState(card.description ?? ''); + const [titleHovered, setTitleHovered] = useState(false); + const [descriptionHovered, setDescriptionHovered] = useState(false); + const [titleEditMode, setTitleEditMode] = useState(false); + const [descriptionEditMode, setDescriptionEditMode] = useState(false); + + const minHeight = 400; + let kanbanBounds; + + const kanbanElement = document.getElementById(kanbanCardWidgetId); + + const handleClickOutside = (event) => { + if (parentRef.current && !parentRef.current.contains(event.target)) { + popoverClosed(); + } + }; + + useEffect(() => { + document.addEventListener('click', handleClickOutside, true); + return () => { + document.removeEventListener('click', handleClickOutside, true); + }; + }); + + useEffect(() => { + setShow(show); + }, [show]); + + useEffect(() => { + if (offset?.top && showPopover) { + const _left = offset.left - kanbanBounds.x + offset.width; + const _top = ((offset.top - kanbanBounds.y) * 100) / kanbanBounds.height; + setTop(_top); + setLeft(_left); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [offset?.top, showPopover]); + + if (kanbanElement && showPopover) { + kanbanBounds = kanbanElement.getBoundingClientRect(); + } + const darkMode = localStorage.getItem('darkMode') === 'true'; + return ( + + ); +}; diff --git a/frontend/src/Editor/Components/KanbanBoard/Column.jsx b/frontend/src/Editor/Components/KanbanBoard/Column.jsx new file mode 100644 index 0000000000..8c3faa2267 --- /dev/null +++ b/frontend/src/Editor/Components/KanbanBoard/Column.jsx @@ -0,0 +1,128 @@ +import React from 'react'; +import { Droppable } from 'react-beautiful-dnd'; +import { Card } from './Card'; +import { BoardContext } from './KanbanBoard'; + +const Column = ({ + state, + group, + keyIndex, + getListStyle, + getItemStyle, + updateCb, + addNewItem, + fireEvent, + setExposedVariable, + updateCardProperty, + boardHeight, +}) => { + const styles = { + overflowX: 'hidden', + overflowY: 'hidden', + maxHeight: boardHeight - 80, + }; + + const cards = group['cards']; + + const updateGroupTitle = (newTitle) => { + const newState = [...state]; + newState[keyIndex]['title'] = newTitle; + updateCb(newState); + }; + + const flipTitleToEditMode = (index) => { + const newState = [...state]; + const isEditing = newState[index]['isEditing']; + + if (isEditing === true) { + newState[index]['isEditing'] = false; + } else { + newState[index]['isEditing'] = true; + } + updateCb(newState); + }; + + const { enableAddCard, accentColor, darkMode } = React.useContext(BoardContext); + + const hexaCodeToRgb = (hex) => { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + + return `rgba(${r},${g},${b},0.2)`; + }; + + const colAccentColor = { + color: accentColor ?? '#4d72fa', + backgroundColor: accentColor ? hexaCodeToRgb(accentColor) : hexaCodeToRgb('#4d72fa'), + }; + + return ( + + {(dndProps, dndState) => ( +
+
+
+ {group['isEditing'] ? ( + { + updateGroupTitle(e.target.value); + flipTitleToEditMode(keyIndex); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + updateGroupTitle(e.target.value); + flipTitleToEditMode(keyIndex); + } + }} + /> + ) : ( + flipTitleToEditMode(keyIndex)} + className="bade-component cursor-text" + > + {group.title} + + )} +
+
+
+ {cards?.map((item, index) => ( + + ))} + + {dndProps.placeholder} + {enableAddCard && ( + + )} +
+
+ )} +
+ ); +}; + +export default Column; diff --git a/frontend/src/Editor/Components/KanbanBoard/KanbanBoard.jsx b/frontend/src/Editor/Components/KanbanBoard/KanbanBoard.jsx new file mode 100644 index 0000000000..3428351f63 --- /dev/null +++ b/frontend/src/Editor/Components/KanbanBoard/KanbanBoard.jsx @@ -0,0 +1,131 @@ +import _ from 'lodash'; +import React from 'react'; +import Board from './Board'; +import { isCardColoumnIdUpdated, updateCardData, updateColumnData, getData, isArray, isValidCardData } from './utils'; + +export const BoardContext = React.createContext({}); + +export const KanbanBoard = ({ + id, + height, + properties, + styles, + currentState, + setExposedVariable, + containerProps, + removeComponent, + fireEvent, +}) => { + const { columns, cardData, enableAddCard } = properties; + + const { visibility, disabledState, width, minWidth, accentColor } = styles; + + const [rawColumnData, setRawColumnData] = React.useState([]); + const [rawCardData, setRawCardData] = React.useState([]); + + const [state, setState] = React.useState([]); + + React.useEffect(() => { + setExposedVariable('columns', state); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state]); + + React.useEffect(() => { + if (isArray(rawColumnData) || isArray(rawCardData)) { + const colData = JSON.parse(JSON.stringify(columns)); + const _cardData = JSON.parse(JSON.stringify(cardData)); + setRawColumnData(colData); + setRawCardData(_cardData); + const data = getData(colData, _cardData); + setState(data); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + React.useEffect(() => { + if (JSON.stringify(columns) !== JSON.stringify(rawColumnData) && isArray(columns)) { + const newData = updateColumnData(state, rawColumnData, columns); + + if (newData && isArray(newData)) { + setState(newData); + } + + if (!newData && columns.length !== rawColumnData.length) { + setState(() => getData(columns, rawCardData)); + } + setRawColumnData(columns); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [columns]); + + React.useEffect(() => { + if (isValidCardData(cardData)) { + if (cardData.length !== rawCardData.length) { + setState(() => getData(columns, cardData)); + } else if (JSON.stringify(cardData) !== JSON.stringify(rawCardData) && isArray(cardData)) { + if (cardData.length === 0) { + return; + } + + const isColumnIdUpdated = isCardColoumnIdUpdated(rawCardData, cardData); + + if (isColumnIdUpdated) { + const newData = getData(columns, cardData); + if (newData && isArray(newData)) { + setState(newData); + } + } + + if (!isColumnIdUpdated) { + const newData = updateCardData(state, rawCardData, cardData); + + if (newData && isArray(newData)) { + setState(newData); + } + if (newData === null) { + return setState(() => getData(columns, cardData)); + } + } + } + + setRawCardData(cardData); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cardData]); + + const colStyles = { + width: !width ? '100%' : width, + minWidth: !minWidth ? '350px' : minWidth, + }; + + if (!state || state.length === 0) { + return ( +
+
Board is empty.
+
+ ); + } + const darkMode = localStorage.getItem('darkMode') === 'true'; + return ( + +
+ +
+
+ ); +}; diff --git a/frontend/src/Editor/Components/KanbanBoard/utils.js b/frontend/src/Editor/Components/KanbanBoard/utils.js new file mode 100644 index 0000000000..65631fd92a --- /dev/null +++ b/frontend/src/Editor/Components/KanbanBoard/utils.js @@ -0,0 +1,140 @@ +import _ from 'lodash'; + +export const getData = (columns, cards) => { + if (isArray(cards) && isArray(columns)) { + const clonedColumns = [...columns]; + cards.forEach((card) => { + const column = clonedColumns.find((column) => column.id === card.columnId); + if (column) { + column['cards'] = column?.cards ? [...column.cards, card] : [card]; + } + }); + + return clonedColumns; + } + return null; +}; + +export const reorderCards = (list, startIndex, endIndex) => { + const result = Array.from(list); + const [removed] = result.splice(startIndex, 1); + result.splice(endIndex, 0, removed); + + return result; +}; + +export const moveCards = (source, destination, droppableSource, droppableDestination) => { + const sourceClone = Array.from(source); + const destinationClone = destination ? Array.from(destination) : []; + const [removed] = sourceClone.splice(droppableSource.index, 1); + + destinationClone.splice(droppableDestination.index, 0, removed); + + const result = {}; + result[droppableSource.droppableId] = sourceClone; + result[droppableDestination.droppableId] = destinationClone; + + return result; +}; + +const diffCol = (next, current) => { + const nextState = [...next]; + const currentState = [...current]; + const diff = []; + + nextState.forEach((col, index) => { + const curr = col; + const next = currentState[index]; + + const isDiff = curr.id === next?.id && curr.title === next.title; + if (!isDiff && next) { + const newCol = { + ...next, + id: curr.id, + title: curr.title, + }; + diff.push(newCol); + } + }); + return diff; +}; + +export const updateColumnData = (currentData, column, newData) => { + const diff = diffCol(newData, currentData); + + if (diff.length === 0) return null; + + const nextState = [...currentData]; + diff.forEach((col) => { + const index = nextState.findIndex((c) => c.id === col.id); + nextState[index] = col; + }); + return nextState; +}; + +const cardDiffExits = (currentCards, newCards, state) => { + const diff = []; + + if (!currentCards) return null; + + newCards.forEach((card) => { + const index = currentCards.findIndex((c) => c.id === card.id); + const updatedColumnId = findCard(state, card.id)?.columnId; + + if (index !== -1) { + const newCard = { + ...card, + columnId: updatedColumnId, + }; + diff.push(newCard); + } + }); + return diff; +}; + +export const updateCardData = (currentData, cards, newData) => { + const diffing = cardDiffExits(cards, newData, currentData); + if (!diffing || diffing.length === 0) return null; + + const newState = [...currentData]; + diffing.forEach((card) => { + const colIndex = newState.findIndex((c) => c.id === card.columnId); + const cardIndex = newState[colIndex].cards.findIndex((c) => c.id === card.id); + newState[colIndex].cards[cardIndex] = card; + }); + return newState; +}; + +const findCard = (state, cardId) => { + for (let i = 0; i < state.length; i++) { + for (let j = 0; j < state[i].cards?.length ?? 0; j++) { + if (state[i].cards[j].id === cardId) { + return state[i].cards[j]; + } + } + } +}; + +export const isCardColoumnIdUpdated = (currentCardData, nextCardData) => { + const currentState = [...currentCardData]; + const nextState = [...nextCardData]; + + let isColoumnIdUpdated = false; + + currentState.forEach((card, index) => { + if (nextState[index]) { + const prevColId = card.columnId; + const newColId = nextState[index].columnId; + if (prevColId !== newColId) { + isColoumnIdUpdated = true; + } + } + }); + return isColoumnIdUpdated; +}; + +export const isArray = (value) => Object.prototype.toString.call(value).slice(8, -1) === 'Array'; + +export const isValidCardData = (cardData) => { + return _.isArray(cardData) && cardData.every((card) => _.isString(card.id)); +}; diff --git a/frontend/src/Editor/DraggableBox.jsx b/frontend/src/Editor/DraggableBox.jsx index 6a634fe6ef..c759290b0a 100644 --- a/frontend/src/Editor/DraggableBox.jsx +++ b/frontend/src/Editor/DraggableBox.jsx @@ -236,7 +236,7 @@ export const DraggableBox = function DraggableBox({ setDragging(false); onDragStop(e, id, direction, currentLayout, currentLayoutOptions); }} - cancel={`div.table-responsive.jet-data-table, div.calendar-widget, div.text-input, .textarea, .map-widget, .range-slider`} + cancel={`div.table-responsive.jet-data-table, div.calendar-widget, div.text-input, .textarea, .map-widget, .range-slider, .kanban-container`} onDragStart={(e) => e.stopPropagation()} onResizeStop={(e, direction, ref, d, position) => { setResizing(false); diff --git a/frontend/src/Editor/WidgetManager/widgetConfig.js b/frontend/src/Editor/WidgetManager/widgetConfig.js index f806b65b8e..1e17371576 100644 --- a/frontend/src/Editor/WidgetManager/widgetConfig.js +++ b/frontend/src/Editor/WidgetManager/widgetConfig.js @@ -2384,4 +2384,70 @@ ReactDOM.render(, document.body);`, }, }, }, + { + name: 'KanbanBoard', + displayName: 'Kanban Board', + description: 'Kanban Board', + component: 'KanbanBoard', + defaultSize: { + width: 40, + height: 490, + }, + others: { + showOnDesktop: { type: 'toggle', displayName: 'Show on desktop' }, + showOnMobile: { type: 'toggle', displayName: 'Show on mobile' }, + }, + properties: { + columns: { type: 'code', displayName: 'Columns' }, + cardData: { type: 'code', displayName: 'Card Data' }, + enableAddCard: { type: 'toggle', displayName: 'Enable Add Card' }, + }, + events: { + onCardAdded: { displayName: 'Card added' }, + onCardRemoved: { displayName: 'Card removed' }, + onCardMoved: { displayName: 'Card moved' }, + onCardSelected: { displayName: 'Card selected' }, + onCardUpdated: { displayName: 'Card updated' }, + }, + styles: { + disabledState: { type: 'toggle', displayName: 'Disable' }, + visibility: { type: 'toggle', displayName: 'Visibility' }, + width: { type: 'number', displayName: 'Width' }, + minWidth: { type: 'number', displayName: 'Min Width' }, + accentColor: { type: 'color', displayName: 'Accent color' }, + }, + exposedVariables: { + columns: {}, + lastAddedCard: {}, + lastRemovedCard: {}, + lastCardMovement: {}, + lastUpdatedCard: {}, + }, + definition: { + others: { + showOnDesktop: { value: '{{true}}' }, + showOnMobile: { value: '{{false}}' }, + }, + properties: { + columns: { + value: '{{[{ "id": "1", "title": "to do" },{ "id": "2", "title": "in progress" }]}}', + }, + cardData: { + value: + '{{[{ id: "01", title: "one", columnId: "1" },{ id: "02", title: "two", columnId: "1" },{ id: "03", title: "three", columnId: "2" }]}}', + }, + enableAddCard: { + value: `{{true}}`, + }, + }, + events: [], + styles: { + visibility: { value: '{{true}}' }, + disabledState: { value: '{{false}}' }, + width: { value: '{{400}}' }, + minWidth: { value: '{{200}}' }, + textColor: { value: '#4d72fa' }, + }, + }, + }, ]; diff --git a/frontend/src/_helpers/appUtils.js b/frontend/src/_helpers/appUtils.js index 09a9d13fcb..15af12911d 100644 --- a/frontend/src/_helpers/appUtils.js +++ b/frontend/src/_helpers/appUtils.js @@ -493,6 +493,11 @@ export async function onEvent(_ref, eventName, options, mode = 'edit') { 'onCalendarViewChange', 'onSearchTextChanged', 'onPageChange', + 'onCardAdded', + 'onCardRemoved', + 'onCardMoved', + 'onCardSelected', + 'onCardUpdated', 'onTabSwitch', ].includes(eventName) ) { diff --git a/frontend/src/_styles/theme.scss b/frontend/src/_styles/theme.scss index 1d6cfbb185..2a898bc902 100644 --- a/frontend/src/_styles/theme.scss +++ b/frontend/src/_styles/theme.scss @@ -5320,6 +5320,120 @@ div#driver-page-overlay { } } } + + +//Kanban board + +.kanban-container.dark-themed { + background-color: $bg-dark-light !important; + .kanban-column { + .card-header { + background-color: #324156 !important; + } + } +} + +.kanban-container { + background-color: #fefefe; + + .kanban-column { + background-color: #f4f4f4; + padding: 0 !important; + height: fit-content !important; + + .card-body { + &:hover { + overflow-y: auto !important; + &::-webkit-scrollbar { + width: 0 !important; + height: 0 !important; + } + } + } + + .card-header { + background-color: #fefefe; + + .badge { + font-size: 12px !important; + } + } + + .card-body .dnd-card { + border-radius: 5px !important; + } + .dnd-card.card { + height: 52px !important; + padding: 5px !important; + } + .dnd-card.card.card-dark { + background-color: $bg-dark !important; + } + } + + .kanban-board-add-group { + justify-content: center; + align-items: center; + cursor: pointer; + color: rgba(0, 0, 0, 0.5); + background-color: transparent; + border-style: dashed; + border-color: rgba(0, 0, 0, 0.08); + display: flex; + flex-direction: column; + grid-auto-rows: max-content; + overflow: hidden; + box-sizing: border-box; + appearance: none; + outline: none; + margin: 10px; + border-radius: 5px; + min-width: 350px; + height: 200px; + font-size: 1em; + } + .add-card-btn { + font-size: 1em; + font-weight: 400; + color: #3e525b; + border-radius: 5px; + padding: 5px; + margin: 5px; + background-color: transparent; + border-style: dashed; + border-color: rgba(0, 0, 0, 0.08); + cursor: pointer; + transition: all 0.2s ease-in-out; + &:hover { + background-color: #e6e6e6; + } + } +} + +.cursor-pointer { + cursor: pointer; +} +.cursor-text { + cursor: text; +} + +.bade-component { + display: inline-flex; + justify-content: center; + align-items: center; + overflow: hidden; + user-select: none; + padding: calc(0.25rem - 1px) 0.25rem; + height: 1.25rem; + border: 1px solid transparent; + min-width: 1.25rem; + font-weight: 600; + font-size: .625rem; + letter-spacing: .04em; + text-transform: uppercase; + vertical-align: bottom; + border-radius: 4px; +} // sso-helper-page .sso-helper-container{ width: 60vw;