mirror of https://github.com/portainer/portainer
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
parent
eb9f6c77f4
commit
7ae5a3042c
|
@ -13,10 +13,11 @@
|
|||
},
|
||||
{
|
||||
"files": [
|
||||
"*.{j,t}sx"
|
||||
"*.{j,t}sx",
|
||||
"*.ts"
|
||||
],
|
||||
"options": {
|
||||
"printWidth": 80,
|
||||
"printWidth": 80
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
@ -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>
|
||||
),
|
||||
];
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -23,7 +23,8 @@ class DockerComposeFormController {
|
|||
this.formValues = values;
|
||||
}
|
||||
|
||||
onChangeMethod() {
|
||||
onChangeMethod(method) {
|
||||
this.state.Method = method;
|
||||
this.formValues.StackFileContent = '';
|
||||
this.selectedTemplate = null;
|
||||
}
|
||||
|
|
|
@ -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'"
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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'"
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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'"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
|
@ -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;
|
|
@ -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>
|
|
@ -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">
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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 -->
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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({});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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';
|
|
@ -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 };
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
.root {
|
||||
float: left;
|
||||
width: 100%;
|
||||
}
|
|
@ -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
|
|
@ -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);
|
||||
});
|
|
@ -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 };
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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 />;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
.breadcrumb-links {
|
||||
font-size: 10px;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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*/
|
|
@ -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>
|
|
@ -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',
|
||||
};
|
|
@ -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',
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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,
|
||||
};
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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: '<',
|
||||
},
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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 };
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
.root {
|
||||
margin-bottom: 0;
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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();
|
||||
});
|
|
@ -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',
|
||||
]);
|
|
@ -0,0 +1,9 @@
|
|||
.root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
padding: 0;
|
||||
}
|
|
@ -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',
|
||||
};
|
|
@ -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);
|
||||
});
|
|
@ -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',
|
||||
]);
|
|
@ -0,0 +1 @@
|
|||
export { SwitchField, SwitchFieldAngular } from './SwitchField';
|
|
@ -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;
|
||||
|
|
|
@ -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 : '',
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
|
@ -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);
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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);
|
|
@ -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;
|
||||
},
|
||||
]);
|
|
@ -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;
|
||||
},
|
||||
]);
|
|
@ -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;
|
||||
});
|
|
@ -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;
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
export const EDITIONS = {
|
||||
CE: 0,
|
||||
BE: 1,
|
||||
};
|
||||
|
||||
export const STATES = {
|
||||
HIDDEN: 0,
|
||||
VISIBLE: 1,
|
||||
LIMITED_BE: 2,
|
||||
};
|
|
@ -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',
|
||||
}
|
|
@ -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
Loading…
Reference in New Issue