refactor(ui/box-selector): replace all selectors [EE-3856] (#7902)

pull/8454/head
Chaim Lev-Ari 2023-02-07 09:03:57 +05:30 committed by GitHub
parent c9253319d9
commit 2dddc1c6b9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
80 changed files with 1267 additions and 1011 deletions

View File

@ -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: {

View File

@ -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);

View File

@ -1,5 +0,0 @@
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.21426" y="0.5" width="15" height="15" rx="7.5" fill="#0086C9"/>
<path d="M12.0474 5.5L7.4641 10.0833L5.38077 8" stroke="white" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="1.21426" y="0.5" width="15" height="15" rx="7.5" stroke="#0086C9"/>
</svg>

Before

Width:  |  Height:  |  Size: 390 B

View File

@ -1,39 +1,14 @@
<div>
<div class="col-sm-12 form-section-title"> Macvlan configuration </div>
<!-- selector -->
<div class="form-group">
<span class="col-sm-12 text-muted small vertical-center">
<pr-icon icon="'alert-circle'" mode="'primary'"></pr-icon>
To create a MACVLAN network you need to create a configuration, then create the network from this configuration.
</span>
</div>
<div class="col-sm-12">
<div class="form-group">
<div class="boxselector_wrapper">
<div>
<input type="radio" id="network_config" ng-model="$ctrl.data.Scope" value="local" />
<label for="network_config">
<div class="boxselector_header">
<pr-icon icon="'sliders'"></pr-icon>
Configuration
</div>
<p>I want to configure a network before deploying it</p>
</label>
</div>
<div>
<input type="radio" id="network_deploy" ng-model="$ctrl.data.Scope" value="swarm" ng-disabled="$ctrl.availableNetworks.length === 0" />
<label for="network_deploy" ng-class="$ctrl.availableNetworks.length === 0 ? 'boxselector_disabled' : ''">
<div class="boxselector_header">
<pr-icon icon="'share-2'"></pr-icon>
Creation
</div>
<p>I want to create a network from a configuration</p>
</label>
</div>
</div>
</div>
</div>
<!-- !selector -->
<box-selector slim="true" options="$ctrl.options" value="$ctrl.data.Scope" on-change="($ctrl.onChangeScope)"></box-selector>
<ng-form name="macvlanConfigurationForm">
<!-- configuration-inputs -->

View File

@ -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');

View File

@ -0,0 +1,27 @@
import { Share2, Sliders } from 'lucide-react';
import { BoxSelectorOption } from '@@/BoxSelector';
export function getOptions(
hasNetworks: boolean
): ReadonlyArray<BoxSelectorOption<string>> {
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;
}

View File

@ -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;

View File

@ -69,46 +69,9 @@
</div>
</div>
<!-- !name-input-list -->
<!-- build-method -->
<div class="col-sm-12 form-section-title"> Build method </div>
<div class="form-group"></div>
<div class="form-group">
<div class="col-sm-12">
<div class="boxselector_wrapper">
<div>
<input type="radio" id="method_editor" ng-model="state.BuildType" value="editor" ng-click="toggleEditor()" />
<label for="method_editor">
<div class="boxselector_header vertical-center">
<pr-icon icon="'edit'"></pr-icon>
Web editor
</div>
<p>Use our Web editor</p>
</label>
</div>
<div>
<input type="radio" id="method_upload" ng-model="state.BuildType" value="upload" ng-click="saveEditorContent()" />
<label for="method_upload">
<div class="boxselector_header vertical-center">
<pr-icon icon="'upload'"></pr-icon>
Upload
</div>
<p>Upload a tarball or a Dockerfile from your computer</p>
</label>
</div>
<div>
<input type="radio" id="method_url" ng-model="state.BuildType" value="url" ng-click="saveEditorContent()" />
<label for="method_url">
<div class="boxselector_header vertical-center">
<pr-icon icon="'globe'"></pr-icon>
URL
</div>
<p>Specify a URL to a file</p>
</label>
</div>
</div>
</div>
</div>
<!-- !build-method -->
<box-selector options="options" slim="true" value="state.BuildType" on-change="(onChangeBuildType)"></box-selector>
<!-- web-editor -->
<div ng-show="state.BuildType === 'editor'">
<div class="col-sm-12 form-section-title"> Web editor </div>

View File

@ -0,0 +1,7 @@
import {
editor,
upload,
url,
} from '@@/BoxSelector/common-options/build-methods';
export const options = [editor, upload, url] as const;

View File

@ -0,0 +1,22 @@
import { Calendar, Edit } from 'lucide-react';
import { BoxSelectorOption } from '@@/BoxSelector';
export const cronMethodOptions: ReadonlyArray<BoxSelectorOption<string>> = [
{
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;

View File

@ -34,33 +34,9 @@
<!-- cron-input -->
<!-- edge-job-method-select -->
<div class="col-sm-12 form-section-title"> Edge job configuration </div>
<div class="form-group"></div>
<div class="form-group">
<div class="col-sm-12">
<div class="boxselector_wrapper !mt-0">
<div>
<input type="radio" id="config_basic" ng-model="$ctrl.formValues.cronMethod" value="basic" />
<label for="config_basic">
<div class="boxselector_header vertical-center">
<pr-icon icon="'calendar'"></pr-icon>
Basic configuration
</div>
<p>Select date from calendar</p>
</label>
</div>
<div>
<input type="radio" id="config_advanced" ng-model="$ctrl.formValues.cronMethod" value="advanced" />
<label for="config_advanced">
<div class="boxselector_header vertical-center">
<pr-icon icon="'edit'"></pr-icon>
Advanced configuration
</div>
<p>Write your own cron rule</p>
</label>
</div>
</div>
</div>
</div>
<box-selector slim="true" radio-name="'configuration'" value="$ctrl.formValues.cronMethod" options="$ctrl.cronMethods" on-change="($ctrl.onCronMethodChange)"></box-selector>
<!-- !edge-job-method-select -->
<!-- basic-edge-job -->
<div ng-if="$ctrl.formValues.cronMethod === 'basic'">
@ -154,34 +130,10 @@
<!-- execution-method -->
<div ng-if="!$ctrl.model.Id">
<div class="col-sm-12 form-section-title"> Job content </div>
<div class="form-group">
<div class="col-sm-12">
<div class="boxselector_wrapper">
<div>
<input type="radio" id="method_editor" ng-model="$ctrl.formValues.method" value="editor" />
<label for="method_editor">
<div class="boxselector_header vertical-center">
<pr-icon icon="'edit'"></pr-icon>
Web editor
</div>
<p>Use our Web editor</p>
</label>
</div>
<div>
<input type="radio" id="method_upload" ng-model="$ctrl.formValues.method" value="upload" />
<label for="method_upload">
<div class="boxselector_header vertical-center">
<pr-icon icon="'upload'"></pr-icon>
Upload
</div>
<p>Upload from your computer</p>
</label>
</div>
</div>
</div>
</div>
<box-selector value="$ctrl.formValues.method" options="$ctrl.buildMethods" radio-name="buildMethod" on-change="($ctrl.onBuildMethodChange)" slim="true"></box-selector>
</div>
<!-- !execution-method -->
<!-- web-editor -->
<div ng-show="$ctrl.formValues.method === 'editor'">
<div class="col-sm-12 form-section-title"> Web editor </div>

View File

@ -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) {

View File

@ -0,0 +1,22 @@
import { List, Tag } from 'lucide-react';
import { BoxSelectorOption } from '@@/BoxSelector';
export const groupTypeOptions: ReadonlyArray<BoxSelectorOption<boolean>> = [
{
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;

View File

@ -24,32 +24,8 @@
</div>
<div class="col-sm-12 form-section-title"> Group type </div>
<div class="form-group">
<div class="col-sm-12">
<div class="boxselector_wrapper">
<div class="boxselector">
<input type="radio" id="static-group" ng-model="$ctrl.model.Dynamic" ng-value="false" ng-checked="!$ctrl.model.Dynamic" />
<label for="static-group">
<div class="boxselector_header vertical-center">
<pr-icon icon="'list'"></pr-icon>
Static
</div>
<p>Manually select Edge environments</p>
</label>
</div>
<div class="boxselector">
<input type="radio" id="dynamic-group" ng-model="$ctrl.model.Dynamic" ng-value="true" ng-checked="$ctrl.model.Dynamic" />
<label for="dynamic-group">
<div class="boxselector_header vertical-center">
<pr-icon icon="'tag'"></pr-icon>
Dynamic
</div>
<p>Automatically associate environments via tags</p>
</label>
</div>
</div>
</div>
</div>
<box-selector slim="true" value="$ctrl.model.Dynamic" on-change="($ctrl.onChangeDynamic)" options="$ctrl.groupTypeOptions"></box-selector>
<!-- StaticGroup -->
<div ng-if="!$ctrl.model.Dynamic">
@ -78,32 +54,8 @@
<!-- DynamicGroup -->
<div ng-if="$ctrl.model.Dynamic">
<div class="col-sm-12 form-section-title"> Tags </div>
<div class="form-group">
<div class="col-sm-12">
<div class="boxselector_wrapper">
<div class="boxselector">
<input type="radio" id="or-selector" ng-model="$ctrl.model.PartialMatch" ng-value="true" ng-checked="$ctrl.model.PartialMatch" />
<label for="or-selector">
<div class="boxselector_header vertical-center">
<pr-icon icon="'tag'"></pr-icon>
Partial match
</div>
<p>Associate any environment matching at least one of the selected tags</p>
</label>
</div>
<div class="boxselector">
<input type="radio" id="and-selector" ng-model="$ctrl.model.PartialMatch" ng-value="false" ng-checked="!$ctrl.model.PartialMatch" />
<label for="and-selector">
<div class="boxselector_header vertical-center">
<pr-icon icon="'tag'"></pr-icon>
Full match
</div>
<p>Associate any environment matching all of the selected tags</p>
</label>
</div>
</div>
</div>
</div>
<box-selector slim="true" value="$ctrl.model.PartialMatch" on-change="($ctrl.onChangePartialMatch)" options="$ctrl.tagOptions"></box-selector>
<tag-selector ng-if="$ctrl.model.TagIds" value="$ctrl.model.TagIds" on-change="($ctrl.onChangeTags)"> </tag-selector>

View File

@ -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];

View File

@ -0,0 +1,23 @@
import { Tag } from 'lucide-react';
import { BoxSelectorOption } from '@@/BoxSelector';
export const tagOptions: ReadonlyArray<BoxSelectorOption<boolean>> = [
{
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',
},
];

View File

@ -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;

View File

@ -1,5 +1,5 @@
<div class="col-sm-12 form-section-title"> Build method </div>
<box-selector radio-name="'method'" value="$ctrl.state.Method" options="$ctrl.methodOptions" on-change="($ctrl.onChangeMethod)"></box-selector>
<box-selector slim="true" radio-name="'method'" value="$ctrl.state.Method" options="$ctrl.methodOptions" on-change="($ctrl.onChangeMethod)"></box-selector>
<web-editor-form
ng-if="$ctrl.state.Method === 'editor'"

View File

@ -10,7 +10,7 @@
</div>
<div class="col-sm-12 form-section-title"> Build method </div>
<box-selector radio-name="'method'" value="$ctrl.state.Method" options="$ctrl.methodOptions" on-change="($ctrl.onChangeMethod)"></box-selector>
<box-selector slim="true" radio-name="'method'" value="$ctrl.state.Method" options="$ctrl.methodOptions" on-change="($ctrl.onChangeMethod)"></box-selector>
<web-editor-form
ng-if="$ctrl.state.Method === 'editor'"

View File

@ -42,8 +42,8 @@
<div
id="page-wrapper"
ng-class="{
open: isSidebarOpen() && ['portainer.auth', 'portainer.init.admin', 'portainer.init.endpoint'].indexOf($state.current.name) === -1,
nopadding: ['portainer.auth', 'portainer.init.admin', 'portainer.init.endpoint', 'portainer.logout'].indexOf($state.current.name) > -1 || applicationState.loading
open: isSidebarOpen() && ['portainer.auth', 'portainer.init.admin'].indexOf($state.current.name) === -1,
nopadding: ['portainer.auth', 'portainer.init.admin', 'portainer.logout'].indexOf($state.current.name) > -1 || applicationState.loading
}"
ng-cloak
>

View File

@ -9,7 +9,7 @@
<!-- build-method -->
<div class="col-sm-12 form-section-title"> Build method </div>
<box-selector radio-name="'method'" value="$ctrl.state.method" options="$ctrl.methodOptions" on-change="($ctrl.onChangeMethod)"></box-selector>
<box-selector slim="true" radio-name="'method'" value="$ctrl.state.method" options="$ctrl.methodOptions" on-change="($ctrl.onChangeMethod)"></box-selector>
<div class="mt-4">
<web-editor-form

View File

@ -6,6 +6,8 @@ import { NamespacesSelector } from '@/react/kubernetes/cluster/RegistryAccessVie
import { StorageAccessModeSelector } from '@/react/kubernetes/cluster/ConfigureView/StorageAccessModeSelector';
import { NamespaceAccessUsersSelector } from '@/react/kubernetes/namespaces/AccessView/NamespaceAccessUsersSelector';
import { CreateNamespaceRegistriesSelector } from '@/react/kubernetes/namespaces/CreateView/CreateNamespaceRegistriesSelector';
import { KubeApplicationAccessPolicySelector } from '@/react/kubernetes/applications/CreateView/KubeApplicationAccessPolicySelector';
import { KubeApplicationDeploymentTypeSelector } from '@/react/kubernetes/applications/CreateView/KubeApplicationDeploymentTypeSelector';
export const componentsModule = angular
.module('portainer.kubernetes.react.components', [])
@ -63,4 +65,21 @@ export const componentsModule = angular
'options',
'value',
])
)
.component(
'kubeApplicationAccessPolicySelector',
r2a(KubeApplicationAccessPolicySelector, [
'value',
'onChange',
'isEdit',
'persistedFoldersUseExistingVolumes',
])
)
.component(
'kubeApplicationDeploymentTypeSelector',
r2a(KubeApplicationDeploymentTypeSelector, [
'value',
'onChange',
'supportGlobalDeployment',
])
).name;

View File

@ -734,90 +734,12 @@
<div class="col-sm-12 small text-muted"> Specify how the data will be used across instances. </div>
</div>
<!-- access policy options -->
<div class="form-group">
<div class="col-sm-12">
<div class="boxselector_wrapper">
<div
ng-if="
(!ctrl.state.isEdit && !ctrl.state.persistedFoldersUseExistingVolumes) ||
(ctrl.state.isEdit && ctrl.formValues.DataAccessPolicy === ctrl.ApplicationDataAccessPolicies.ISOLATED)
"
>
<input
type="radio"
id="data_access_isolated"
ng-value="ctrl.ApplicationDataAccessPolicies.ISOLATED"
ng-model="ctrl.formValues.DataAccessPolicy"
ng-change="ctrl.resetDeploymentType()"
/>
<label for="data_access_isolated">
<div class="boxselector_header">
<pr-icon icon="'boxes'"></pr-icon>
Isolated
</div>
<p>Application will be deployed as a StatefulSet with each instantiating their own data</p>
</label>
</div>
<div
style="color: #767676"
ng-if="
(ctrl.state.isEdit && ctrl.formValues.DataAccessPolicy === ctrl.ApplicationDataAccessPolicies.SHARED) || ctrl.state.persistedFoldersUseExistingVolumes
"
>
<input type="radio" id="data_access_isolated" disabled />
<label
for="data_access_isolated"
tooltip-append-to-body="true"
tooltip-placement="bottom"
tooltip-class="portainer-tooltip"
uib-tooltip="Changing the data access policy is not allowed"
style="cursor: pointer; border-color: #767676"
>
<div class="boxselector_header">
<pr-icon icon="'boxes'"></pr-icon>
Isolated
</div>
<p>Application will be deployed as a StatefulSet with each instantiating their own data</p>
</label>
</div>
<div ng-if="!ctrl.state.isEdit || (ctrl.state.isEdit && ctrl.formValues.DataAccessPolicy === ctrl.ApplicationDataAccessPolicies.SHARED)">
<input
type="radio"
id="data_access_shared"
ng-value="ctrl.ApplicationDataAccessPolicies.SHARED"
ng-model="ctrl.formValues.DataAccessPolicy"
ng-change="ctrl.resetDeploymentType()"
/>
<label for="data_access_shared">
<div class="boxselector_header">
<pr-icon icon="'box'"></pr-icon>
Shared
</div>
<p>Application will be deployed as a Deployment with a shared storage access</p>
</label>
</div>
<div style="color: #767676" ng-if="ctrl.state.isEdit && ctrl.formValues.DataAccessPolicy === ctrl.ApplicationDataAccessPolicies.ISOLATED">
<input type="radio" id="data_access_shared" disabled />
<label
for="data_access_shared"
tooltip-append-to-body="true"
tooltip-placement="bottom"
tooltip-class="portainer-tooltip"
uib-tooltip="Changing the data access policy is not allowed"
style="cursor: pointer; border-color: #767676"
>
<div class="boxselector_header">
<pr-icon icon="'sliders'"></pr-icon>
Shared
</div>
<p>Application will be deployed as a Deployment with a shared storage access</p>
</label>
</div>
</div>
</div>
</div>
<!-- !access policy options -->
<kube-application-access-policy-selector
value="ctrl.formValues.DataAccessPolicy"
on-change="(ctrl.onDataAccessPolicyChange)"
is-edit="ctrl.state.isEdit"
persisted-folders-use-existing-volumes="ctrl.state.persistedFoldersUseExistingVolumes"
></kube-application-access-policy-selector>
</div>
<!-- #endregion -->
@ -925,62 +847,12 @@
</div>
<!-- deployment options -->
<div class="form-group">
<div class="col-sm-12">
<div class="boxselector_wrapper">
<div>
<input
type="radio"
id="deployment_replicated"
ng-value="ctrl.ApplicationDeploymentTypes.REPLICATED"
ng-model="ctrl.formValues.DeploymentType"
data-cy="k8sAppCreate-replicatedDeploymentButton"
/>
<label for="deployment_replicated">
<div class="boxselector_header">
<pr-icon icon="'sliders'"></pr-icon>
Replicated
</div>
<p>Run one or multiple instances of this container</p>
</label>
</div>
<div ng-if="!ctrl.supportGlobalDeployment()">
<input type="radio" id="deployment_global" disabled />
<label
for="deployment_global"
tooltip-append-to-body="true"
tooltip-placement="bottom"
tooltip-class="portainer-tooltip"
uib-tooltip="The storage or access policy used for persisted folders cannot be used with this option"
>
<div class="boxselector_header">
<pr-icon icon="'boxes'"></pr-icon>
Global
</div>
<p>Application will be deployed as a DaemonSet with an instance on each node of the cluster</p>
</label>
</div>
<div ng-if="ctrl.supportGlobalDeployment()">
<input
type="radio"
id="deployment_global"
ng-value="ctrl.ApplicationDeploymentTypes.GLOBAL"
ng-model="ctrl.formValues.DeploymentType"
ng-click="ctrl.unselectAutoScaler()"
data-cy="k8sAppCreate-globalDeployButton"
/>
<label for="deployment_global">
<div class="boxselector_header">
<pr-icon icon="'boxes'"></pr-icon>
Global
</div>
<p>Application will be deployed as a DaemonSet with an instance on each node of the cluster</p>
</label>
</div>
</div>
</div>
</div>
<!-- !deployment options -->
<kube-application-deployment-type-selector
value="ctrl.formValues.DeploymentType"
on-change="(ctrl.onChangeDeploymentType)"
support-global-deployment="ctrl.supportGlobalDeployment()"
radio-name="'deploymentType'"
></kube-application-deployment-type-selector>
<!-- replica count -->
<div class="form-group" ng-if="ctrl.formValues.DeploymentType === ctrl.ApplicationDeploymentTypes.REPLICATED">
@ -1260,46 +1132,14 @@
<div class="col-sm-12 small text-muted"> Specify the policy associated to the placement rules. </div>
</div>
<!-- placement policy options -->
<div class="form-group" ng-if="ctrl.formValues.Placements.length">
<div class="col-sm-12">
<div class="boxselector_wrapper">
<div>
<input
type="radio"
id="placement_hard"
ng-value="ctrl.ApplicationPlacementTypes.MANDATORY"
ng-model="ctrl.formValues.PlacementType"
data-cy="k8sAppCreate-mandatoryPlacementButton"
/>
<label for="placement_hard">
<div class="boxselector_header">
<pr-icon icon="'sliders'"></pr-icon>
Mandatory
</div>
<p>Schedule this application <b>ONLY</b> on nodes that match <b>ALL</b> Rules</p>
</label>
</div>
<div>
<input
type="radio"
id="placement_soft"
ng-value="ctrl.ApplicationPlacementTypes.PREFERRED"
ng-model="ctrl.formValues.PlacementType"
data-cy="k8sAppCreate-prefferedPlacementButton"
/>
<label for="placement_soft">
<div class="boxselector_header">
<pr-icon icon="'align-justify'"></pr-icon>
Preferred
</div>
<p>Schedule this application on nodes that match the rules if possible</p>
</label>
</div>
</div>
</div>
</div>
<!-- !placement policy options -->
<box-selector
ng-if="ctrl.formValues.Placements.length"
options="ctrl.placementOptions"
slim="true"
value="ctrl.formValues.PlacementType"
on-change="(ctrl.onChangePlacementType)"
radio-name="'placementType'"
></box-selector>
</div>
<!-- #endregion -->
</div>

View File

@ -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;
}

View File

@ -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<BoxSelectorOption<number>> = [
{
id: 'placement_hard',
value: KubernetesApplicationPlacementTypes.MANDATORY,
icon: Sliders,
iconType: 'badge',
label: 'Mandatory',
description: (
<>
Schedule this application <b>ONLY</b> on nodes that match <b>ALL</b>{' '}
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;

View File

@ -85,32 +85,7 @@
<div class="col-sm-12 small text-muted"> Select the kind of data that you want to save in the configuration. </div>
</div>
<!-- type options -->
<div class="form-group px-[15px] mb-0">
<div class="boxselector_wrapper">
<div>
<input type="radio" id="type_basic" ng-value="ctrl.KubernetesConfigurationKinds.CONFIGMAP" ng-model="ctrl.formValues.Kind" ng-change="ctrl.onChangeKind()" />
<label for="type_basic" data-cy="k8sConfigCreate-nonSensitiveButton">
<div class="boxselector_header">
<pr-icon icon="'file-code'"></pr-icon>
ConfigMap
</div>
<p>This configuration holds non-sensitive information</p>
</label>
</div>
<div>
<input type="radio" id="type_secret" ng-value="ctrl.KubernetesConfigurationKinds.SECRET" ng-model="ctrl.formValues.Kind" ng-change="ctrl.onChangeKind()" />
<label for="type_secret" data-cy="k8sConfigCreate-sensitiveButton">
<div class="boxselector_header">
<pr-icon icon="'lock'"></pr-icon>
Secret
</div>
<p>This configuration holds sensitive information</p>
</label>
</div>
</div>
</div>
<!-- !type options -->
<box-selector options="ctrl.typeOptions" value="ctrl.formValues.Kind" on-change="(ctrl.onChangeKind)" radio-name="'Kind'" slim="true"> </box-selector>
<div ng-if="ctrl.formValues.Kind === ctrl.KubernetesConfigurationKinds.SECRET">
<div class="col-sm-12 form-section-title"> Information </div>

View File

@ -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() {

View File

@ -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<BoxSelectorOption<number>> = [
{
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;

View File

@ -56,6 +56,7 @@
<div class="col-sm-12 form-section-title"> Build method </div>
<box-selector
slim="true"
radio-name="'method'"
value="ctrl.state.BuildMethod"
options="ctrl.methodOptions"

View File

@ -8,7 +8,7 @@ import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, Kubernetes
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { kubernetes } from '@@/BoxSelector/common-options/deployment-methods';
import { editor, git, template, url } from '@@/BoxSelector/common-options/build-methods';
import { editor, git, customTemplate, url } from '@@/BoxSelector/common-options/build-methods';
class KubernetesDeployController {
/* @ngInject */
@ -33,7 +33,7 @@ class KubernetesDeployController {
{ ...git, value: KubernetesDeployBuildMethods.GIT },
{ ...editor, value: KubernetesDeployBuildMethods.WEB_EDITOR },
{ ...url, value: KubernetesDeployBuildMethods.URL },
{ ...template, description: 'Use custom template', value: KubernetesDeployBuildMethods.CUSTOM_TEMPLATE },
{ ...customTemplate, value: KubernetesDeployBuildMethods.CUSTOM_TEMPLATE },
];
this.state = {

View File

@ -35,12 +35,14 @@ export const BoxSelectorAngular: IComponentOptions = {
on-change="$ctrl.handleChange"
options="$ctrl.options"
radio-name="$ctrl.radioName"
slim="$ctrl.slim"
></box-selector-react>`,
bindings: {
value: '<',
onChange: '<',
options: '<',
radioName: '<',
slim: '<',
},
require: {
formCtrl: '^form',

View File

@ -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

View File

@ -4,11 +4,11 @@ import { BoxSelectorOption } from '@@/BoxSelector/types';
import { IconProps } from '@@/Icon';
export function buildOption<T extends number | string>(
id: string,
id: BoxSelectorOption<T>['id'],
icon: IconProps['icon'],
label: string,
description: string,
value: T,
label: BoxSelectorOption<T>['label'],
description: BoxSelectorOption<T>['description'],
value: BoxSelectorOption<T>['value'],
feature?: FeatureId
): BoxSelectorOption<T> {
return { id, icon, label, description, value, feature };

View File

@ -15,57 +15,16 @@
</div>
<!-- !access-control-switch -->
<!-- restricted-access -->
<div class="form-group" ng-if="$ctrl.formData.AccessControlEnabled">
<div class="col-sm-12">
<div class="boxselector_wrapper">
<div ng-if="$ctrl.isAdmin">
<input type="radio" id="access_administrators" ng-model="$ctrl.formData.Ownership" value="administrators" />
<label for="access_administrators" data-cy="portainer-selectAdminAccess">
<div class="boxselector_header">
<pr-icon icon="'eye-off'"></pr-icon>
Administrators
</div>
<p class="boxselector_content">I want to restrict the management of this resource to administrators only</p>
</label>
</div>
<div ng-if="$ctrl.isAdmin">
<input type="radio" id="access_restricted" ng-model="$ctrl.formData.Ownership" value="restricted" />
<label for="access_restricted" data-cy="portainer-selectRestrictedAccess">
<div class="boxselector_header">
<pr-icon icon="'users'"></pr-icon>
Restricted
</div>
<p class="boxselector_content"> I want to restrict the management of this resource to a set of users and/or teams </p>
</label>
</div>
<div ng-if="!$ctrl.isAdmin">
<input type="radio" id="access_private" ng-model="$ctrl.formData.Ownership" value="private" />
<label for="access_private">
<div class="boxselector_header">
<pr-icon icon="'eye-off'"></pr-icon>
Private
</div>
<p> I want to restrict this resource to be manageable by myself only </p>
</label>
</div>
<div ng-if="!$ctrl.isAdmin && $ctrl.availableTeams.length > 0">
<input type="radio" id="access_restricted" ng-model="$ctrl.formData.Ownership" value="restricted" />
<label for="access_restricted">
<div class="boxselector_header">
<pr-icon icon="'users'"></pr-icon>
Restricted
</div>
<p ng-if="$ctrl.availableTeams.length === 1">
I want any member of my team (<b>{{ $ctrl.availableTeams[0].Name }}</b
>) to be able to manage this resource
</p>
<p ng-if="$ctrl.availableTeams.length > 1"> I want to restrict the management of this resource to one or more of my teams </p>
</label>
</div>
</div>
</div>
</div>
<access-type-selector
ng-if="$ctrl.formData.AccessControlEnabled"
value="$ctrl.formData.Ownership"
on-change="($ctrl.onChangeOwnership)"
is-admin="$ctrl.isAdmin"
name="Ownership"
teams="$ctrl.availableTeams"
></access-type-selector>
<!-- restricted-access -->
<!-- authorized-teams -->
<div

View File

@ -21,6 +21,13 @@ angular.module('portainer.app').controller('porAccessControlFormController', [
ctrl.availableTeams = [];
ctrl.availableUsers = [];
ctrl.onChangeEnablement = onChangeEnablement;
ctrl.onChangeOwnership = onChangeOwnership;
function onChangeOwnership(ownership) {
onChange({ Ownership: ownership });
}
function setOwnership(resourceControl, isAdmin) {
if (isAdmin && resourceControl.Ownership === RCO.PRIVATE) {
ctrl.formData.Ownership = RCO.RESTRICTED;
@ -88,15 +95,11 @@ angular.module('portainer.app').controller('porAccessControlFormController', [
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve access control information');
});
}
this.onChangeEnablement = function (enable) {
$scope.$evalAsync(() => {
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 });
}
},
]);

View File

@ -22,55 +22,16 @@
</span>
</div>
</div>
<div class="form-group"></div>
<!-- endpoint-tls-mode -->
<div class="form-group" ng-if="$ctrl.formData.TLS">
<div class="col-sm-12">
<div class="boxselector_wrapper">
<div>
<input type="radio" id="tls_client_ca" ng-model="$ctrl.formData.TLSMode" value="tls_client_ca" />
<label for="tls_client_ca">
<div class="boxselector_header">
<pr-icon icon="'shield'"></pr-icon>
TLS with server and client verification
</div>
<p>Use client certificates and server verification</p>
</label>
</div>
<div>
<input type="radio" id="tls_client_noca" ng-model="$ctrl.formData.TLSMode" value="tls_client_noca" />
<label for="tls_client_noca">
<div class="boxselector_header">
<pr-icon icon="'shield'"></pr-icon>
TLS with client verification only
</div>
<p>Use client certificates without server verification</p>
</label>
</div>
<div>
<input type="radio" id="tls_ca" ng-model="$ctrl.formData.TLSMode" value="tls_ca" />
<label for="tls_ca">
<div class="boxselector_header">
<pr-icon icon="'shield'"></pr-icon>
TLS with server verification only
</div>
<p>Only verify the server certificate</p>
</label>
</div>
<div>
<input type="radio" id="tls_only" ng-model="$ctrl.formData.TLSMode" value="tls_only" />
<label for="tls_only">
<div class="boxselector_header">
<pr-icon icon="'shield'"></pr-icon>
TLS only
</div>
<p>No server/client verification</p>
</label>
</div>
</div>
</div>
</div>
<!-- !endpoint-tls-mode -->
<box-selector
ng-if="$ctrl.formData.TLS"
slim="true"
radio-name="'tls_mode'"
options="$ctrl.tlsOptions"
value="$ctrl.formData.TLSMode"
on-change="($ctrl.onChangeTLSMode)"
></box-selector>
<div class="col-sm-12 form-section-title" ng-if="$ctrl.formData.TLS && $ctrl.formData.TLSMode !== 'tls_only'"> Required TLS files </div>
<!-- tls-file-upload -->
<div ng-if="$ctrl.formData.TLS">

View File

@ -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() {

View File

@ -0,0 +1,38 @@
import { Shield } from 'lucide-react';
import { BoxSelectorOption } from '@@/BoxSelector';
export const tlsOptions: ReadonlyArray<BoxSelectorOption<string>> = [
{
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;

View File

@ -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'])

View File

@ -15,42 +15,15 @@
<!-- build-method -->
<div ng-if="!$ctrl.state.fromStack">
<div class="col-sm-12 form-section-title"> Build method </div>
<div class="form-group">
<div class="col-sm-12">
<div class="boxselector_wrapper">
<div>
<input type="radio" id="method_editor" ng-model="$ctrl.state.Method" value="editor" ng-change="$ctrl.onChangeMethod()" />
<label for="method_editor">
<div class="boxselector_header">
<pr-icon icon="'edit'"></pr-icon>
Web editor
</div>
<p>Use our Web editor</p>
</label>
</div>
<div>
<input type="radio" id="method_upload" ng-model="$ctrl.state.Method" value="upload" ng-change="$ctrl.onChangeMethod()" />
<label for="method_upload">
<div class="boxselector_header">
<pr-icon icon="'upload'"></pr-icon>
Upload
</div>
<p>Upload from your computer</p>
</label>
</div>
<div>
<input type="radio" id="method_repository" ng-model="$ctrl.state.Method" value="repository" ng-change="$ctrl.onChangeMethod()" />
<label for="method_repository">
<div class="boxselector_header">
<pr-icon icon="'git-pull-request'"></pr-icon>
Repository
</div>
<p>Use a git repository</p>
</label>
</div>
</div>
</div>
</div>
<box-selector
slim="true"
options="$ctrl.buildMethods"
value="$ctrl.state.Method"
on-change="($ctrl.onChangeMethod)"
radio-name="'buildMethod'"
slim="true"
></box-selector>
</div>
<!-- !build-method -->
<!-- web-editor -->

View File

@ -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() {

View File

@ -16,22 +16,8 @@
<!-- !name-input -->
<!-- build-method -->
<div class="col-sm-12 form-section-title"> Profile configuration </div>
<div class="form-group">
<div class="col-sm-12">
<div class="boxselector_wrapper">
<div>
<input type="radio" id="method_editor" ng-model="state.method" value="editor" />
<label for="method_editor">
<div class="boxselector_header">
<pr-icon icon="'edit'"></pr-icon>
Web editor
</div>
<p>Use our Web editor</p>
</label>
</div>
</div>
</div>
</div>
<box-selector slim="true" options="buildMethods" value="state.method"></box-selector>
<!-- !build-method -->
<web-editor-form

View File

@ -1,10 +1,13 @@
import angular from 'angular';
import { editor } from '@@/BoxSelector/common-options/build-methods';
import { createProfile } from 'Portainer/hostmanagement/fdo/fdo.service';
angular.module('portainer.app').controller('AddProfileController', AddProfileController);
export default function AddProfileController($scope, $async, $state, $window, ModalService, Authentication, Notifications) {
$scope.buildMethods = [editor];
$scope.formValues = {
name: '',
profileFileContent: '',

View File

@ -16,23 +16,9 @@
<!-- !name-input -->
<!-- build-method -->
<div class="col-sm-12 form-section-title"> Profile configuration </div>
<div class="form-group"></div>
<div class="form-group">
<div class="col-sm-12">
<div class="boxselector_wrapper">
<div>
<input type="radio" id="method_editor" ng-model="state.method" value="editor" />
<label for="method_editor">
<div class="boxselector_header">
<pr-icon icon="'edit'"></pr-icon>
Web editor
</div>
<p>Use our Web editor</p>
</label>
</div>
</div>
</div>
</div>
<box-selector slim="true" options="buildMethods" value="state.method"></box-selector>
<!-- !build-method -->
<web-editor-form

View File

@ -1,9 +1,12 @@
import angular from 'angular';
import { editor } from '@@/BoxSelector/common-options/build-methods';
import { getProfile, updateProfile } from 'Portainer/hostmanagement/fdo/fdo.service';
angular.module('portainer.app').controller('EditProfileController', EditProfileController);
export default function EditProfileController($scope, $async, $state, $window, ModalService, Authentication, Notifications) {
$scope.buildMethods = [editor];
$scope.formValues = {
name: '',
profileFileContent: '',

View File

@ -131,37 +131,9 @@
</span>
</div>
</div>
<!-- !note -->
<div class="form-group">
<div class="col-sm-12">
<div class="boxselector_wrapper">
<div>
<input type="radio" id="restore_file" checked="checked" />
<label for="restore_file" data-cy="init-selectLocalFile">
<div class="boxselector_header">
<pr-icon icon="'upload'"></pr-icon>
Upload backup file
</div>
<p></p>
</label>
</div>
<div>
<input type="radio" id="restore_s3" disabled />
<label for="restore_s3" class="boxselector_disabled">
<div class="boxselector_header">
<pr-icon icon="'download'"></pr-icon>
Retrieve from S3
</div>
<p
>This feature is available in
<a class="hyperlink" href="https://www.portainer.io/business-upsell?from=restore-s3-form" target="_blank"> Portainer Business Edition</a></p
>
</label>
</div>
</div>
</div>
</div>
<!-- note -->
<box-selector slim="true" options="restoreOptions" value="formValues.restoreFormType" on-change="(onChangeRestoreType)" radio-name="'restore-type'"></box-selector>
<div class="form-group">
<div class="col-sm-12">
<span class="small text-muted"> You can upload a backup file from your computer. </span>

View File

@ -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;

View File

@ -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<BoxSelectorOption<string>> = [
{
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;

View File

@ -278,7 +278,7 @@
<form class="form-horizontal" ng-submit="backupPortainer()" name="backupPortainerForm">
<div class="col-sm-12 form-section-title"> Backup configuration </div>
<box-selector options="backupOptions" value="formValues.backupFormType" on-change="(onBackupOptionsChange)" radio-name="'backupOptions'"></box-selector>
<box-selector slim="true" options="backupOptions" value="formValues.backupFormType" on-change="(onBackupOptionsChange)" radio-name="'backupOptions'"></box-selector>
<div ng-if="formValues.backupFormType === BACKUP_FORM_TYPES.S3">
<!-- Schedule automatic backups -->

View File

@ -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(() => {

View File

@ -49,52 +49,9 @@
</div>
<!-- build-method -->
<div class="col-sm-12 form-section-title"> Build method </div>
<div class="form-group">
<div class="col-sm-12">
<div class="boxselector_wrapper">
<div>
<input type="radio" id="method_editor" ng-model="state.Method" value="editor" />
<label for="method_editor">
<div class="boxselector_header">
<pr-icon icon="'edit'"></pr-icon>
Web editor
</div>
<p>Use our Web editor</p>
</label>
</div>
<div>
<input type="radio" id="method_upload" ng-model="state.Method" value="upload" />
<label for="method_upload">
<div class="boxselector_header">
<pr-icon icon="'upload'"></pr-icon>
Upload
</div>
<p>Upload from your computer</p>
</label>
</div>
<div>
<input type="radio" id="method_repository" ng-model="state.Method" value="repository" />
<label for="method_repository">
<div class="boxselector_header">
<pr-icon icon="'git-pull-request'"></pr-icon>
Repository
</div>
<p>Use a git repository</p>
</label>
</div>
<div>
<input type="radio" id="method_template" ng-model="state.Method" value="template" />
<label for="method_template">
<div class="boxselector_header">
<pr-icon icon="'edit'"></pr-icon>
Custom template
</div>
<p>Use a custom template</p>
</label>
</div>
</div>
</div>
</div>
<box-selector slim="true" radio-name="'build-method'" options="buildMethods" value="state.Method" on-change="(onBuildMethodChange)" slim="true"></box-selector>
<!-- !build-method -->
<!-- upload -->

View File

@ -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;
}

View File

@ -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<T extends number | string> {
interface Props<T extends Value> {
radioName: string;
option: BoxSelectorOption<T>;
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<T extends number | string>({
export function BoxOption<T extends Value>({
radioName,
option,
onChange = () => {},
selectedValue,
onSelect = () => {},
isSelected,
disabled,
tooltip,
className,
type = 'radio',
children,
checkIcon: Check,
}: PropsWithChildren<Props<T>>) {
const BoxOption = (
<div className={clsx('box-selector-item', className)}>
const selected = isSelected(option.value);
const item = (
<div className={clsx(styles.root, className)}>
<input
type={type}
name={radioName}
id={option.id}
checked={option.value === selectedValue}
value={option.value}
checked={selected}
value={option.value.toString()}
disabled={disabled}
onChange={() => onChange(option.value)}
onChange={() => onSelect(option.value)}
/>
<label htmlFor={option.id} data-cy={`${radioName}_${option.value}`}>
{children}
{!disabled && (
<div
className={clsx(
'absolute top-4 right-4 h-4 w-4 rounded-full border border-solid border-blue-8 text-white font-bold flex items-center justify-center',
{
'bg-white': !selected,
'bg-blue-8': selected,
}
)}
>
{selected && <Check className="lucide" strokeWidth={3} />}
</div>
)}
</label>
</div>
);
if (tooltip) {
return (
<TooltipWithChildren message={tooltip}>{BoxOption}</TooltipWithChildren>
);
return <TooltipWithChildren message={tooltip}>{item}</TooltipWithChildren>;
}
return BoxOption;
return item;
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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<number>[] = [
{
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<number>[] = [
{
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<number>[] = [
{
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 (
<BoxSelector
isMulti
radioName="name"
onChange={(value: number[]) => {
setValue(value);
}}
value={value}
options={options}
/>
);
}
export function SlimMultiSelect() {
const [value, setValue] = useState([3]);
const options: BoxSelectorOption<number>[] = [
{
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 (
<BoxSelector
isMulti
radioName="name"
onChange={(value: number[]) => {
setValue(value);
}}
value={value}
options={options}
slim
/>
);
}

View File

@ -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<T extends string | number>({
function renderDefault<T extends Value>({
options = [],
onChange = () => {},
radioName = 'radio',
value,
}: Partial<Props<T>> = {}) {
}: {
options?: BoxSelectorOption<T>[];
onChange?: (value: T) => void;
radioName?: string;
value: T;
}) {
return render(
<BoxSelector
options={options}
onChange={onChange}
radioName={radioName}
value={value || 0}
value={value}
/>
);
}

View File

@ -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<T extends number | string> {
radioName: string;
value: T;
onChange(value: T, limitedToBE: boolean): void;
options: BoxSelectorOption<T>[];
interface IsMultiProps<T extends Value> {
isMulti: true;
value: T[];
onChange(value: T[], limitedToBE: boolean): void;
}
export function BoxSelector<T extends number | string>({
interface SingleProps<T extends Value> {
isMulti?: never;
value: T;
onChange(value: T, limitedToBE: boolean): void;
}
type Union<T extends Value> = IsMultiProps<T> | SingleProps<T>;
export type Props<T extends Value> = Union<T> & {
radioName: string;
options: ReadonlyArray<BoxSelectorOption<T>> | Array<BoxSelectorOption<T>>;
slim?: boolean;
};
export function BoxSelector<T extends Value>({
radioName,
value,
options,
onChange,
slim = false,
...props
}: Props<T>) {
return (
<div className="form-group">
<div className="col-sm-12">
<div
className={clsx('boxselector_wrapper', styles.root)}
role="radiogroup"
>
<div className={styles.root} role="radiogroup">
{options
.filter((option) => !option.hide)
.map((option) => (
@ -32,14 +41,41 @@ export function BoxSelector<T extends number | string>({
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}
/>
))}
</div>
</div>
</div>
);
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;
}
}

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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 (
<div className="boxselector_wrapper">
<BoxSelectorItem
onChange={() => {}}
onSelect={() => {}}
option={option}
radioName="radio"
selectedValue={selected ? option.value : 0}
isSelected={() => selected}
/>
</div>
);
@ -78,3 +80,36 @@ export function SelectedLimitedFeatureItem() {
return <Template feature={FeatureId.ACTIVITY_AUDIT} selected />;
}
function IconTemplate({
icon,
iconType,
}: {
icon: ReactNode;
iconType: 'raw' | 'logo' | 'badge';
}) {
return (
<BoxSelectorItem
onSelect={() => {}}
option={{
description: 'description',
icon,
iconType,
label: 'label',
id: 'id',
value: 'value',
}}
isSelected={() => false}
radioName="radio"
slim
/>
);
}
export function LogoItem() {
return <IconTemplate icon={Docker} iconType="logo" />;
}
export function BadgeItem() {
return <IconTemplate icon={Briefcase} iconType="badge" />;
}

View File

@ -1,46 +1,57 @@
import clsx from 'clsx';
import { Icon as ReactFeatherComponentType, Check } from 'lucide-react';
import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { Icon } from '@/react/components/Icon';
import './BoxSelectorItem.css';
import { BadgeIcon } from '@@/BadgeIcon';
import { BoxSelectorOption } from './types';
import styles from './BoxSelectorItem.module.css';
import { BoxSelectorOption, Value } from './types';
import { LimitedToBeIndicator } from './LimitedToBeIndicator';
import { BoxOption } from './BoxOption';
import { LogoIcon } from './LogoIcon';
interface Props<T extends number | string> {
radioName: string;
type Props<T extends Value> = {
option: BoxSelectorOption<T>;
onChange(value: T, limitedToBE: boolean): void;
selectedValue: T;
radioName: string;
disabled?: boolean;
tooltip?: string;
}
onSelect(value: T, limitedToBE: boolean): void;
isSelected(value: T): boolean;
type?: 'radio' | 'checkbox';
slim?: boolean;
checkIcon?: ReactFeatherComponentType;
};
export function BoxSelectorItem<T extends number | string>({
export function BoxSelectorItem<T extends Value>({
radioName,
option,
onChange,
selectedValue,
onSelect = () => {},
disabled,
tooltip,
type = 'radio',
isSelected,
slim = false,
checkIcon = Check,
}: Props<T>) {
const limitedToBE = isLimitedToBE(option.feature);
const beIndicatorTooltipId = `box-selector-item-${radioName}-${option.id}-limited`;
return (
<BoxOption
className={clsx({
business: limitedToBE,
limited: limitedToBE,
className={clsx(styles.boxSelectorItem, {
[styles.business]: limitedToBE,
[styles.limited]: limitedToBE,
})}
radioName={radioName}
option={option}
selectedValue={selectedValue}
disabled={disabled}
onChange={(value) => onChange(value, limitedToBE)}
isSelected={isSelected}
disabled={isDisabled()}
onSelect={(value) => onSelect(value, limitedToBE)}
tooltip={tooltip}
type={type}
checkIcon={checkIcon}
>
<>
{limitedToBE && (
@ -49,19 +60,51 @@ export function BoxSelectorItem<T extends number | string>({
featureId={option.feature}
/>
)}
<div className={clsx({ 'opacity-30': limitedToBE })}>
<div className="boxselector_img_container">
{!!option.icon && (
<Icon
icon={option.icon}
className="boxselector_icon !flex items-center"
/>
)}
<div
className={clsx('flex gap-2', {
'opacity-30': limitedToBE,
'flex-col justify-between h-full': !slim,
'items-center slim': slim,
})}
>
<div
className={clsx(styles.imageContainer, 'flex items-center', {
'flex-1': !slim,
})}
>
{renderIcon()}
</div>
<div>
<div className={styles.header}>{option.label}</div>
<p>{option.description}</p>
</div>
<div className="boxselector_header">{option.label}</div>
<p className="box-selector-item-description">{option.description}</p>
</div>
</>
</BoxOption>
);
function isDisabled() {
return disabled || (limitedToBE && option.disabledWhenLimited);
}
function renderIcon() {
if (!option.icon) {
return null;
}
if (option.iconType === 'badge') {
return <BadgeIcon icon={option.icon} />;
}
if (option.iconType === 'logo') {
return <LogoIcon icon={option.icon} />;
}
return (
<Icon
icon={option.icon}
className={clsx(styles.icon, '!flex items-center')}
/>
);
}
}

View File

@ -1,44 +1,58 @@
import { Edit, FileText, Globe, Upload } from 'lucide-react';
import { Edit, FileText, Globe, UploadCloud } from 'lucide-react';
import GitIcon from '@/assets/ico/git.svg?c';
import { BadgeIcon } from '@@/BadgeIcon';
import { BoxSelectorOption } from '../types';
export const editor: BoxSelectorOption<'editor'> = {
id: 'method_editor',
icon: <BadgeIcon icon={Edit} />,
icon: Edit,
iconType: 'badge',
label: 'Web editor',
description: 'Use our Web editor',
value: 'editor',
};
export const upload: BoxSelectorOption<'upload'> = {
id: 'method_upload',
icon: <BadgeIcon icon={Upload} />,
icon: UploadCloud,
iconType: 'badge',
label: 'Upload',
description: 'Upload from your computer',
value: 'upload',
};
export const git: BoxSelectorOption<'repository'> = {
id: 'method_repository',
icon: <GitIcon />,
icon: GitIcon,
iconType: 'logo',
label: 'Repository',
description: 'Use a git repository',
value: 'repository',
};
export const template: BoxSelectorOption<'template'> = {
export const edgeStackTemplate: BoxSelectorOption<'template'> = {
id: 'method_template',
icon: <BadgeIcon icon={FileText} />,
icon: FileText,
iconType: 'badge',
label: 'Template',
description: 'Use an Edge stack template',
value: 'template',
};
export const customTemplate: BoxSelectorOption<'template'> = {
id: 'method_template',
icon: FileText,
iconType: 'badge',
label: 'Custom template',
description: 'Use a custom template',
value: 'template',
};
export const url: BoxSelectorOption<'url'> = {
id: 'method_url',
icon: <BadgeIcon icon={Globe} />,
icon: Globe,
iconType: 'badge',
label: 'URL',
description: 'Specify a URL to a file',
value: 'url',

View File

@ -1,14 +1,20 @@
import { ReactNode } from 'react';
import type { FeatureId } from '@/react/portainer/feature-flags/enums';
import { IconProps } from '@@/Icon';
export interface BoxSelectorOption<T> extends IconProps {
id: string;
label: string;
description: string;
value: T;
disabled?: () => boolean;
tooltip?: () => string;
feature?: FeatureId;
hide?: boolean;
export type Value = number | string | boolean;
export interface BoxSelectorOption<T extends Value> extends IconProps {
readonly id: string;
readonly label: string;
readonly description?: ReactNode;
readonly value: T;
readonly disabled?: () => boolean;
readonly tooltip?: () => string;
readonly feature?: FeatureId;
readonly disabledWhenLimited?: boolean;
readonly hide?: boolean;
readonly iconType?: 'raw' | 'badge' | 'logo';
}

View File

@ -16,7 +16,7 @@ interface Args {
function Template({ totalSteps = 5 }: Args) {
const steps: Step[] = Array.from({ length: totalSteps }).map((_, index) => ({
title: `step ${index + 1}`,
label: `step ${index + 1}`,
}));
const [currentStep, setCurrentStep] = useState(1);

View File

@ -3,7 +3,7 @@ import clsx from 'clsx';
import styles from './Stepper.module.css';
export interface Step {
title: string;
label: string;
}
interface Props {
@ -16,7 +16,7 @@ export function Stepper({ currentStep, steps }: Props) {
<div className={styles.stepperWrapper}>
{steps.map((step, index) => (
<div
key={step.title}
key={step.label}
className={clsx(styles.stepWrapper, {
[styles.active]: index + 1 === currentStep,
[styles.completed]: index + 1 < currentStep,
@ -24,7 +24,7 @@ export function Stepper({ currentStep, steps }: Props) {
>
<div className={styles.step}>
<div className={styles.stepCounter}>{index + 1}</div>
<div className={styles.stepName}>{step.title}</div>
<div className={styles.stepName}>{step.label}</div>
</div>
</div>
))}

View File

@ -6,7 +6,6 @@ import highcontrastmode from '@/assets/ico/theme/highcontrastmode.svg?c';
// general icons
import heartbeatup from '@/assets/ico/heartbeat-up.svg?c';
import heartbeatdown from '@/assets/ico/heartbeat-down.svg?c';
import checked from '@/assets/ico/checked.svg?c';
import dataflow from '@/assets/ico/dataflow-1.svg?c';
import git from '@/assets/ico/git.svg?c';
import kube from '@/assets/ico/kube.svg?c';
@ -53,7 +52,6 @@ export const SvgIcons = {
lightmode,
highcontrastmode,
dataflow,
checked,
dockericon,
git,
laptopcode,

View File

@ -0,0 +1,70 @@
import { Box, Boxes } from 'lucide-react';
import { KubernetesApplicationDataAccessPolicies } from '@/kubernetes/models/application/models';
import { BoxSelector, BoxSelectorOption } from '@@/BoxSelector';
interface Props {
isEdit: boolean;
persistedFoldersUseExistingVolumes: boolean;
value: number;
onChange(value: number): void;
}
export function KubeApplicationAccessPolicySelector({
isEdit,
persistedFoldersUseExistingVolumes,
value,
onChange,
}: Props) {
const options = getOptions(value, isEdit, persistedFoldersUseExistingVolumes);
return (
<BoxSelector
slim
options={options}
value={value}
onChange={onChange}
radioName="data_access_policy"
/>
);
}
function getOptions(
value: number,
isEdit: boolean,
persistedFoldersUseExistingVolumes: boolean
): ReadonlyArray<BoxSelectorOption<number>> {
return [
{
value: KubernetesApplicationDataAccessPolicies.ISOLATED,
id: 'data_access_isolated',
icon: Boxes,
iconType: 'badge',
label: 'Isolated',
description:
'Application will be deployed as a StatefulSet with each instantiating their own data',
tooltip: () =>
isEdit || persistedFoldersUseExistingVolumes
? 'Changing the data access policy is not allowed'
: '',
disabled: () =>
(isEdit &&
value !== KubernetesApplicationDataAccessPolicies.ISOLATED) ||
persistedFoldersUseExistingVolumes,
},
{
value: KubernetesApplicationDataAccessPolicies.SHARED,
id: 'data_access_shared',
icon: Box,
iconType: 'badge',
label: 'Shared',
description:
'Application will be deployed as a Deployment with a shared storage access',
tooltip: () =>
isEdit ? 'Changing the data access policy is not allowed' : '',
disabled: () =>
isEdit && value !== KubernetesApplicationDataAccessPolicies.SHARED,
},
] as const;
}

View File

@ -0,0 +1,27 @@
import { BoxSelector } from '@@/BoxSelector';
import { getDeploymentOptions } from './deploymentOptions';
interface Props {
value: number;
onChange(value: number): void;
supportGlobalDeployment: boolean;
}
export function KubeApplicationDeploymentTypeSelector({
supportGlobalDeployment,
value,
onChange,
}: Props) {
const options = getDeploymentOptions(supportGlobalDeployment);
return (
<BoxSelector
slim
options={options}
value={value}
onChange={onChange}
radioName="deploymentType"
/>
);
}

View File

@ -0,0 +1,34 @@
import { Boxes, Sliders } from 'lucide-react';
import { KubernetesApplicationDeploymentTypes } from '@/kubernetes/models/application/models';
import { BoxSelectorOption } from '@@/BoxSelector';
export function getDeploymentOptions(
supportGlobalDeployment: boolean
): ReadonlyArray<BoxSelectorOption<number>> {
return [
{
id: 'deployment_replicated',
label: 'Replicated',
value: KubernetesApplicationDeploymentTypes.REPLICATED,
icon: Sliders,
iconType: 'badge',
description: 'Run one or multiple instances of this container',
},
{
id: 'deployment_global',
disabled: () => !supportGlobalDeployment,
tooltip: () =>
!supportGlobalDeployment
? 'The storage or access policy used for persisted folders cannot be used with this option'
: '',
label: 'Global',
description:
'Application will be deployed as a DaemonSet with an instance on each node of the cluster',
value: KubernetesApplicationDeploymentTypes.GLOBAL,
icon: Boxes,
iconType: 'badge',
},
] as const;
}

View File

@ -0,0 +1,34 @@
import { BoxSelector } from '@@/BoxSelector';
import { Team } from '../../users/teams/types';
import { ResourceControlOwnership } from '../types';
import { useOptions } from './useOptions';
export function AccessTypeSelector({
name,
isAdmin,
isPublicVisible,
teams,
value,
onChange,
}: {
name: string;
isAdmin: boolean;
teams: Team[];
isPublicVisible: boolean;
value: ResourceControlOwnership;
onChange(value: ResourceControlOwnership): void;
}) {
const options = useOptions(isAdmin, teams, isPublicVisible);
return (
<BoxSelector
slim
radioName={name}
value={value}
options={options}
onChange={onChange}
/>
);
}

View File

@ -4,7 +4,6 @@ import { FormikErrors } from 'formik';
import { useUser } from '@/react/hooks/useUser';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { BoxSelector } from '@@/BoxSelector';
import { FormError } from '@@/form-components/FormError';
import { ResourceControlOwnership, AccessControlFormData } from '../types';
@ -12,7 +11,7 @@ import { ResourceControlOwnership, AccessControlFormData } from '../types';
import { UsersField } from './UsersField';
import { TeamsField } from './TeamsField';
import { useLoadState } from './useLoadState';
import { useOptions } from './useOptions';
import { AccessTypeSelector } from './AccessTypeSelector';
interface Props {
values: AccessControlFormData;
@ -34,7 +33,6 @@ export function EditDetails({
const { user, isAdmin } = useUser();
const { users, teams, isLoading } = useLoadState(environmentId);
const options = useOptions(isAdmin, teams, isPublicVisible);
const handleChange = useCallback(
(partialValues: Partial<typeof values>) => {
@ -50,11 +48,13 @@ export function EditDetails({
return (
<>
<BoxSelector
radioName={withNamespace('ownership')}
<AccessTypeSelector
onChange={handleChangeOwnership}
name={withNamespace('ownership')}
value={values.ownership}
options={options}
onChange={(ownership) => handleChangeOwnership(ownership)}
isAdmin={isAdmin}
isPublicVisible={isPublicVisible}
teams={teams}
/>
{values.ownership === ResourceControlOwnership.RESTRICTED && (

View File

@ -70,9 +70,17 @@ function nonAdminOptions(teams?: Team[]) {
'access_restricted',
<BadgeIcon icon={ownershipIcon('restricted')} />,
'Restricted',
teams.length === 1
? `I want any member of my team (${teams[0].Name}) to be able to manage this resource`
: 'I want to restrict the management of this resource to one or more of my teams',
teams.length === 1 ? (
<>
I want any member of my team (<b>{teams[0].Name}</b>) to be able to
manage this resource
</>
) : (
<>
I want to restrict the management of this resource to one or more of
my teams
</>
),
ResourceControlOwnership.RESTRICTED
),
]);

View File

@ -66,7 +66,7 @@ export function EnvironmentTypeSelectView() {
trackEvent('endpoint-wizard-endpoint-select', {
category: 'portainer',
metadata: {
environment: steps.map((step) => step.title).join('/'),
environment: steps.map((step) => step.label).join('/'),
},
});

View File

@ -1,7 +1,6 @@
import { BoxSelector } from '@@/BoxSelector';
import { FormSection } from '@@/form-components/FormSection';
import { Option } from '../components/Option';
import { environmentTypes } from './environment-types';
export type EnvironmentSelectorValue = typeof environmentTypes[number]['id'];
@ -23,40 +22,26 @@ export function EnvironmentSelector({
onChange,
createEdgeDevice,
}: Props) {
const options = filterEdgeDevicesIfNeed(environmentTypes, createEdgeDevice);
return (
<div className="row">
<div className="form-horizontal">
<FormSection title="Select your environment(s)">
<p className="text-muted small">
You can onboard different types of environments, select all that
apply.
</p>
<div className="flex gap-4 flex-wrap">
{filterEdgeDevicesIfNeed(environmentTypes, createEdgeDevice).map(
(eType) => (
<Option
key={eType.id}
featureId={eType.featureId}
title={eType.title}
description={eType.description}
icon={eType.icon}
active={value.includes(eType.id)}
onClick={() => handleClick(eType.id)}
/>
)
)}
</div>
<BoxSelector
options={options}
isMulti
value={value}
onChange={onChange}
radioName="type-selector"
/>
</FormSection>
</div>
);
function handleClick(eType: EnvironmentSelectorValue) {
if (value.includes(eType)) {
onChange(value.filter((v) => v !== eType));
return;
}
onChange([...value, eType]);
}
}
function filterEdgeDevicesIfNeed(
@ -64,8 +49,8 @@ function filterEdgeDevicesIfNeed(
createEdgeDevice?: boolean
) {
if (!createEdgeDevice) {
return types;
return [...types];
}
return types.filter((eType) => hasEdge.includes(eType.id));
return [...types.filter((eType) => hasEdge.includes(eType.id))];
}

View File

@ -1,52 +1,71 @@
import { FeatureId } from '@/react/portainer/feature-flags/enums';
import DockerIcon from '@/assets/ico/vendor/docker-icon.svg?c';
import Kube from '@/assets/ico/kube.svg?c';
import MicrosoftIcon from '@/assets/ico/vendor/microsoft-icon.svg?c';
import NomadIcon from '@/assets/ico/vendor/nomad-icon.svg?c';
import Docker from '@/assets/ico/vendor/docker.svg?c';
import Kubernetes from '@/assets/ico/vendor/kubernetes.svg?c';
import Azure from '@/assets/ico/vendor/azure.svg?c';
import Nomad from '@/assets/ico/vendor/nomad.svg?c';
import KaaSIcon from './kaas-icon.svg?c';
export const environmentTypes = [
{
id: 'dockerStandalone',
title: 'Docker Standalone',
icon: DockerIcon,
value: 'dockerStandalone',
label: 'Docker Standalone',
icon: Docker,
iconType: 'logo',
description: 'Connect to Docker Standalone via URL/IP, API or Socket',
featureId: undefined,
},
{
id: 'dockerSwarm',
title: 'Docker Swarm',
icon: DockerIcon,
value: 'dockerSwarm',
label: 'Docker Swarm',
icon: Docker,
iconType: 'logo',
description: 'Connect to Docker Swarm via URL/IP, API or Socket',
featureId: undefined,
},
{
id: 'kubernetes',
title: 'Kubernetes',
icon: Kube,
value: 'kubernetes',
label: 'Kubernetes',
icon: Kubernetes,
iconType: 'logo',
description: 'Connect to a kubernetes environment via URL/IP',
featureId: undefined,
},
{
id: 'aci',
title: 'ACI',
value: 'aci',
label: 'ACI',
description: 'Connect to ACI environment via API',
icon: MicrosoftIcon,
featureId: undefined,
iconType: 'logo',
icon: Azure,
},
{
id: 'nomad',
title: 'Nomad',
value: 'nomad',
label: 'Nomad',
description: 'Connect to HashiCorp Nomad environment via API',
icon: NomadIcon,
featureId: FeatureId.NOMAD,
icon: Nomad,
iconType: 'logo',
feature: FeatureId.NOMAD,
disabledWhenLimited: true,
},
{
id: 'kaas',
title: 'KaaS',
value: 'kaas',
label: 'KaaS',
description: 'Provision a Kubernetes environment with a cloud provider',
icon: KaaSIcon,
featureId: FeatureId.KAAS_PROVISIONING,
iconType: 'logo',
feature: FeatureId.KAAS_PROVISIONING,
disabledWhenLimited: true,
},
] as const;
export const formTitles = {
dockerStandalone: 'Connect to your Docker Standalone environment',
dockerSwarm: 'Connect to your Docker Swarm environment',
kubernetes: 'Connect to your Kubernetes environment',
aci: 'Connect to your ACI environment',
nomad: 'Connect to your Nomad environment',
kaas: 'Provision a KaaS environment',
};

View File

@ -18,7 +18,10 @@ import { Button } from '@@/buttons';
import { FormSection } from '@@/form-components/FormSection';
import { Icon } from '@@/Icon';
import { environmentTypes } from '../EnvironmentTypeSelectView/environment-types';
import {
environmentTypes,
formTitles,
} from '../EnvironmentTypeSelectView/environment-types';
import { EnvironmentSelectorValue } from '../EnvironmentTypeSelectView/EnvironmentSelector';
import { WizardDocker } from './WizardDocker';
@ -77,10 +80,7 @@ export function EnvironmentCreationView() {
<Stepper steps={steps} currentStep={currentStepIndex + 1} />
<div className="mt-12">
<FormSection
title={`Connect to your ${currentStep.title}
environment`}
>
<FormSection title={formTitles[currentStep.id]}>
<Component
onCreate={handleCreateEnvironment}
isDockerStandalone={isDockerStandalone}

View File

@ -1,13 +1,12 @@
import { EnvironmentCreationTypes } from '@/react/portainer/environments/types';
import { BoxSelectorOption } from '@@/BoxSelector';
import { Value, BoxSelectorOption } from '@@/BoxSelector/types';
import { useCreateEdgeDeviceParam } from '../hooks/useCreateEdgeDeviceParam';
export function useFilterEdgeOptionsIfNeeded<T = EnvironmentCreationTypes>(
options: BoxSelectorOption<T>[],
edgeValue: T
) {
export function useFilterEdgeOptionsIfNeeded<
T extends Value = EnvironmentCreationTypes
>(options: BoxSelectorOption<T>[], edgeValue: T) {
const createEdgeDevice = useCreateEdgeDeviceParam();
if (!createEdgeDevice) {

View File

@ -20,6 +20,7 @@ export enum FeatureId {
REGISTRY_MANAGEMENT = 'registry-management',
K8S_SETUP_DEFAULT = 'k8s-setup-default',
S3_BACKUP_SETTING = 's3-backup-setting',
S3_RESTORE = 'restore-s3-form',
HIDE_INTERNAL_AUTHENTICATION_PROMPT = 'hide-internal-authentication-prompt',
TEAM_MEMBERSHIP = 'team-membership',
HIDE_INTERNAL_AUTH = 'hide-internal-auth',

View File

@ -28,6 +28,7 @@ export async function init(edition: Edition) {
[FeatureId.RBAC_ROLES]: Edition.BE,
[FeatureId.REGISTRY_MANAGEMENT]: Edition.BE,
[FeatureId.S3_BACKUP_SETTING]: Edition.BE,
[FeatureId.S3_RESTORE]: Edition.BE,
[FeatureId.TEAM_MEMBERSHIP]: Edition.BE,
[FeatureId.FORCE_REDEPLOYMENT]: Edition.BE,
[FeatureId.HIDE_AUTO_UPDATE_WINDOW]: Edition.BE,