feat(app): introduce component library in react [EE-1816] (#6236)

* refactor(app): replace notification with es6 service (#6015) [EE-1897]

chore(app): format

* refactor(containers): remove the dependency on angular modal service (#6017) [EE-1898]

* refactor(app): remove angular from http-request [EE-1899] (#6016)

* feat(app): add axios [EE-2035](#6077)

* refactor(feature): remove angular dependency from feature service [EE-2034] (#6078)

* refactor(app): replace box-selector with react component (#6046)

fix: rename angular2react

refactor(app): make box-selector type generic

feat(app): add story for box-selector

feat(app): test box-selector

feat(app): add stories for box selector item

fix(app): remove unneccesary element

refactor(app): remove assign

* feat(feature): add be-indicator in react [EE-2005] (#6106)

* refactor(app): add react components for headers [EE-1949] (#6023)

* feat(auth): provide user context

* feat(app): added base header component [EE-1949]

style(app): reformat

refactor(app/header): use same api as angular

* feat(app): add breadcrumbs component [EE-2024]

* feat(app): remove u element from user links

* fix(users): handle axios errors

Co-authored-by: Chaim Lev-Ari <chiptus@gmail.com>

* refactor(app): convert switch component to react [EE-2005] (#6025)

Co-authored-by: Marcelo Rydel <marcelorydel26@gmail.com>
pull/6279/head
Chaim Lev-Ari 2021-12-14 21:14:53 +02:00 committed by GitHub
parent eb9f6c77f4
commit 7ae5a3042c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
157 changed files with 3204 additions and 1469 deletions

View File

@ -13,10 +13,11 @@
},
{
"files": [
"*.{j,t}sx"
"*.{j,t}sx",
"*.ts"
],
"options": {
"printWidth": 80,
"printWidth": 80
}
}
]

View File

@ -1,5 +1,7 @@
import '../app/assets/css';
import { pushStateLocationPlugin, UIRouter } from '@uirouter/react';
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
@ -9,3 +11,11 @@ export const parameters = {
},
},
};
export const decorators = [
(Story) => (
<UIRouter plugins={[pushStateLocationPlugin]}>
<Story />
</UIRouter>
),
];

View File

@ -596,10 +596,6 @@ a[ng-click] {
background-color: var(--bg-log-line-selected-color);
}
.row.header .meta .page {
padding-top: 7px;
}
.tag:not(.token) {
padding: 2px 6px;
color: white;
@ -739,19 +735,6 @@ a[ng-click] {
}
/*!toaster override*/
/*angular-loading-bar override*/
#loadingbar-placeholder {
margin-bottom: 0;
height: 3px;
}
#loading-bar .bar {
position: relative;
height: 3px;
background: var(--blue-3);
}
/*!angular-loading-bar override*/
.monospaced {
font-family: monospace;
font-weight: 600;

View File

@ -48,94 +48,6 @@
}
}
/**
* Header
*/
.row.header {
height: 60px;
background: var(--bg-row-header-color);
margin-bottom: 15px;
}
.row.header > div:last-child {
padding-right: 0;
}
.row.header .meta .page {
font-size: 17px;
padding-top: 11px;
}
.row.header .meta .breadcrumb-links {
font-size: 10px;
}
.row.header .meta div {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row.header .login a {
padding: 18px;
display: block;
}
.row.header .user {
min-width: 130px;
}
.row.header .user > .item {
width: 65px;
height: 60px;
float: right;
display: inline-block;
text-align: center;
vertical-align: middle;
}
.row.header .user > .item a {
color: #919191;
display: block;
}
.row.header .user > .item i {
font-size: 20px;
line-height: 55px;
}
.row.header .user > .item img {
width: 40px;
height: 40px;
margin-top: 10px;
border-radius: 2px;
}
.row.header .user > .item ul.dropdown-menu {
border-radius: 2px;
-webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.05);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.05);
}
.row.header .user > .item ul.dropdown-menu .dropdown-header {
text-align: center;
}
.row.header .user > .item ul.dropdown-menu li.link {
text-align: left;
}
.row.header .user > .item ul.dropdown-menu li.link a {
padding-left: 7px;
padding-right: 7px;
}
.row.header .user > .item ul.dropdown-menu:before {
position: absolute;
top: -7px;
right: 23px;
display: inline-block;
border-right: 7px solid transparent;
border-bottom: 7px solid rgba(0, 0, 0, 0.2);
border-left: 7px solid transparent;
content: '';
}
.row.header .user > .item ul.dropdown-menu:after {
position: absolute;
top: -6px;
right: 24px;
display: inline-block;
border-right: 6px solid transparent;
border-bottom: 6px solid #ffffff;
border-left: 6px solid transparent;
content: '';
}
.loading {
width: 40px;
height: 40px;

View File

@ -1,6 +1,6 @@
import toastr from 'toastr';
import { Terminal } from 'xterm';
import * as fit from 'xterm/lib/addons/fit/fit';
import { agentInterceptor } from './portainer/services/axios';
/* @ngInject */
export function configApp($urlRouterProvider, $httpProvider, localStorageServiceProvider, jwtOptionsProvider, $uibTooltipProvider, $compileProvider, cfpLoadingBarProvider) {
@ -21,28 +21,9 @@ export function configApp($urlRouterProvider, $httpProvider, localStorageService
$httpProvider.defaults.headers.put['Content-Type'] = 'application/json';
$httpProvider.defaults.headers.patch['Content-Type'] = 'application/json';
$httpProvider.interceptors.push(
/* @ngInject */ function (HttpRequestHelper) {
return {
request(config) {
if (config.url.indexOf('/docker/') > -1) {
config.headers['X-PortainerAgent-Target'] = HttpRequestHelper.portainerAgentTargetHeader();
if (HttpRequestHelper.portainerAgentManagerOperation()) {
config.headers['X-PortainerAgent-ManagerOperation'] = '1';
}
}
return config;
},
};
}
);
toastr.options = {
timeOut: 3000,
closeButton: true,
progressBar: true,
tapToDismiss: false,
};
$httpProvider.interceptors.push(() => ({
request: agentInterceptor,
}));
Terminal.applyAddon(fit);

View File

@ -1,14 +1,15 @@
import { HIDE_AUTO_UPDATE_WINDOW } from 'Portainer/feature-flags/feature-ids';
import { FeatureId } from '@/portainer/feature-flags/enums';
export default class DockerFeaturesConfigurationController {
/* @ngInject */
constructor($async, EndpointService, Notifications, StateManager) {
constructor($async, $scope, EndpointService, Notifications, StateManager) {
this.$async = $async;
this.$scope = $scope;
this.EndpointService = EndpointService;
this.Notifications = Notifications;
this.StateManager = StateManager;
this.limitedFeature = HIDE_AUTO_UPDATE_WINDOW;
this.limitedFeature = FeatureId.HIDE_AUTO_UPDATE_WINDOW;
this.formValues = {
enableHostManagementFeatures: false,
@ -26,9 +27,45 @@ export default class DockerFeaturesConfigurationController {
this.state = {
actionInProgress: false,
autoUpdateSettings: { Enabled: false },
timeZone: '',
};
this.save = this.save.bind(this);
this.onChangeField = this.onChangeField.bind(this);
this.onToggleAutoUpdate = this.onToggleAutoUpdate.bind(this);
this.onChangeEnableHostManagementFeatures = this.onChangeField('enableHostManagementFeatures');
this.onChangeAllowVolumeBrowserForRegularUsers = this.onChangeField('allowVolumeBrowserForRegularUsers');
this.onChangeDisableBindMountsForRegularUsers = this.onChangeField('disableBindMountsForRegularUsers');
this.onChangeDisablePrivilegedModeForRegularUsers = this.onChangeField('disablePrivilegedModeForRegularUsers');
this.onChangeDisableHostNamespaceForRegularUsers = this.onChangeField('disableHostNamespaceForRegularUsers');
this.onChangeDisableStackManagementForRegularUsers = this.onChangeField('disableStackManagementForRegularUsers');
this.onChangeDisableDeviceMappingForRegularUsers = this.onChangeField('disableDeviceMappingForRegularUsers');
this.onChangeDisableContainerCapabilitiesForRegularUsers = this.onChangeField('disableContainerCapabilitiesForRegularUsers');
this.onChangeDisableSysctlSettingForRegularUsers = this.onChangeField('disableSysctlSettingForRegularUsers');
}
onToggleAutoUpdate(value) {
return this.$scope.$evalAsync(() => {
this.state.autoUpdateSettings.Enabled = value;
});
}
onChange(values) {
return this.$scope.$evalAsync(() => {
this.formValues = {
...this.formValues,
...values,
};
});
}
onChangeField(field) {
return (value) => {
this.onChange({
[field]: value,
});
};
}
isContainerEditDisabled() {

View File

@ -20,24 +20,26 @@
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
ng-model="$ctrl.formValues.enableHostManagementFeatures"
name="enableHostManagementFeatures"
label="Enable host management features"
tooltip="Enable host management features: host system browsing and advanced host details."
checked="$ctrl.formValues.enableHostManagementFeatures"
name="'enableHostManagementFeatures'"
label="'Enable host management features'"
tooltip="'Enable host management features: host system browsing and advanced host details.'"
label-class="'col-sm-7 col-lg-4'"
disabled="!$ctrl.isAgent"
label-class="col-sm-7 col-lg-4"
on-change="($ctrl.onChangeEnableHostManagementFeatures)"
></por-switch-field>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
ng-model="$ctrl.formValues.allowVolumeBrowserForRegularUsers"
name="allowVolumeBrowserForRegularUsers"
label="Enable volume management for non-administrators"
tooltip="When enabled, regular users will be able to use Portainer volume management features."
checked="$ctrl.formValues.allowVolumeBrowserForRegularUsers"
name="'allowVolumeBrowserForRegularUsers'"
label="'Enable volume management for non-administrators'"
tooltip="'When enabled, regular users will be able to use Portainer volume management features.'"
label-class="'col-sm-7 col-lg-4'"
on-change="($ctrl.onChangeAllowVolumeBrowserForRegularUsers)"
disabled="!$ctrl.isAgent"
label-class="col-sm-7 col-lg-4"
></por-switch-field>
</div>
</div>
@ -49,12 +51,13 @@
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
ng-model="$ctrl.state.autoUpdateSettings.Enabled"
name="disableSysctlSettingForRegularUsers"
label="Enable Change Window"
label-class="col-sm-7 col-lg-4"
feature="$ctrl.limitedFeature"
tooltip="Specify a timeframe during which automatic updates can occur in this environment."
checked="$ctrl.state.autoUpdateSettings.Enabled"
name="'disableSysctlSettingForRegularUsers'"
label="'Enable Change Window'"
label-class="'col-sm-7 col-lg-4'"
feature-id="$ctrl.limitedFeature"
tooltip="'Specify a time-frame during which automatic updates can occur in this environment.'"
on-change="($ctrl.onToggleAutoUpdate)"
>
</por-switch-field>
</div>
@ -67,73 +70,80 @@
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
ng-model="$ctrl.formValues.disableBindMountsForRegularUsers"
name="disableBindMountsForRegularUsers"
label="Disable bind mounts for non-administrators"
tooltip="When enabled, regular users will not be able to use bind mounts when creating containers."
label-class="col-sm-7 col-lg-4"
checked="$ctrl.formValues.disableBindMountsForRegularUsers"
name="'disableBindMountsForRegularUsers'"
label="'Disable bind mounts for non-administrators'"
tooltip="'When enabled, regular users will not be able to use bind mounts when creating containers.'"
label-class="'col-sm-7 col-lg-4'"
on-change="($ctrl.onChangeDisableBindMountsForRegularUsers)"
></por-switch-field>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
ng-model="$ctrl.formValues.disablePrivilegedModeForRegularUsers"
name="disablePrivilegedModeForRegularUsers"
label="Disable privileged mode for non-administrators"
tooltip="When enabled, regular users will not be able to use privileged mode when creating containers."
label-class="col-sm-7 col-lg-4"
checked="$ctrl.formValues.disablePrivilegedModeForRegularUsers"
name="'disablePrivilegedModeForRegularUsers'"
label="'Disable privileged mode for non-administrators'"
tooltip="'When enabled, regular users will not be able to use privileged mode when creating containers.'"
label-class="'col-sm-7 col-lg-4'"
on-change="($ctrl.onChangeDisablePrivilegedModeForRegularUsers)"
></por-switch-field>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
ng-model="$ctrl.formValues.disableHostNamespaceForRegularUsers"
name="disableHostNamespaceForRegularUsers"
label="Disable the use of host PID 1 for non-administrators"
tooltip="Prevent users from accessing the host filesystem through the host PID namespace."
label-class="col-sm-7 col-lg-4"
checked="$ctrl.formValues.disableHostNamespaceForRegularUsers"
name="'disableHostNamespaceForRegularUsers'"
label="'Disable the use of host PID 1 for non-administrators'"
tooltip="'Prevent users from accessing the host filesystem through the host PID namespace.'"
label-class="'col-sm-7 col-lg-4'"
on-change="($ctrl.onChangeDisableHostNamespaceForRegularUsers)"
></por-switch-field>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
ng-model="$ctrl.formValues.disableStackManagementForRegularUsers"
name="disableStackManagementForRegularUsers"
label="Disable the use of Stacks for non-administrators"
label-class="col-sm-7 col-lg-4"
checked="$ctrl.formValues.disableStackManagementForRegularUsers"
name="'disableStackManagementForRegularUsers'"
label="'Disable the use of Stacks for non-administrators'"
label-class="'col-sm-7 col-lg-4'"
on-change="($ctrl.onChangeDisableStackManagementForRegularUsers)"
></por-switch-field>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
ng-model="$ctrl.formValues.disableDeviceMappingForRegularUsers"
name="disableDeviceMappingForRegularUsers"
label="Disable device mappings for non-administrators"
label-class="col-sm-7 col-lg-4"
checked="$ctrl.formValues.disableDeviceMappingForRegularUsers"
name="'disableDeviceMappingForRegularUsers'"
label="'Disable device mappings for non-administrators'"
label-class="'col-sm-7 col-lg-4'"
on-change="($ctrl.onChangeDisableDeviceMappingForRegularUsers)"
></por-switch-field>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
ng-model="$ctrl.formValues.disableContainerCapabilitiesForRegularUsers"
name="disableContainerCapabilitiesForRegularUsers"
label="Disable container capabilities for non-administrators"
label-class="col-sm-7 col-lg-4"
checked="$ctrl.formValues.disableContainerCapabilitiesForRegularUsers"
name="'disableContainerCapabilitiesForRegularUsers'"
label="'Disable container capabilities for non-administrators'"
label-class="'col-sm-7 col-lg-4'"
on-change="($ctrl.onChangeDisableContainerCapabilitiesForRegularUsers)"
></por-switch-field>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
ng-model="$ctrl.formValues.disableSysctlSettingForRegularUsers"
name="disableSysctlSettingForRegularUsers"
label="Disable sysctl settings for non-administrators"
label-class="col-sm-7 col-lg-4"
checked="$ctrl.formValues.disableSysctlSettingForRegularUsers"
name="'disableSysctlSettingForRegularUsers'"
label="'Disable sysctl settings for non-administrators'"
label-class="'col-sm-7 col-lg-4'"
on-change="($ctrl.onChangeDisableSysctlSettingForRegularUsers)"
></por-switch-field>
</div>
</div>

View File

@ -1,4 +1,4 @@
<div class="col-sm-12 form-section-title">
Deployment type
</div>
<box-selector radio-name="deploymentType" ng-model="$ctrl.value" options="$ctrl.deploymentOptions" on-change="($ctrl.onChange)"></box-selector>
<box-selector radio-name="'deploymentType'" value="$ctrl.value" options="$ctrl.deploymentOptions" on-change="($ctrl.onChange)"></box-selector>

View File

@ -23,7 +23,8 @@ class DockerComposeFormController {
this.formValues = values;
}
onChangeMethod() {
onChangeMethod(method) {
this.state.Method = method;
this.formValues.StackFileContent = '';
this.selectedTemplate = null;
}

View File

@ -1,7 +1,7 @@
<div class="col-sm-12 form-section-title">
Build method
</div>
<box-selector radio-name="method" ng-model="$ctrl.state.Method" options="$ctrl.methodOptions" on-change="($ctrl.onChangeMethod)"></box-selector>
<box-selector 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,6 +10,7 @@ class KubeManifestFormController {
this.onChangeFileContent = this.onChangeFileContent.bind(this);
this.onChangeFormValues = this.onChangeFormValues.bind(this);
this.onChangeFile = this.onChangeFile.bind(this);
this.onChangeMethod = this.onChangeMethod.bind(this);
}
onChangeFormValues(values) {
@ -24,6 +25,10 @@ class KubeManifestFormController {
onChangeFile(value) {
this.formValues.StackFile = value;
}
onChangeMethod(method) {
this.state.Method = method;
}
}
export default KubeManifestFormController;

View File

@ -1,7 +1,7 @@
<div class="col-sm-12 form-section-title">
Build method
</div>
<box-selector radio-name="method" ng-model="$ctrl.state.Method" options="$ctrl.methodOptions" on-change="($ctrl.onChangeMethod)"></box-selector>
<box-selector 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

@ -16,6 +16,11 @@ import './portainer/__module';
import { onStartupAngular } from './app';
import { configApp } from './config';
import { init as initFeatureService } from './portainer/feature-flags/feature-flags.service';
import { Edition } from './portainer/feature-flags/enums';
initFeatureService(Edition.CE);
angular
.module('portainer', [
'ui.bootstrap',

View File

@ -1,4 +1,4 @@
import { buildOption } from '@/portainer/components/box-selector';
import { buildOption } from '@/portainer/components/BoxSelector';
import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel';
class KubeCreateCustomTemplateViewController {

View File

@ -14,7 +14,7 @@
<div class="col-sm-12 form-section-title">
Build method
</div>
<box-selector radio-name="method" ng-model="$ctrl.state.method" options="$ctrl.methodOptions" on-change="($ctrl.onChangeMethod)"></box-selector>
<box-selector 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

@ -149,11 +149,12 @@
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
ng-model="$ctrl.state.autoUpdateSettings.Enabled"
name="disableSysctlSettingForRegularUsers"
label="Enable Change Window"
feature="ctrl.limitedFeatureAutoWindow"
tooltip="Specify a timeframe during which automatic updates can occur in this environment."
checked="ctrl.state.autoUpdateSettings.Enabled"
name="'disableSysctlSettingForRegularUsers'"
label="'Enable Change Window'"
feature-id="ctrl.limitedFeatureAutoWindow"
tooltip="'Specify a timeframe during which automatic updates can occur in this environment.'"
on-change="(ctrl.onToggleAutoUpdate)"
>
</por-switch-field>
</div>
@ -197,12 +198,12 @@
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
label="Allow resource over-commit"
name="resource-over-commit-switch"
feature="ctrl.limitedFeature"
ng-model="ctrl.formValues.EnableResourceOverCommit"
ng-change="ctrl.onChangeEnableResourceOverCommit()"
ng-data-cy="kubeSetup-resourceOverCommitToggle"
data-cy="'kubeSetup-resourceOverCommitToggle'"
label="'Allow resource over-commit'"
name="'resource-over-commit-switch'"
feature-id="ctrl.limitedFeature"
checked="ctrl.formValues.EnableResourceOverCommit"
on-change="(ctrl.onChangeEnableResourceOverCommit)"
></por-switch-field>
</div>
</div>

View File

@ -6,8 +6,8 @@ import { KubernetesIngressClass } from 'Kubernetes/ingress/models';
import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper';
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
import { K8S_SETUP_DEFAULT } from '@/portainer/feature-flags/feature-ids';
import { HIDE_AUTO_UPDATE_WINDOW } from 'Portainer/feature-flags/feature-ids';
import { FeatureId } from '@/portainer/feature-flags/enums';
class KubernetesConfigureController {
/* #region CONSTRUCTOR */
@ -15,6 +15,7 @@ class KubernetesConfigureController {
constructor(
$async,
$state,
$scope,
Notifications,
KubernetesStorageService,
EndpointService,
@ -26,6 +27,7 @@ class KubernetesConfigureController {
) {
this.$async = $async;
this.$state = $state;
this.$scope = $scope;
this.Notifications = Notifications;
this.KubernetesStorageService = KubernetesStorageService;
this.EndpointService = EndpointService;
@ -39,8 +41,10 @@ class KubernetesConfigureController {
this.onInit = this.onInit.bind(this);
this.configureAsync = this.configureAsync.bind(this);
this.limitedFeature = K8S_SETUP_DEFAULT;
this.limitedFeatureAutoWindow = HIDE_AUTO_UPDATE_WINDOW;
this.limitedFeature = FeatureId.K8S_SETUP_DEFAULT;
this.limitedFeatureAutoWindow = FeatureId.HIDE_AUTO_UPDATE_WINDOW;
this.onToggleAutoUpdate = this.onToggleAutoUpdate.bind(this);
this.onChangeEnableResourceOverCommit = this.onChangeEnableResourceOverCommit.bind(this);
}
/* #endregion */
@ -103,6 +107,15 @@ class KubernetesConfigureController {
}
/* #endregion */
onChangeEnableResourceOverCommit(enabled) {
this.$scope.$evalAsync(() => {
this.formValues.EnableResourceOverCommit = enabled;
if (enabled) {
this.formValues.ResourceOverCommitPercentage = 20;
}
});
}
/* #region CONFIGURE */
assignFormValuesToEndpoint(endpoint, storageClasses, ingressClasses) {
endpoint.Kubernetes.Configuration.StorageClasses = storageClasses;
@ -244,6 +257,12 @@ class KubernetesConfigureController {
return this.formValues.RestrictDefaultNamespace && !this.oldFormValues.RestrictDefaultNamespace;
}
onToggleAutoUpdate(value) {
return this.$scope.$evalAsync(() => {
this.state.autoUpdateSettings.Enabled = value;
});
}
/* #region ON INIT */
async onInit() {
this.state = {

View File

@ -39,18 +39,24 @@
<div class="col-sm-12 form-section-title">
Build method
</div>
<box-selector radio-name="method" ng-model="ctrl.state.BuildMethod" options="ctrl.methodOptions" data-cy="k8sAppDeploy-buildSelector"></box-selector>
<box-selector
radio-name="'method'"
value="ctrl.state.BuildMethod"
options="ctrl.methodOptions"
data-cy="k8sAppDeploy-buildSelector"
on-change="(ctrl.onChangeMethod)"
></box-selector>
<div ng-if="ctrl.state.BuildMethod !== ctrl.BuildMethods.CUSTOM_TEMPLATE">
<div class="col-sm-12 form-section-title">
Deployment type
</div>
<box-selector
radio-name="deploy"
ng-model="ctrl.state.DeployType"
on-change="(ctrl.onDeployTypeChange)"
radio-name="'deploy'"
value="ctrl.state.DeployType"
options="ctrl.deployOptions"
data-cy="k8sAppDeploy-deploymentSelector"
on-change="(ctrl.onChangeDeployType)"
></box-selector>
</div>

View File

@ -5,7 +5,8 @@ import uuidv4 from 'uuid/v4';
import PortainerError from 'Portainer/error';
import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, KubernetesDeployRequestMethods, RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
import { buildOption } from '@/portainer/components/box-selector';
import { buildOption } from '@/portainer/components/BoxSelector';
class KubernetesDeployController {
/* @ngInject */
constructor($async, $state, $window, Authentication, ModalService, Notifications, KubernetesResourcePoolService, StackService, WebhookHelper, CustomTemplateService) {
@ -67,7 +68,8 @@ class KubernetesDeployController {
this.getNamespacesAsync = this.getNamespacesAsync.bind(this);
this.onChangeFormValues = this.onChangeFormValues.bind(this);
this.buildAnalyticsProperties = this.buildAnalyticsProperties.bind(this);
this.onDeployTypeChange = this.onDeployTypeChange.bind(this);
this.onChangeMethod = this.onChangeMethod.bind(this);
this.onChangeDeployType = this.onChangeDeployType.bind(this);
}
buildAnalyticsProperties() {
@ -122,6 +124,19 @@ class KubernetesDeployController {
}
}
onChangeMethod(method) {
this.state.BuildMethod = method;
}
onChangeDeployType(type) {
this.state.DeployType = type;
if (type == this.ManifestDeployTypes.COMPOSE) {
this.DeployMethod = 'compose';
} else {
this.DeployMethod = 'manifest';
}
}
disableDeploy() {
const isGitFormInvalid =
this.state.BuildMethod === KubernetesDeployBuildMethods.GIT &&
@ -275,14 +290,6 @@ class KubernetesDeployController {
return this.$async(this.getNamespacesAsync);
}
onDeployTypeChange(value) {
if (value == this.ManifestDeployTypes.COMPOSE) {
this.DeployMethod = 'compose';
} else {
this.DeployMethod = 'manifest';
}
}
async uiCanExit() {
if (this.formValues.EditorContent && this.state.isEditorDirty) {
return this.ModalService.confirmWebEditorDiscard();

View File

@ -0,0 +1,14 @@
import angular from 'angular';
import controller from './storage-class-switch.controller.js';
export const storageClassSwitch = {
templateUrl: './storage-class-switch.html',
controller,
bindings: {
value: '<',
onChange: '<',
name: '<',
},
};
angular.module('portainer.kubernetes').component('storageClassSwitch', storageClassSwitch);

View File

@ -0,0 +1,16 @@
import { FeatureId } from '@/portainer/feature-flags/enums';
class StorageClassSwitchController {
/* @ngInject */
constructor() {
this.featureId = FeatureId.K8S_RESOURCE_POOL_STORAGE_QUOTA;
this.handleChange = this.handleChange.bind(this);
}
handleChange(value) {
this.onChange(this.name, value);
}
}
export default StorageClassSwitchController;

View File

@ -0,0 +1,12 @@
<div class="form-group">
<div class="col-sm-2">
<por-switch-field
data-cy="'k8sNamespaceCreate-enableQuotaToggle'"
label="'Enable quota'"
name="'k8s-resourcepool-storagequota'"
feature-id="$ctrl.featureId"
checked="$ctrl.value"
on-change="($ctrl.handleChange)"
></por-switch-field>
</div>
</div>

View File

@ -163,11 +163,12 @@
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
ng-data-cy="k8sNamespaceCreate-loadBalancerQuotaToggle"
label="Load Balancer quota"
name="k8s-resourcepool-Ibquota"
feature="$ctrl.LBQuotaFeatureId"
ng-model="lbquota"
data-cy="'k8sNamespaceCreate-loadBalancerQuotaToggle'"
label="'Load Balancer quota'"
name="'k8s-resourcepool-lbquota'"
feature-id="$ctrl.LBQuotaFeatureId"
checked="$ctrl.formValues.UseLoadBalancersQuota"
on-change="($ctrl.onToggleLoadBalancerQuota)"
></por-switch-field>
</div>
</div>
@ -189,17 +190,9 @@
<i class="fa fa-route" aria-hidden="true" style="margin-right: 2px;"></i>
standard
</div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
ng-data-cy="k8sNamespaceCreate-enableQuotaToggle"
label="Enable quota"
name="k8s-resourcepool-storagequota"
feature="$ctrl.StorageQuotaFeatureId"
ng-model="storagequota"
></por-switch-field>
</div>
</div>
<storage-class-switch value="sc.Selected" name="sc.Name" on-change="(ctrl.onToggleStorageQuota)" authorization="K8sResourcePoolDetailsW"></storage-class-switch>
<!-- #endregion -->
<div ng-if="$ctrl.state.canUseIngress">

View File

@ -12,15 +12,16 @@ import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHel
import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues';
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
import { K8S_RESOURCE_POOL_LB_QUOTA, K8S_RESOURCE_POOL_STORAGE_QUOTA } from '@/portainer/feature-flags/feature-ids';
import { FeatureId } from '@/portainer/feature-flags/enums';
class KubernetesCreateResourcePoolController {
/* #region CONSTRUCTOR */
/* @ngInject */
constructor($async, $state, Notifications, KubernetesNodeService, KubernetesResourcePoolService, KubernetesIngressService, Authentication, EndpointService) {
constructor($async, $state, $scope, Notifications, KubernetesNodeService, KubernetesResourcePoolService, KubernetesIngressService, Authentication, EndpointService) {
Object.assign(this, {
$async,
$state,
$scope,
Notifications,
KubernetesNodeService,
KubernetesResourcePoolService,
@ -30,11 +31,25 @@ class KubernetesCreateResourcePoolController {
});
this.IngressClassTypes = KubernetesIngressClassTypes;
this.LBQuotaFeatureId = K8S_RESOURCE_POOL_LB_QUOTA;
this.StorageQuotaFeatureId = K8S_RESOURCE_POOL_STORAGE_QUOTA;
this.LBQuotaFeatureId = FeatureId.K8S_RESOURCE_POOL_LB_QUOTA;
this.onToggleStorageQuota = this.onToggleStorageQuota.bind(this);
this.onToggleLoadBalancerQuota = this.onToggleLoadBalancerQuota.bind(this);
}
/* #endregion */
onToggleStorageQuota(storageClassName, enabled) {
this.$scope.$evalAsync(() => {
this.formValues.StorageClasses = this.formValues.StorageClasses.map((sClass) => (sClass.Name !== storageClassName ? sClass : { ...sClass, Selected: enabled }));
});
}
onToggleLoadBalancerQuota(enabled) {
this.$scope.$evalAsync(() => {
this.formValues.UseLoadBalancersQuota = enabled;
});
}
onChangeIngressHostname() {
const state = this.state.duplicates.ingressHosts;
const hosts = _.flatMap(this.formValues.IngressClasses, 'Hosts');

View File

@ -147,11 +147,12 @@
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
ng-data-cy="k8sNamespaceCreate-loadBalancerQuotaToggle"
label="Load Balancer quota"
name="k8s-resourcepool-Lbquota"
feature="ctrl.LBQuotaFeatureId"
ng-model="lbquota"
data-cy="'k8sNamespaceCreate-loadBalancerQuotaToggle'"
label="'Load Balancer quota'"
name="'k8s-resourcepool-Lbquota'"
feature-id="ctrl.LBQuotaFeatureId"
checked="ctrl.formValues.UseLoadBalancersQuota"
on-change="(ctrl.onToggleLoadBalancersQuota)"
></por-switch-field>
</div>
</div>
@ -386,17 +387,9 @@
<i class="fa fa-route" aria-hidden="true" style="margin-right: 2px;"></i>
standard
</div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
ng-data-cy="k8sNamespaceCreate-enableQuotaToggle"
label="Enable quota"
name="k8s-resourcepool-storagequota"
feature="ctrl.StorageQuotaFeatureId"
ng-model="storagequota"
></por-switch-field>
</div>
</div>
<storage-class-switch value="sc.Selected" name="sc.Name" on-change="(ctrl.onToggleStorageQuota)" authorization="K8sResourcePoolDetailsW"></storage-class-switch>
<!-- #endregion -->
<!-- summary -->

View File

@ -16,7 +16,7 @@ import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHel
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
import KubernetesResourceQuotaConverter from 'Kubernetes/converters/resourceQuota';
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
import { K8S_RESOURCE_POOL_LB_QUOTA, K8S_RESOURCE_POOL_STORAGE_QUOTA } from '@/portainer/feature-flags/feature-ids';
import { FeatureId } from '@/portainer/feature-flags/enums';
class KubernetesResourcePoolController {
/* #region CONSTRUCTOR */
@ -24,6 +24,7 @@ class KubernetesResourcePoolController {
constructor(
$async,
$state,
$scope,
Authentication,
Notifications,
LocalStorage,
@ -42,6 +43,7 @@ class KubernetesResourcePoolController {
Object.assign(this, {
$async,
$state,
$scope,
Authentication,
Notifications,
LocalStorage,
@ -61,11 +63,13 @@ class KubernetesResourcePoolController {
this.IngressClassTypes = KubernetesIngressClassTypes;
this.ResourceQuotaDefaults = KubernetesResourceQuotaDefaults;
this.LBQuotaFeatureId = K8S_RESOURCE_POOL_LB_QUOTA;
this.StorageQuotaFeatureId = K8S_RESOURCE_POOL_STORAGE_QUOTA;
this.LBQuotaFeatureId = FeatureId.K8S_RESOURCE_POOL_LB_QUOTA;
this.StorageQuotaFeatureId = FeatureId.K8S_RESOURCE_POOL_STORAGE_QUOTA;
this.updateResourcePoolAsync = this.updateResourcePoolAsync.bind(this);
this.getEvents = this.getEvents.bind(this);
this.onToggleLoadBalancersQuota = this.onToggleLoadBalancersQuota.bind(this);
this.onToggleStorageQuota = this.onToggleStorageQuota.bind(this);
}
/* #endregion */
@ -80,6 +84,18 @@ class KubernetesResourcePoolController {
}
/* #endregion */
onToggleLoadBalancersQuota(checked) {
return this.$scope.$evalAsync(() => {
this.formValues.UseLoadBalancersQuota = checked;
});
}
onToggleStorageQuota(storageClassName, enabled) {
this.$scope.$evalAsync(() => {
this.formValues.StorageClasses = this.formValues.StorageClasses.map((sClass) => (sClass.Name !== storageClassName ? sClass : { ...sClass, Selected: enabled }));
});
}
/* #region INGRESS MANAGEMENT */
onChangeIngressHostname() {
const state = this.state.duplicates.ingressHosts;

View File

@ -5,6 +5,7 @@ import componentsModule from './components';
import settingsModule from './settings';
import featureFlagModule from './feature-flags';
import userActivityModule from './user-activity';
import servicesModule from './services';
async function initAuthentication(authManager, Authentication, $rootScope, $state) {
authManager.checkAuthOnRefresh();
@ -22,12 +23,19 @@ async function initAuthentication(authManager, Authentication, $rootScope, $stat
}
angular
.module('portainer.app', ['portainer.oauth', 'portainer.rbac', componentsModule, settingsModule, featureFlagModule, userActivityModule, 'portainer.shared.datatable'])
.module('portainer.app', [
'portainer.oauth',
'portainer.rbac',
componentsModule,
settingsModule,
featureFlagModule,
userActivityModule,
'portainer.shared.datatable',
servicesModule,
])
.config([
'$stateRegistryProvider',
function ($stateRegistryProvider) {
'use strict';
var root = {
name: 'root',
abstract: true,
@ -56,18 +64,6 @@ angular
controller: 'SidebarController',
},
},
resolve: {
featuresServiceInitialized: /* @ngInject */ function featuresServiceInitialized($async, featureService, Notifications) {
return $async(async () => {
try {
await featureService.init();
} catch (e) {
Notifications.error('Failed initializing features service', e);
throw e;
}
});
},
},
};
var endpointRoot = {

View File

@ -0,0 +1,24 @@
import { FeatureId } from '@/portainer/feature-flags/enums';
import { getFeatureDetails } from './utils';
export default class BeIndicatorController {
limitedToBE?: boolean;
url?: string;
feature?: FeatureId;
/* @ngInject */
constructor() {
this.limitedToBE = false;
this.url = '';
}
$onInit() {
const { url, limitedToBE } = getFeatureDetails(this.feature);
this.limitedToBE = limitedToBE;
this.url = url;
}
}

View File

@ -0,0 +1,25 @@
import { Meta } from '@storybook/react';
import { Edition, FeatureId } from '@/portainer/feature-flags/enums';
import { init as initFeatureService } from '@/portainer/feature-flags/feature-flags.service';
import { BEFeatureIndicator, Props } from './BEFeatureIndicator';
export default {
component: BEFeatureIndicator,
title: 'Components/BEFeatureIndicator',
argTypes: {
featureId: {
control: { type: 'select', options: Object.values(FeatureId) },
},
},
} as Meta<Props>;
// : JSX.IntrinsicAttributes & PropsWithChildren<Props>
function Template({ featureId }: Props) {
initFeatureService(Edition.CE);
return <BEFeatureIndicator featureId={featureId} />;
}
export const Example = Template.bind({});

View File

@ -0,0 +1,33 @@
import { PropsWithChildren } from 'react';
import { FeatureId } from '@/portainer/feature-flags/enums';
import { getFeatureDetails } from './utils';
export interface Props {
featureId?: FeatureId;
}
export function BEFeatureIndicator({
featureId,
children,
}: PropsWithChildren<Props>) {
const { url, limitedToBE } = getFeatureDetails(featureId);
if (!limitedToBE) {
return null;
}
return (
<a
className="be-indicator"
href={url}
target="_blank"
rel="noopener noreferrer"
>
{children}
<i className="fas fa-briefcase space-right be-indicator-icon" />
<span className="be-indicator-label">Business Edition Feature</span>
</a>
);
}

View File

@ -0,0 +1,14 @@
import controller from './BEFeatureIndicator.controller';
import './BEFeatureIndicator.css';
export const beFeatureIndicatorAngular = {
templateUrl: './BEFeatureIndicator.html',
controller,
bindings: {
feature: '<',
},
transclude: true,
};
export { BEFeatureIndicator } from './BEFeatureIndicator';

View File

@ -0,0 +1,15 @@
import { FeatureId } from '@/portainer/feature-flags/enums';
import { isLimitedToBE } from '@/portainer/feature-flags/feature-flags.service';
const BE_URL = 'https://www.portainer.io/business-upsell?from=';
export function getFeatureDetails(featureId?: FeatureId) {
if (!featureId) {
return {};
}
const url = `${BE_URL}${featureId}`;
const limitedToBE = isLimitedToBE(featureId);
return { url, limitedToBE };
}

View File

@ -0,0 +1,4 @@
.root {
float: left;
width: 100%;
}

View File

@ -0,0 +1,83 @@
import { Meta } from '@storybook/react';
import { useState } from 'react';
import { init as initFeatureService } from '@/portainer/feature-flags/feature-flags.service';
import { Edition, FeatureId } from '@/portainer/feature-flags/enums';
import { BoxSelector } from './BoxSelector';
import { BoxSelectorOption } from './types';
const meta: Meta = {
title: 'BoxSelector',
component: BoxSelector,
};
export default meta;
export function Example() {
const [value, setValue] = useState(3);
const options: BoxSelectorOption<number>[] = [
{
description: 'description 1',
icon: 'fa fa-rocket',
id: '1',
value: 3,
label: 'option 1',
},
{
description: 'description 2',
icon: 'fa fa-rocket',
id: '2',
value: 4,
label: 'option 2',
},
];
return (
<BoxSelector
radioName="name"
onChange={(value: number) => {
setValue(value);
}}
value={value}
options={options}
/>
);
}
export function LimitedFeature() {
initFeatureService(Edition.CE);
const [value, setValue] = useState(3);
const options: BoxSelectorOption<number>[] = [
{
description: 'description 1',
icon: 'fa fa-rocket',
id: '1',
value: 3,
label: 'option 1',
},
{
description: 'description 2',
icon: 'fa fa-rocket',
id: '2',
value: 4,
label: 'option 2',
feature: FeatureId.ACTIVITY_AUDIT,
},
];
return (
<BoxSelector
radioName="name"
onChange={(value: number) => {
setValue(value);
}}
value={value}
options={options}
/>
);
}
// regular example
// story with limited feature

View File

@ -0,0 +1,59 @@
import { render, fireEvent } from '@/react-tools/test-utils';
import { BoxSelector, Props } from './BoxSelector';
import { BoxSelectorOption } from './types';
function renderDefault<T extends string | number>({
options = [],
onChange = () => {},
radioName = 'radio',
value,
}: Partial<Props<T>> = {}) {
return render(
<BoxSelector
options={options}
onChange={onChange}
radioName={radioName}
value={value || 0}
/>
);
}
test('should render with the initial value selected and call onChange when clicking a different value', async () => {
const options: BoxSelectorOption<number>[] = [
{
description: 'description 1',
icon: 'fa fa-rocket',
id: '1',
value: 3,
label: 'option 1',
},
{
description: 'description 2',
icon: 'fa fa-rocket',
id: '2',
value: 4,
label: 'option 2',
},
];
const onChange = jest.fn();
const { getByLabelText } = renderDefault({
options,
onChange,
value: options[0].value,
});
const item1 = getByLabelText(options[0].label, {
exact: false,
}) as HTMLInputElement;
expect(item1.checked).toBeTruthy();
const item2 = getByLabelText(options[1].label, {
exact: false,
}) as HTMLInputElement;
expect(item2.checked).toBeFalsy();
fireEvent.click(item2);
expect(onChange).toHaveBeenCalledWith(options[1].value, false);
});

View File

@ -0,0 +1,49 @@
import clsx from 'clsx';
import type { FeatureId } from '@/portainer/feature-flags/enums';
import './BoxSelector.css';
import styles from './BoxSelector.module.css';
import { BoxSelectorItem } from './BoxSelectorItem';
import { BoxSelectorOption } from './types';
export interface Props<T extends number | string> {
radioName: string;
value: T;
onChange(value: T, limitedToBE: boolean): void;
options: BoxSelectorOption<T>[];
}
export function BoxSelector<T extends number | string>({
radioName,
value,
options,
onChange,
}: Props<T>) {
return (
<div className={clsx('boxselector_wrapper', styles.root)}>
{options.map((option) => (
<BoxSelectorItem
key={option.id}
radioName={radioName}
option={option}
onChange={onChange}
selectedValue={value}
disabled={option.disabled && option.disabled()}
tooltip={option.tooltip && option.tooltip()}
/>
))}
</div>
);
}
export function buildOption<T extends number | string>(
id: string,
icon: string,
label: string,
description: string,
value: T,
feature: FeatureId
): BoxSelectorOption<T> {
return { id, icon, label, description, value, feature };
}

View File

@ -0,0 +1,49 @@
import {
IComponentOptions,
IComponentController,
IFormController,
IScope,
} from 'angular';
class BoxSelectorController implements IComponentController {
formCtrl!: IFormController;
onChange!: (value: string | number) => void;
radioName!: string;
$scope: IScope;
/* @ngInject */
constructor($scope: IScope) {
this.handleChange = this.handleChange.bind(this);
this.$scope = $scope;
}
handleChange(value: string | number, limitedToBE: boolean) {
this.$scope.$evalAsync(() => {
this.formCtrl.$setValidity(this.radioName, !limitedToBE, this.formCtrl);
this.onChange(value);
});
}
}
export const BoxSelectorAngular: IComponentOptions = {
template: `<box-selector-react
value="$ctrl.value"
on-change="$ctrl.handleChange"
options="$ctrl.options"
radio-name="$ctrl.radioName"
></box-selector-react>`,
bindings: {
value: '<',
onChange: '<',
options: '<',
radioName: '<',
},
require: {
formCtrl: '^form',
},
controller: BoxSelectorController,
};

View File

@ -0,0 +1,77 @@
import { Meta } from '@storybook/react';
import { init as initFeatureService } from '@/portainer/feature-flags/feature-flags.service';
import { Edition, FeatureId } from '@/portainer/feature-flags/enums';
import { BoxSelectorItem } from './BoxSelectorItem';
import { BoxSelectorOption } from './types';
const meta: Meta = {
title: 'BoxSelector/Item',
args: {
selected: false,
description: 'description',
icon: 'fa-rocket',
label: 'label',
},
};
export default meta;
interface ExampleProps {
selected?: boolean;
description?: string;
icon?: string;
label?: string;
feature?: FeatureId;
}
function Template({
selected,
description = 'description',
icon,
label = 'label',
feature,
}: ExampleProps) {
const option: BoxSelectorOption<number> = {
description,
icon: `fa ${icon}`,
id: 'id',
label,
value: 1,
feature,
};
return (
<div className="boxselector_wrapper">
<BoxSelectorItem
onChange={() => {}}
option={option}
radioName="radio"
selectedValue={selected ? option.value : 0}
/>
</div>
);
}
export const Example = Template.bind({});
export function SelectedItem() {
return <Template selected />;
}
SelectedItem.args = {
selected: true,
};
export function LimitedFeatureItem() {
initFeatureService(Edition.CE);
return <Template feature={FeatureId.ACTIVITY_AUDIT} />;
}
export function SelectedLimitedFeatureItem() {
initFeatureService(Edition.CE);
return <Template feature={FeatureId.ACTIVITY_AUDIT} selected />;
}

View File

@ -0,0 +1,77 @@
import clsx from 'clsx';
import ReactTooltip from 'react-tooltip';
import { isLimitedToBE } from '@/portainer/feature-flags/feature-flags.service';
import './BoxSelectorItem.css';
import { BoxSelectorOption } from './types';
interface Props<T extends number | string> {
radioName: string;
option: BoxSelectorOption<T>;
onChange(value: T, limitedToBE: boolean): void;
selectedValue: T;
disabled?: boolean;
tooltip?: string;
}
export function BoxSelectorItem<T extends number | string>({
radioName,
option,
onChange,
selectedValue,
disabled,
tooltip,
}: Props<T>) {
const limitedToBE = isLimitedToBE(option.feature);
const tooltipId = `box-selector-item-${radioName}-${option.id}`;
return (
<div
className={clsx('box-selector-item', {
business: limitedToBE,
limited: limitedToBE,
})}
data-tip
data-for={tooltipId}
tooltip-append-to-body="true"
tooltip-placement="bottom"
tooltip-class="portainer-tooltip"
>
<input
type="radio"
name={radioName}
id={option.id}
checked={option.value === selectedValue}
value={option.value}
disabled={disabled}
onChange={() => onChange(option.value, limitedToBE)}
/>
<label htmlFor={option.id} data-cy={`${radioName}_${option.value}`}>
{limitedToBE && <i className="fas fa-briefcase limited-icon" />}
<div className="boxselector_header">
{!!option.icon && (
<i
className={clsx(option.icon, 'space-right')}
aria-hidden="true"
/>
)}
{option.label}
</div>
<p className="box-selector-item-description">{option.description}</p>
</label>
{tooltip && (
<ReactTooltip
place="bottom"
className="portainer-tooltip"
id={tooltipId}
>
{tooltip}
</ReactTooltip>
)}
</div>
);
}

View File

@ -0,0 +1,11 @@
import angular from 'angular';
import { react2angular } from '@/react-tools/react2angular';
import { BoxSelector, buildOption } from './BoxSelector';
import { BoxSelectorAngular } from './BoxSelectorAngular';
export { BoxSelector, buildOption };
const BoxSelectorReact = react2angular(BoxSelector, ['value', 'onChange', 'options', 'radioName']);
export default angular.module('app.portainer.component.box-selector', []).component('boxSelectorReact', BoxSelectorReact).component('boxSelector', BoxSelectorAngular).name;

View File

@ -0,0 +1,12 @@
import type { FeatureId } from '@/portainer/feature-flags/enums';
export interface BoxSelectorOption<T> {
id: string;
icon: string;
label: string;
description: string;
value: T;
disabled?: () => boolean;
tooltip?: () => string;
feature?: FeatureId;
}

View File

@ -0,0 +1,3 @@
.breadcrumb-links {
font-size: 10px;
}

View File

@ -0,0 +1,27 @@
import { Meta } from '@storybook/react';
import { UIRouter, pushStateLocationPlugin } from '@uirouter/react';
import { Link } from '@/portainer/components/Link';
import { Breadcrumbs } from './Breadcrumbs';
const meta: Meta = {
title: 'Components/Header/Breadcrumbs',
component: Breadcrumbs,
};
export default meta;
export function Example() {
return (
<UIRouter plugins={[pushStateLocationPlugin]}>
<Breadcrumbs>
<Link to="portainer.endpoints">Environments</Link>
<Link to="portainer.endpoints.endpoint({id: endpoint.Id})">
endpointName
</Link>
String item
</Breadcrumbs>
</UIRouter>
);
}

View File

@ -0,0 +1,20 @@
import { ReactNode } from 'react';
import './Breadcrumbs.css';
interface Props {
children: ReactNode[];
}
export function Breadcrumbs({ children }: Props) {
return (
<div className="breadcrumb-links">
{children.map((child, index) => (
<>
{child}
{index !== children.length - 1 ? ' > ' : ''}
</>
))}
</div>
);
}

View File

@ -0,0 +1,103 @@
.row.header .meta .page {
padding-top: 7px;
}
body.hamburg .row.header .meta {
margin-left: 70px;
}
.row.header {
height: 60px;
background: var(--bg-row-header-color);
margin-bottom: 15px;
}
.row.header > div:last-child {
padding-right: 0;
}
.row.header .meta .page {
font-size: 17px;
padding-top: 11px;
}
.row.header .meta div {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row.header .login a {
padding: 18px;
display: block;
}
.row.header .user {
min-width: 130px;
}
.row.header .user > .item {
width: 65px;
height: 60px;
float: right;
display: inline-block;
text-align: center;
vertical-align: middle;
}
.row.header .user > .item a {
color: #919191;
display: block;
}
.row.header .user > .item i {
font-size: 20px;
line-height: 55px;
}
.row.header .user > .item img {
width: 40px;
height: 40px;
margin-top: 10px;
border-radius: 2px;
}
.row.header .user > .item ul.dropdown-menu {
border-radius: 2px;
-webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.05);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.05);
}
.row.header .user > .item ul.dropdown-menu .dropdown-header {
text-align: center;
}
.row.header .user > .item ul.dropdown-menu li.link {
text-align: left;
}
.row.header .user > .item ul.dropdown-menu li.link a {
padding-left: 7px;
padding-right: 7px;
}
.row.header .user > .item ul.dropdown-menu:before {
position: absolute;
top: -7px;
right: 23px;
display: inline-block;
border-right: 7px solid transparent;
border-bottom: 7px solid rgba(0, 0, 0, 0.2);
border-left: 7px solid transparent;
content: '';
}
.row.header .user > .item ul.dropdown-menu:after {
position: absolute;
top: -6px;
right: 24px;
display: inline-block;
border-right: 6px solid transparent;
border-bottom: 6px solid #ffffff;
border-left: 6px solid transparent;
content: '';
}
/*angular-loading-bar override*/
#loadingbar-placeholder {
margin-bottom: 0;
height: 3px;
}
#loading-bar .bar {
position: relative;
height: 3px;
background: var(--blue-3);
}
/*!angular-loading-bar override*/

View File

@ -0,0 +1,4 @@
<div class="row header">
<div id="loadingbar-placeholder"></div>
<div class="col-xs-12"> <div class="meta" ng-transclude></div></div>
</div>

View File

@ -0,0 +1,42 @@
import { Meta, Story } from '@storybook/react';
import { Link } from '@/portainer/components/Link';
import { UserContext } from '@/portainer/hooks/useUser';
import { UserViewModel } from '@/portainer/models/user';
import { Header } from './Header';
import { Breadcrumbs } from './Breadcrumbs/Breadcrumbs';
import { HeaderContent, HeaderTitle } from '.';
export default {
component: Header,
title: 'Components/Header',
} as Meta;
interface StoryProps {
title: string;
}
function Template({ title }: StoryProps) {
return (
<UserContext.Provider
value={{ user: new UserViewModel({ Username: 'test' }) }}
>
<Header>
<HeaderTitle title={title} />
<HeaderContent>
<Breadcrumbs>
<Link to="example">Container instances</Link>
Add container
</Breadcrumbs>
</HeaderContent>
</Header>
</UserContext.Provider>
);
}
export const Primary: Story<StoryProps> = Template.bind({});
Primary.args = {
title: 'Container details',
};

View File

@ -0,0 +1,31 @@
import { PropsWithChildren, createContext, useContext } from 'react';
import './Header.css';
const Context = createContext<null | boolean>(null);
export function useHeaderContext() {
const context = useContext(Context);
if (context == null) {
throw new Error('Should be nested inside a Header component');
}
}
export function Header({ children }: PropsWithChildren<unknown>) {
return (
<Context.Provider value>
<div className="row header">
<div id="loadingbar-placeholder" />
<div className="col-xs-12">
<div className="meta">{children}</div>
</div>
</div>
</Context.Provider>
);
}
export const HeaderAngular = {
transclude: true,
templateUrl: './Header.html',
};

View File

@ -0,0 +1,15 @@
export default class HeaderContentController {
/* @ngInject */
constructor(Authentication) {
this.Authentication = Authentication;
this.username = null;
}
$onInit() {
const userDetails = this.Authentication.getUserDetails();
if (userDetails) {
this.username = userDetails.username;
}
}
}

View File

@ -0,0 +1,11 @@
<div class="breadcrumb-links">
<div class="pull-left" ng-transclude></div>
<div class="pull-right" ng-if="$ctrl.username">
<a ui-sref="portainer.account" style="margin-right: 5px;">
<u><i class="fa fa-wrench" aria-hidden="true"></i> my account </u>
</a>
<a ui-sref="portainer.logout({performApiLogout: true})" class="text-danger" style="margin-right: 25px;" data-cy="template-logoutButton">
<u><i class="fa fa-sign-out-alt" aria-hidden="true"></i> log out</u>
</a>
</div>
</div>

View File

@ -0,0 +1,19 @@
.user-links {
margin-right: 25px;
}
.user-links > * + * {
margin-left: 5px;
}
.link {
cursor: pointer;
}
.link .link-text {
text-decoration: underline;
}
.link .link-icon {
margin-right: 2px;
}

View File

@ -0,0 +1,50 @@
import { PropsWithChildren } from 'react';
import clsx from 'clsx';
import { Link } from '@/portainer/components/Link';
import { useUser } from '@/portainer/hooks/useUser';
import controller from './HeaderContent.controller';
import styles from './HeaderContent.module.css';
import { useHeaderContext } from './Header';
export function HeaderContent({ children }: PropsWithChildren<unknown>) {
useHeaderContext();
const { user } = useUser();
return (
<div className="breadcrumb-links">
<div className="pull-left">{children}</div>
{user && (
<div className={clsx('pull-right', styles.userLinks)}>
<Link to="portainer.account" className={styles.link}>
<i
className={clsx('fa fa-wrench', styles.linkIcon)}
aria-hidden="true"
/>
<span className={styles.linkText}>my account</span>
</Link>
<Link
to="portainer.logout"
params={{ performApiLogout: true }}
className={clsx('text-danger', styles.link)}
data-cy="template-logoutButton"
>
<i
className={clsx('fa fa-sign-out-alt', styles.linkIcon)}
aria-hidden="true"
/>
<span className={styles.linkText}>log out</span>
</Link>
</div>
)}
</div>
);
}
export const HeaderContentAngular = {
requires: '^rdHeader',
transclude: true,
templateUrl: './HeaderContent.html',
controller,
};

View File

@ -0,0 +1,15 @@
export default class HeaderTitle {
/* @ngInject */
constructor(Authentication) {
this.Authentication = Authentication;
this.username = null;
}
$onInit() {
const userDetails = this.Authentication.getUserDetails();
if (userDetails) {
this.username = userDetails.username;
}
}
}

View File

@ -0,0 +1,5 @@
<div class="page white-space-normal">
{{ $ctrl.titleText }}
<span class="header_title_content" ng-transclude></span>
<span class="pull-right user-box" ng-if="$ctrl.username"> <i class="fa fa-user-circle" aria-hidden="true"></i> {{ $ctrl.username }} </span>
</div>

View File

@ -0,0 +1,37 @@
import { PropsWithChildren } from 'react';
import { useUser } from '@/portainer/hooks/useUser';
import { useHeaderContext } from './Header';
import controller from './HeaderTitle.controller';
interface Props {
title: string;
}
export function HeaderTitle({ title, children }: PropsWithChildren<Props>) {
useHeaderContext();
const { user } = useUser();
return (
<div className="page white-space-normal">
{title}
<span className="header_title_content">{children}</span>
{user && (
<span className="pull-right user-box">
<i className="fa fa-user-circle" aria-hidden="true" /> {user.Username}
</span>
)}
</div>
);
}
export const HeaderTitleAngular = {
requires: '^rdHeader',
bindings: {
titleText: '@',
},
transclude: true,
templateUrl: './HeaderTitle.html',
controller,
};

View File

@ -0,0 +1,14 @@
import angular from 'angular';
import { Header, HeaderAngular } from './Header';
import { HeaderContent, HeaderContentAngular } from './HeaderContent';
import { HeaderTitle, HeaderTitleAngular } from './HeaderTitle';
export { Header, HeaderTitle, HeaderContent };
export default angular
.module('portainer.app.components.header', [])
.component('rdHeader', HeaderAngular)
.component('rdHeaderContent', HeaderContentAngular)
.component('rdHeaderTitle', HeaderTitleAngular).name;

View File

@ -1,15 +1,20 @@
import { ReactNode } from 'react';
import { PropsWithChildren } from 'react';
import { UISref, UISrefProps } from '@uirouter/react';
interface Props {
title?: string;
}
export function Link({
title = '',
children,
...props
}: { children: ReactNode } & UISrefProps) {
}: PropsWithChildren<Props> & UISrefProps) {
return (
// eslint-disable-next-line react/jsx-props-no-spreading
<UISref {...props}>
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a>{children}</a>
<a title={title}>{children}</a>
</UISref>
);
}

View File

@ -2,11 +2,12 @@ import _ from 'lodash-es';
import angular from 'angular';
import { RoleTypes } from '@/portainer/rbac/models/role';
import { isLimitedToBE } from '@/portainer/feature-flags/feature-flags.service';
class PorAccessManagementController {
/* @ngInject */
constructor(Notifications, AccessService, RoleService, featureService) {
Object.assign(this, { Notifications, AccessService, RoleService, featureService });
constructor(Notifications, AccessService, RoleService) {
Object.assign(this, { Notifications, AccessService, RoleService });
this.limitedToBE = false;
@ -76,7 +77,7 @@ class PorAccessManagementController {
async $onInit() {
try {
if (this.limitedFeature) {
this.limitedToBE = this.featureService.isLimitedToBE(this.limitedFeature);
this.limitedToBE = isLimitedToBE(this.limitedFeature);
}
const entity = this.accessControlledEntity;

View File

@ -1,18 +0,0 @@
const BE_URL = 'https://www.portainer.io/business-upsell?from=';
export default class BeIndicatorController {
/* @ngInject */
constructor(featureService) {
Object.assign(this, { featureService });
this.limitedToBE = false;
}
$onInit() {
if (this.feature) {
this.url = `${BE_URL}${this.feature}`;
this.limitedToBE = this.featureService.isLimitedToBE(this.feature);
}
}
}

View File

@ -1,15 +0,0 @@
import angular from 'angular';
import controller from './be-feature-indicator.controller.js';
import './be-feature-indicator.css';
export const beFeatureIndicator = {
templateUrl: './be-feature-indicator.html',
controller,
bindings: {
feature: '<',
},
transclude: true,
};
angular.module('portainer.app').component('beFeatureIndicator', beFeatureIndicator);

View File

@ -1,23 +0,0 @@
export default class BoxSelectorItemController {
/* @ngInject */
constructor(featureService) {
Object.assign(this, { featureService });
this.limitedToBE = false;
}
handleChange(value) {
this.formCtrl.$setValidity(this.radioName, !this.limitedToBE, this.formCtrl);
this.onChange(value);
}
$onInit() {
if (this.option.feature) {
this.limitedToBE = this.featureService.isLimitedToBE(this.option.feature);
}
}
$onDestroy() {
this.formCtrl.$setValidity(this.radioName, true, this.formCtrl);
}
}

View File

@ -1,27 +0,0 @@
<div
class="box-selector-item"
ng-class="{ business: $ctrl.limitedToBE, limited: $ctrl.limitedToBE }"
tooltip-append-to-body="true"
tooltip-placement="bottom"
tooltip-class="portainer-tooltip"
tooltip-enable="$ctrl.tooltip"
uib-tooltip="{{ $ctrl.tooltip }}"
>
<input
type="radio"
name="{{ $ctrl.radioName }}"
id="{{ $ctrl.option.id }}"
ng-checked="$ctrl.isChecked($ctrl.option.value)"
ng-value="$ctrl.option.value"
ng-disabled="$ctrl.disabled"
/>
<label for="{{ $ctrl.option.id }}" ng-click="$ctrl.handleChange($ctrl.option.value)" data-cy="{{ $ctrl.radioName }}_{{ $ctrl.option.value }}">
<i class="fas fa-briefcase limited-icon" ng-if="$ctrl.limitedToBE"></i>
<div class="boxselector_header">
<i ng-class="$ctrl.option.icon" aria-hidden="true" style="margin-right: 2px;"></i>
{{ $ctrl.option.label }}
</div>
<p class="box-selector-item-description">{{ $ctrl.option.description }}</p>
</label>
</div>

View File

@ -1,21 +0,0 @@
import angular from 'angular';
import './box-selector-item.css';
import controller from './box-selector-item.controller';
angular.module('portainer.app').component('boxSelectorItem', {
templateUrl: './box-selector-item.html',
controller,
require: {
formCtrl: '^^form',
},
bindings: {
radioName: '@',
isChecked: '<',
option: '<',
onChange: '<',
disabled: '<',
tooltip: '<',
},
});

View File

@ -1,17 +0,0 @@
export default class BoxSelectorController {
constructor() {
this.isChecked = this.isChecked.bind(this);
this.change = this.change.bind(this);
}
change(value, limited) {
this.ngModel = value;
if (this.onChange) {
this.onChange(value, limited);
}
}
isChecked(value) {
return this.ngModel === value;
}
}

View File

@ -1,15 +0,0 @@
<div class="form-group"></div>
<div class="form-group">
<div class="boxselector_wrapper">
<box-selector-item
ng-repeat="option in $ctrl.options"
radio-name="{{ $ctrl.radioName }}"
option="option"
on-change="($ctrl.change)"
is-checked="$ctrl.isChecked"
disabled="option.disabled()"
tooltip="option.tooltip()"
></box-selector-item>
</div>
</div>

View File

@ -1,20 +0,0 @@
import angular from 'angular';
import './box-selector.css';
import controller from './box-selector.controller';
angular.module('portainer.app').component('boxSelector', {
templateUrl: './box-selector.html',
controller,
bindings: {
radioName: '@',
ngModel: '=',
options: '<',
onChange: '<',
},
});
export function buildOption(id, icon, label, description, value, feature) {
return { id, icon, label, description, value, feature };
}

View File

@ -1,5 +1,6 @@
import { FeatureId } from '@/portainer/feature-flags/enums';
import { PortainerEndpointTypes } from 'Portainer/models/endpoint/models';
import { REGISTRY_MANAGEMENT } from '@/portainer/feature-flags/feature-ids';
angular.module('portainer.docker').controller('RegistriesDatatableController', RegistriesDatatableController);
/* @ngInject */
@ -45,7 +46,7 @@ function RegistriesDatatableController($scope, $controller, $state, Authenticati
};
this.$onInit = function () {
this.limitedFeature = REGISTRY_MANAGEMENT;
this.limitedFeature = FeatureId.REGISTRY_MANAGEMENT;
this.isAdmin = Authentication.isAdmin();
this.setDefaults();
this.prepareTableFromDataset();

View File

@ -0,0 +1,3 @@
.root {
margin-bottom: 0;
}

View File

@ -0,0 +1,35 @@
import { Meta, Story } from '@storybook/react';
import { useState } from 'react';
import { Switch } from './Switch';
export default {
title: 'Components/Form/SwitchField/Switch',
} as Meta;
export function Example() {
const [isChecked, setIsChecked] = useState(false);
function onChange() {
setIsChecked(!isChecked);
}
return <Switch name="name" checked={isChecked} onChange={onChange} id="id" />;
}
interface Args {
checked: boolean;
}
function Template({ checked }: Args) {
return <Switch name="name" checked={checked} onChange={() => {}} id="id" />;
}
export const Checked: Story<Args> = Template.bind({});
Checked.args = {
checked: true,
};
export const Unchecked: Story<Args> = Template.bind({});
Unchecked.args = {
checked: false,
};

View File

@ -0,0 +1,20 @@
import { render } from '@testing-library/react';
import { PropsWithChildren } from 'react';
import { Switch, Props } from './Switch';
function renderDefault({
name = 'default name',
checked = false,
}: Partial<PropsWithChildren<Props>> = {}) {
return render(
<Switch id="id" name={name} checked={checked} onChange={() => {}} />
);
}
test('should display a Switch component', async () => {
const { findByRole } = renderDefault();
const switchElem = await findByRole('checkbox');
expect(switchElem).toBeTruthy();
});

View File

@ -0,0 +1,68 @@
import clsx from 'clsx';
import { isLimitedToBE } from '@/portainer/feature-flags/feature-flags.service';
import { BEFeatureIndicator } from '@/portainer/components/BEFeatureIndicator';
import { FeatureId } from '@/portainer/feature-flags/enums';
import { r2a } from '@/react-tools/react2angular';
import './Switch.css';
import styles from './Switch.module.css';
export interface Props {
checked: boolean;
id: string;
name: string;
onChange(checked: boolean): void;
className?: string;
dataCy?: string;
disabled?: boolean;
featureId?: FeatureId;
}
export function Switch({
name,
checked,
id,
disabled,
dataCy,
onChange,
featureId,
className,
}: Props) {
const limitedToBE = isLimitedToBE(featureId);
return (
<>
<label
className={clsx('switch', className, styles.root, {
business: limitedToBE,
limited: limitedToBE,
})}
>
<input
type="checkbox"
name={name}
id={id}
checked={checked}
disabled={disabled || limitedToBE}
onChange={({ target: { checked } }) => onChange(checked)}
/>
<i data-cy={dataCy} />
</label>
{limitedToBE && <BEFeatureIndicator featureId={featureId} />}
</>
);
}
export const SwitchAngular = r2a(Switch, [
'name',
'checked',
'id',
'disabled',
'dataCy',
'onChange',
'feature',
'className',
]);

View File

@ -0,0 +1,9 @@
.root {
display: flex;
align-items: center;
margin: 0;
}
.label {
padding: 0;
}

View File

@ -0,0 +1,56 @@
import { Meta, Story } from '@storybook/react';
import { useState } from 'react';
import { SwitchField } from './SwitchField';
export default {
title: 'Components/Form/SwitchField',
} as Meta;
export function Example() {
const [isChecked, setIsChecked] = useState(false);
function onChange() {
setIsChecked(!isChecked);
}
return (
<SwitchField
name="name"
checked={isChecked}
onChange={onChange}
label="Example"
/>
);
}
interface Args {
checked: boolean;
label: string;
labelClass: string;
}
function Template({ checked, label, labelClass }: Args) {
return (
<SwitchField
name="name"
checked={checked}
onChange={() => {}}
label={label}
labelClass={labelClass}
/>
);
}
export const Checked: Story<Args> = Template.bind({});
Checked.args = {
checked: true,
label: 'label',
labelClass: 'col-sm-6',
};
export const Unchecked: Story<Args> = Template.bind({});
Unchecked.args = {
checked: false,
label: 'label',
labelClass: 'col-sm-6',
};

View File

@ -0,0 +1,37 @@
import { render, fireEvent } from '@/react-tools/test-utils';
import { SwitchField, Props } from './SwitchField';
function renderDefault({
name = 'default name',
checked = false,
label = 'label',
onChange = jest.fn(),
}: Partial<Props> = {}) {
return render(
<SwitchField
label={label}
name={name}
checked={checked}
onChange={onChange}
/>
);
}
test('should display a Switch component', async () => {
const { findByRole } = renderDefault();
const switchElem = await findByRole('checkbox');
expect(switchElem).toBeTruthy();
});
test('clicking should emit on-change with the opposite value', async () => {
const onChange = jest.fn();
const checked = true;
const { findByRole } = renderDefault({ onChange, checked });
const switchElem = await findByRole('checkbox');
fireEvent.click(switchElem);
expect(onChange).toHaveBeenCalledWith(!checked);
});

View File

@ -0,0 +1,72 @@
import clsx from 'clsx';
import { FeatureId } from '@/portainer/feature-flags/enums';
import { Tooltip } from '@/portainer/components/Tip/Tooltip';
import { r2a } from '@/react-tools/react2angular';
import styles from './SwitchField.module.css';
import { Switch } from './Switch';
export interface Props {
label: string;
checked: boolean;
onChange(value: boolean): void;
name?: string;
tooltip?: string;
labelClass?: string;
dataCy?: string;
disabled?: boolean;
featureId?: FeatureId;
}
export function SwitchField({
tooltip,
checked,
label,
name,
labelClass,
dataCy,
disabled,
onChange,
featureId,
}: Props) {
const toggleName = name ? `toggle_${name}` : '';
return (
<label className={styles.root}>
<span
className={clsx(
'control-label text-left space-right',
styles.label,
labelClass
)}
>
{label}
{tooltip && <Tooltip position="bottom" message={tooltip} />}
</span>
<Switch
className="space-right"
name={toggleName}
id={toggleName}
checked={checked}
disabled={disabled}
onChange={onChange}
featureId={featureId}
dataCy={dataCy}
/>
</label>
);
}
export const SwitchFieldAngular = r2a(SwitchField, [
'tooltip',
'checked',
'label',
'name',
'labelClass',
'dataCy',
'disabled',
'onChange',
'featureId',
]);

View File

@ -0,0 +1 @@
export { SwitchField, SwitchFieldAngular } from './SwitchField';

View File

@ -3,4 +3,10 @@ import angular from 'angular';
import { webEditorForm } from './web-editor-form';
import { fileUploadForm } from './file-upload-form';
export default angular.module('portainer.app.components.form', []).component('webEditorForm', webEditorForm).component('fileUploadForm', fileUploadForm).name;
import { SwitchFieldAngular } from './SwitchField';
export default angular
.module('portainer.app.components.form', [])
.component('webEditorForm', webEditorForm)
.component('fileUploadForm', fileUploadForm)
.component('porSwitchField', SwitchFieldAngular).name;

View File

@ -1,20 +1,29 @@
class GitFormComposeAuthFieldsetController {
/* @ngInject */
constructor() {
constructor($scope) {
Object.assign(this, { $scope });
this.authValues = {
username: '',
password: '',
};
this.handleChange = this.handleChange.bind(this);
this.onChangeField = this.onChangeField.bind(this);
this.onChangeAuth = this.onChangeAuth.bind(this);
this.onChangeUsername = this.onChangeField('RepositoryUsername');
this.onChangePassword = this.onChangeField('RepositoryPassword');
}
handleChange(...args) {
this.$scope.$evalAsync(() => {
this.onChange(...args);
});
}
onChangeField(field) {
return (value) => {
this.onChange({
this.handleChange({
...this.model,
[field]: value,
});
@ -27,7 +36,7 @@ class GitFormComposeAuthFieldsetController {
this.authValues.password = this.model.RepositoryPassword;
}
this.onChange({
this.handleChange({
...this.model,
RepositoryAuthentication: auth,
RepositoryUsername: auth ? this.authValues.username : '',

View File

@ -1,11 +1,11 @@
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
ng-model="$ctrl.model.RepositoryAuthentication"
label="Authentication"
name="authSwitch"
checked="$ctrl.model.RepositoryAuthentication"
label="'Authentication'"
name="'authSwitch'"
on-change="($ctrl.onChangeAuth)"
data-cy="component-gitAuthToggle"
data-cy="'component-gitAuthToggle'"
></por-switch-field>
</div>
</div>

View File

@ -1,14 +1,15 @@
import { FORCE_REDEPLOYMENT } from '@/portainer/feature-flags/feature-ids';
import { FeatureId } from '@/portainer/feature-flags/enums';
class GitFormAutoUpdateFieldsetController {
/* @ngInject */
constructor(clipboard) {
constructor($scope, clipboard) {
Object.assign(this, { $scope, clipboard });
this.onChangeAutoUpdate = this.onChangeField('RepositoryAutomaticUpdates');
this.onChangeMechanism = this.onChangeField('RepositoryMechanism');
this.onChangeInterval = this.onChangeField('RepositoryFetchInterval');
this.clipboard = clipboard;
this.limitedFeature = FORCE_REDEPLOYMENT;
this.limitedFeature = FeatureId.FORCE_REDEPLOYMENT;
}
copyWebhook() {
@ -19,9 +20,11 @@ class GitFormAutoUpdateFieldsetController {
onChangeField(field) {
return (value) => {
this.onChange({
...this.model,
[field]: value,
this.$scope.$evalAsync(() => {
this.onChange({
...this.model,
[field]: value,
});
});
};
}

View File

@ -1,7 +1,7 @@
<ng-form name="autoUpdateForm">
<div class="form-group">
<div class="col-sm-12">
<por-switch-field name="autoUpdate" ng-model="$ctrl.model.RepositoryAutomaticUpdates" label="Automatic Updates" on-change="($ctrl.onChangeAutoUpdate)"></por-switch-field>
<por-switch-field name="'autoUpdate'" checked="$ctrl.model.RepositoryAutomaticUpdates" label="'Automatic Updates'" on-change="($ctrl.onChangeAutoUpdate)"></por-switch-field>
</div>
</div>
<div class="small text-warning" style="margin: 5px 0 10px 0;" ng-if="$ctrl.model.RepositoryAutomaticUpdates">
@ -59,7 +59,13 @@
</div>
<div class="form-group" ng-if="$ctrl.model.RepositoryAutomaticUpdates">
<div class="col-sm-12">
<por-switch-field name="forceUpdate" feature="$ctrl.limitedFeature" ng-model="$ctrl.model.RepositoryAutomaticUpdatesForce" label="Force Redeployment"></por-switch-field>
<por-switch-field
name="'forceUpdate'"
feature-id="$ctrl.limitedFeature"
checked="$ctrl.model.RepositoryAutomaticUpdatesForce"
label="'Force Redeployment'"
on-change="($ctrl.onChangeAutoUpdateForce)"
></por-switch-field>
</div>
</div>
<div class="small text-warning" style="margin: 5px 0 10px 0;" ng-if="$ctrl.model.RepositoryAutomaticUpdates">

View File

@ -1,16 +0,0 @@
<label style="display: flex; align-items: center; margin: 0;">
<span for="toggle_{{::$ctrl.name}}" class="control-label text-left space-right" ng-class="$ctrl.labelClass" style="padding: 0;">
{{::$ctrl.label}}
<portainer-tooltip ng-if="$ctrl.tooltip" position="bottom" message="{{::$ctrl.tooltip}}"></portainer-tooltip>
</span>
<por-switch
class-name="space-right"
name="toggle_{{::$ctrl.name}}"
id="toggle_{{::$ctrl.name}}"
ng-model="$ctrl.ngModel"
disabled="$ctrl.disabled"
on-change="($ctrl.onChange)"
feature="$ctrl.feature"
ng-data-cy="{{::$ctrl.ngDataCy}}"
></por-switch>
</label>

View File

@ -1,18 +0,0 @@
import angular from 'angular';
export const porSwitchField = {
templateUrl: './por-switch-field.html',
bindings: {
tooltip: '@',
ngModel: '=',
label: '@',
name: '@',
labelClass: '@',
ngDataCy: '@',
disabled: '<',
onChange: '<',
feature: '<', // feature id
},
};
angular.module('portainer.app').component('porSwitchField', porSwitchField);

View File

@ -1,14 +0,0 @@
export default class PorSwitchController {
/* @ngInject */
constructor(featureService) {
Object.assign(this, { featureService });
this.limitedToBE = false;
}
$onInit() {
if (this.feature) {
this.limitedToBE = this.featureService.isLimitedToBE(this.feature);
}
}
}

View File

@ -1,12 +0,0 @@
<label class="switch" ng-class="[$ctrl.className, { business: $ctrl.limitedToBE, limited: $ctrl.limitedToBE }]" style="margin-bottom: 0;">
<input
type="checkbox"
name="{{::$ctrl.name}}"
id="{{::$ctrl.id}}"
ng-model="$ctrl.ngModel"
ng-disabled="$ctrl.disabled || $ctrl.limitedToBE"
ng-change="$ctrl.onChange($ctrl.ngModel)"
/>
<i data-cy="{{::$ctrl.ngDataCy}}"></i>
</label>
<be-feature-indicator ng-if="$ctrl.limitedToBE" feature="$ctrl.feature"></be-feature-indicator>

View File

@ -1,21 +0,0 @@
import angular from 'angular';
import controller from './por-switch.controller';
import './por-switch.css';
const porSwitch = {
templateUrl: './por-switch.html',
controller,
bindings: {
ngModel: '=',
id: '@',
className: '@',
name: '@',
ngDataCy: '@',
disabled: '<',
onChange: '<',
feature: '<', // feature id
},
};
angular.module('portainer.app').component('porSwitch', porSwitch);

View File

@ -1,16 +0,0 @@
angular.module('portainer.app').directive('rdHeaderContent', [
'Authentication',
function rdHeaderContent(Authentication) {
var directive = {
requires: '^rdHeader',
transclude: true,
link: function (scope) {
scope.username = Authentication.getUserDetails().username;
},
template:
'<div class="breadcrumb-links"><div class="pull-left" ng-transclude></div><div class="pull-right" ng-if="username"><a ui-sref="portainer.account" style="margin-right: 5px;"><u><i class="fa fa-wrench" aria-hidden="true"></i> my account </u></a><a ui-sref="portainer.logout({performApiLogout: true})" class="text-danger" style="margin-right: 25px;" data-cy="template-logoutButton"><u><i class="fa fa-sign-out-alt" aria-hidden="true"></i> log out</u></a></div></div>',
restrict: 'E',
};
return directive;
},
]);

View File

@ -1,19 +0,0 @@
angular.module('portainer.app').directive('rdHeaderTitle', [
'Authentication',
function rdHeaderTitle(Authentication) {
var directive = {
requires: '^rdHeader',
scope: {
titleText: '@',
},
link: function (scope) {
scope.username = Authentication.getUserDetails().username;
},
transclude: true,
template:
'<div class="page white-space-normal">{{titleText}}<span class="header_title_content" ng-transclude></span><span class="pull-right user-box" ng-if="username"><i class="fa fa-user-circle" aria-hidden="true"></i> {{username}}</span></div>',
restrict: 'E',
};
return directive;
},
]);

View File

@ -1,11 +0,0 @@
angular.module('portainer.app').directive('rdHeader', function rdHeader() {
var directive = {
scope: {
ngModel: '=',
},
transclude: true,
template: '<div class="row header"><div id="loadingbar-placeholder"></div><div class="col-xs-12"><div class="meta" ng-transclude></div></div></div>',
restrict: 'EA',
};
return directive;
});

View File

@ -7,12 +7,16 @@ import gitFormModule from './forms/git-form';
import porAccessManagementModule from './accessManagement';
import formComponentsModule from './form-components';
import widgetModule from './widget';
import boxSelectorModule from './BoxSelector';
import headerModule from './Header';
import { ReactExampleAngular } from './ReactExample';
import { TooltipAngular } from './Tip/Tooltip';
import { beFeatureIndicatorAngular } from './BEFeatureIndicator';
export default angular
.module('portainer.app.components', [widgetModule, sidebarModule, gitFormModule, porAccessManagementModule, formComponentsModule])
.module('portainer.app.components', [headerModule, boxSelectorModule, widgetModule, sidebarModule, gitFormModule, porAccessManagementModule, formComponentsModule])
.component('portainerTooltip', TooltipAngular)
.component('reactExample', ReactExampleAngular)
.component('beFeatureIndicator', beFeatureIndicatorAngular)
.component('createAccessToken', CreateAccessTokenAngular).name;

View File

@ -1,4 +1,4 @@
import { buildOption } from '@/portainer/components/box-selector';
import { buildOption } from '@/portainer/components/BoxSelector';
export default class ThemeSettingsController {
/* @ngInject */
@ -31,6 +31,7 @@ export default class ThemeSettingsController {
this.ThemeManager.setTheme(theme);
}
this.state.themeInProgress = true;
this.state.userTheme = theme;
}
$onInit() {

View File

@ -10,8 +10,8 @@
<rd-widget-header icon="fa-palette" title-text="Change user theme"></rd-widget-header>
<rd-widget-body>
<form class="theme-panel">
<!-- Theme -->
<box-selector radio-name="theme" ng-model="$ctrl.state.userTheme" options="$ctrl.state.availableThemes" on-change="($ctrl.setTheme)"></box-selector>
<!-- Theme Selector-->
<box-selector radio-name="'theme'" value="$ctrl.state.userTheme" options="$ctrl.state.availableThemes" on-change="($ctrl.setTheme)"></box-selector>
<button ng-click="$ctrl.updateTheme()" class="btn btn-primary btn-sm">Update theme</button>
<!-- !Theme -->
</form>

View File

@ -1,10 +0,0 @@
export const EDITIONS = {
CE: 0,
BE: 1,
};
export const STATES = {
HIDDEN: 0,
VISIBLE: 1,
LIMITED_BE: 2,
};

View File

@ -0,0 +1,26 @@
export enum Edition {
CE,
BE,
}
export enum FeatureState {
HIDDEN,
VISIBLE,
LIMITED_BE,
}
export enum FeatureId {
K8S_RESOURCE_POOL_LB_QUOTA = 'k8s-resourcepool-Ibquota',
K8S_RESOURCE_POOL_STORAGE_QUOTA = 'k8s-resourcepool-storagequota',
RBAC_ROLES = 'rbac-roles',
REGISTRY_MANAGEMENT = 'registry-management',
K8S_SETUP_DEFAULT = 'k8s-setup-default',
S3_BACKUP_SETTING = 's3-backup-setting',
HIDE_INTERNAL_AUTHENTICATION_PROMPT = 'hide-internal-authentication-prompt',
TEAM_MEMBERSHIP = 'team-membership',
HIDE_INTERNAL_AUTH = 'hide-internal-auth',
EXTERNAL_AUTH_LDAP = 'external-auth-ldap',
ACTIVITY_AUDIT = 'activity-audit',
FORCE_REDEPLOYMENT = 'force-redeployment',
HIDE_AUTO_UPDATE_WINDOW = 'hide-auto-update-window',
}

View File

@ -1,59 +0,0 @@
import { EDITIONS, STATES } from './enums';
import * as FEATURE_IDS from './feature-ids';
export function featureService() {
const state = {
currentEdition: undefined,
features: {},
};
return {
selectShow,
init,
isLimitedToBE,
};
async function init() {
// will be loaded on runtime
const currentEdition = EDITIONS.CE;
const features = {
[FEATURE_IDS.K8S_RESOURCE_POOL_LB_QUOTA]: EDITIONS.BE,
[FEATURE_IDS.K8S_RESOURCE_POOL_STORAGE_QUOTA]: EDITIONS.BE,
[FEATURE_IDS.ACTIVITY_AUDIT]: EDITIONS.BE,
[FEATURE_IDS.EXTERNAL_AUTH_LDAP]: EDITIONS.BE,
[FEATURE_IDS.HIDE_INTERNAL_AUTH]: EDITIONS.BE,
[FEATURE_IDS.HIDE_INTERNAL_AUTHENTICATION_PROMPT]: EDITIONS.BE,
[FEATURE_IDS.K8S_SETUP_DEFAULT]: EDITIONS.BE,
[FEATURE_IDS.RBAC_ROLES]: EDITIONS.BE,
[FEATURE_IDS.REGISTRY_MANAGEMENT]: EDITIONS.BE,
[FEATURE_IDS.S3_BACKUP_SETTING]: EDITIONS.BE,
[FEATURE_IDS.TEAM_MEMBERSHIP]: EDITIONS.BE,
[FEATURE_IDS.HIDE_AUTO_UPDATE_WINDOW]: EDITIONS.BE,
[FEATURE_IDS.FORCE_REDEPLOYMENT]: EDITIONS.BE,
};
state.currentEdition = currentEdition;
state.features = features;
}
function selectShow(featureId) {
if (!state.features[featureId]) {
return STATES.HIDDEN;
}
if (state.features[featureId] <= state.currentEdition) {
return STATES.VISIBLE;
}
if (state.features[featureId] === EDITIONS.BE) {
return STATES.LIMITED_BE;
}
return STATES.HIDDEN;
}
function isLimitedToBE(featureId) {
return selectShow(featureId) === STATES.LIMITED_BE;
}
}

Some files were not shown because too many files have changed in this diff Show More