diff --git a/.storybook/main.js b/.storybook/main.js index 236bd3fa1..4b0260b35 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -29,6 +29,23 @@ module.exports = { extensions: config.resolve.extensions, }), ]; + + const svgRule = config.module.rules.find((rule) => rule.test && typeof rule.test.test === 'function' && rule.test.test('.svg')); + svgRule.test = new RegExp(svgRule.test.source.replace('svg|', '')); + + config.module.rules.unshift({ + test: /\.svg$/i, + type: 'asset', + resourceQuery: { not: [/c/] }, // exclude react component if *.svg?url + }); + + config.module.rules.unshift({ + test: /\.svg$/i, + issuer: /\.(js|ts)(x)?$/, + resourceQuery: /c/, // *.svg?c + use: [{ loader: '@svgr/webpack', options: { icon: true } }], + }); + return config; }, core: { diff --git a/app/assets/css/theme.css b/app/assets/css/theme.css index 732b4b41b..a052f6b29 100644 --- a/app/assets/css/theme.css +++ b/app/assets/css/theme.css @@ -112,7 +112,6 @@ --bg-input-group-addon-color: var(--ui-gray-3); --bg-btn-default-color: var(--ui-blue-10); --bg-blocklist-hover-color: var(--ui-blue-2); - --bg-boxselector-color: var(--ui-gray-2); --bg-table-color: var(--white-color); --bg-md-checkbox-color: var(--grey-12); --bg-form-control-disabled-color: var(--grey-11); @@ -152,7 +151,6 @@ --bg-input-autofill-color: var(--bg-inputbox); --bg-btn-default-hover-color: var(--ui-blue-9); --bg-btn-focus: var(--grey-59); - --bg-boxselector-disabled-color: var(--white-color); --bg-small-select-color: var(--white-color); --bg-stepper-item-active: var(--white-color); --bg-stepper-item-counter: var(--grey-61); @@ -214,8 +212,6 @@ --text-button-group-color: var(--ui-gray-9); --text-button-dangerlight-color: var(--ui-error-5); --text-stepper-active-color: var(--ui-blue-8); - --text-boxselector-header: var(--ui-black); - --border-color: var(--grey-42); --border-widget-color: var(--grey-43); --border-sidebar-color: var(--ui-blue-9); @@ -225,7 +221,6 @@ --border-datatable-top-color: var(--grey-10); --border-input-group-addon-color: var(--grey-44); --border-btn-default-color: var(--grey-44); - --border-boxselector-color: var(--grey-6); --border-md-checkbox-color: var(--grey-19); --border-modal-header-color: var(--grey-45); --border-navtabs-color: var(--ui-white); @@ -282,7 +277,6 @@ --bg-body-color: var(--grey-2); --bg-btn-default-color: var(--grey-3); --bg-blocklist-hover-color: var(--ui-gray-iron-10); - --bg-boxselector-color: var(--ui-gray-iron-10); --bg-blocklist-item-selected-color: var(--grey-3); --bg-card-color: var(--grey-1); --bg-checkbox-border-color: var(--grey-8); @@ -337,7 +331,6 @@ --bg-input-autofill-color: var(--bg-inputbox); --bg-btn-default-hover-color: var(--grey-4); --bg-btn-focus: var(--grey-3); - --bg-boxselector-disabled-color: var(--grey-54); --bg-small-select-color: var(--grey-2); --bg-stepper-item-active: var(--grey-1); --bg-stepper-item-counter: var(--grey-7); @@ -385,7 +378,6 @@ --text-pagination-span-color: var(--ui-white); --text-pagination-span-hover-color: var(--ui-white); --text-summary-color: var(--white-color); - --text-boxselector-wrapper-color: var(--white-color); --text-tooltip-color: var(--white-color); --text-rzslider-color: var(--white-color); --text-rzslider-limit-color: var(--white-color); @@ -399,8 +391,6 @@ --text-button-group-color: var(--ui-white); --text-button-dangerlight-color: var(--ui-error-7); --text-stepper-active-color: var(--ui-white); - --text-boxselector-header: var(--ui-white); - --border-color: var(--grey-3); --border-widget-color: var(--grey-1); --border-sidebar-color: var(--ui-gray-8); @@ -410,7 +400,6 @@ --border-datatable-top-color: var(--grey-3); --border-input-group-addon-color: var(--grey-38); --border-btn-default-color: var(--grey-38); - --border-boxselector-color: var(--grey-1); --border-md-checkbox-color: var(--grey-41); --border-modal-header-color: var(--grey-1); --border-navtabs-color: var(--grey-38); @@ -519,8 +508,6 @@ --bg-code-color: var(--ui-black); --bg-btn-default-hover-color: var(--grey-4); --bg-btn-focus: var(--black-color); - --bg-boxselector-color: var(--black-color); - --bg-boxselector-disabled-color: var(--black-color); --bg-small-select-color: var(--black-color); --bg-stepper-item-active: var(--black-color); --bg-stepper-item-counter: var(--grey-3); @@ -544,7 +531,6 @@ --text-code-color: var(--red-7); --text-form-control-color: var(--white-color); --text-blocklist-hover-color: var(--blue-11); - --text-boxselector-wrapper-color: var(--white-color); --text-dashboard-item-color: var(--blue-12); --text-muted-color: var(--white-color); --text-tooltip-color: var(--white-color); @@ -574,7 +560,6 @@ --text-bootbox: var(--white-color); --text-pagination-span-hover-color: var(--ui-white); --text-stepper-active-color: var(--ui-white); - --text-boxselector-header: var(--ui-white); --border-color: var(--grey-55); --border-widget-color: var(--white-color); diff --git a/app/assets/ico/checked.svg b/app/assets/ico/checked.svg deleted file mode 100644 index 300b53609..000000000 --- a/app/assets/ico/checked.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/docker/components/network-macvlan-form/networkMacvlanForm.html b/app/docker/components/network-macvlan-form/networkMacvlanForm.html index ee5112f81..d52bcc6ed 100644 --- a/app/docker/components/network-macvlan-form/networkMacvlanForm.html +++ b/app/docker/components/network-macvlan-form/networkMacvlanForm.html @@ -1,39 +1,14 @@
Macvlan configuration
- +
To create a MACVLAN network you need to create a configuration, then create the network from this configuration.
-
-
-
-
- - -
-
- - -
-
-
-
- + + diff --git a/app/docker/components/network-macvlan-form/networkMacvlanFormController.js b/app/docker/components/network-macvlan-form/networkMacvlanFormController.js index 959adca46..ec63b4eeb 100644 --- a/app/docker/components/network-macvlan-form/networkMacvlanFormController.js +++ b/app/docker/components/network-macvlan-form/networkMacvlanFormController.js @@ -1,13 +1,17 @@ +import { getOptions } from './options'; + angular.module('portainer.docker').controller('NetworkMacvlanFormController', [ '$q', 'NodeService', 'NetworkService', 'Notifications', - 'StateManager', + '$scope', 'Authentication', - function ($q, NodeService, NetworkService, Notifications, StateManager, Authentication) { + function ($q, NodeService, NetworkService, Notifications, $scope, Authentication) { var ctrl = this; + this.options = []; + ctrl.requiredNodeSelection = function () { if (ctrl.data.Scope !== 'local' || ctrl.data.DatatableState === undefined) { return false; @@ -22,6 +26,13 @@ angular.module('portainer.docker').controller('NetworkMacvlanFormController', [ return !ctrl.data.SelectedNetworkConfig; }; + this.onChangeScope = onChangeScope.bind(this); + function onChangeScope(value) { + return $scope.$evalAsync(() => { + this.data.Scope = value; + }); + } + this.$onInit = $onInit; function $onInit() { var isAdmin = Authentication.isAdmin(); @@ -40,6 +51,8 @@ angular.module('portainer.docker').controller('NetworkMacvlanFormController', [ ctrl.availableNetworks = data.networks.filter(function (item) { return item.ConfigOnly === true; }); + + ctrl.options = getOptions(ctrl.availableNetworks.length > 0); }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve informations for macvlan'); diff --git a/app/docker/components/network-macvlan-form/options.tsx b/app/docker/components/network-macvlan-form/options.tsx new file mode 100644 index 000000000..99a5ee577 --- /dev/null +++ b/app/docker/components/network-macvlan-form/options.tsx @@ -0,0 +1,27 @@ +import { Share2, Sliders } from 'lucide-react'; + +import { BoxSelectorOption } from '@@/BoxSelector'; + +export function getOptions( + hasNetworks: boolean +): ReadonlyArray> { + return [ + { + id: 'network_config', + icon: Sliders, + iconType: 'badge', + label: 'Configuration', + description: 'I want to configure a network before deploying it', + value: 'local', + }, + { + id: 'network_deploy', + icon: Share2, + iconType: 'badge', + label: 'Creation', + description: 'I want to create a network from a configuration', + value: 'swarm', + disabled: () => !hasNetworks, + }, + ] as const; +} diff --git a/app/docker/views/images/build/buildImageController.js b/app/docker/views/images/build/buildImageController.js index 1b3b96f2f..a332900d8 100644 --- a/app/docker/views/images/build/buildImageController.js +++ b/app/docker/views/images/build/buildImageController.js @@ -1,8 +1,11 @@ +import { options } from './options'; + angular.module('portainer.docker').controller('BuildImageController', BuildImageController); /* @ngInject */ function BuildImageController($scope, $async, $window, ModalService, BuildService, Notifications, HttpRequestHelper, endpoint) { $scope.endpoint = endpoint; + $scope.options = options; $scope.state = { BuildType: 'editor', @@ -31,6 +34,12 @@ function BuildImageController($scope, $async, $window, ModalService, BuildServic $scope.state.isEditorDirty = false; }); + $scope.onChangeBuildType = function (type) { + $scope.$evalAsync(() => { + $scope.state.BuildType = type; + }); + }; + $scope.checkName = function (index) { var item = $scope.formValues.ImageNames[index]; item.Valid = true; diff --git a/app/docker/views/images/build/buildimage.html b/app/docker/views/images/build/buildimage.html index 996d03274..5a2c3bddc 100644 --- a/app/docker/views/images/build/buildimage.html +++ b/app/docker/views/images/build/buildimage.html @@ -69,46 +69,9 @@
-
Build method
-
-
-
-
-
- - -
-
- - -
-
- - -
-
-
-
- + +
Web editor
diff --git a/app/docker/views/images/build/options.tsx b/app/docker/views/images/build/options.tsx new file mode 100644 index 000000000..96e242c59 --- /dev/null +++ b/app/docker/views/images/build/options.tsx @@ -0,0 +1,7 @@ +import { + editor, + upload, + url, +} from '@@/BoxSelector/common-options/build-methods'; + +export const options = [editor, upload, url] as const; diff --git a/app/edge/components/edge-job-form/cron-method-options.tsx b/app/edge/components/edge-job-form/cron-method-options.tsx new file mode 100644 index 000000000..aa86d2798 --- /dev/null +++ b/app/edge/components/edge-job-form/cron-method-options.tsx @@ -0,0 +1,22 @@ +import { Calendar, Edit } from 'lucide-react'; + +import { BoxSelectorOption } from '@@/BoxSelector'; + +export const cronMethodOptions: ReadonlyArray> = [ + { + id: 'config_basic', + value: 'basic', + icon: Calendar, + iconType: 'badge', + label: 'Basic configuration', + description: 'Select date from calendar', + }, + { + id: 'config_advanced', + value: 'advanced', + icon: Edit, + iconType: 'badge', + label: 'Advanced configuration', + description: 'Write your own cron rule', + }, +] as const; diff --git a/app/edge/components/edge-job-form/edgeJobForm.html b/app/edge/components/edge-job-form/edgeJobForm.html index 46b5bbc0a..1e10be760 100644 --- a/app/edge/components/edge-job-form/edgeJobForm.html +++ b/app/edge/components/edge-job-form/edgeJobForm.html @@ -34,33 +34,9 @@
Edge job configuration
-
-
-
-
-
- - -
-
- - -
-
-
-
+ + +
@@ -154,34 +130,10 @@
Job content
-
-
-
-
- - -
-
- - -
-
-
-
+
+
Web editor
diff --git a/app/edge/components/edge-job-form/edgeJobFormController.js b/app/edge/components/edge-job-form/edgeJobFormController.js index 03fc880a9..6e609cfd5 100644 --- a/app/edge/components/edge-job-form/edgeJobFormController.js +++ b/app/edge/components/edge-job-form/edgeJobFormController.js @@ -1,9 +1,20 @@ import _ from 'lodash-es'; import moment from 'moment'; +import { editor, upload } from '@@/BoxSelector/common-options/build-methods'; + +import { cronMethodOptions } from './cron-method-options'; export class EdgeJobFormController { /* @ngInject */ constructor($async, $scope, EdgeGroupService, Notifications) { + this.$scope = $scope; + this.$async = $async; + this.EdgeGroupService = EdgeGroupService; + this.Notifications = Notifications; + + this.cronMethods = cronMethodOptions; + this.buildMethods = [editor, upload]; + this.state = { formValidationError: '', }; @@ -34,17 +45,31 @@ export class EdgeJobFormController { this.cronRegex = /(@(annually|yearly|monthly|weekly|daily|hourly|reboot))|(@every (\d+(ns|us|µs|ms|s|m|h))+)|((((\d+,)+\d+|(\d+(\/|-)\d+)|\d+|\*) ){4,6}((\d+,)+\d+|(\d+(\/|-)\d+)|\d+|\*))/; - this.$async = $async; - this.$scope = $scope; - this.action = this.action.bind(this); this.editorUpdate = this.editorUpdate.bind(this); this.associateEndpoint = this.associateEndpoint.bind(this); this.dissociateEndpoint = this.dissociateEndpoint.bind(this); this.onChangeGroups = this.onChangeGroups.bind(this); + this.onChange = this.onChange.bind(this); + this.onCronMethodChange = this.onCronMethodChange.bind(this); + this.onBuildMethodChange = this.onBuildMethodChange.bind(this); + } - this.EdgeGroupService = EdgeGroupService; - this.Notifications = Notifications; + onChange(values) { + this.$scope.$evalAsync(() => { + this.formValues = { + ...this.formValues, + ...values, + }; + }); + } + + onBuildMethodChange(value) { + this.onChange({ method: value }); + } + + onCronMethodChange(value) { + this.onChange({ cronMethod: value }); } onChangeModel(model) { diff --git a/app/edge/components/group-form/group-type-options.tsx b/app/edge/components/group-form/group-type-options.tsx new file mode 100644 index 000000000..51dffaa84 --- /dev/null +++ b/app/edge/components/group-form/group-type-options.tsx @@ -0,0 +1,22 @@ +import { List, Tag } from 'lucide-react'; + +import { BoxSelectorOption } from '@@/BoxSelector'; + +export const groupTypeOptions: ReadonlyArray> = [ + { + id: 'static-group', + value: false, + label: 'Static', + description: 'Manually select Edge environments', + icon: List, + iconType: 'badge', + }, + { + id: 'dynamic-group', + value: true, + label: 'Dynamic', + description: 'Automatically associate environments via tags', + icon: Tag, + iconType: 'badge', + }, +] as const; diff --git a/app/edge/components/group-form/groupForm.html b/app/edge/components/group-form/groupForm.html index eb2c564c9..f01bdac88 100644 --- a/app/edge/components/group-form/groupForm.html +++ b/app/edge/components/group-form/groupForm.html @@ -24,32 +24,8 @@
Group type
-
-
-
-
- - -
-
- - -
-
-
-
+ +
@@ -78,32 +54,8 @@
Tags
-
-
-
-
- - -
-
- - -
-
-
-
+ + diff --git a/app/edge/components/group-form/groupFormController.js b/app/edge/components/group-form/groupFormController.js index 50ddd63dd..78c1b602c 100644 --- a/app/edge/components/group-form/groupFormController.js +++ b/app/edge/components/group-form/groupFormController.js @@ -4,6 +4,8 @@ import { EdgeTypes } from '@/react/portainer/environments/types'; import { getEnvironments } from '@/react/portainer/environments/environment.service'; import { getTags } from '@/portainer/tags/tags.service'; import { notifyError } from '@/portainer/services/notifications'; +import { groupTypeOptions } from './group-type-options'; +import { tagOptions } from './tag-options'; export class EdgeGroupFormController { /* @ngInject */ @@ -11,6 +13,9 @@ export class EdgeGroupFormController { this.$async = $async; this.$scope = $scope; + this.groupTypeOptions = groupTypeOptions; + this.tagOptions = tagOptions; + this.endpoints = { state: { limit: '10', @@ -28,6 +33,9 @@ export class EdgeGroupFormController { this.getDynamicEndpointsAsync = this.getDynamicEndpointsAsync.bind(this); this.getDynamicEndpoints = this.getDynamicEndpoints.bind(this); this.onChangeTags = this.onChangeTags.bind(this); + this.onChangeDynamic = this.onChangeDynamic.bind(this); + this.onChangeModel = this.onChangeModel.bind(this); + this.onChangePartialMatch = this.onChangePartialMatch.bind(this); $scope.$watch( () => this.model, @@ -40,12 +48,27 @@ export class EdgeGroupFormController { ); } - onChangeTags(value) { + onChangeModel(model) { return this.$scope.$evalAsync(() => { - this.model.TagIds = value; + this.model = { + ...this.model, + ...model, + }; }); } + onChangePartialMatch(value) { + return this.onChangeModel({ PartialMatch: value }); + } + + onChangeDynamic(value) { + this.onChangeModel({ Dynamic: value }); + } + + onChangeTags(value) { + this.onChangeModel({ TagIds: value }); + } + associateEndpoint(endpoint) { if (!_.includes(this.model.Endpoints, endpoint.Id)) { this.model.Endpoints = [...this.model.Endpoints, endpoint.Id]; diff --git a/app/edge/components/group-form/tag-options.tsx b/app/edge/components/group-form/tag-options.tsx new file mode 100644 index 000000000..4b54acc80 --- /dev/null +++ b/app/edge/components/group-form/tag-options.tsx @@ -0,0 +1,23 @@ +import { Tag } from 'lucide-react'; + +import { BoxSelectorOption } from '@@/BoxSelector'; + +export const tagOptions: ReadonlyArray> = [ + { + id: 'or-selector', + value: true, + label: 'Partial Match', + description: + 'Associate any environment matching at least one of the selected tags', + icon: Tag, + iconType: 'badge', + }, + { + id: 'and-selector', + value: false, + label: 'Full Match', + description: 'Associate any environment matching all of the selected tags', + icon: Tag, + iconType: 'badge', + }, +]; 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 4a0def6a1..2ee077516 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 @@ -1,11 +1,11 @@ -import { editor, git, template, upload } from '@@/BoxSelector/common-options/build-methods'; +import { editor, git, edgeStackTemplate, upload } from '@@/BoxSelector/common-options/build-methods'; class DockerComposeFormController { /* @ngInject */ constructor($async, EdgeTemplateService, Notifications) { Object.assign(this, { $async, EdgeTemplateService, Notifications }); - this.methodOptions = [editor, upload, git, template]; + this.methodOptions = [editor, upload, git, edgeStackTemplate]; 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 89328cb6d..9a600e6fd 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,5 +1,5 @@
Build method
- +
Build method
- + diff --git a/app/kubernetes/custom-templates/kube-create-custom-template-view/kube-create-custom-template-view.html b/app/kubernetes/custom-templates/kube-create-custom-template-view/kube-create-custom-template-view.html index 7db559ce0..823887f80 100644 --- a/app/kubernetes/custom-templates/kube-create-custom-template-view/kube-create-custom-template-view.html +++ b/app/kubernetes/custom-templates/kube-create-custom-template-view/kube-create-custom-template-view.html @@ -9,7 +9,7 @@
Build method
- +
Specify how the data will be used across instances.
- -
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
- +
@@ -925,62 +847,12 @@
-
-
-
-
- - -
-
- - -
-
- - -
-
-
-
- +
@@ -1260,46 +1132,14 @@
Specify the policy associated to the placement rules.
- -
-
-
-
- - -
-
- - -
-
-
-
- +
diff --git a/app/kubernetes/views/applications/create/createApplicationController.js b/app/kubernetes/views/applications/create/createApplicationController.js index d3b1178ed..bc72cdcbe 100644 --- a/app/kubernetes/views/applications/create/createApplicationController.js +++ b/app/kubernetes/views/applications/create/createApplicationController.js @@ -34,6 +34,7 @@ import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper'; import { KubernetesNodeHelper } from 'Kubernetes/node/helper'; import { updateIngress, getIngresses } from '@/react/kubernetes/ingresses/service'; import { confirmUpdateAppIngress } from '@/portainer/services/modal.service/prompt'; +import { placementOptions } from './placementTypes'; class KubernetesCreateApplicationController { /* #region CONSTRUCTOR */ @@ -85,6 +86,8 @@ class KubernetesCreateApplicationController { this.ServiceTypes = KubernetesServiceTypes; this.KubernetesDeploymentTypes = KubernetesDeploymentTypes; + this.placementOptions = placementOptions; + this.state = { appType: this.KubernetesDeploymentTypes.APPLICATION_FORM, updateWebEditorInProgress: false, @@ -148,9 +151,25 @@ class KubernetesCreateApplicationController { this.onServicePublishChange = this.onServicePublishChange.bind(this); this.checkIngressesToUpdate = this.checkIngressesToUpdate.bind(this); this.confirmUpdateApplicationAsync = this.confirmUpdateApplicationAsync.bind(this); + this.onDataAccessPolicyChange = this.onDataAccessPolicyChange.bind(this); + this.onChangeDeploymentType = this.onChangeDeploymentType.bind(this); + this.supportGlobalDeployment = this.supportGlobalDeployment.bind(this); + this.onChangePlacementType = this.onChangePlacementType.bind(this); } /* #endregion */ + onChangePlacementType(value) { + this.$scope.$evalAsync(() => { + this.formValues.PlacementType = value; + }); + } + + onChangeDeploymentType(value) { + this.$scope.$evalAsync(() => { + this.formValues.DeploymentType = value; + }); + } + onChangeFileContent(value) { if (this.stackFileContent.replace(/(\r\n|\n|\r)/gm, '') !== value.replace(/(\r\n|\n|\r)/gm, '')) { this.state.isEditorDirty = true; @@ -158,6 +177,13 @@ class KubernetesCreateApplicationController { } } + onDataAccessPolicyChange(value) { + this.$scope.$evalAsync(() => { + this.formValues.DataAccessPolicy = value; + this.resetDeploymentType(); + }); + } + async updateApplicationViaWebEditor() { return this.$async(async () => { try { @@ -616,6 +642,7 @@ class KubernetesCreateApplicationController { if (hasFolders && (hasRWOOnly || isIsolated)) { return false; } + return true; } diff --git a/app/kubernetes/views/applications/create/placementTypes.tsx b/app/kubernetes/views/applications/create/placementTypes.tsx new file mode 100644 index 000000000..665a553ba --- /dev/null +++ b/app/kubernetes/views/applications/create/placementTypes.tsx @@ -0,0 +1,30 @@ +import { AlignJustify, Sliders } from 'lucide-react'; + +import { KubernetesApplicationPlacementTypes } from '@/kubernetes/models/application/models'; + +import { BoxSelectorOption } from '@@/BoxSelector'; + +export const placementOptions: ReadonlyArray> = [ + { + id: 'placement_hard', + value: KubernetesApplicationPlacementTypes.MANDATORY, + icon: Sliders, + iconType: 'badge', + label: 'Mandatory', + description: ( + <> + Schedule this application ONLY on nodes that match ALL{' '} + Rules + + ), + }, + { + id: 'placement_soft', + value: KubernetesApplicationPlacementTypes.PREFERRED, + icon: AlignJustify, + iconType: 'badge', + label: 'Preferred', + description: + 'Schedule this application on nodes that match the rules if possible', + }, +] as const; diff --git a/app/kubernetes/views/configurations/create/createConfiguration.html b/app/kubernetes/views/configurations/create/createConfiguration.html index 234ef23c1..1c7950657 100644 --- a/app/kubernetes/views/configurations/create/createConfiguration.html +++ b/app/kubernetes/views/configurations/create/createConfiguration.html @@ -85,32 +85,7 @@
Select the kind of data that you want to save in the configuration.
- -
-
-
- - -
-
- - -
-
-
- +
Information
diff --git a/app/kubernetes/views/configurations/create/createConfigurationController.js b/app/kubernetes/views/configurations/create/createConfigurationController.js index 3869b8da0..3844ebe48 100644 --- a/app/kubernetes/views/configurations/create/createConfigurationController.js +++ b/app/kubernetes/views/configurations/create/createConfigurationController.js @@ -7,12 +7,14 @@ import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper'; import { getServiceAccounts } from 'Kubernetes/rest/serviceAccount'; import { isConfigurationFormValid } from '../validation'; +import { typeOptions } from './options'; class KubernetesCreateConfigurationController { /* @ngInject */ - constructor($async, $state, $window, ModalService, Notifications, Authentication, KubernetesConfigurationService, KubernetesResourcePoolService, EndpointProvider) { + constructor($async, $state, $scope, $window, ModalService, Notifications, Authentication, KubernetesConfigurationService, KubernetesResourcePoolService, EndpointProvider) { this.$async = $async; this.$state = $state; + this.$scope = $scope; this.$window = $window; this.EndpointProvider = EndpointProvider; this.ModalService = ModalService; @@ -23,11 +25,14 @@ class KubernetesCreateConfigurationController { this.KubernetesConfigurationKinds = KubernetesConfigurationKinds; this.KubernetesSecretTypeOptions = KubernetesSecretTypeOptions; + this.typeOptions = typeOptions; + this.onInit = this.onInit.bind(this); this.createConfigurationAsync = this.createConfigurationAsync.bind(this); this.getConfigurationsAsync = this.getConfigurationsAsync.bind(this); this.onResourcePoolSelectionChangeAsync = this.onResourcePoolSelectionChangeAsync.bind(this); this.onSecretTypeChange = this.onSecretTypeChange.bind(this); + this.onChangeKind = this.onChangeKind.bind(this); } onChangeName() { @@ -38,18 +43,21 @@ class KubernetesCreateConfigurationController { this.state.alreadyExist = _.find(filteredConfigurations, (config) => config.Name === this.formValues.Name) !== undefined; } - onChangeKind() { - this.onChangeName(); - // if there is no data field, add one - if (this.formValues.Data.length === 0) { - this.formValues.Data.push(new KubernetesConfigurationFormValuesEntry()); - } - // if changing back to a secret, that is a service account token, remove the data field - if (this.formValues.Kind === this.KubernetesConfigurationKinds.SECRET) { - this.onSecretTypeChange(); - } else { - this.isDockerConfig = false; - } + onChangeKind(value) { + this.$scope.$evalAsync(() => { + this.formValues.Kind = value; + this.onChangeName(); + // if there is no data field, add one + if (this.formValues.Data.length === 0) { + this.formValues.Data.push(new KubernetesConfigurationFormValuesEntry()); + } + // if changing back to a secret, that is a service account token, remove the data field + if (this.formValues.Kind === this.KubernetesConfigurationKinds.SECRET) { + this.onSecretTypeChange(); + } else { + this.isDockerConfig = false; + } + }); } async onResourcePoolSelectionChangeAsync() { diff --git a/app/kubernetes/views/configurations/create/options.tsx b/app/kubernetes/views/configurations/create/options.tsx new file mode 100644 index 000000000..cec821195 --- /dev/null +++ b/app/kubernetes/views/configurations/create/options.tsx @@ -0,0 +1,23 @@ +import { KubernetesConfigurationKinds } from 'Kubernetes/models/configuration/models'; +import { FileCode, Lock } from 'lucide-react'; + +import { BoxSelectorOption } from '@@/BoxSelector'; + +export const typeOptions: ReadonlyArray> = [ + { + id: 'type_basic', + value: KubernetesConfigurationKinds.CONFIGMAP, + icon: FileCode, + iconType: 'badge', + label: 'ConfigMap', + description: 'This configuration holds non-sensitive information', + }, + { + id: 'type_secret', + value: KubernetesConfigurationKinds.SECRET, + icon: Lock, + iconType: 'badge', + label: 'Secret', + description: 'This configuration holds sensitive information', + }, +] as const; diff --git a/app/kubernetes/views/deploy/deploy.html b/app/kubernetes/views/deploy/deploy.html index 9c1c4fefb..efb179586 100644 --- a/app/kubernetes/views/deploy/deploy.html +++ b/app/kubernetes/views/deploy/deploy.html @@ -56,6 +56,7 @@
Build method
`, bindings: { value: '<', onChange: '<', options: '<', radioName: '<', + slim: '<', }, require: { formCtrl: '^form', diff --git a/app/portainer/components/BoxSelector/index.ts b/app/portainer/components/BoxSelector/index.ts index 4180703d5..14115ea3d 100644 --- a/app/portainer/components/BoxSelector/index.ts +++ b/app/portainer/components/BoxSelector/index.ts @@ -8,10 +8,12 @@ import { BoxSelectorAngular } from './BoxSelectorAngular'; export { buildOption } from './utils'; const BoxSelectorReact = react2angular(BoxSelector, [ + 'isMulti', 'value', 'onChange', 'options', 'radioName', + 'slim', ]); export const boxSelectorModule = angular diff --git a/app/portainer/components/BoxSelector/utils.ts b/app/portainer/components/BoxSelector/utils.ts index 23fa567b2..8a6afc491 100644 --- a/app/portainer/components/BoxSelector/utils.ts +++ b/app/portainer/components/BoxSelector/utils.ts @@ -4,11 +4,11 @@ import { BoxSelectorOption } from '@@/BoxSelector/types'; import { IconProps } from '@@/Icon'; export function buildOption( - id: string, + id: BoxSelectorOption['id'], icon: IconProps['icon'], - label: string, - description: string, - value: T, + label: BoxSelectorOption['label'], + description: BoxSelectorOption['description'], + value: BoxSelectorOption['value'], feature?: FeatureId ): BoxSelectorOption { return { id, icon, label, description, value, feature }; diff --git a/app/portainer/components/accessControlForm/porAccessControlForm.html b/app/portainer/components/accessControlForm/porAccessControlForm.html index 6aed1af4c..9d518313f 100644 --- a/app/portainer/components/accessControlForm/porAccessControlForm.html +++ b/app/portainer/components/accessControlForm/porAccessControlForm.html @@ -15,57 +15,16 @@
-
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
+ +
{ - ctrl.formData.AccessControlEnabled = enable; - if (enable) { - ctrl.formData.Ownership = isAdmin ? RCO.ADMINISTRATORS : RCO.PRIVATE; - } - }); - }; + function onChangeEnablement(enable) { + const isAdmin = Authentication.isAdmin(); + onChange({ AccessControlEnabled: enable, Ownership: isAdmin ? RCO.ADMINISTRATORS : RCO.PRIVATE }); } }, ]); diff --git a/app/portainer/components/endpointSecurity/porEndpointSecurity.html b/app/portainer/components/endpointSecurity/porEndpointSecurity.html index bb241d959..b76955bd4 100644 --- a/app/portainer/components/endpointSecurity/porEndpointSecurity.html +++ b/app/portainer/components/endpointSecurity/porEndpointSecurity.html @@ -22,55 +22,16 @@
-
- -
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
- + + +
Required TLS files
diff --git a/app/portainer/components/endpointSecurity/porEndpointSecurityController.js b/app/portainer/components/endpointSecurity/porEndpointSecurityController.js index e9c220e44..32facf119 100644 --- a/app/portainer/components/endpointSecurity/porEndpointSecurityController.js +++ b/app/portainer/components/endpointSecurity/porEndpointSecurityController.js @@ -1,13 +1,30 @@ +import { tlsOptions } from './tls-options'; + angular.module('portainer.app').controller('porEndpointSecurityController', [ '$scope', function ($scope) { var ctrl = this; - ctrl.onToggleTLS = function (newValue) { + this.tlsOptions = tlsOptions; + + function onChange(values) { $scope.$evalAsync(() => { - ctrl.formData.TLS = newValue; + ctrl.formData = { + ...ctrl.formData, + ...values, + }; }); - }; + } + + ctrl.onChangeTLSMode = onChangeTLSMode; + function onChangeTLSMode(mode) { + onChange({ TLSMode: mode }); + } + + ctrl.onToggleTLS = onToggleTLS; + function onToggleTLS(newValue) { + onChange({ TLS: newValue }); + } this.$onInit = $onInit; function $onInit() { diff --git a/app/portainer/components/endpointSecurity/tls-options.tsx b/app/portainer/components/endpointSecurity/tls-options.tsx new file mode 100644 index 000000000..5b6ea16e4 --- /dev/null +++ b/app/portainer/components/endpointSecurity/tls-options.tsx @@ -0,0 +1,38 @@ +import { Shield } from 'lucide-react'; + +import { BoxSelectorOption } from '@@/BoxSelector'; + +export const tlsOptions: ReadonlyArray> = [ + { + id: 'tls_client_ca', + value: 'tls_client_ca', + icon: Shield, + iconType: 'badge', + label: 'TLS with server and client verification', + description: 'Use client certificates and server verification', + }, + { + id: 'tls_client_noca', + value: 'tls_client_noca', + icon: Shield, + iconType: 'badge', + label: 'TLS with client verification only', + description: 'Use client certificates without server verification', + }, + { + id: 'tls_ca', + value: 'tls_ca', + icon: Shield, + iconType: 'badge', + label: 'TLS with server verification only', + description: 'Only verify the server certificate', + }, + { + id: 'tls_only', + value: 'tls_only', + icon: Shield, + iconType: 'badge', + label: 'TLS only', + description: 'No server/client verification', + }, +] as const; diff --git a/app/portainer/react/components/index.ts b/app/portainer/react/components/index.ts index 17f2b1dbe..b176fbd2a 100644 --- a/app/portainer/react/components/index.ts +++ b/app/portainer/react/components/index.ts @@ -18,6 +18,7 @@ import { InternalAuth } from '@/react/portainer/settings/AuthenticationView/Inte import { PorAccessControlFormTeamSelector } from '@/react/portainer/access-control/PorAccessControlForm/TeamsSelector'; import { PorAccessControlFormUserSelector } from '@/react/portainer/access-control/PorAccessControlForm/UsersSelector'; import { PorAccessManagementUsersSelector } from '@/react/portainer/access-control/AccessManagement/PorAccessManagementUsersSelector'; +import { AccessTypeSelector } from '@/react/portainer/access-control/EditDetails/AccessTypeSelector'; import { PageHeader } from '@@/PageHeader'; import { TagSelector } from '@@/TagSelector'; @@ -66,6 +67,17 @@ export const componentsModule = angular 'tagButton', r2a(TagButton, ['value', 'label', 'title', 'onRemove']) ) + .component( + 'accessTypeSelector', + r2a(AccessTypeSelector, [ + 'isAdmin', + 'isPublicVisible', + 'name', + 'onChange', + 'value', + 'teams', + ]) + ) .component( 'portainerTooltip', r2a(Tooltip, ['message', 'position', 'className', 'setHtmlMessage']) diff --git a/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateView.html b/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateView.html index 9a5595972..cf4b77ce2 100644 --- a/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateView.html +++ b/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateView.html @@ -15,42 +15,15 @@
Build method
-
-
-
-
- - -
-
- - -
-
- - -
-
-
-
+ +
diff --git a/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateViewController.js b/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateViewController.js index e0b184cbc..4bc6548e2 100644 --- a/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateViewController.js +++ b/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateViewController.js @@ -3,14 +3,29 @@ import { AccessControlFormData } from 'Portainer/components/accessControlForm/po import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants'; import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils'; import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; +import { editor, upload, git } from '@@/BoxSelector/common-options/build-methods'; class CreateCustomTemplateViewController { /* @ngInject */ - constructor($async, $state, $window, Authentication, ModalService, CustomTemplateService, FormValidator, Notifications, ResourceControlService, StackService, StateManager) { + constructor( + $async, + $state, + $scope, + $window, + Authentication, + ModalService, + CustomTemplateService, + FormValidator, + Notifications, + ResourceControlService, + StackService, + StateManager + ) { Object.assign(this, { $async, $state, $window, + $scope, Authentication, ModalService, CustomTemplateService, @@ -21,6 +36,8 @@ class CreateCustomTemplateViewController { StateManager, }); + this.buildMethods = [editor, upload, git]; + this.isTemplateVariablesEnabled = isBE; this.formValues = { @@ -85,10 +102,13 @@ class CreateCustomTemplateViewController { return this.$async(this.createCustomTemplateAsync); } - onChangeMethod() { - this.formValues.FileContent = ''; - this.formValues.Variables = []; - this.selectedTemplate = null; + onChangeMethod(method) { + return this.$scope.$evalAsync(() => { + this.formValues.FileContent = ''; + this.formValues.Variables = []; + this.selectedTemplate = null; + this.state.Method = method; + }); } async createCustomTemplateAsync() { diff --git a/app/portainer/views/devices/profiles/add/addProfile.html b/app/portainer/views/devices/profiles/add/addProfile.html index e04552a99..e625c60ab 100644 --- a/app/portainer/views/devices/profiles/add/addProfile.html +++ b/app/portainer/views/devices/profiles/add/addProfile.html @@ -16,22 +16,8 @@
Profile configuration
-
-
-
-
- - -
-
-
-
+ +
Profile configuration
-
-
-
-
-
- - -
-
-
-
+ + +
- -
-
-
-
- - -
-
- - -
-
-
-
- + + +
You can upload a backup file from your computer. diff --git a/app/portainer/views/init/admin/initAdminController.js b/app/portainer/views/init/admin/initAdminController.js index 4783c10ab..fdc0304aa 100644 --- a/app/portainer/views/init/admin/initAdminController.js +++ b/app/portainer/views/init/admin/initAdminController.js @@ -1,4 +1,5 @@ import { getEnvironments } from '@/react/portainer/environments/environment.service'; +import { restoreOptions } from './restore-options'; angular.module('portainer.app').controller('InitAdminController', [ '$scope', @@ -11,6 +12,8 @@ angular.module('portainer.app').controller('InitAdminController', [ 'BackupService', 'StatusService', function ($scope, $state, Notifications, Authentication, StateManager, SettingsService, UserService, BackupService, StatusService) { + $scope.restoreOptions = restoreOptions; + $scope.uploadBackup = uploadBackup; $scope.logo = StateManager.getState().application.logo; @@ -35,6 +38,13 @@ angular.module('portainer.app').controller('InitAdminController', [ $scope.state.showRestorePortainer = !$scope.state.showRestorePortainer; }; + $scope.onChangeRestoreType = onChangeRestoreType; + function onChangeRestoreType(value) { + $scope.$evalAsync(() => { + $scope.formValues.restoreFormType = value; + }); + } + $scope.createAdminUser = function () { var username = $scope.formValues.Username; var password = $scope.formValues.Password; diff --git a/app/portainer/views/init/admin/restore-options.tsx b/app/portainer/views/init/admin/restore-options.tsx new file mode 100644 index 000000000..3b0e87396 --- /dev/null +++ b/app/portainer/views/init/admin/restore-options.tsx @@ -0,0 +1,23 @@ +import { Download, Upload } from 'lucide-react'; + +import { FeatureId } from '@/react/portainer/feature-flags/enums'; + +import { BoxSelectorOption } from '@@/BoxSelector'; + +export const restoreOptions: ReadonlyArray> = [ + { + id: 'restore_file', + value: 'file', + icon: Upload, + iconType: 'badge', + label: 'Upload backup file', + }, + { + id: 'restore_s3', + value: 's3', + icon: Download, + iconType: 'badge', + label: 'Retrieve from S3', + feature: FeatureId.S3_RESTORE, + }, +] as const; diff --git a/app/portainer/views/settings/settings.html b/app/portainer/views/settings/settings.html index 819e21548..fad64ef59 100644 --- a/app/portainer/views/settings/settings.html +++ b/app/portainer/views/settings/settings.html @@ -278,7 +278,7 @@
Backup configuration
- +
diff --git a/app/portainer/views/stacks/create/createStackController.js b/app/portainer/views/stacks/create/createStackController.js index 9a17f1bdf..b6d9703ae 100644 --- a/app/portainer/views/stacks/create/createStackController.js +++ b/app/portainer/views/stacks/create/createStackController.js @@ -7,6 +7,7 @@ import { RepositoryMechanismTypes } from '@/kubernetes/models/deploy'; import { FeatureId } from '@/react/portainer/feature-flags/enums'; import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; import { renderTemplate } from '@/react/portainer/custom-templates/components/utils'; +import { editor, upload, git, customTemplate } from '@@/BoxSelector/common-options/build-methods'; angular .module('portainer.app') @@ -37,6 +38,7 @@ angular $scope.isTemplateVariablesEnabled = isBE; $scope.buildAnalyticsProperties = buildAnalyticsProperties; $scope.stackWebhookFeature = FeatureId.STACK_WEBHOOK; + $scope.buildMethods = [editor, upload, git, customTemplate]; $scope.STACK_NAME_VALIDATION_REGEX = STACK_NAME_VALIDATION_REGEX; $scope.isAdmin = Authentication.isAdmin(); @@ -83,6 +85,13 @@ angular }); $scope.onChangeFormValues = onChangeFormValues; + $scope.onBuildMethodChange = onBuildMethodChange; + + function onBuildMethodChange(value) { + $scope.$evalAsync(() => { + $scope.state.Method = value; + }); + } $scope.onEnableWebhookChange = function (enable) { $scope.$evalAsync(() => { diff --git a/app/portainer/views/stacks/create/createstack.html b/app/portainer/views/stacks/create/createstack.html index 56e449d63..a64828a87 100644 --- a/app/portainer/views/stacks/create/createstack.html +++ b/app/portainer/views/stacks/create/createstack.html @@ -49,52 +49,9 @@
Build method
-
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
+ + + diff --git a/app/react/components/BoxSelector/BoxOption.module.css b/app/react/components/BoxSelector/BoxOption.module.css new file mode 100644 index 000000000..12a9929ea --- /dev/null +++ b/app/react/components/BoxSelector/BoxOption.module.css @@ -0,0 +1,52 @@ +.root input { + display: none; +} + +.root label { + @apply border border-solid; + @apply bg-gray-2 border-gray-5 text-black; + @apply th-dark:bg-gray-iron-10 th-dark:border-gray-neutral-8 th-dark:text-white; + @apply th-highcontrast:text-white; + + font-weight: normal; + font-size: 12px; + display: block; + border-radius: 8px; + padding: 15px; + text-align: left; + box-shadow: var(--shadow-boxselector-color); + position: relative; + + text-align: left; + height: 100%; +} + +/* not disabled */ +.root input:not(:disabled) ~ label { + @apply bg-gray-2; + @apply th-dark:bg-gray-iron-10; + @apply th-highcontrast:bg-black; + + box-shadow: none; + cursor: pointer; +} + +/* disabled */ +.root input:disabled + label { + @apply bg-white; + @apply th-dark:bg-gray-7; + @apply th-highcontrast:bg-black; + filter: opacity(0.3) grayscale(1); + + cursor: not-allowed; +} + +.root input:checked + label { + @apply bg-blue-2 border-blue-6; + @apply th-dark:bg-blue-10 th-dark:border-blue-7; + @apply th-highcontrast:bg-blue-10 th-highcontrast:border-blue-7; + + border-radius: 8px; + padding: 15px; + box-shadow: none; +} diff --git a/app/react/components/BoxSelector/BoxOption.tsx b/app/react/components/BoxSelector/BoxOption.tsx index 2abc23286..a380e5229 100644 --- a/app/react/components/BoxSelector/BoxOption.tsx +++ b/app/react/components/BoxSelector/BoxOption.tsx @@ -1,56 +1,73 @@ import clsx from 'clsx'; import { PropsWithChildren } from 'react'; +import type { Icon } from 'lucide-react'; import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren'; -import './BoxSelectorItem.css'; +import styles from './BoxOption.module.css'; +import { BoxSelectorOption, Value } from './types'; -import { BoxSelectorOption } from './types'; - -interface Props { +interface Props { radioName: string; option: BoxSelectorOption; - onChange?(value: T): void; - selectedValue: T; + onSelect?(value: T): void; + isSelected(value: T): boolean; disabled?: boolean; tooltip?: string; className?: string; type?: 'radio' | 'checkbox'; + checkIcon: Icon; } -export function BoxOption({ +export function BoxOption({ radioName, option, - onChange = () => {}, - selectedValue, + onSelect = () => {}, + isSelected, disabled, tooltip, className, type = 'radio', children, + checkIcon: Check, }: PropsWithChildren>) { - const BoxOption = ( -
+ const selected = isSelected(option.value); + + const item = ( +
onChange(option.value)} + onChange={() => onSelect(option.value)} />
); if (tooltip) { - return ( - {BoxOption} - ); + return {item}; } - return BoxOption; + + return item; } diff --git a/app/react/components/BoxSelector/BoxSelector.css b/app/react/components/BoxSelector/BoxSelector.css deleted file mode 100644 index 03657e86a..000000000 --- a/app/react/components/BoxSelector/BoxSelector.css +++ /dev/null @@ -1,14 +0,0 @@ -.boxselector_wrapper { - display: flex; - flex-flow: row wrap; - gap: 10px; - overflow: hidden !important; - margin-bottom: 5px; - margin-top: 5px; -} - -@media only screen and (max-width: 700px) { - .boxselector_wrapper { - flex-direction: column; - } -} diff --git a/app/react/components/BoxSelector/BoxSelector.module.css b/app/react/components/BoxSelector/BoxSelector.module.css index e8b8ce3d3..2218f3a57 100644 --- a/app/react/components/BoxSelector/BoxSelector.module.css +++ b/app/react/components/BoxSelector/BoxSelector.module.css @@ -1,4 +1,16 @@ .root { width: 100%; overflow: auto; + display: flex; + flex-flow: row wrap; + gap: 10px; + overflow: hidden !important; + margin-bottom: 5px; + margin-top: 5px; +} + +@media only screen and (max-width: 700px) { + .root { + flex-direction: column; + } } diff --git a/app/react/components/BoxSelector/BoxSelector.stories.tsx b/app/react/components/BoxSelector/BoxSelector.stories.tsx index ece9e0567..fde02f495 100644 --- a/app/react/components/BoxSelector/BoxSelector.stories.tsx +++ b/app/react/components/BoxSelector/BoxSelector.stories.tsx @@ -1,7 +1,8 @@ import { Meta } from '@storybook/react'; import { useState } from 'react'; -import { User } from 'lucide-react'; +import { Anchor, Briefcase } from 'lucide-react'; +import Docker from '@/assets/ico/vendor/docker.svg?c'; import { init as initFeatureService } from '@/react/portainer/feature-flags/feature-flags.service'; import { Edition, FeatureId } from '@/react/portainer/feature-flags/enums'; @@ -22,14 +23,16 @@ function Example() { const options: BoxSelectorOption[] = [ { description: 'description 1', - icon: User, + icon: Anchor, + iconType: 'badge', id: '1', value: 3, label: 'option 1', }, { description: 'description 2', - icon: User, + icon: Briefcase, + iconType: 'badge', id: '2', value: 4, label: 'option 2', @@ -54,14 +57,16 @@ function LimitedFeature() { const options: BoxSelectorOption[] = [ { description: 'description 1', - icon: User, + icon: Anchor, + iconType: 'badge', id: '1', value: 3, label: 'option 1', }, { description: 'description 2', - icon: User, + icon: Briefcase, + iconType: 'badge', id: '2', value: 4, label: 'option 2', @@ -81,6 +86,85 @@ function LimitedFeature() { ); } -// regular example +export function MultiSelect() { + const [value, setValue] = useState([3]); + const options: BoxSelectorOption[] = [ + { + description: 'description 1', + icon: Anchor, + iconType: 'badge', + id: '1', + value: 1, + label: 'option 1', + }, + { + description: 'description 2', + icon: Briefcase, + iconType: 'badge', + id: '2', + value: 2, + label: 'option 2', + }, + { + description: 'description 3', + icon: Docker, + id: '3', + value: 3, + label: 'option 2', + }, + ]; -// story with limited feature + return ( + { + setValue(value); + }} + value={value} + options={options} + /> + ); +} + +export function SlimMultiSelect() { + const [value, setValue] = useState([3]); + const options: BoxSelectorOption[] = [ + { + description: 'description 1', + icon: Anchor, + iconType: 'badge', + id: '1', + value: 1, + label: 'option 1', + }, + { + description: 'description 2', + icon: Briefcase, + iconType: 'badge', + id: '2', + value: 2, + label: 'option 2', + }, + { + description: 'description 3', + icon: Docker, + id: '3', + value: 3, + label: 'option 2', + }, + ]; + + return ( + { + setValue(value); + }} + value={value} + options={options} + slim + /> + ); +} diff --git a/app/react/components/BoxSelector/BoxSelector.test.tsx b/app/react/components/BoxSelector/BoxSelector.test.tsx index a932c71e6..85bac2dc1 100644 --- a/app/react/components/BoxSelector/BoxSelector.test.tsx +++ b/app/react/components/BoxSelector/BoxSelector.test.tsx @@ -2,21 +2,26 @@ import { Rocket } from 'lucide-react'; import { render, fireEvent } from '@/react-tools/test-utils'; -import { BoxSelector, Props } from './BoxSelector'; -import { BoxSelectorOption } from './types'; +import { BoxSelector } from './BoxSelector'; +import { BoxSelectorOption, Value } from './types'; -function renderDefault({ +function renderDefault({ options = [], onChange = () => {}, radioName = 'radio', value, -}: Partial> = {}) { +}: { + options?: BoxSelectorOption[]; + onChange?: (value: T) => void; + radioName?: string; + value: T; +}) { return render( ); } diff --git a/app/react/components/BoxSelector/BoxSelector.tsx b/app/react/components/BoxSelector/BoxSelector.tsx index 1e3d330f6..3580076f0 100644 --- a/app/react/components/BoxSelector/BoxSelector.tsx +++ b/app/react/components/BoxSelector/BoxSelector.tsx @@ -1,30 +1,39 @@ -import clsx from 'clsx'; +import { Check, Minus } from 'lucide-react'; -import './BoxSelector.css'; import styles from './BoxSelector.module.css'; import { BoxSelectorItem } from './BoxSelectorItem'; -import { BoxSelectorOption } from './types'; +import { BoxSelectorOption, Value } from './types'; -export interface Props { - radioName: string; - value: T; - onChange(value: T, limitedToBE: boolean): void; - options: BoxSelectorOption[]; +interface IsMultiProps { + isMulti: true; + value: T[]; + onChange(value: T[], limitedToBE: boolean): void; } -export function BoxSelector({ +interface SingleProps { + isMulti?: never; + value: T; + onChange(value: T, limitedToBE: boolean): void; +} + +type Union = IsMultiProps | SingleProps; + +export type Props = Union & { + radioName: string; + options: ReadonlyArray> | Array>; + slim?: boolean; +}; + +export function BoxSelector({ radioName, - value, options, - onChange, + slim = false, + ...props }: Props) { return (
-
+
{options .filter((option) => !option.hide) .map((option) => ( @@ -32,14 +41,41 @@ export function BoxSelector({ key={option.id} radioName={radioName} option={option} - onChange={onChange} - selectedValue={value} + onSelect={handleSelect} disabled={option.disabled && option.disabled()} tooltip={option.tooltip && option.tooltip()} + type={props.isMulti ? 'checkbox' : 'radio'} + isSelected={isSelected} + slim={slim} + checkIcon={props.isMulti ? Minus : Check} /> ))}
); + + function handleSelect(optionValue: T, limitedToBE: boolean) { + if (props.isMulti) { + const newValue = isSelected(optionValue) + ? props.value.filter((v) => v !== optionValue) + : [...props.value, optionValue]; + props.onChange(newValue, limitedToBE); + return; + } + + if (isSelected(optionValue)) { + return; + } + + props.onChange(optionValue, limitedToBE); + } + + function isSelected(optionValue: T) { + if (props.isMulti) { + return props.value.includes(optionValue); + } + + return props.value === optionValue; + } } diff --git a/app/react/components/BoxSelector/BoxSelectorItem.css b/app/react/components/BoxSelector/BoxSelectorItem.css deleted file mode 100644 index 473a2c555..000000000 --- a/app/react/components/BoxSelector/BoxSelectorItem.css +++ /dev/null @@ -1,129 +0,0 @@ -.boxselector_wrapper > div, -.box-selector-item { - flex: 1; -} - -.boxselector_wrapper .boxselector_header, -.box-selector-item .boxselector_header { - font-size: 18px; - margin-right: 20px; - margin-bottom: 5px; - font-weight: bold; - user-select: none; - color: var(--text-boxselector-header); -} - -.boxselector_wrapper input[type='radio'], -.box-selector-item input[type='radio'] { - display: none; -} - -.boxselector_wrapper label, -.box-selector-item label { - @apply border border-solid; - @apply bg-gray-2 border-gray-5 text-black; - @apply th-dark:bg-gray-iron-10 th-dark:border-gray-neutral-8 th-dark:text-white; - - font-weight: normal; - font-size: 12px; - display: block; - border-radius: 8px; - padding: 15px; - text-align: left; - box-shadow: var(--shadow-boxselector-color); - position: relative; - - text-align: left; - height: 100%; -} - -/* not disabled */ -.boxselector_wrapper input[type='radio']:not(:disabled) ~ label, -.box-selector-item input[type='radio']:not(:disabled) ~ label { - background-color: var(--bg-boxselector-color); - - box-shadow: none; - cursor: pointer; -} - -/* disabled */ -.box-selector-item input:disabled + label, -.boxselector_wrapper label.boxselector_disabled { - @apply !bg-white; - @apply th-dark:!bg-gray-7; - @apply th-highcontrast:!bg-black; - filter: opacity(0.3) grayscale(1); - - cursor: not-allowed; - pointer-events: none; -} - -.boxselector_wrapper label.boxselector_disabled a { - cursor: pointer; - pointer-events: auto; -} - -/* checked */ -.boxselector_wrapper input[type='radio']:checked + label, -.box-selector-item input[type='radio']:checked + label { - @apply bg-blue-2 border-blue-6; - @apply th-dark:bg-blue-10 th-dark:border-blue-7; - @apply th-highcontrast:bg-blue-10 th-highcontrast:border-blue-7; - - background-image: url(../../../assets/ico/checked.svg); - background-repeat: no-repeat; - background-position: right 15px top 15px; - - border-radius: 8px; - padding: 15px; - box-shadow: none; -} - -@media only screen and (max-width: 700px) { - .boxselector_wrapper { - flex-direction: column; - } -} - -.box-selector-item.limited.business label, -.box-selector-item.limited.business input[type='radio'] + label { - @apply border-warning-7 bg-warning-1 text-black; - @apply th-dark:bg-warning-8 th-dark:bg-opacity-10; - @apply th-highcontrast:bg-warning-8 th-highcontrast:bg-opacity-10; -} - -.boxselector_img_container { - width: 100%; - margin-bottom: 20px; - text-align: left; - - line-height: 90px; - margin-bottom: 0; -} - -.boxselector_icon, -.boxselector_icon img { - font-size: 90px; -} - -.boxselector_icon > svg { - margin-left: -5px; -} - -.boxselector_header pr-icon { - margin-right: 5px; -} - -.boxselector_content { - padding-left: 20px; -} - -.boxselector_img_container { - line-height: 90px; - margin-bottom: 0; -} - -.box-selector-item p { - margin-bottom: 0; - color: var(--text-boxselector-header); -} diff --git a/app/react/components/BoxSelector/BoxSelectorItem.module.css b/app/react/components/BoxSelector/BoxSelectorItem.module.css new file mode 100644 index 000000000..1681a864b --- /dev/null +++ b/app/react/components/BoxSelector/BoxSelectorItem.module.css @@ -0,0 +1,50 @@ +.box-selector-item { + flex: 1; +} + +.box-selector-item .header { + @apply text-black; + @apply th-dark:text-white; + @apply th-highcontrast:text-white; + + font-size: 18px; + font-weight: bold; + user-select: none; +} + +.image-container { + margin-bottom: 20px; + text-align: left; + + margin-bottom: 0; +} + +.icon, +.icon img { + font-size: 90px; +} + +.slim .icon { + font-size: 56px; +} + +.icon > svg { + margin-left: -5px; +} + +.header pr-icon { + margin-right: 5px; +} + +.content { + padding-left: 20px; +} + +.box-selector-item.limited.business label, +.box-selector-item.limited.business input:checked + label { + @apply border-warning-7 bg-warning-1 text-black; + @apply th-dark:bg-warning-8 th-dark:bg-opacity-10; + @apply th-highcontrast:bg-warning-8 th-highcontrast:bg-opacity-10; + + filter: none; +} diff --git a/app/react/components/BoxSelector/BoxSelectorItem.stories.tsx b/app/react/components/BoxSelector/BoxSelectorItem.stories.tsx index 937cb9342..3b6c5e8a7 100644 --- a/app/react/components/BoxSelector/BoxSelectorItem.stories.tsx +++ b/app/react/components/BoxSelector/BoxSelectorItem.stories.tsx @@ -1,8 +1,10 @@ import { Meta } from '@storybook/react'; -import { User } from 'lucide-react'; +import { ReactNode } from 'react'; +import { Briefcase } from 'lucide-react'; import { init as initFeatureService } from '@/react/portainer/feature-flags/feature-flags.service'; import { Edition, FeatureId } from '@/react/portainer/feature-flags/enums'; +import Docker from '@/assets/ico/vendor/docker.svg?c'; import { IconProps } from '@@/Icon'; @@ -14,7 +16,7 @@ const meta: Meta = { args: { selected: false, description: 'description', - icon: User, + icon: Briefcase, label: 'label', }, }; @@ -30,7 +32,7 @@ interface ExampleProps { } function Template({ - selected, + selected = false, description = 'description', icon, label = 'label', @@ -48,10 +50,10 @@ function Template({ return (
{}} + onSelect={() => {}} option={option} radioName="radio" - selectedValue={selected ? option.value : 0} + isSelected={() => selected} />
); @@ -78,3 +80,36 @@ export function SelectedLimitedFeatureItem() { return