From 7ae5a3042c148f772ed420a8c6d6842347052f01 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Tue, 14 Dec 2021 21:14:53 +0200 Subject: [PATCH] feat(app): introduce component library in react [EE-1816] (#6236) * refactor(app): replace notification with es6 service (#6015) [EE-1897] chore(app): format * refactor(containers): remove the dependency on angular modal service (#6017) [EE-1898] * refactor(app): remove angular from http-request [EE-1899] (#6016) * feat(app): add axios [EE-2035](#6077) * refactor(feature): remove angular dependency from feature service [EE-2034] (#6078) * refactor(app): replace box-selector with react component (#6046) fix: rename angular2react refactor(app): make box-selector type generic feat(app): add story for box-selector feat(app): test box-selector feat(app): add stories for box selector item fix(app): remove unneccesary element refactor(app): remove assign * feat(feature): add be-indicator in react [EE-2005] (#6106) * refactor(app): add react components for headers [EE-1949] (#6023) * feat(auth): provide user context * feat(app): added base header component [EE-1949] style(app): reformat refactor(app/header): use same api as angular * feat(app): add breadcrumbs component [EE-2024] * feat(app): remove u element from user links * fix(users): handle axios errors Co-authored-by: Chaim Lev-Ari * refactor(app): convert switch component to react [EE-2005] (#6025) Co-authored-by: Marcelo Rydel --- .prettierrc | 5 +- .storybook/preview.js | 10 + app/assets/css/app.css | 17 - app/assets/css/rdash.css | 88 ----- app/config.js | 27 +- ...ocker-features-configuration.controller.js | 43 ++- .../docker-features-configuration.html | 104 ++--- .../edge-stack-deployment-type-selector.html | 2 +- .../docker-compose-form.controller.js | 3 +- .../docker-compose-form.html | 2 +- .../kube-manifest-form.controller.js | 5 + .../kube-manifest-form.html | 2 +- app/index.js | 5 + ...-create-custom-template-view.controller.js | 2 +- .../kube-create-custom-template-view.html | 2 +- app/kubernetes/views/configure/configure.html | 23 +- .../views/configure/configureController.js | 27 +- app/kubernetes/views/deploy/deploy.html | 14 +- .../views/deploy/deployController.js | 27 +- .../components/storage-class-switch/index.js | 14 + .../storage-class-switch.controller.js | 16 + .../storage-class-switch.html | 12 + .../create/createResourcePool.html | 25 +- .../create/createResourcePoolController.js | 23 +- .../resource-pools/edit/resourcePool.html | 25 +- .../edit/resourcePoolController.js | 22 +- app/portainer/__module.js | 26 +- .../BEFeatureIndicator.controller.ts | 24 ++ .../BEFeatureIndicator.css} | 0 .../BEFeatureIndicator.html} | 0 .../BEFeatureIndicator.stories.tsx | 25 ++ .../BEFeatureIndicator/BEFeatureIndicator.tsx | 33 ++ .../components/BEFeatureIndicator/index.ts | 14 + .../components/BEFeatureIndicator/utils.ts | 15 + .../BoxSelector.css} | 0 .../BoxSelector/BoxSelector.module.css | 4 + .../BoxSelector/BoxSelector.stories.tsx | 83 ++++ .../BoxSelector/BoxSelector.test.tsx | 59 +++ .../components/BoxSelector/BoxSelector.tsx | 49 +++ .../BoxSelector/BoxSelectorAngular.tsx | 49 +++ .../BoxSelectorItem.css} | 0 .../BoxSelector/BoxSelectorItem.stories.tsx | 77 ++++ .../BoxSelector/BoxSelectorItem.tsx | 77 ++++ app/portainer/components/BoxSelector/index.ts | 11 + app/portainer/components/BoxSelector/types.ts | 12 + .../Header/Breadcrumbs/Breadcrumbs.css | 3 + .../Breadcrumbs/Breadcrumbs.stories.tsx | 27 ++ .../Header/Breadcrumbs/Breadcrumbs.tsx | 20 + app/portainer/components/Header/Header.css | 103 +++++ app/portainer/components/Header/Header.html | 4 + .../components/Header/Header.stories.tsx | 42 ++ app/portainer/components/Header/Header.tsx | 31 ++ .../Header/HeaderContent.controller.js | 15 + .../components/Header/HeaderContent.html | 11 + .../Header/HeaderContent.module.css | 19 + .../components/Header/HeaderContent.tsx | 50 +++ .../Header/HeaderTitle.controller.js | 15 + .../components/Header/HeaderTitle.html | 5 + .../components/Header/HeaderTitle.tsx | 37 ++ app/portainer/components/Header/index.ts | 14 + app/portainer/components/Link.tsx | 11 +- .../porAccessManagementController.js | 7 +- .../be-feature-indicator.controller.js | 18 - .../components/be-feature-indicator/index.js | 15 - .../box-selector-item.controller.js | 23 -- .../box-selector-item/box-selector-item.html | 27 -- .../box-selector/box-selector-item/index.js | 21 - .../box-selector/box-selector.controller.js | 17 - .../components/box-selector/box-selector.html | 15 - .../components/box-selector/index.js | 20 - .../registriesDatatableController.js | 5 +- .../SwitchField/Switch.css} | 0 .../SwitchField/Switch.module.css | 3 + .../SwitchField/Switch.stories.tsx | 35 ++ .../SwitchField/Switch.test.tsx | 20 + .../form-components/SwitchField/Switch.tsx | 68 ++++ .../SwitchField/SwitchField.module.css | 9 + .../SwitchField/SwitchField.stories.tsx | 56 +++ .../SwitchField/SwitchField.test.tsx | 37 ++ .../SwitchField/SwitchField.tsx | 72 ++++ .../form-components/SwitchField/index.ts | 1 + .../components/form-components/index.js | 8 +- .../git-form-auth-fieldset.controller.js | 15 +- .../git-form-auth-fieldset.html | 8 +- ...it-form-auto-update-fieldset.controller.js | 17 +- .../git-form-auto-update-fieldset.html | 10 +- .../por-switch-field/por-switch-field.html | 16 - .../por-switch-field/por-switch-field.js | 18 - .../forms/por-switch/por-switch.controller.js | 14 - .../forms/por-switch/por-switch.html | 12 - .../components/forms/por-switch/por-switch.js | 21 - app/portainer/components/header-content.js | 16 - app/portainer/components/header-title.js | 19 - app/portainer/components/header.js | 11 - app/portainer/components/index.js | 6 +- .../theme/theme-settings.controller.js | 3 +- .../components/theme/theme-settings.html | 4 +- app/portainer/feature-flags/enums.js | 10 - app/portainer/feature-flags/enums.ts | 26 ++ .../feature-flags/feature-flags.service.js | 59 --- .../feature-flags/feature-flags.service.ts | 58 +++ app/portainer/feature-flags/feature-ids.js | 13 - app/portainer/feature-flags/index.js | 7 - app/portainer/feature-flags/index.ts | 8 + ...ective.js => limited-feature.directive.ts} | 15 +- app/portainer/helpers/userHelper.js | 16 - app/portainer/helpers/userHelper.ts | 5 + app/portainer/hooks/useLocalStorage.ts | 46 +++ app/portainer/hooks/useUser.tsx | 118 ++++++ app/portainer/models/user.js | 1 + .../oauth-provider-selector.controller.js | 11 +- .../oauth-providers-selector.html | 2 +- .../oauth-settings.controller.js | 36 +- .../oauth-settings/oauth-settings.html | 27 +- .../access-viewer/access-viewer.controller.js | 6 +- app/portainer/services/api/index.ts | 7 + app/portainer/services/api/userService.js | 364 +++++++++--------- app/portainer/services/axios.ts | 42 ++ .../services/http-request.helper.test.ts | 69 ++++ app/portainer/services/http-request.helper.ts | 74 ++++ app/portainer/services/httpRequestHelper.js | 56 --- app/portainer/services/index.ts | 12 + .../services/modal.service/confirm.ts | 205 ++++++++++ app/portainer/services/modal.service/index.ts | 60 +++ .../services/modal.service/prompt.ts | 166 ++++++++ app/portainer/services/modal.service/utils.ts | 37 ++ app/portainer/services/modalService.js | 339 ---------------- app/portainer/services/notifications.js | 59 --- app/portainer/services/notifications.test.ts | 47 +++ app/portainer/services/notifications.ts | 69 ++++ .../services/registryModalService.js | 4 +- .../ad-settings/ad-settings.controller.js | 5 +- .../ldap-settings-custom.controller.js | 5 +- .../ldap-settings-openldap.controller.js | 4 +- .../ldap-settings/ldap-settings.controller.js | 4 +- .../ldap/ldap-settings/ldap-settings.html | 4 +- .../ssl-certificate.controller.js | 11 +- .../ssl-certificate/ssl-certificate.html | 2 +- .../activity-logs-view.controller.js | 7 +- .../auth-logs-view.controller.js | 5 +- .../access/endpointAccessController.js | 4 +- .../views/endpoints/edit/endpoint.html | 9 +- .../endpoints/edit/endpointController.js | 6 + .../groups/access/groupAccessController.js | 4 +- .../settingsAuthentication.html | 4 +- .../settingsAuthenticationController.js | 8 +- app/portainer/views/settings/settings.html | 17 +- .../views/settings/settingsController.js | 14 +- .../wizard-aci.controller.js | 36 +- .../wizard-endpoint-aci/wizard-aci.html | 2 +- .../wizard-docker.controller.js | 81 ++-- .../wizard-endpoint-docker/wizard-docker.html | 19 +- .../wizard-kubernetes.controller.js | 29 +- .../wizard-kubernetes.html | 2 +- app/react-tools/RootProvider.tsx | 6 +- package.json | 13 +- yarn.lock | 121 +++++- 157 files changed, 3204 insertions(+), 1469 deletions(-) create mode 100644 app/kubernetes/views/resource-pools/components/storage-class-switch/index.js create mode 100644 app/kubernetes/views/resource-pools/components/storage-class-switch/storage-class-switch.controller.js create mode 100644 app/kubernetes/views/resource-pools/components/storage-class-switch/storage-class-switch.html create mode 100644 app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.controller.ts rename app/portainer/components/{be-feature-indicator/be-feature-indicator.css => BEFeatureIndicator/BEFeatureIndicator.css} (100%) rename app/portainer/components/{be-feature-indicator/be-feature-indicator.html => BEFeatureIndicator/BEFeatureIndicator.html} (100%) create mode 100644 app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.stories.tsx create mode 100644 app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.tsx create mode 100644 app/portainer/components/BEFeatureIndicator/index.ts create mode 100644 app/portainer/components/BEFeatureIndicator/utils.ts rename app/portainer/components/{box-selector/box-selector.css => BoxSelector/BoxSelector.css} (100%) create mode 100644 app/portainer/components/BoxSelector/BoxSelector.module.css create mode 100644 app/portainer/components/BoxSelector/BoxSelector.stories.tsx create mode 100644 app/portainer/components/BoxSelector/BoxSelector.test.tsx create mode 100644 app/portainer/components/BoxSelector/BoxSelector.tsx create mode 100644 app/portainer/components/BoxSelector/BoxSelectorAngular.tsx rename app/portainer/components/{box-selector/box-selector-item/box-selector-item.css => BoxSelector/BoxSelectorItem.css} (100%) create mode 100644 app/portainer/components/BoxSelector/BoxSelectorItem.stories.tsx create mode 100644 app/portainer/components/BoxSelector/BoxSelectorItem.tsx create mode 100644 app/portainer/components/BoxSelector/index.ts create mode 100644 app/portainer/components/BoxSelector/types.ts create mode 100644 app/portainer/components/Header/Breadcrumbs/Breadcrumbs.css create mode 100644 app/portainer/components/Header/Breadcrumbs/Breadcrumbs.stories.tsx create mode 100644 app/portainer/components/Header/Breadcrumbs/Breadcrumbs.tsx create mode 100644 app/portainer/components/Header/Header.css create mode 100644 app/portainer/components/Header/Header.html create mode 100644 app/portainer/components/Header/Header.stories.tsx create mode 100644 app/portainer/components/Header/Header.tsx create mode 100644 app/portainer/components/Header/HeaderContent.controller.js create mode 100644 app/portainer/components/Header/HeaderContent.html create mode 100644 app/portainer/components/Header/HeaderContent.module.css create mode 100644 app/portainer/components/Header/HeaderContent.tsx create mode 100644 app/portainer/components/Header/HeaderTitle.controller.js create mode 100644 app/portainer/components/Header/HeaderTitle.html create mode 100644 app/portainer/components/Header/HeaderTitle.tsx create mode 100644 app/portainer/components/Header/index.ts delete mode 100644 app/portainer/components/be-feature-indicator/be-feature-indicator.controller.js delete mode 100644 app/portainer/components/be-feature-indicator/index.js delete mode 100644 app/portainer/components/box-selector/box-selector-item/box-selector-item.controller.js delete mode 100644 app/portainer/components/box-selector/box-selector-item/box-selector-item.html delete mode 100644 app/portainer/components/box-selector/box-selector-item/index.js delete mode 100644 app/portainer/components/box-selector/box-selector.controller.js delete mode 100644 app/portainer/components/box-selector/box-selector.html delete mode 100644 app/portainer/components/box-selector/index.js rename app/portainer/components/{forms/por-switch/por-switch.css => form-components/SwitchField/Switch.css} (100%) create mode 100644 app/portainer/components/form-components/SwitchField/Switch.module.css create mode 100644 app/portainer/components/form-components/SwitchField/Switch.stories.tsx create mode 100644 app/portainer/components/form-components/SwitchField/Switch.test.tsx create mode 100644 app/portainer/components/form-components/SwitchField/Switch.tsx create mode 100644 app/portainer/components/form-components/SwitchField/SwitchField.module.css create mode 100644 app/portainer/components/form-components/SwitchField/SwitchField.stories.tsx create mode 100644 app/portainer/components/form-components/SwitchField/SwitchField.test.tsx create mode 100644 app/portainer/components/form-components/SwitchField/SwitchField.tsx create mode 100644 app/portainer/components/form-components/SwitchField/index.ts delete mode 100644 app/portainer/components/forms/por-switch-field/por-switch-field.html delete mode 100644 app/portainer/components/forms/por-switch-field/por-switch-field.js delete mode 100644 app/portainer/components/forms/por-switch/por-switch.controller.js delete mode 100644 app/portainer/components/forms/por-switch/por-switch.html delete mode 100644 app/portainer/components/forms/por-switch/por-switch.js delete mode 100644 app/portainer/components/header-content.js delete mode 100644 app/portainer/components/header-title.js delete mode 100644 app/portainer/components/header.js delete mode 100644 app/portainer/feature-flags/enums.js create mode 100644 app/portainer/feature-flags/enums.ts delete mode 100644 app/portainer/feature-flags/feature-flags.service.js create mode 100644 app/portainer/feature-flags/feature-flags.service.ts delete mode 100644 app/portainer/feature-flags/feature-ids.js delete mode 100644 app/portainer/feature-flags/index.js create mode 100644 app/portainer/feature-flags/index.ts rename app/portainer/feature-flags/{limited-feature.directive.js => limited-feature.directive.ts} (61%) delete mode 100644 app/portainer/helpers/userHelper.js create mode 100644 app/portainer/helpers/userHelper.ts create mode 100644 app/portainer/hooks/useLocalStorage.ts create mode 100644 app/portainer/hooks/useUser.tsx create mode 100644 app/portainer/services/api/index.ts create mode 100644 app/portainer/services/axios.ts create mode 100644 app/portainer/services/http-request.helper.test.ts create mode 100644 app/portainer/services/http-request.helper.ts delete mode 100644 app/portainer/services/httpRequestHelper.js create mode 100644 app/portainer/services/index.ts create mode 100644 app/portainer/services/modal.service/confirm.ts create mode 100644 app/portainer/services/modal.service/index.ts create mode 100644 app/portainer/services/modal.service/prompt.ts create mode 100644 app/portainer/services/modal.service/utils.ts delete mode 100644 app/portainer/services/modalService.js delete mode 100644 app/portainer/services/notifications.js create mode 100644 app/portainer/services/notifications.test.ts create mode 100644 app/portainer/services/notifications.ts diff --git a/.prettierrc b/.prettierrc index 86d3c52f2..184ed56cb 100644 --- a/.prettierrc +++ b/.prettierrc @@ -13,10 +13,11 @@ }, { "files": [ - "*.{j,t}sx" + "*.{j,t}sx", + "*.ts" ], "options": { - "printWidth": 80, + "printWidth": 80 } } ] diff --git a/.storybook/preview.js b/.storybook/preview.js index 9a31caa06..127a08b28 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -1,5 +1,7 @@ import '../app/assets/css'; +import { pushStateLocationPlugin, UIRouter } from '@uirouter/react'; + export const parameters = { actions: { argTypesRegex: '^on[A-Z].*' }, controls: { @@ -9,3 +11,11 @@ export const parameters = { }, }, }; + +export const decorators = [ + (Story) => ( + + + + ), +]; diff --git a/app/assets/css/app.css b/app/assets/css/app.css index 3a3a9c16a..e3e3d5480 100644 --- a/app/assets/css/app.css +++ b/app/assets/css/app.css @@ -596,10 +596,6 @@ a[ng-click] { background-color: var(--bg-log-line-selected-color); } -.row.header .meta .page { - padding-top: 7px; -} - .tag:not(.token) { padding: 2px 6px; color: white; @@ -739,19 +735,6 @@ a[ng-click] { } /*!toaster override*/ -/*angular-loading-bar override*/ -#loadingbar-placeholder { - margin-bottom: 0; - height: 3px; -} - -#loading-bar .bar { - position: relative; - height: 3px; - background: var(--blue-3); -} -/*!angular-loading-bar override*/ - .monospaced { font-family: monospace; font-weight: 600; diff --git a/app/assets/css/rdash.css b/app/assets/css/rdash.css index e885c51f3..072f0880a 100644 --- a/app/assets/css/rdash.css +++ b/app/assets/css/rdash.css @@ -48,94 +48,6 @@ } } -/** - * Header - */ -.row.header { - height: 60px; - background: var(--bg-row-header-color); - margin-bottom: 15px; -} -.row.header > div:last-child { - padding-right: 0; -} -.row.header .meta .page { - font-size: 17px; - padding-top: 11px; -} -.row.header .meta .breadcrumb-links { - font-size: 10px; -} -.row.header .meta div { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.row.header .login a { - padding: 18px; - display: block; -} -.row.header .user { - min-width: 130px; -} -.row.header .user > .item { - width: 65px; - height: 60px; - float: right; - display: inline-block; - text-align: center; - vertical-align: middle; -} -.row.header .user > .item a { - color: #919191; - display: block; -} -.row.header .user > .item i { - font-size: 20px; - line-height: 55px; -} -.row.header .user > .item img { - width: 40px; - height: 40px; - margin-top: 10px; - border-radius: 2px; -} -.row.header .user > .item ul.dropdown-menu { - border-radius: 2px; - -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.05); - box-shadow: 0 6px 12px rgba(0, 0, 0, 0.05); -} -.row.header .user > .item ul.dropdown-menu .dropdown-header { - text-align: center; -} -.row.header .user > .item ul.dropdown-menu li.link { - text-align: left; -} -.row.header .user > .item ul.dropdown-menu li.link a { - padding-left: 7px; - padding-right: 7px; -} -.row.header .user > .item ul.dropdown-menu:before { - position: absolute; - top: -7px; - right: 23px; - display: inline-block; - border-right: 7px solid transparent; - border-bottom: 7px solid rgba(0, 0, 0, 0.2); - border-left: 7px solid transparent; - content: ''; -} -.row.header .user > .item ul.dropdown-menu:after { - position: absolute; - top: -6px; - right: 24px; - display: inline-block; - border-right: 6px solid transparent; - border-bottom: 6px solid #ffffff; - border-left: 6px solid transparent; - content: ''; -} - .loading { width: 40px; height: 40px; diff --git a/app/config.js b/app/config.js index 26f639522..0a7ef1125 100644 --- a/app/config.js +++ b/app/config.js @@ -1,6 +1,6 @@ -import toastr from 'toastr'; import { Terminal } from 'xterm'; import * as fit from 'xterm/lib/addons/fit/fit'; +import { agentInterceptor } from './portainer/services/axios'; /* @ngInject */ export function configApp($urlRouterProvider, $httpProvider, localStorageServiceProvider, jwtOptionsProvider, $uibTooltipProvider, $compileProvider, cfpLoadingBarProvider) { @@ -21,28 +21,9 @@ export function configApp($urlRouterProvider, $httpProvider, localStorageService $httpProvider.defaults.headers.put['Content-Type'] = 'application/json'; $httpProvider.defaults.headers.patch['Content-Type'] = 'application/json'; - $httpProvider.interceptors.push( - /* @ngInject */ function (HttpRequestHelper) { - return { - request(config) { - if (config.url.indexOf('/docker/') > -1) { - config.headers['X-PortainerAgent-Target'] = HttpRequestHelper.portainerAgentTargetHeader(); - if (HttpRequestHelper.portainerAgentManagerOperation()) { - config.headers['X-PortainerAgent-ManagerOperation'] = '1'; - } - } - return config; - }, - }; - } - ); - - toastr.options = { - timeOut: 3000, - closeButton: true, - progressBar: true, - tapToDismiss: false, - }; + $httpProvider.interceptors.push(() => ({ + request: agentInterceptor, + })); Terminal.applyAddon(fit); diff --git a/app/docker/views/docker-features-configuration/docker-features-configuration.controller.js b/app/docker/views/docker-features-configuration/docker-features-configuration.controller.js index 4570f8afc..4d6de8ede 100644 --- a/app/docker/views/docker-features-configuration/docker-features-configuration.controller.js +++ b/app/docker/views/docker-features-configuration/docker-features-configuration.controller.js @@ -1,14 +1,15 @@ -import { HIDE_AUTO_UPDATE_WINDOW } from 'Portainer/feature-flags/feature-ids'; +import { FeatureId } from '@/portainer/feature-flags/enums'; export default class DockerFeaturesConfigurationController { /* @ngInject */ - constructor($async, EndpointService, Notifications, StateManager) { + constructor($async, $scope, EndpointService, Notifications, StateManager) { this.$async = $async; + this.$scope = $scope; this.EndpointService = EndpointService; this.Notifications = Notifications; this.StateManager = StateManager; - this.limitedFeature = HIDE_AUTO_UPDATE_WINDOW; + this.limitedFeature = FeatureId.HIDE_AUTO_UPDATE_WINDOW; this.formValues = { enableHostManagementFeatures: false, @@ -26,9 +27,45 @@ export default class DockerFeaturesConfigurationController { this.state = { actionInProgress: false, + autoUpdateSettings: { Enabled: false }, + timeZone: '', }; this.save = this.save.bind(this); + this.onChangeField = this.onChangeField.bind(this); + this.onToggleAutoUpdate = this.onToggleAutoUpdate.bind(this); + this.onChangeEnableHostManagementFeatures = this.onChangeField('enableHostManagementFeatures'); + this.onChangeAllowVolumeBrowserForRegularUsers = this.onChangeField('allowVolumeBrowserForRegularUsers'); + this.onChangeDisableBindMountsForRegularUsers = this.onChangeField('disableBindMountsForRegularUsers'); + this.onChangeDisablePrivilegedModeForRegularUsers = this.onChangeField('disablePrivilegedModeForRegularUsers'); + this.onChangeDisableHostNamespaceForRegularUsers = this.onChangeField('disableHostNamespaceForRegularUsers'); + this.onChangeDisableStackManagementForRegularUsers = this.onChangeField('disableStackManagementForRegularUsers'); + this.onChangeDisableDeviceMappingForRegularUsers = this.onChangeField('disableDeviceMappingForRegularUsers'); + this.onChangeDisableContainerCapabilitiesForRegularUsers = this.onChangeField('disableContainerCapabilitiesForRegularUsers'); + this.onChangeDisableSysctlSettingForRegularUsers = this.onChangeField('disableSysctlSettingForRegularUsers'); + } + + onToggleAutoUpdate(value) { + return this.$scope.$evalAsync(() => { + this.state.autoUpdateSettings.Enabled = value; + }); + } + + onChange(values) { + return this.$scope.$evalAsync(() => { + this.formValues = { + ...this.formValues, + ...values, + }; + }); + } + + onChangeField(field) { + return (value) => { + this.onChange({ + [field]: value, + }); + }; } isContainerEditDisabled() { diff --git a/app/docker/views/docker-features-configuration/docker-features-configuration.html b/app/docker/views/docker-features-configuration/docker-features-configuration.html index 3f9675b6c..c08d902b8 100644 --- a/app/docker/views/docker-features-configuration/docker-features-configuration.html +++ b/app/docker/views/docker-features-configuration/docker-features-configuration.html @@ -20,24 +20,26 @@
@@ -49,12 +51,13 @@
@@ -67,73 +70,80 @@
diff --git a/app/edge/components/edge-stack-deployment-type-selector/edge-stack-deployment-type-selector.html b/app/edge/components/edge-stack-deployment-type-selector/edge-stack-deployment-type-selector.html index 4eae73cc5..2f53d2fcd 100644 --- a/app/edge/components/edge-stack-deployment-type-selector/edge-stack-deployment-type-selector.html +++ b/app/edge/components/edge-stack-deployment-type-selector/edge-stack-deployment-type-selector.html @@ -1,4 +1,4 @@
Deployment type
- + diff --git a/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.controller.js b/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.controller.js index c78caa495..25ee41886 100644 --- a/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.controller.js +++ b/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.controller.js @@ -23,7 +23,8 @@ class DockerComposeFormController { this.formValues = values; } - onChangeMethod() { + onChangeMethod(method) { + this.state.Method = method; this.formValues.StackFileContent = ''; this.selectedTemplate = null; } diff --git a/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.html b/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.html index 48388c9dc..4a61d2889 100644 --- a/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.html +++ b/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.html @@ -1,7 +1,7 @@
Build method
- + Build method
- + Build method - +
@@ -197,12 +198,12 @@
diff --git a/app/kubernetes/views/configure/configureController.js b/app/kubernetes/views/configure/configureController.js index 330cc9853..67cd69356 100644 --- a/app/kubernetes/views/configure/configureController.js +++ b/app/kubernetes/views/configure/configureController.js @@ -6,8 +6,8 @@ import { KubernetesIngressClass } from 'Kubernetes/ingress/models'; import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper'; import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants'; import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper'; -import { K8S_SETUP_DEFAULT } from '@/portainer/feature-flags/feature-ids'; -import { HIDE_AUTO_UPDATE_WINDOW } from 'Portainer/feature-flags/feature-ids'; +import { FeatureId } from '@/portainer/feature-flags/enums'; + class KubernetesConfigureController { /* #region CONSTRUCTOR */ @@ -15,6 +15,7 @@ class KubernetesConfigureController { constructor( $async, $state, + $scope, Notifications, KubernetesStorageService, EndpointService, @@ -26,6 +27,7 @@ class KubernetesConfigureController { ) { this.$async = $async; this.$state = $state; + this.$scope = $scope; this.Notifications = Notifications; this.KubernetesStorageService = KubernetesStorageService; this.EndpointService = EndpointService; @@ -39,8 +41,10 @@ class KubernetesConfigureController { this.onInit = this.onInit.bind(this); this.configureAsync = this.configureAsync.bind(this); - this.limitedFeature = K8S_SETUP_DEFAULT; - this.limitedFeatureAutoWindow = HIDE_AUTO_UPDATE_WINDOW; + this.limitedFeature = FeatureId.K8S_SETUP_DEFAULT; + this.limitedFeatureAutoWindow = FeatureId.HIDE_AUTO_UPDATE_WINDOW; + this.onToggleAutoUpdate = this.onToggleAutoUpdate.bind(this); + this.onChangeEnableResourceOverCommit = this.onChangeEnableResourceOverCommit.bind(this); } /* #endregion */ @@ -103,6 +107,15 @@ class KubernetesConfigureController { } /* #endregion */ + onChangeEnableResourceOverCommit(enabled) { + this.$scope.$evalAsync(() => { + this.formValues.EnableResourceOverCommit = enabled; + if (enabled) { + this.formValues.ResourceOverCommitPercentage = 20; + } + }); + } + /* #region CONFIGURE */ assignFormValuesToEndpoint(endpoint, storageClasses, ingressClasses) { endpoint.Kubernetes.Configuration.StorageClasses = storageClasses; @@ -244,6 +257,12 @@ class KubernetesConfigureController { return this.formValues.RestrictDefaultNamespace && !this.oldFormValues.RestrictDefaultNamespace; } + onToggleAutoUpdate(value) { + return this.$scope.$evalAsync(() => { + this.state.autoUpdateSettings.Enabled = value; + }); + } + /* #region ON INIT */ async onInit() { this.state = { diff --git a/app/kubernetes/views/deploy/deploy.html b/app/kubernetes/views/deploy/deploy.html index 135a5be00..ccc4d56a9 100644 --- a/app/kubernetes/views/deploy/deploy.html +++ b/app/kubernetes/views/deploy/deploy.html @@ -39,18 +39,24 @@
Build method
- +
Deployment type
diff --git a/app/kubernetes/views/deploy/deployController.js b/app/kubernetes/views/deploy/deployController.js index 5d6f08e15..77610061a 100644 --- a/app/kubernetes/views/deploy/deployController.js +++ b/app/kubernetes/views/deploy/deployController.js @@ -5,7 +5,8 @@ import uuidv4 from 'uuid/v4'; import PortainerError from 'Portainer/error'; import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, KubernetesDeployRequestMethods, RepositoryMechanismTypes } from 'Kubernetes/models/deploy'; -import { buildOption } from '@/portainer/components/box-selector'; +import { buildOption } from '@/portainer/components/BoxSelector'; + class KubernetesDeployController { /* @ngInject */ constructor($async, $state, $window, Authentication, ModalService, Notifications, KubernetesResourcePoolService, StackService, WebhookHelper, CustomTemplateService) { @@ -67,7 +68,8 @@ class KubernetesDeployController { this.getNamespacesAsync = this.getNamespacesAsync.bind(this); this.onChangeFormValues = this.onChangeFormValues.bind(this); this.buildAnalyticsProperties = this.buildAnalyticsProperties.bind(this); - this.onDeployTypeChange = this.onDeployTypeChange.bind(this); + this.onChangeMethod = this.onChangeMethod.bind(this); + this.onChangeDeployType = this.onChangeDeployType.bind(this); } buildAnalyticsProperties() { @@ -122,6 +124,19 @@ class KubernetesDeployController { } } + onChangeMethod(method) { + this.state.BuildMethod = method; + } + + onChangeDeployType(type) { + this.state.DeployType = type; + if (type == this.ManifestDeployTypes.COMPOSE) { + this.DeployMethod = 'compose'; + } else { + this.DeployMethod = 'manifest'; + } + } + disableDeploy() { const isGitFormInvalid = this.state.BuildMethod === KubernetesDeployBuildMethods.GIT && @@ -275,14 +290,6 @@ class KubernetesDeployController { return this.$async(this.getNamespacesAsync); } - onDeployTypeChange(value) { - if (value == this.ManifestDeployTypes.COMPOSE) { - this.DeployMethod = 'compose'; - } else { - this.DeployMethod = 'manifest'; - } - } - async uiCanExit() { if (this.formValues.EditorContent && this.state.isEditorDirty) { return this.ModalService.confirmWebEditorDiscard(); diff --git a/app/kubernetes/views/resource-pools/components/storage-class-switch/index.js b/app/kubernetes/views/resource-pools/components/storage-class-switch/index.js new file mode 100644 index 000000000..502d1614f --- /dev/null +++ b/app/kubernetes/views/resource-pools/components/storage-class-switch/index.js @@ -0,0 +1,14 @@ +import angular from 'angular'; +import controller from './storage-class-switch.controller.js'; + +export const storageClassSwitch = { + templateUrl: './storage-class-switch.html', + controller, + bindings: { + value: '<', + onChange: '<', + name: '<', + }, +}; + +angular.module('portainer.kubernetes').component('storageClassSwitch', storageClassSwitch); diff --git a/app/kubernetes/views/resource-pools/components/storage-class-switch/storage-class-switch.controller.js b/app/kubernetes/views/resource-pools/components/storage-class-switch/storage-class-switch.controller.js new file mode 100644 index 000000000..ddfc63312 --- /dev/null +++ b/app/kubernetes/views/resource-pools/components/storage-class-switch/storage-class-switch.controller.js @@ -0,0 +1,16 @@ +import { FeatureId } from '@/portainer/feature-flags/enums'; + +class StorageClassSwitchController { + /* @ngInject */ + constructor() { + this.featureId = FeatureId.K8S_RESOURCE_POOL_STORAGE_QUOTA; + + this.handleChange = this.handleChange.bind(this); + } + + handleChange(value) { + this.onChange(this.name, value); + } +} + +export default StorageClassSwitchController; diff --git a/app/kubernetes/views/resource-pools/components/storage-class-switch/storage-class-switch.html b/app/kubernetes/views/resource-pools/components/storage-class-switch/storage-class-switch.html new file mode 100644 index 000000000..dc52f5f00 --- /dev/null +++ b/app/kubernetes/views/resource-pools/components/storage-class-switch/storage-class-switch.html @@ -0,0 +1,12 @@ +
+
+ +
+
diff --git a/app/kubernetes/views/resource-pools/create/createResourcePool.html b/app/kubernetes/views/resource-pools/create/createResourcePool.html index 4ef095823..9023c8c0d 100644 --- a/app/kubernetes/views/resource-pools/create/createResourcePool.html +++ b/app/kubernetes/views/resource-pools/create/createResourcePool.html @@ -163,11 +163,12 @@
@@ -189,17 +190,9 @@ standard -
-
- -
-
+ + +
diff --git a/app/kubernetes/views/resource-pools/create/createResourcePoolController.js b/app/kubernetes/views/resource-pools/create/createResourcePoolController.js index a352b0a9a..b767d09a8 100644 --- a/app/kubernetes/views/resource-pools/create/createResourcePoolController.js +++ b/app/kubernetes/views/resource-pools/create/createResourcePoolController.js @@ -12,15 +12,16 @@ import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHel import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues'; import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants'; -import { K8S_RESOURCE_POOL_LB_QUOTA, K8S_RESOURCE_POOL_STORAGE_QUOTA } from '@/portainer/feature-flags/feature-ids'; +import { FeatureId } from '@/portainer/feature-flags/enums'; class KubernetesCreateResourcePoolController { /* #region CONSTRUCTOR */ /* @ngInject */ - constructor($async, $state, Notifications, KubernetesNodeService, KubernetesResourcePoolService, KubernetesIngressService, Authentication, EndpointService) { + constructor($async, $state, $scope, Notifications, KubernetesNodeService, KubernetesResourcePoolService, KubernetesIngressService, Authentication, EndpointService) { Object.assign(this, { $async, $state, + $scope, Notifications, KubernetesNodeService, KubernetesResourcePoolService, @@ -30,11 +31,25 @@ class KubernetesCreateResourcePoolController { }); this.IngressClassTypes = KubernetesIngressClassTypes; - this.LBQuotaFeatureId = K8S_RESOURCE_POOL_LB_QUOTA; - this.StorageQuotaFeatureId = K8S_RESOURCE_POOL_STORAGE_QUOTA; + this.LBQuotaFeatureId = FeatureId.K8S_RESOURCE_POOL_LB_QUOTA; + + this.onToggleStorageQuota = this.onToggleStorageQuota.bind(this); + this.onToggleLoadBalancerQuota = this.onToggleLoadBalancerQuota.bind(this); } /* #endregion */ + onToggleStorageQuota(storageClassName, enabled) { + this.$scope.$evalAsync(() => { + this.formValues.StorageClasses = this.formValues.StorageClasses.map((sClass) => (sClass.Name !== storageClassName ? sClass : { ...sClass, Selected: enabled })); + }); + } + + onToggleLoadBalancerQuota(enabled) { + this.$scope.$evalAsync(() => { + this.formValues.UseLoadBalancersQuota = enabled; + }); + } + onChangeIngressHostname() { const state = this.state.duplicates.ingressHosts; const hosts = _.flatMap(this.formValues.IngressClasses, 'Hosts'); diff --git a/app/kubernetes/views/resource-pools/edit/resourcePool.html b/app/kubernetes/views/resource-pools/edit/resourcePool.html index efbf08b61..5b3a3cc6b 100644 --- a/app/kubernetes/views/resource-pools/edit/resourcePool.html +++ b/app/kubernetes/views/resource-pools/edit/resourcePool.html @@ -147,11 +147,12 @@
@@ -386,17 +387,9 @@ standard
-
-
- -
-
+ + + diff --git a/app/kubernetes/views/resource-pools/edit/resourcePoolController.js b/app/kubernetes/views/resource-pools/edit/resourcePoolController.js index 630cd5d8e..1e82c25fd 100644 --- a/app/kubernetes/views/resource-pools/edit/resourcePoolController.js +++ b/app/kubernetes/views/resource-pools/edit/resourcePoolController.js @@ -16,7 +16,7 @@ import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHel import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants'; import KubernetesResourceQuotaConverter from 'Kubernetes/converters/resourceQuota'; import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper'; -import { K8S_RESOURCE_POOL_LB_QUOTA, K8S_RESOURCE_POOL_STORAGE_QUOTA } from '@/portainer/feature-flags/feature-ids'; +import { FeatureId } from '@/portainer/feature-flags/enums'; class KubernetesResourcePoolController { /* #region CONSTRUCTOR */ @@ -24,6 +24,7 @@ class KubernetesResourcePoolController { constructor( $async, $state, + $scope, Authentication, Notifications, LocalStorage, @@ -42,6 +43,7 @@ class KubernetesResourcePoolController { Object.assign(this, { $async, $state, + $scope, Authentication, Notifications, LocalStorage, @@ -61,11 +63,13 @@ class KubernetesResourcePoolController { this.IngressClassTypes = KubernetesIngressClassTypes; this.ResourceQuotaDefaults = KubernetesResourceQuotaDefaults; - this.LBQuotaFeatureId = K8S_RESOURCE_POOL_LB_QUOTA; - this.StorageQuotaFeatureId = K8S_RESOURCE_POOL_STORAGE_QUOTA; + this.LBQuotaFeatureId = FeatureId.K8S_RESOURCE_POOL_LB_QUOTA; + this.StorageQuotaFeatureId = FeatureId.K8S_RESOURCE_POOL_STORAGE_QUOTA; this.updateResourcePoolAsync = this.updateResourcePoolAsync.bind(this); this.getEvents = this.getEvents.bind(this); + this.onToggleLoadBalancersQuota = this.onToggleLoadBalancersQuota.bind(this); + this.onToggleStorageQuota = this.onToggleStorageQuota.bind(this); } /* #endregion */ @@ -80,6 +84,18 @@ class KubernetesResourcePoolController { } /* #endregion */ + onToggleLoadBalancersQuota(checked) { + return this.$scope.$evalAsync(() => { + this.formValues.UseLoadBalancersQuota = checked; + }); + } + + onToggleStorageQuota(storageClassName, enabled) { + this.$scope.$evalAsync(() => { + this.formValues.StorageClasses = this.formValues.StorageClasses.map((sClass) => (sClass.Name !== storageClassName ? sClass : { ...sClass, Selected: enabled })); + }); + } + /* #region INGRESS MANAGEMENT */ onChangeIngressHostname() { const state = this.state.duplicates.ingressHosts; diff --git a/app/portainer/__module.js b/app/portainer/__module.js index 6df82a2d5..d7992c645 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -5,6 +5,7 @@ import componentsModule from './components'; import settingsModule from './settings'; import featureFlagModule from './feature-flags'; import userActivityModule from './user-activity'; +import servicesModule from './services'; async function initAuthentication(authManager, Authentication, $rootScope, $state) { authManager.checkAuthOnRefresh(); @@ -22,12 +23,19 @@ async function initAuthentication(authManager, Authentication, $rootScope, $stat } angular - .module('portainer.app', ['portainer.oauth', 'portainer.rbac', componentsModule, settingsModule, featureFlagModule, userActivityModule, 'portainer.shared.datatable']) + .module('portainer.app', [ + 'portainer.oauth', + 'portainer.rbac', + componentsModule, + settingsModule, + featureFlagModule, + userActivityModule, + 'portainer.shared.datatable', + servicesModule, + ]) .config([ '$stateRegistryProvider', function ($stateRegistryProvider) { - 'use strict'; - var root = { name: 'root', abstract: true, @@ -56,18 +64,6 @@ angular controller: 'SidebarController', }, }, - resolve: { - featuresServiceInitialized: /* @ngInject */ function featuresServiceInitialized($async, featureService, Notifications) { - return $async(async () => { - try { - await featureService.init(); - } catch (e) { - Notifications.error('Failed initializing features service', e); - throw e; - } - }); - }, - }, }; var endpointRoot = { diff --git a/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.controller.ts b/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.controller.ts new file mode 100644 index 000000000..3520699ed --- /dev/null +++ b/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.controller.ts @@ -0,0 +1,24 @@ +import { FeatureId } from '@/portainer/feature-flags/enums'; + +import { getFeatureDetails } from './utils'; + +export default class BeIndicatorController { + limitedToBE?: boolean; + + url?: string; + + feature?: FeatureId; + + /* @ngInject */ + constructor() { + this.limitedToBE = false; + this.url = ''; + } + + $onInit() { + const { url, limitedToBE } = getFeatureDetails(this.feature); + + this.limitedToBE = limitedToBE; + this.url = url; + } +} diff --git a/app/portainer/components/be-feature-indicator/be-feature-indicator.css b/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.css similarity index 100% rename from app/portainer/components/be-feature-indicator/be-feature-indicator.css rename to app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.css diff --git a/app/portainer/components/be-feature-indicator/be-feature-indicator.html b/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.html similarity index 100% rename from app/portainer/components/be-feature-indicator/be-feature-indicator.html rename to app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.html diff --git a/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.stories.tsx b/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.stories.tsx new file mode 100644 index 000000000..d4bf97338 --- /dev/null +++ b/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.stories.tsx @@ -0,0 +1,25 @@ +import { Meta } from '@storybook/react'; + +import { Edition, FeatureId } from '@/portainer/feature-flags/enums'; +import { init as initFeatureService } from '@/portainer/feature-flags/feature-flags.service'; + +import { BEFeatureIndicator, Props } from './BEFeatureIndicator'; + +export default { + component: BEFeatureIndicator, + title: 'Components/BEFeatureIndicator', + argTypes: { + featureId: { + control: { type: 'select', options: Object.values(FeatureId) }, + }, + }, +} as Meta; + +// : JSX.IntrinsicAttributes & PropsWithChildren +function Template({ featureId }: Props) { + initFeatureService(Edition.CE); + + return ; +} + +export const Example = Template.bind({}); diff --git a/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.tsx b/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.tsx new file mode 100644 index 000000000..b5dffd773 --- /dev/null +++ b/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.tsx @@ -0,0 +1,33 @@ +import { PropsWithChildren } from 'react'; + +import { FeatureId } from '@/portainer/feature-flags/enums'; + +import { getFeatureDetails } from './utils'; + +export interface Props { + featureId?: FeatureId; +} + +export function BEFeatureIndicator({ + featureId, + children, +}: PropsWithChildren) { + const { url, limitedToBE } = getFeatureDetails(featureId); + + if (!limitedToBE) { + return null; + } + + return ( + + {children} + + Business Edition Feature + + ); +} diff --git a/app/portainer/components/BEFeatureIndicator/index.ts b/app/portainer/components/BEFeatureIndicator/index.ts new file mode 100644 index 000000000..05a1f628f --- /dev/null +++ b/app/portainer/components/BEFeatureIndicator/index.ts @@ -0,0 +1,14 @@ +import controller from './BEFeatureIndicator.controller'; + +import './BEFeatureIndicator.css'; + +export const beFeatureIndicatorAngular = { + templateUrl: './BEFeatureIndicator.html', + controller, + bindings: { + feature: '<', + }, + transclude: true, +}; + +export { BEFeatureIndicator } from './BEFeatureIndicator'; diff --git a/app/portainer/components/BEFeatureIndicator/utils.ts b/app/portainer/components/BEFeatureIndicator/utils.ts new file mode 100644 index 000000000..b2933c7b1 --- /dev/null +++ b/app/portainer/components/BEFeatureIndicator/utils.ts @@ -0,0 +1,15 @@ +import { FeatureId } from '@/portainer/feature-flags/enums'; +import { isLimitedToBE } from '@/portainer/feature-flags/feature-flags.service'; + +const BE_URL = 'https://www.portainer.io/business-upsell?from='; + +export function getFeatureDetails(featureId?: FeatureId) { + if (!featureId) { + return {}; + } + const url = `${BE_URL}${featureId}`; + + const limitedToBE = isLimitedToBE(featureId); + + return { url, limitedToBE }; +} diff --git a/app/portainer/components/box-selector/box-selector.css b/app/portainer/components/BoxSelector/BoxSelector.css similarity index 100% rename from app/portainer/components/box-selector/box-selector.css rename to app/portainer/components/BoxSelector/BoxSelector.css diff --git a/app/portainer/components/BoxSelector/BoxSelector.module.css b/app/portainer/components/BoxSelector/BoxSelector.module.css new file mode 100644 index 000000000..83fdb4f94 --- /dev/null +++ b/app/portainer/components/BoxSelector/BoxSelector.module.css @@ -0,0 +1,4 @@ +.root { + float: left; + width: 100%; +} diff --git a/app/portainer/components/BoxSelector/BoxSelector.stories.tsx b/app/portainer/components/BoxSelector/BoxSelector.stories.tsx new file mode 100644 index 000000000..7a9a7b473 --- /dev/null +++ b/app/portainer/components/BoxSelector/BoxSelector.stories.tsx @@ -0,0 +1,83 @@ +import { Meta } from '@storybook/react'; +import { useState } from 'react'; + +import { init as initFeatureService } from '@/portainer/feature-flags/feature-flags.service'; +import { Edition, FeatureId } from '@/portainer/feature-flags/enums'; + +import { BoxSelector } from './BoxSelector'; +import { BoxSelectorOption } from './types'; + +const meta: Meta = { + title: 'BoxSelector', + component: BoxSelector, +}; + +export default meta; + +export function Example() { + const [value, setValue] = useState(3); + const options: BoxSelectorOption[] = [ + { + description: 'description 1', + icon: 'fa fa-rocket', + id: '1', + value: 3, + label: 'option 1', + }, + { + description: 'description 2', + icon: 'fa fa-rocket', + id: '2', + value: 4, + label: 'option 2', + }, + ]; + + return ( + { + setValue(value); + }} + value={value} + options={options} + /> + ); +} + +export function LimitedFeature() { + initFeatureService(Edition.CE); + const [value, setValue] = useState(3); + const options: BoxSelectorOption[] = [ + { + description: 'description 1', + icon: 'fa fa-rocket', + id: '1', + value: 3, + label: 'option 1', + }, + { + description: 'description 2', + icon: 'fa fa-rocket', + id: '2', + value: 4, + label: 'option 2', + feature: FeatureId.ACTIVITY_AUDIT, + }, + ]; + + return ( + { + setValue(value); + }} + value={value} + options={options} + /> + ); +} + +// regular example + +// story with limited feature diff --git a/app/portainer/components/BoxSelector/BoxSelector.test.tsx b/app/portainer/components/BoxSelector/BoxSelector.test.tsx new file mode 100644 index 000000000..5a063886b --- /dev/null +++ b/app/portainer/components/BoxSelector/BoxSelector.test.tsx @@ -0,0 +1,59 @@ +import { render, fireEvent } from '@/react-tools/test-utils'; + +import { BoxSelector, Props } from './BoxSelector'; +import { BoxSelectorOption } from './types'; + +function renderDefault({ + options = [], + onChange = () => {}, + radioName = 'radio', + value, +}: Partial> = {}) { + return render( + + ); +} + +test('should render with the initial value selected and call onChange when clicking a different value', async () => { + const options: BoxSelectorOption[] = [ + { + description: 'description 1', + icon: 'fa fa-rocket', + id: '1', + value: 3, + label: 'option 1', + }, + { + description: 'description 2', + icon: 'fa fa-rocket', + id: '2', + value: 4, + label: 'option 2', + }, + ]; + + const onChange = jest.fn(); + const { getByLabelText } = renderDefault({ + options, + onChange, + value: options[0].value, + }); + + const item1 = getByLabelText(options[0].label, { + exact: false, + }) as HTMLInputElement; + expect(item1.checked).toBeTruthy(); + + const item2 = getByLabelText(options[1].label, { + exact: false, + }) as HTMLInputElement; + expect(item2.checked).toBeFalsy(); + + fireEvent.click(item2); + expect(onChange).toHaveBeenCalledWith(options[1].value, false); +}); diff --git a/app/portainer/components/BoxSelector/BoxSelector.tsx b/app/portainer/components/BoxSelector/BoxSelector.tsx new file mode 100644 index 000000000..32cd106c1 --- /dev/null +++ b/app/portainer/components/BoxSelector/BoxSelector.tsx @@ -0,0 +1,49 @@ +import clsx from 'clsx'; + +import type { FeatureId } from '@/portainer/feature-flags/enums'; + +import './BoxSelector.css'; +import styles from './BoxSelector.module.css'; +import { BoxSelectorItem } from './BoxSelectorItem'; +import { BoxSelectorOption } from './types'; + +export interface Props { + radioName: string; + value: T; + onChange(value: T, limitedToBE: boolean): void; + options: BoxSelectorOption[]; +} + +export function BoxSelector({ + radioName, + value, + options, + onChange, +}: Props) { + return ( +
+ {options.map((option) => ( + + ))} +
+ ); +} + +export function buildOption( + id: string, + icon: string, + label: string, + description: string, + value: T, + feature: FeatureId +): BoxSelectorOption { + return { id, icon, label, description, value, feature }; +} diff --git a/app/portainer/components/BoxSelector/BoxSelectorAngular.tsx b/app/portainer/components/BoxSelector/BoxSelectorAngular.tsx new file mode 100644 index 000000000..e09737746 --- /dev/null +++ b/app/portainer/components/BoxSelector/BoxSelectorAngular.tsx @@ -0,0 +1,49 @@ +import { + IComponentOptions, + IComponentController, + IFormController, + IScope, +} from 'angular'; + +class BoxSelectorController implements IComponentController { + formCtrl!: IFormController; + + onChange!: (value: string | number) => void; + + radioName!: string; + + $scope: IScope; + + /* @ngInject */ + constructor($scope: IScope) { + this.handleChange = this.handleChange.bind(this); + + this.$scope = $scope; + } + + handleChange(value: string | number, limitedToBE: boolean) { + this.$scope.$evalAsync(() => { + this.formCtrl.$setValidity(this.radioName, !limitedToBE, this.formCtrl); + this.onChange(value); + }); + } +} + +export const BoxSelectorAngular: IComponentOptions = { + template: ``, + bindings: { + value: '<', + onChange: '<', + options: '<', + radioName: '<', + }, + require: { + formCtrl: '^form', + }, + controller: BoxSelectorController, +}; diff --git a/app/portainer/components/box-selector/box-selector-item/box-selector-item.css b/app/portainer/components/BoxSelector/BoxSelectorItem.css similarity index 100% rename from app/portainer/components/box-selector/box-selector-item/box-selector-item.css rename to app/portainer/components/BoxSelector/BoxSelectorItem.css diff --git a/app/portainer/components/BoxSelector/BoxSelectorItem.stories.tsx b/app/portainer/components/BoxSelector/BoxSelectorItem.stories.tsx new file mode 100644 index 000000000..a3c518cda --- /dev/null +++ b/app/portainer/components/BoxSelector/BoxSelectorItem.stories.tsx @@ -0,0 +1,77 @@ +import { Meta } from '@storybook/react'; + +import { init as initFeatureService } from '@/portainer/feature-flags/feature-flags.service'; +import { Edition, FeatureId } from '@/portainer/feature-flags/enums'; + +import { BoxSelectorItem } from './BoxSelectorItem'; +import { BoxSelectorOption } from './types'; + +const meta: Meta = { + title: 'BoxSelector/Item', + args: { + selected: false, + description: 'description', + icon: 'fa-rocket', + label: 'label', + }, +}; + +export default meta; + +interface ExampleProps { + selected?: boolean; + description?: string; + icon?: string; + label?: string; + feature?: FeatureId; +} + +function Template({ + selected, + description = 'description', + icon, + label = 'label', + feature, +}: ExampleProps) { + const option: BoxSelectorOption = { + description, + icon: `fa ${icon}`, + id: 'id', + label, + value: 1, + feature, + }; + + return ( +
+ {}} + option={option} + radioName="radio" + selectedValue={selected ? option.value : 0} + /> +
+ ); +} + +export const Example = Template.bind({}); + +export function SelectedItem() { + return