mirror of https://github.com/portainer/portainer
refactor(groups): migrate groups selectors to react [EE-3842] (#8936)
parent
2018529add
commit
e91b4f5c83
|
@ -173,14 +173,7 @@
|
|||
|
||||
<div class="col-sm-12 form-section-title"> Target environments </div>
|
||||
<!-- node-selection -->
|
||||
<associated-endpoints-selector
|
||||
endpoint-ids="$ctrl.model.Endpoints"
|
||||
tags="$ctrl.tags"
|
||||
groups="$ctrl.groups"
|
||||
has-backend-pagination="true"
|
||||
on-associate="($ctrl.associateEndpoint)"
|
||||
on-dissociate="($ctrl.dissociateEndpoint)"
|
||||
></associated-endpoints-selector>
|
||||
<associated-edge-environments-selector value="$ctrl.model.Endpoints" on-change="($ctrl.onChangeEnvironments)"></associated-edge-environments-selector>
|
||||
<!-- !node-selection -->
|
||||
<!-- actions -->
|
||||
<div class="col-sm-12 form-section-title"> Actions </div>
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import _ from 'lodash-es';
|
||||
import moment from 'moment';
|
||||
import { editor, upload } from '@@/BoxSelector/common-options/build-methods';
|
||||
|
||||
|
@ -45,8 +44,7 @@ export class EdgeJobFormController {
|
|||
|
||||
this.action = this.action.bind(this);
|
||||
this.editorUpdate = this.editorUpdate.bind(this);
|
||||
this.associateEndpoint = this.associateEndpoint.bind(this);
|
||||
this.dissociateEndpoint = this.dissociateEndpoint.bind(this);
|
||||
this.onChangeEnvironments = this.onChangeEnvironments.bind(this);
|
||||
this.onChangeGroups = this.onChangeGroups.bind(this);
|
||||
this.onChange = this.onChange.bind(this);
|
||||
this.onCronMethodChange = this.onCronMethodChange.bind(this);
|
||||
|
@ -115,14 +113,10 @@ export class EdgeJobFormController {
|
|||
this.isEditorDirty = true;
|
||||
}
|
||||
|
||||
associateEndpoint(endpoint) {
|
||||
if (!_.includes(this.model.Endpoints, endpoint.Id)) {
|
||||
this.model.Endpoints = [...this.model.Endpoints, endpoint.Id];
|
||||
}
|
||||
}
|
||||
|
||||
dissociateEndpoint(endpoint) {
|
||||
this.model.Endpoints = _.filter(this.model.Endpoints, (id) => id !== endpoint.Id);
|
||||
onChangeEnvironments(value) {
|
||||
return this.$scope.$evalAsync(() => {
|
||||
this.model.Endpoints = value;
|
||||
});
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
|
|
|
@ -33,14 +33,7 @@
|
|||
<!-- environments -->
|
||||
<div class="col-sm-12 form-section-title"> Associated environments </div>
|
||||
<div class="form-group">
|
||||
<associated-endpoints-selector
|
||||
endpoint-ids="$ctrl.model.Endpoints"
|
||||
tags="$ctrl.tags"
|
||||
groups="$ctrl.groups"
|
||||
has-backend-pagination="true"
|
||||
on-associate="($ctrl.associateEndpoint)"
|
||||
on-dissociate="($ctrl.dissociateEndpoint)"
|
||||
></associated-endpoints-selector>
|
||||
<associated-edge-environments-selector value="$ctrl.model.Endpoints" on-change="($ctrl.onChangeEnvironments)"></associated-edge-environments-selector>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-if="$ctrl.noEndpoints">
|
||||
|
@ -59,23 +52,11 @@
|
|||
|
||||
<tag-selector ng-if="$ctrl.model.TagIds" value="$ctrl.model.TagIds" on-change="($ctrl.onChangeTags)"> </tag-selector>
|
||||
|
||||
<div class="table-in-row">
|
||||
<group-association-table
|
||||
loaded="$ctrl.loaded"
|
||||
page-type="$ctrl.pageType"
|
||||
table-type="associated"
|
||||
retrieve-page="$ctrl.getDynamicEndpoints"
|
||||
dataset="$ctrl.endpoints.value"
|
||||
pagination-state="$ctrl.endpoints.state"
|
||||
empty-dataset-message="No associated endpoint"
|
||||
tags="$ctrl.tags"
|
||||
show-tags="true"
|
||||
groups="$ctrl.groups"
|
||||
show-groups="true"
|
||||
has-backend-pagination="true"
|
||||
title="Associated environments by tags"
|
||||
></group-association-table>
|
||||
</div>
|
||||
<edge-group-association-table
|
||||
title="'Associated environments by tags'"
|
||||
empty-content-message="'No associated available'"
|
||||
query="$ctrl.dynamicQuery"
|
||||
></edge-group-association-table>
|
||||
</div>
|
||||
<!-- !DynamicGroup -->
|
||||
|
||||
|
|
|
@ -1,9 +1,5 @@
|
|||
import _ from 'lodash-es';
|
||||
import { confirmDestructive } from '@@/modals/confirm';
|
||||
import { EdgeTypes } from '@/react/portainer/environments/types';
|
||||
import { getEnvironments } from '@/react/portainer/environments/environment.service';
|
||||
import { getTags } from '@/portainer/tags/tags.service';
|
||||
import { notifyError } from '@/portainer/services/notifications';
|
||||
import { buildConfirmButton } from '@@/modals/utils';
|
||||
import { tagOptions } from '@/react/edge/edge-groups/CreateView/tag-options';
|
||||
import { groupTypeOptions } from '@/react/edge/edge-groups/CreateView/group-type-options';
|
||||
|
@ -17,22 +13,13 @@ export class EdgeGroupFormController {
|
|||
this.groupTypeOptions = groupTypeOptions;
|
||||
this.tagOptions = tagOptions;
|
||||
|
||||
this.endpoints = {
|
||||
state: {
|
||||
limit: '10',
|
||||
filter: '',
|
||||
pageNumber: 1,
|
||||
totalCount: 0,
|
||||
},
|
||||
value: null,
|
||||
this.dynamicQuery = {
|
||||
types: EdgeTypes,
|
||||
tagIds: [],
|
||||
tagsPartialMatch: false,
|
||||
};
|
||||
|
||||
this.tags = [];
|
||||
|
||||
this.associateEndpoint = this.associateEndpoint.bind(this);
|
||||
this.dissociateEndpoint = this.dissociateEndpoint.bind(this);
|
||||
this.getDynamicEndpointsAsync = this.getDynamicEndpointsAsync.bind(this);
|
||||
this.getDynamicEndpoints = this.getDynamicEndpoints.bind(this);
|
||||
this.onChangeEnvironments = this.onChangeEnvironments.bind(this);
|
||||
this.onChangeTags = this.onChangeTags.bind(this);
|
||||
this.onChangeDynamic = this.onChangeDynamic.bind(this);
|
||||
this.onChangeModel = this.onChangeModel.bind(this);
|
||||
|
@ -43,7 +30,11 @@ export class EdgeGroupFormController {
|
|||
() => this.model,
|
||||
() => {
|
||||
if (this.model.Dynamic) {
|
||||
this.getDynamicEndpoints();
|
||||
this.dynamicQuery = {
|
||||
types: EdgeTypes,
|
||||
tagIds: this.model.TagIds,
|
||||
tagsPartialMatch: this.model.PartialMatch,
|
||||
};
|
||||
}
|
||||
},
|
||||
true
|
||||
|
@ -71,59 +62,25 @@ export class EdgeGroupFormController {
|
|||
this.onChangeModel({ TagIds: value });
|
||||
}
|
||||
|
||||
associateEndpoint(endpoint) {
|
||||
if (!_.includes(this.model.Endpoints, endpoint.Id)) {
|
||||
this.model.Endpoints = [...this.model.Endpoints, endpoint.Id];
|
||||
}
|
||||
}
|
||||
|
||||
dissociateEndpoint(endpoint) {
|
||||
onChangeEnvironments(value, meta) {
|
||||
return this.$async(async () => {
|
||||
const confirmed = await confirmDestructive({
|
||||
title: 'Confirm action',
|
||||
message: 'Removing the environment from this group will remove its corresponding edge stacks',
|
||||
confirmButton: buildConfirmButton('Confirm'),
|
||||
});
|
||||
if (meta.type === 'remove' && this.pageType === 'edit') {
|
||||
const confirmed = await confirmDestructive({
|
||||
title: 'Confirm action',
|
||||
message: 'Removing the environment from this group will remove its corresponding edge stacks',
|
||||
confirmButton: buildConfirmButton('Confirm'),
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.model.Endpoints = _.filter(this.model.Endpoints, (id) => id !== endpoint.Id);
|
||||
});
|
||||
}
|
||||
|
||||
getDynamicEndpoints() {
|
||||
return this.$async(this.getDynamicEndpointsAsync);
|
||||
}
|
||||
|
||||
async getDynamicEndpointsAsync() {
|
||||
const { pageNumber, limit, search } = this.endpoints.state;
|
||||
const start = (pageNumber - 1) * limit + 1;
|
||||
const query = { search, types: EdgeTypes, tagIds: this.model.TagIds, tagsPartialMatch: this.model.PartialMatch };
|
||||
|
||||
const response = await getEnvironments({ start, limit, query });
|
||||
|
||||
const totalCount = parseInt(response.totalCount, 10);
|
||||
this.endpoints.value = response.value;
|
||||
this.endpoints.state.totalCount = totalCount;
|
||||
}
|
||||
|
||||
getTags() {
|
||||
return this.$async(async () => {
|
||||
try {
|
||||
this.tags = await getTags();
|
||||
} catch (err) {
|
||||
notifyError('Failure', err, 'Unable to retrieve tags');
|
||||
}
|
||||
this.onChangeModel({ Endpoints: value });
|
||||
});
|
||||
}
|
||||
|
||||
handleSubmit() {
|
||||
this.formAction(this.model);
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
this.getTags();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,6 @@ angular.module('portainer.edge').component('edgeGroupForm', {
|
|||
controller: EdgeGroupFormController,
|
||||
bindings: {
|
||||
model: '<',
|
||||
groups: '<',
|
||||
formActionLabel: '@',
|
||||
formAction: '<',
|
||||
actionInProgress: '<',
|
||||
|
|
|
@ -10,6 +10,8 @@ import { EdgeStackDeploymentTypeSelector } from '@/react/edge/edge-stacks/compon
|
|||
import { EditEdgeStackForm } from '@/react/edge/edge-stacks/ItemView/EditEdgeStackForm/EditEdgeStackForm';
|
||||
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||
import { EdgeGroupAssociationTable } from '@/react/edge/components/EdgeGroupAssociationTable';
|
||||
import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector';
|
||||
|
||||
export const componentsModule = angular
|
||||
.module('portainer.edge.react.components', [])
|
||||
|
@ -76,4 +78,22 @@ export const componentsModule = angular
|
|||
'onSubmit',
|
||||
'allowKubeToSelectCompose',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'edgeGroupAssociationTable',
|
||||
r2a(withReactQuery(EdgeGroupAssociationTable), [
|
||||
'emptyContentLabel',
|
||||
'onClickRow',
|
||||
'query',
|
||||
'title',
|
||||
'data-cy',
|
||||
'hideEnvironmentIds',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'associatedEdgeEnvironmentsSelector',
|
||||
r2a(withReactQuery(AssociatedEdgeEnvironmentsSelector), [
|
||||
'onChange',
|
||||
'value',
|
||||
])
|
||||
).name;
|
||||
|
|
|
@ -1,50 +0,0 @@
|
|||
<div class="col-sm-12 small text-muted">
|
||||
You can select which environment should be part of this group by moving them to the associated environments table. Simply click on any environment entry to move it from one table
|
||||
to the other.
|
||||
</div>
|
||||
<div class="col-sm-12" style="margin-top: 20px">
|
||||
<div class="table-row-container">
|
||||
<!-- available-endpoints -->
|
||||
<div class="datatable table-in-row">
|
||||
<group-association-table
|
||||
loaded="$ctrl.loaded"
|
||||
page-type="$ctrl.pageType"
|
||||
table-type="available"
|
||||
retrieve-page="$ctrl.getAvailableEndpoints"
|
||||
dataset="$ctrl.endpoints.available"
|
||||
entry-click="$ctrl.associateEndpoint"
|
||||
pagination-state="$ctrl.state.available"
|
||||
empty-dataset-message="No environment available"
|
||||
tags="$ctrl.tags"
|
||||
show-tags="true"
|
||||
groups="$ctrl.groups"
|
||||
show-groups="true"
|
||||
has-backend-pagination="true"
|
||||
title="Available environments"
|
||||
data-cy="edgeGroupCreate-availableEndpoints"
|
||||
></group-association-table>
|
||||
</div>
|
||||
<!-- !available-endpoints -->
|
||||
<!-- associated-endpoints -->
|
||||
<div class="datatable table-in-row">
|
||||
<group-association-table
|
||||
loaded="$ctrl.loaded"
|
||||
page-type="$ctrl.pageType"
|
||||
table-type="associated"
|
||||
retrieve-page="$ctrl.getAssociatedEndpoints"
|
||||
dataset="$ctrl.endpoints.associated"
|
||||
entry-click="$ctrl.dissociateEndpoint"
|
||||
pagination-state="$ctrl.state.associated"
|
||||
empty-dataset-message="No associated environment"
|
||||
tags="$ctrl.tags"
|
||||
show-tags="true"
|
||||
groups="$ctrl.groups"
|
||||
show-groups="true"
|
||||
has-backend-pagination="true"
|
||||
title="Associated environments"
|
||||
data-cy="edgeGroupCreate-associatedEndpoints"
|
||||
></group-association-table>
|
||||
</div>
|
||||
<!-- !associated-endpoints -->
|
||||
</div>
|
||||
</div>
|
|
@ -1,16 +0,0 @@
|
|||
import angular from 'angular';
|
||||
import AssociatedEndpointsSelectorController from './associatedEndpointsSelectorController';
|
||||
|
||||
angular.module('portainer.app').component('associatedEndpointsSelector', {
|
||||
templateUrl: './associatedEndpointsSelector.html',
|
||||
controller: AssociatedEndpointsSelectorController,
|
||||
bindings: {
|
||||
endpointIds: '<',
|
||||
tags: '<',
|
||||
groups: '<',
|
||||
hasBackendPagination: '<',
|
||||
|
||||
onAssociate: '<',
|
||||
onDissociate: '<',
|
||||
},
|
||||
});
|
|
@ -1,111 +0,0 @@
|
|||
import angular from 'angular';
|
||||
import _ from 'lodash-es';
|
||||
|
||||
import { EdgeTypes } from '@/react/portainer/environments/types';
|
||||
import { getEnvironments } from '@/react/portainer/environments/environment.service';
|
||||
|
||||
class AssoicatedEndpointsSelectorController {
|
||||
/* @ngInject */
|
||||
constructor($async) {
|
||||
this.$async = $async;
|
||||
|
||||
this.state = {
|
||||
available: {
|
||||
limit: '10',
|
||||
filter: '',
|
||||
pageNumber: 1,
|
||||
totalCount: 0,
|
||||
},
|
||||
associated: {
|
||||
limit: '10',
|
||||
filter: '',
|
||||
pageNumber: 1,
|
||||
totalCount: 0,
|
||||
},
|
||||
};
|
||||
|
||||
this.endpoints = {
|
||||
associated: [],
|
||||
available: null,
|
||||
};
|
||||
|
||||
this.getAvailableEndpoints = this.getAvailableEndpoints.bind(this);
|
||||
this.getAssociatedEndpoints = this.getAssociatedEndpoints.bind(this);
|
||||
this.associateEndpoint = this.associateEndpoint.bind(this);
|
||||
this.dissociateEndpoint = this.dissociateEndpoint.bind(this);
|
||||
this.loadData = this.loadData.bind(this);
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
$onChanges({ endpointIds }) {
|
||||
if (endpointIds && endpointIds.currentValue) {
|
||||
this.loadData();
|
||||
}
|
||||
}
|
||||
|
||||
loadData() {
|
||||
this.getAvailableEndpoints();
|
||||
this.getAssociatedEndpoints();
|
||||
}
|
||||
|
||||
/* #region internal queries to retrieve endpoints per "side" of the selector */
|
||||
getAvailableEndpoints() {
|
||||
return this.$async(async () => {
|
||||
const { start, filter, limit } = this.getPaginationData('available');
|
||||
const query = { search: filter, types: EdgeTypes };
|
||||
|
||||
const response = await getEnvironments({ start, limit, query });
|
||||
|
||||
const endpoints = _.filter(response.value, (endpoint) => !_.includes(this.endpointIds, endpoint.Id));
|
||||
this.setTableData('available', endpoints, response.totalCount);
|
||||
this.noEndpoints = this.state.available.totalCount === 0;
|
||||
});
|
||||
}
|
||||
|
||||
getAssociatedEndpoints() {
|
||||
return this.$async(async () => {
|
||||
let response = { value: [], totalCount: 0 };
|
||||
if (this.endpointIds.length > 0) {
|
||||
// fetch only if already has associated endpoints
|
||||
const { start, filter, limit } = this.getPaginationData('associated');
|
||||
const query = { search: filter, types: EdgeTypes, endpointIds: this.endpointIds };
|
||||
|
||||
response = await getEnvironments({ start, limit, query });
|
||||
}
|
||||
|
||||
this.setTableData('associated', response.value, response.totalCount);
|
||||
});
|
||||
}
|
||||
|
||||
/* #endregion */
|
||||
|
||||
/* #region On endpoint click (either available or associated) */
|
||||
associateEndpoint(endpoint) {
|
||||
this.onAssociate(endpoint);
|
||||
}
|
||||
|
||||
dissociateEndpoint(endpoint) {
|
||||
this.onDissociate(endpoint);
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region Utils funcs */
|
||||
getPaginationData(tableType) {
|
||||
const { pageNumber, limit, filter } = this.state[tableType];
|
||||
const start = (pageNumber - 1) * limit + 1;
|
||||
|
||||
return { start, filter, limit };
|
||||
}
|
||||
|
||||
setTableData(tableType, endpoints, totalCount) {
|
||||
this.endpoints[tableType] = endpoints;
|
||||
this.state[tableType].totalCount = parseInt(totalCount, 10);
|
||||
}
|
||||
/* #endregion */
|
||||
}
|
||||
|
||||
angular.module('portainer.app').controller('AssoicatedEndpointsSelectorController', AssoicatedEndpointsSelectorController);
|
||||
export default AssoicatedEndpointsSelectorController;
|
|
@ -6,14 +6,12 @@ angular.module('portainer.app').component('groupForm', {
|
|||
controller: GroupFormController,
|
||||
bindings: {
|
||||
loaded: '<',
|
||||
pageType: '@',
|
||||
model: '=',
|
||||
availableEndpoints: '=',
|
||||
associatedEndpoints: '=',
|
||||
addLabelAction: '<',
|
||||
removeLabelAction: '<',
|
||||
formAction: '<',
|
||||
formActionLabel: '@',
|
||||
actionInProgress: '<',
|
||||
|
||||
onChangeEnvironments: '<',
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<form class="form-horizontal" name="endpointGroupForm">
|
||||
<form class="form-horizontal" name="endpointGroupForm" ng-if="$ctrl.model">
|
||||
<!-- name-input -->
|
||||
<div class="form-group">
|
||||
<label for="group_name" class="col-sm-3 col-lg-2 control-label required text-left">Name</label>
|
||||
|
@ -29,68 +29,19 @@
|
|||
|
||||
<tag-selector ng-if="$ctrl.model.TagIds" value="$ctrl.model.TagIds" on-change="($ctrl.onChangeTags)" allow-create="$ctrl.state.allowCreateTag"> </tag-selector>
|
||||
|
||||
<!-- environments -->
|
||||
<div ng-if="$ctrl.model.Id !== 1">
|
||||
<div class="col-sm-12 form-section-title"> Associated environments </div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 small text-muted">
|
||||
You can select which environment should be part of this group by moving them to the associated environments table. Simply click on any environment entry to move it from one
|
||||
table to the other.
|
||||
</div>
|
||||
<div class="col-sm-12" style="margin-top: 20px">
|
||||
<!-- available-endpoints -->
|
||||
<div class="table-row-container">
|
||||
<div class="datatable table-in-row">
|
||||
<group-association-table
|
||||
loaded="$ctrl.loaded"
|
||||
page-type="$ctrl.pageType"
|
||||
table-type="available"
|
||||
retrieve-page="$ctrl.getPaginatedEndpointsByGroup"
|
||||
dataset="$ctrl.availableEndpoints"
|
||||
entry-click="$ctrl.associateEndpoint"
|
||||
pagination-state="$ctrl.state.available"
|
||||
empty-dataset-message="No environment available"
|
||||
title="Available environments"
|
||||
cy-value="available-endpoints"
|
||||
></group-association-table>
|
||||
</div>
|
||||
<!-- !available-endpoints -->
|
||||
<!-- associated-endpoints -->
|
||||
<div class="table-in-row">
|
||||
<group-association-table
|
||||
loaded="$ctrl.loaded"
|
||||
page-type="$ctrl.pageType"
|
||||
table-type="associated"
|
||||
retrieve-page="$ctrl.getPaginatedEndpointsByGroup"
|
||||
dataset="$ctrl.associatedEndpoints"
|
||||
entry-click="$ctrl.dissociateEndpoint"
|
||||
pagination-state="$ctrl.state.associated"
|
||||
empty-dataset-message="No associated environment"
|
||||
has-backend-pagination="this.pageType !== 'create'"
|
||||
title="Associated environments"
|
||||
cy-value="associated-endpoints"
|
||||
></group-association-table>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !associated-endpoints -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-if="$ctrl.model.Id !== 1">
|
||||
<associated-endpoints-selector value="$ctrl.associatedEndpoints" on-change="($ctrl.onChangeEnvironments)"></associated-endpoints-selector>
|
||||
</div>
|
||||
<div ng-if="$ctrl.model.Id === 1">
|
||||
<div class="table-in-row">
|
||||
<group-association-table
|
||||
loaded="$ctrl.loaded"
|
||||
page-type="$ctrl.pageType"
|
||||
table-type="associated"
|
||||
retrieve-page="$ctrl.getPaginatedEndpointsByGroup"
|
||||
dataset="$ctrl.associatedEndpoints"
|
||||
pagination-state="$ctrl.state.associated"
|
||||
empty-dataset-message="No environment available"
|
||||
title="Unassociated environments"
|
||||
></group-association-table>
|
||||
</div>
|
||||
|
||||
<div class="-mx-[15px]">
|
||||
<group-association-table
|
||||
ng-if="$ctrl.model.Id === 1"
|
||||
title="'Unassociated environments'"
|
||||
empty-content-message="'No environment available'"
|
||||
query="$ctrl.unassociatedQuery"
|
||||
></group-association-table>
|
||||
</div>
|
||||
<!-- !endpoints -->
|
||||
|
||||
<!-- actions -->
|
||||
<div class="col-sm-12 form-section-title"> Actions </div>
|
||||
<div class="form-group">
|
||||
|
|
|
@ -1,7 +1,4 @@
|
|||
import _ from 'lodash-es';
|
||||
import angular from 'angular';
|
||||
import { endpointsByGroup } from '@/react/portainer/environments/environment.service';
|
||||
import { notifyError } from '@/portainer/services/notifications';
|
||||
|
||||
class GroupFormController {
|
||||
/* @ngInject */
|
||||
|
@ -12,9 +9,14 @@ class GroupFormController {
|
|||
this.Notifications = Notifications;
|
||||
this.Authentication = Authentication;
|
||||
|
||||
this.associateEndpoint = this.associateEndpoint.bind(this);
|
||||
this.dissociateEndpoint = this.dissociateEndpoint.bind(this);
|
||||
this.getPaginatedEndpointsByGroup = this.getPaginatedEndpointsByGroup.bind(this);
|
||||
this.state = {
|
||||
allowCreateTag: this.Authentication.isAdmin(),
|
||||
};
|
||||
|
||||
this.unassociatedQuery = {
|
||||
groupIds: [1],
|
||||
};
|
||||
|
||||
this.onChangeTags = this.onChangeTags.bind(this);
|
||||
}
|
||||
|
||||
|
@ -23,81 +25,6 @@ class GroupFormController {
|
|||
this.model.TagIds = value;
|
||||
});
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
this.state = {
|
||||
available: {
|
||||
limit: '10',
|
||||
filter: '',
|
||||
pageNumber: 1,
|
||||
totalCount: 0,
|
||||
},
|
||||
associated: {
|
||||
limit: '10',
|
||||
filter: '',
|
||||
pageNumber: 1,
|
||||
totalCount: 0,
|
||||
},
|
||||
allowCreateTag: this.Authentication.isAdmin(),
|
||||
};
|
||||
}
|
||||
associateEndpoint(endpoint) {
|
||||
if (this.pageType === 'create' && !_.includes(this.associatedEndpoints, endpoint)) {
|
||||
this.associatedEndpoints.push(endpoint);
|
||||
} else if (this.pageType === 'edit') {
|
||||
this.GroupService.addEndpoint(this.model.Id, endpoint)
|
||||
.then(() => {
|
||||
this.Notifications.success('Success', 'Environment successfully added to group');
|
||||
this.reloadTablesContent();
|
||||
})
|
||||
.catch((err) => this.Notifications.error('Error', err, 'Unable to add environment to group'));
|
||||
}
|
||||
}
|
||||
|
||||
dissociateEndpoint(endpoint) {
|
||||
if (this.pageType === 'create') {
|
||||
_.remove(this.associatedEndpoints, (item) => item.Id === endpoint.Id);
|
||||
} else if (this.pageType === 'edit') {
|
||||
this.GroupService.removeEndpoint(this.model.Id, endpoint.Id)
|
||||
.then(() => {
|
||||
this.Notifications.success('Success', 'Environment successfully removed from group');
|
||||
this.reloadTablesContent();
|
||||
})
|
||||
.catch((err) => this.Notifications.error('Error', err, 'Unable to remove environment from group'));
|
||||
}
|
||||
}
|
||||
|
||||
reloadTablesContent() {
|
||||
this.getPaginatedEndpointsByGroup(this.pageType, 'available');
|
||||
this.getPaginatedEndpointsByGroup(this.pageType, 'associated');
|
||||
this.GroupService.group(this.model.Id).then((data) => {
|
||||
this.model = data;
|
||||
});
|
||||
}
|
||||
|
||||
getPaginatedEndpointsByGroup(pageType, tableType) {
|
||||
this.$async(async () => {
|
||||
try {
|
||||
if (tableType === 'available') {
|
||||
const context = this.state.available;
|
||||
const start = (context.pageNumber - 1) * context.limit + 1;
|
||||
const data = await endpointsByGroup(1, start, context.limit, { search: context.filter });
|
||||
this.availableEndpoints = data.value;
|
||||
this.state.available.totalCount = data.totalCount;
|
||||
} else if (tableType === 'associated' && pageType === 'edit') {
|
||||
const groupId = this.model.Id ? this.model.Id : 1;
|
||||
const context = this.state.associated;
|
||||
const start = (context.pageNumber - 1) * context.limit + 1;
|
||||
const data = await endpointsByGroup(groupId, start, context.limit, { search: context.filter });
|
||||
this.associatedEndpoints = data.value;
|
||||
this.state.associated.totalCount = data.totalCount;
|
||||
}
|
||||
// ignore (associated + create) group as there is no backend pagination for this table
|
||||
} catch (err) {
|
||||
notifyError('Failure', err, 'Failed getting endpoints for group');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
angular.module('portainer.app').controller('GroupFormController', GroupFormController);
|
||||
|
|
|
@ -1,70 +0,0 @@
|
|||
import _ from 'lodash-es';
|
||||
import { idsToTagNames } from 'Portainer/helpers/tagHelper';
|
||||
|
||||
angular.module('portainer.app').component('groupAssociationTable', {
|
||||
templateUrl: './groupAssociationTable.html',
|
||||
controller: function () {
|
||||
this.state = {
|
||||
orderBy: 'Name',
|
||||
reverseOrder: false,
|
||||
paginatedItemLimit: '10',
|
||||
textFilter: '',
|
||||
loading: true,
|
||||
pageNumber: 1,
|
||||
};
|
||||
|
||||
this.changeOrderBy = function (orderField) {
|
||||
this.state.reverseOrder = this.state.orderBy === orderField ? !this.state.reverseOrder : false;
|
||||
this.state.orderBy = orderField;
|
||||
};
|
||||
|
||||
this.onTextFilterChange = function () {
|
||||
this.paginationChangedAction();
|
||||
};
|
||||
|
||||
this.onPageChanged = function (newPageNumber) {
|
||||
this.paginationState.pageNumber = newPageNumber;
|
||||
this.paginationChangedAction();
|
||||
};
|
||||
|
||||
this.onPaginationLimitChanged = function () {
|
||||
this.paginationChangedAction();
|
||||
};
|
||||
|
||||
this.paginationChangedAction = function () {
|
||||
this.retrievePage(this.pageType, this.tableType);
|
||||
};
|
||||
|
||||
this.$onChanges = function (changes) {
|
||||
if (changes.loaded && changes.loaded.currentValue) {
|
||||
this.paginationChangedAction();
|
||||
}
|
||||
};
|
||||
|
||||
this.tagIdsToTagNames = function tagIdsToTagNames(tagIds) {
|
||||
return idsToTagNames(this.tags, tagIds).join(', ') || '-';
|
||||
};
|
||||
|
||||
this.groupIdToGroupName = function groupIdToGroupName(groupId) {
|
||||
const group = _.find(this.groups, { Id: groupId });
|
||||
return group ? group.Name : '';
|
||||
};
|
||||
},
|
||||
bindings: {
|
||||
paginationState: '=',
|
||||
loaded: '<',
|
||||
pageType: '<',
|
||||
tableType: '@',
|
||||
retrievePage: '<',
|
||||
dataset: '<',
|
||||
entryClick: '<',
|
||||
emptyDatasetMessage: '@',
|
||||
tags: '<',
|
||||
showTags: '<',
|
||||
groups: '<',
|
||||
showGroups: '<',
|
||||
hasBackendPagination: '<',
|
||||
cyValue: '@',
|
||||
title: '@',
|
||||
},
|
||||
});
|
|
@ -1,85 +0,0 @@
|
|||
<div class="datatable">
|
||||
<div class="toolBar">
|
||||
<div class="toolBarTitle">{{ $ctrl.title }}</div>
|
||||
<div class="searchBar vertical-center">
|
||||
<pr-icon icon="'search'" class-name="'searchIcon'"></pr-icon>
|
||||
<input
|
||||
type="text"
|
||||
class="searchInput"
|
||||
ng-model="$ctrl.paginationState.filter"
|
||||
ng-change="$ctrl.onTextFilterChange()"
|
||||
ng-model-options="{ debounce: 300 }"
|
||||
placeholder="Search..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<table class="table-hover table" data-cy="{{ $ctrl.cyValue }}">
|
||||
<thead>
|
||||
<tr>
|
||||
<th> Name </th>
|
||||
<th ng-if="$ctrl.showGroups"> Group </th>
|
||||
<th ng-if="$ctrl.showTags"> Tags </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
ng-if="!$ctrl.hasBackendPagination"
|
||||
ng-click="$ctrl.entryClick(item)"
|
||||
class="interactive"
|
||||
dir-paginate="item in $ctrl.dataset | filter:$ctrl.paginationState.filter | itemsPerPage: $ctrl.paginationState.limit"
|
||||
pagination-id="$ctrl.tableType"
|
||||
>
|
||||
<td>
|
||||
{{ item.Name | truncate : 64 }}
|
||||
</td>
|
||||
<td ng-if="$ctrl.showGroups">
|
||||
{{ $ctrl.groupIdToGroupName(item.GroupId) | truncate : 64 }}
|
||||
</td>
|
||||
<td ng-if="$ctrl.showTags">
|
||||
{{ $ctrl.tagIdsToTagNames(item.TagIds) | arraytostr | truncate : 64 }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
ng-if="$ctrl.hasBackendPagination"
|
||||
ng-click="$ctrl.entryClick(item)"
|
||||
class="interactive"
|
||||
dir-paginate="item in $ctrl.dataset | itemsPerPage: $ctrl.paginationState.limit"
|
||||
pagination-id="$ctrl.tableType"
|
||||
total-items="$ctrl.paginationState.totalCount"
|
||||
>
|
||||
<td>
|
||||
{{ item.Name | truncate : 64 }}
|
||||
</td>
|
||||
<td ng-if="$ctrl.showGroups">
|
||||
{{ $ctrl.groupIdToGroupName(item.GroupId) | truncate : 64 }}
|
||||
</td>
|
||||
<td ng-if="$ctrl.showTags">
|
||||
{{ $ctrl.tagIdsToTagNames(item.TagIds) | truncate : 64 }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr ng-if="!$ctrl.dataset">
|
||||
<td colspan="3" class="text-muted text-center">Loading...</td>
|
||||
</tr>
|
||||
<tr ng-if="$ctrl.dataset.length === 0">
|
||||
<td colspan="3" class="text-muted text-center">{{ $ctrl.emptyDatasetMessage }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="footer" ng-if="$ctrl.dataset">
|
||||
<div class="paginationControls">
|
||||
<form class="form-inline">
|
||||
<span class="limitSelector">
|
||||
<span class="space-right"> Items per page </span>
|
||||
<select ng-model="$ctrl.paginationState.limit" ng-change="$ctrl.onPaginationLimitChanged()">
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</span>
|
||||
<dir-pagination-controls pagination-id="$ctrl.tableType" max-size="5" on-page-change="$ctrl.onPageChanged(newPageNumber, oldPageNumber)"></dir-pagination-controls>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -2,6 +2,7 @@ export function EndpointGroupDefaultModel() {
|
|||
this.Name = '';
|
||||
this.Description = '';
|
||||
this.TagIds = [];
|
||||
this.AssociatedEndpoints = [];
|
||||
}
|
||||
|
||||
export function EndpointGroupModel(data) {
|
||||
|
|
|
@ -6,6 +6,8 @@ import { withReactQuery } from '@/react-tools/withReactQuery';
|
|||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||
import { AnnotationsBeTeaser } from '@/react/kubernetes/annotations/AnnotationsBeTeaser';
|
||||
import { withFormValidation } from '@/react-tools/withFormValidation';
|
||||
import { GroupAssociationTable } from '@/react/portainer/environments/environment-groups/components/GroupAssociationTable';
|
||||
import { AssociatedEnvironmentsSelector } from '@/react/portainer/environments/environment-groups/components/AssociatedEnvironmentsSelector';
|
||||
|
||||
import {
|
||||
EnvironmentVariablesFieldset,
|
||||
|
@ -204,7 +206,21 @@ export const ngModule = angular
|
|||
'height',
|
||||
])
|
||||
)
|
||||
.component('annotationsBeTeaser', r2a(AnnotationsBeTeaser, []));
|
||||
.component(
|
||||
'groupAssociationTable',
|
||||
r2a(withReactQuery(GroupAssociationTable), [
|
||||
'emptyContentLabel',
|
||||
'onClickRow',
|
||||
'query',
|
||||
'title',
|
||||
'data-cy',
|
||||
])
|
||||
)
|
||||
.component('annotationsBeTeaser', r2a(AnnotationsBeTeaser, []))
|
||||
.component(
|
||||
'associatedEndpointsSelector',
|
||||
r2a(withReactQuery(AssociatedEnvironmentsSelector), ['onChange', 'value'])
|
||||
);
|
||||
|
||||
export const componentsModule = ngModule.name;
|
||||
|
||||
|
|
|
@ -40,8 +40,8 @@ angular.module('portainer.app').factory('GroupService', [
|
|||
return EndpointGroups.updateAccess({ id: groupId }, { UserAccessPolicies: userAccessPolicies, TeamAccessPolicies: teamAccessPolicies }).$promise;
|
||||
};
|
||||
|
||||
service.addEndpoint = function (groupId, endpoint) {
|
||||
return EndpointGroups.addEndpoint({ id: groupId, action: 'endpoints/' + endpoint.Id }, endpoint).$promise;
|
||||
service.addEndpoint = function (groupId, endpointId) {
|
||||
return EndpointGroups.addEndpoint({ id: groupId, action: 'endpoints/' + endpointId }).$promise;
|
||||
};
|
||||
|
||||
service.removeEndpoint = function (groupId, endpointId) {
|
||||
|
|
|
@ -17,10 +17,12 @@ export const tagKeys = {
|
|||
|
||||
export function useTags<T = Tag[]>({
|
||||
select,
|
||||
}: { select?: (tags: Tag[]) => T } = {}) {
|
||||
enabled = true,
|
||||
}: { select?: (tags: Tag[]) => T; enabled?: boolean } = {}) {
|
||||
return useQuery(tagKeys.all, () => getTags(), {
|
||||
staleTime: 50,
|
||||
select,
|
||||
enabled,
|
||||
...withError('Failed to retrieve tags'),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -5,17 +5,13 @@ angular.module('portainer.app').controller('CreateGroupController', function Cre
|
|||
actionInProgress: false,
|
||||
};
|
||||
|
||||
$scope.onChangeEnvironments = onChangeEnvironments;
|
||||
|
||||
$scope.create = function () {
|
||||
var model = $scope.model;
|
||||
|
||||
var associatedEndpoints = [];
|
||||
for (var i = 0; i < $scope.associatedEndpoints.length; i++) {
|
||||
var endpoint = $scope.associatedEndpoints[i];
|
||||
associatedEndpoints.push(endpoint.Id);
|
||||
}
|
||||
|
||||
$scope.state.actionInProgress = true;
|
||||
GroupService.createGroup(model, associatedEndpoints)
|
||||
GroupService.createGroup(model, $scope.associatedEndpoints)
|
||||
.then(function success() {
|
||||
Notifications.success('Success', 'Group successfully created');
|
||||
$state.go('portainer.groups', {}, { reload: true });
|
||||
|
@ -34,5 +30,11 @@ angular.module('portainer.app').controller('CreateGroupController', function Cre
|
|||
$scope.loaded = true;
|
||||
}
|
||||
|
||||
function onChangeEnvironments(value) {
|
||||
return $scope.$evalAsync(() => {
|
||||
$scope.associatedEndpoints = value;
|
||||
});
|
||||
}
|
||||
|
||||
initView();
|
||||
});
|
||||
|
|
|
@ -6,15 +6,12 @@
|
|||
<rd-widget-body>
|
||||
<group-form
|
||||
loaded="loaded"
|
||||
page-type="create"
|
||||
model="model"
|
||||
available-endpoints="availableEndpoints"
|
||||
associated-endpoints="associatedEndpoints"
|
||||
add-label-action="addLabel"
|
||||
remove-label-action="removeLabel"
|
||||
form-action="create"
|
||||
form-action-label="Create the group"
|
||||
action-in-progress="state.actionInProgress"
|
||||
on-change-environments="(onChangeEnvironments)"
|
||||
></group-form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
|
|
|
@ -6,15 +6,12 @@
|
|||
<rd-widget-body>
|
||||
<group-form
|
||||
loaded="loaded"
|
||||
page-type="edit"
|
||||
model="group"
|
||||
available-endpoints="availableEndpoints"
|
||||
associated-endpoints="associatedEndpoints"
|
||||
add-label-action="addLabel"
|
||||
remove-label-action="removeLabel"
|
||||
form-action="update"
|
||||
form-action-label="Update the group"
|
||||
action-in-progress="state.actionInProgress"
|
||||
on-change-environments="(onChangeEnvironments)"
|
||||
></group-form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
angular.module('portainer.app').controller('GroupController', function GroupController($q, $scope, $state, $transition$, GroupService, Notifications) {
|
||||
import { getEnvironments } from '@/react/portainer/environments/environment.service';
|
||||
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
|
||||
|
||||
angular.module('portainer.app').controller('GroupController', function GroupController($async, $q, $scope, $state, $transition$, GroupService, Notifications) {
|
||||
$scope.state = {
|
||||
actionInProgress: false,
|
||||
};
|
||||
$scope.onChangeEnvironments = onChangeEnvironments;
|
||||
$scope.associatedEndpoints = [];
|
||||
|
||||
$scope.update = function () {
|
||||
var model = $scope.group;
|
||||
|
@ -20,14 +25,53 @@ angular.module('portainer.app').controller('GroupController', function GroupCont
|
|||
});
|
||||
};
|
||||
|
||||
function onChangeEnvironments(value, meta) {
|
||||
return $async(async () => {
|
||||
let success = false;
|
||||
if (meta.type === 'add') {
|
||||
success = await onAssociate(meta.value);
|
||||
} else if (meta.type === 'remove') {
|
||||
success = await onDisassociate(meta.value);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
$scope.associatedEndpoints = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function onAssociate(endpointId) {
|
||||
try {
|
||||
await GroupService.addEndpoint($scope.group.Id, endpointId);
|
||||
|
||||
notifySuccess('Success', `Environment successfully added to group`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
notifyError('Failure', err, `Unable to add environment to group`);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDisassociate(endpointId) {
|
||||
try {
|
||||
await GroupService.removeEndpoint($scope.group.Id, endpointId);
|
||||
|
||||
notifySuccess('Success', `Environment successfully removed to group`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
notifyError('Failure', err, `Unable to remove environment to group`);
|
||||
}
|
||||
}
|
||||
|
||||
function initView() {
|
||||
var groupId = $transition$.params().id;
|
||||
|
||||
$q.all({
|
||||
group: GroupService.group(groupId),
|
||||
endpoints: getEnvironments({ query: { groupIds: [groupId] } }),
|
||||
})
|
||||
.then(function success(data) {
|
||||
$scope.group = data.group;
|
||||
$scope.associatedEndpoints = data.endpoints.value.map((endpoint) => endpoint.Id);
|
||||
$scope.loaded = true;
|
||||
})
|
||||
.catch(function error(err) {
|
||||
|
|
|
@ -17,6 +17,8 @@ import { ReactNode, useMemo } from 'react';
|
|||
import clsx from 'clsx';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { AutomationTestingProps } from '@/types';
|
||||
|
||||
import { IconProps } from '@@/Icon';
|
||||
|
||||
import { DatatableHeader } from './DatatableHeader';
|
||||
|
@ -32,7 +34,7 @@ import { TableRow } from './TableRow';
|
|||
export interface Props<
|
||||
D extends Record<string, unknown>,
|
||||
TSettings extends BasicTableSettings = BasicTableSettings
|
||||
> {
|
||||
> extends AutomationTestingProps {
|
||||
dataset: D[];
|
||||
columns: TableOptions<D>['columns'];
|
||||
renderTableSettings?(instance: TableInstance<D>): ReactNode;
|
||||
|
@ -82,6 +84,7 @@ export function Datatable<D extends Record<string, unknown>>({
|
|||
highlightedItemId,
|
||||
noWidget,
|
||||
getRowCanExpand,
|
||||
'data-cy': dataCy,
|
||||
}: Props<D>) {
|
||||
const isServerSidePagination = typeof pageCount !== 'undefined';
|
||||
const enableRowSelection = getIsSelectionEnabled(
|
||||
|
@ -156,6 +159,7 @@ export function Datatable<D extends Record<string, unknown>>({
|
|||
emptyContentLabel={emptyContentLabel}
|
||||
isLoading={isLoading}
|
||||
onSortChange={handleSortChange}
|
||||
data-cy={dataCy}
|
||||
/>
|
||||
|
||||
<DatatableFooter
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
import { Row, Table as TableInstance } from '@tanstack/react-table';
|
||||
|
||||
import { AutomationTestingProps } from '@/types';
|
||||
|
||||
import { Table } from './Table';
|
||||
|
||||
interface Props<D extends Record<string, unknown>> {
|
||||
interface Props<D extends Record<string, unknown>>
|
||||
extends AutomationTestingProps {
|
||||
tableInstance: TableInstance<D>;
|
||||
renderRow(row: Row<D>): React.ReactNode;
|
||||
onSortChange?(colId: string, desc: boolean): void;
|
||||
|
@ -16,12 +19,13 @@ export function DatatableContent<D extends Record<string, unknown>>({
|
|||
onSortChange,
|
||||
isLoading,
|
||||
emptyContentLabel,
|
||||
'data-cy': dataCy,
|
||||
}: Props<D>) {
|
||||
const headerGroups = tableInstance.getHeaderGroups();
|
||||
const pageRowModel = tableInstance.getPaginationRowModel();
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<Table data-cy={dataCy}>
|
||||
<thead>
|
||||
{headerGroups.map((headerGroup) => (
|
||||
<Table.HeaderRow<D>
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import clsx from 'clsx';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { AutomationTestingProps } from '@/types';
|
||||
|
||||
import { TableContainer } from './TableContainer';
|
||||
import { TableActions } from './TableActions';
|
||||
import { TableFooter } from './TableFooter';
|
||||
|
@ -12,14 +14,19 @@ import { TableHeaderCell } from './TableHeaderCell';
|
|||
import { TableHeaderRow } from './TableHeaderRow';
|
||||
import { TableRow } from './TableRow';
|
||||
|
||||
interface Props {
|
||||
interface Props extends AutomationTestingProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function MainComponent({ children, className }: PropsWithChildren<Props>) {
|
||||
function MainComponent({
|
||||
children,
|
||||
className,
|
||||
'data-cy': dataCy,
|
||||
}: PropsWithChildren<Props>) {
|
||||
return (
|
||||
<div className="table-responsive">
|
||||
<table
|
||||
data-cy={dataCy}
|
||||
className={clsx(
|
||||
'table-hover table-filters nowrap-cells table',
|
||||
className
|
||||
|
|
|
@ -14,6 +14,9 @@ export function createSelectColumn<T>(): ColumnDef<T> {
|
|||
indeterminate={table.getIsSomeRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
disabled={table.getRowModel().rows.every((row) => !row.getCanSelect())}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
cell: ({ row, table }) => (
|
||||
|
@ -24,6 +27,8 @@ export function createSelectColumn<T>(): ColumnDef<T> {
|
|||
onChange={row.getToggleSelectedHandler()}
|
||||
disabled={!row.getCanSelect()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.shiftKey) {
|
||||
const { rows, rowsById } = table.getRowModel();
|
||||
const rowsToToggle = getRowRange(rows, row.id, lastSelectedId);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useStore } from 'zustand';
|
||||
|
||||
import { useSearchBarState } from './SearchBar';
|
||||
|
@ -27,3 +27,23 @@ export function useTableState<
|
|||
[settings, search, setSearch]
|
||||
);
|
||||
}
|
||||
|
||||
export function useTableStateWithoutStorage(
|
||||
defaultSortKey: string
|
||||
): BasicTableSettings & {
|
||||
setSearch: (search: string) => void;
|
||||
search: string;
|
||||
} {
|
||||
const [search, setSearch] = useState('');
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [sortBy, setSortBy] = useState({ id: defaultSortKey, desc: false });
|
||||
|
||||
return {
|
||||
search,
|
||||
setSearch,
|
||||
pageSize,
|
||||
setPageSize,
|
||||
setSortBy: (id: string, desc: boolean) => setSortBy({ id, desc }),
|
||||
sortBy,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
import { EdgeTypes, EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { EdgeGroupAssociationTable } from './EdgeGroupAssociationTable';
|
||||
|
||||
export function AssociatedEdgeEnvironmentsSelector({
|
||||
onChange,
|
||||
value,
|
||||
}: {
|
||||
onChange: (
|
||||
value: EnvironmentId[],
|
||||
meta: { type: 'add' | 'remove'; value: EnvironmentId }
|
||||
) => void;
|
||||
value: EnvironmentId[];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="col-sm-12 small text-muted">
|
||||
You can select which environment should be part of this group by moving
|
||||
them to the associated environments table. Simply click on any
|
||||
environment entry to move it from one table to the other.
|
||||
</div>
|
||||
|
||||
<div className="col-sm-12 mt-4">
|
||||
<div className="flex">
|
||||
<div className="w-1/2">
|
||||
<EdgeGroupAssociationTable
|
||||
title="Available environments"
|
||||
emptyContentLabel="No environment available"
|
||||
query={{
|
||||
types: EdgeTypes,
|
||||
}}
|
||||
onClickRow={(env) => {
|
||||
if (!value.includes(env.Id)) {
|
||||
onChange([...value, env.Id], { type: 'add', value: env.Id });
|
||||
}
|
||||
}}
|
||||
data-cy="edgeGroupCreate-availableEndpoints"
|
||||
hideEnvironmentIds={value}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-1/2">
|
||||
<EdgeGroupAssociationTable
|
||||
title="Associated environments"
|
||||
emptyContentLabel="No associated environment'"
|
||||
query={{
|
||||
types: EdgeTypes,
|
||||
endpointIds: value,
|
||||
}}
|
||||
onClickRow={(env) => {
|
||||
if (value.includes(env.Id)) {
|
||||
onChange(
|
||||
value.filter((id) => id !== env.Id),
|
||||
{ type: 'remove', value: env.Id }
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,117 @@
|
|||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
import { truncate } from 'lodash';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useEnvironmentList } from '@/react/portainer/environments/queries';
|
||||
import {
|
||||
Environment,
|
||||
EnvironmentId,
|
||||
} from '@/react/portainer/environments/types';
|
||||
import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
|
||||
import { useTags } from '@/portainer/tags/queries';
|
||||
import { EnvironmentsQueryParams } from '@/react/portainer/environments/environment.service';
|
||||
import { AutomationTestingProps } from '@/types';
|
||||
|
||||
import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
|
||||
import { Datatable, TableRow } from '@@/datatables';
|
||||
|
||||
type DecoratedEnvironment = Environment & {
|
||||
Tags: string[];
|
||||
Group: string;
|
||||
};
|
||||
|
||||
const columHelper = createColumnHelper<DecoratedEnvironment>();
|
||||
|
||||
const columns = [
|
||||
columHelper.accessor('Name', {
|
||||
header: 'Name',
|
||||
id: 'Name',
|
||||
cell: ({ getValue }) => truncate(getValue(), { length: 64 }),
|
||||
}),
|
||||
columHelper.accessor('Group', {
|
||||
header: 'Group',
|
||||
id: 'Group',
|
||||
cell: ({ getValue }) => truncate(getValue(), { length: 64 }),
|
||||
}),
|
||||
columHelper.accessor((row) => row.Tags.join(','), {
|
||||
header: 'Tags',
|
||||
id: 'tags',
|
||||
enableSorting: false,
|
||||
cell: ({ getValue }) => truncate(getValue(), { length: 64 }),
|
||||
}),
|
||||
];
|
||||
|
||||
export function EdgeGroupAssociationTable({
|
||||
title,
|
||||
query,
|
||||
emptyContentLabel,
|
||||
onClickRow,
|
||||
'data-cy': dataCy,
|
||||
hideEnvironmentIds = [],
|
||||
}: {
|
||||
title: string;
|
||||
query: EnvironmentsQueryParams;
|
||||
emptyContentLabel: string;
|
||||
onClickRow: (env: Environment) => void;
|
||||
hideEnvironmentIds?: EnvironmentId[];
|
||||
} & AutomationTestingProps) {
|
||||
const tableState = useTableStateWithoutStorage('Name');
|
||||
const [page, setPage] = useState(1);
|
||||
const environmentsQuery = useEnvironmentList({
|
||||
pageLimit: tableState.pageSize,
|
||||
page,
|
||||
search: tableState.search,
|
||||
sort: tableState.sortBy.id as 'Group' | 'Name',
|
||||
order: tableState.sortBy.desc ? 'desc' : 'asc',
|
||||
...query,
|
||||
});
|
||||
const groupsQuery = useGroups({
|
||||
enabled: environmentsQuery.environments.length > 0,
|
||||
});
|
||||
const tagsQuery = useTags({
|
||||
enabled: environmentsQuery.environments.length > 0,
|
||||
});
|
||||
|
||||
const environments: Array<DecoratedEnvironment> = useMemo(
|
||||
() =>
|
||||
environmentsQuery.environments
|
||||
.filter((e) => !hideEnvironmentIds.includes(e.Id))
|
||||
.map((env) => ({
|
||||
...env,
|
||||
Group:
|
||||
groupsQuery.data?.find((g) => g.Id === env.GroupId)?.Name || '',
|
||||
Tags: env.TagIds.map(
|
||||
(tagId) => tagsQuery.data?.find((t) => t.ID === tagId)?.Name || ''
|
||||
),
|
||||
})),
|
||||
[
|
||||
environmentsQuery.environments,
|
||||
groupsQuery.data,
|
||||
hideEnvironmentIds,
|
||||
tagsQuery.data,
|
||||
]
|
||||
);
|
||||
|
||||
const totalCount = environmentsQuery.totalCount - hideEnvironmentIds.length;
|
||||
|
||||
return (
|
||||
<Datatable<DecoratedEnvironment>
|
||||
title={title}
|
||||
columns={columns}
|
||||
settingsManager={tableState}
|
||||
dataset={environments}
|
||||
onPageChange={setPage}
|
||||
pageCount={Math.ceil(totalCount / tableState.pageSize)}
|
||||
renderRow={(row) => (
|
||||
<TableRow<DecoratedEnvironment>
|
||||
cells={row.getVisibleCells()}
|
||||
onClick={() => onClickRow(row.original)}
|
||||
/>
|
||||
)}
|
||||
emptyContentLabel={emptyContentLabel}
|
||||
data-cy={dataCy}
|
||||
disableSelect
|
||||
totalCount={totalCount}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -14,6 +14,7 @@ import {
|
|||
import { EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types';
|
||||
import {
|
||||
refetchIfAnyOffline,
|
||||
SortType,
|
||||
useEnvironmentList,
|
||||
} from '@/react/portainer/environments/queries/useEnvironmentList';
|
||||
import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
|
||||
|
@ -68,7 +69,9 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
|
|||
'group',
|
||||
[]
|
||||
);
|
||||
const [sortByFilter, setSortByFilter] = useSearchBarState('sortBy');
|
||||
const [sortByFilter, setSortByFilter] = useHomePageFilter<
|
||||
SortType | undefined
|
||||
>('sortBy', 'Name');
|
||||
const [sortByDescending, setSortByDescending] = useHomePageFilter(
|
||||
'sortOrder',
|
||||
false
|
||||
|
@ -342,7 +345,7 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
|
|||
setConnectionTypes([]);
|
||||
}
|
||||
|
||||
function sortOnchange(value: string) {
|
||||
function sortOnchange(value?: 'Name' | 'Group' | 'Status') {
|
||||
setSortByFilter(value);
|
||||
setSortByButton(!!value);
|
||||
}
|
||||
|
|
|
@ -6,6 +6,10 @@ import { useAgentVersionsList } from '../../environments/queries/useAgentVersion
|
|||
import { EnvironmentStatus, PlatformType } from '../../environments/types';
|
||||
import { isBE } from '../../feature-flags/feature-flags.service';
|
||||
import { useGroups } from '../../environments/environment-groups/queries';
|
||||
import {
|
||||
SortOptions,
|
||||
SortType,
|
||||
} from '../../environments/queries/useEnvironmentList';
|
||||
|
||||
import { HomepageFilter } from './HomepageFilter';
|
||||
import { SortbySelector } from './SortbySelector';
|
||||
|
@ -17,7 +21,7 @@ const status = [
|
|||
{ value: EnvironmentStatus.Down, label: 'Down' },
|
||||
];
|
||||
|
||||
const sortByOptions = ['Name', 'Group', 'Status'].map((v) => ({
|
||||
const sortByOptions = SortOptions.map((v) => ({
|
||||
value: v,
|
||||
label: v,
|
||||
}));
|
||||
|
@ -60,8 +64,8 @@ export function EnvironmentListFilters({
|
|||
setAgentVersions: (value: string[]) => void;
|
||||
agentVersions: string[];
|
||||
|
||||
sortByState: string;
|
||||
sortOnChange: (value: string) => void;
|
||||
sortByState?: SortType;
|
||||
sortOnChange: (value: SortType) => void;
|
||||
|
||||
sortOnDescending: () => void;
|
||||
sortByDescending: boolean;
|
||||
|
|
|
@ -3,16 +3,18 @@ import clsx from 'clsx';
|
|||
import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
|
||||
import { TableHeaderSortIcons } from '@@/datatables/TableHeaderSortIcons';
|
||||
|
||||
import { SortType } from '../../environments/queries/useEnvironmentList';
|
||||
|
||||
import styles from './SortbySelector.module.css';
|
||||
|
||||
interface Props {
|
||||
filterOptions: Option<string>[];
|
||||
onChange: (value: string) => void;
|
||||
filterOptions: Option<SortType>[];
|
||||
onChange: (value: SortType) => void;
|
||||
onDescending: () => void;
|
||||
placeHolder: string;
|
||||
sortByDescending: boolean;
|
||||
sortByButton: boolean;
|
||||
value: string;
|
||||
value?: SortType;
|
||||
}
|
||||
|
||||
export function SortbySelector({
|
||||
|
@ -30,7 +32,7 @@ export function SortbySelector({
|
|||
<PortainerSelect
|
||||
placeholder={placeHolder}
|
||||
options={filterOptions}
|
||||
onChange={(option) => onChange(option || '')}
|
||||
onChange={(option: SortType) => onChange(option || '')}
|
||||
isClearable
|
||||
value={value}
|
||||
/>
|
||||
|
|
|
@ -11,7 +11,7 @@ import { Link } from '@@/Link';
|
|||
import { useTableState } from '@@/datatables/useTableState';
|
||||
|
||||
import { isBE } from '../../feature-flags/feature-flags.service';
|
||||
import { refetchIfAnyOffline } from '../queries/useEnvironmentList';
|
||||
import { isSortType, refetchIfAnyOffline } from '../queries/useEnvironmentList';
|
||||
|
||||
import { columns } from './columns';
|
||||
import { EnvironmentListItem } from './types';
|
||||
|
@ -36,7 +36,7 @@ export function EnvironmentsDatatable({
|
|||
excludeSnapshots: true,
|
||||
page: page + 1,
|
||||
pageLimit: tableState.pageSize,
|
||||
sort: tableState.sortBy.id,
|
||||
sort: isSortType(tableState.sortBy.id) ? tableState.sortBy.id : undefined,
|
||||
order: tableState.sortBy.desc ? 'desc' : 'asc',
|
||||
},
|
||||
{ enabled: groupsQuery.isSuccess, refetchInterval: refetchIfAnyOffline }
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
import { EnvironmentId } from '../../types';
|
||||
|
||||
import { GroupAssociationTable } from './GroupAssociationTable';
|
||||
|
||||
export function AssociatedEnvironmentsSelector({
|
||||
onChange,
|
||||
value,
|
||||
}: {
|
||||
onChange: (
|
||||
value: EnvironmentId[],
|
||||
meta: { type: 'add' | 'remove'; value: EnvironmentId }
|
||||
) => void;
|
||||
value: EnvironmentId[];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="col-sm-12 small text-muted">
|
||||
You can select which environment should be part of this group by moving
|
||||
them to the associated environments table. Simply click on any
|
||||
environment entry to move it from one table to the other.
|
||||
</div>
|
||||
|
||||
<div className="col-sm-12 mt-4">
|
||||
<div className="flex">
|
||||
<div className="w-1/2">
|
||||
<GroupAssociationTable
|
||||
title="Available environments"
|
||||
emptyContentLabel="No environment available"
|
||||
query={{
|
||||
groupIds: [1],
|
||||
}}
|
||||
onClickRow={(env) => {
|
||||
if (!value.includes(env.Id)) {
|
||||
onChange([...value, env.Id], { type: 'add', value: env.Id });
|
||||
}
|
||||
}}
|
||||
data-cy="edgeGroupCreate-availableEndpoints"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-1/2">
|
||||
<GroupAssociationTable
|
||||
title="Associated environments"
|
||||
emptyContentLabel="No associated environment'"
|
||||
query={{
|
||||
endpointIds: value,
|
||||
}}
|
||||
onClickRow={(env) => {
|
||||
if (value.includes(env.Id)) {
|
||||
onChange(
|
||||
value.filter((id) => id !== env.Id),
|
||||
{ type: 'remove', value: env.Id }
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
import { truncate } from 'lodash';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useEnvironmentList } from '@/react/portainer/environments/queries';
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
import { EnvironmentsQueryParams } from '@/react/portainer/environments/environment.service';
|
||||
import { AutomationTestingProps } from '@/types';
|
||||
|
||||
import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
|
||||
import { Datatable, TableRow } from '@@/datatables';
|
||||
|
||||
const columHelper = createColumnHelper<Environment>();
|
||||
|
||||
const columns = [
|
||||
columHelper.accessor('Name', {
|
||||
header: 'Name',
|
||||
id: 'Name',
|
||||
cell: ({ getValue }) => truncate(getValue(), { length: 64 }),
|
||||
}),
|
||||
];
|
||||
|
||||
export function GroupAssociationTable({
|
||||
title,
|
||||
query,
|
||||
emptyContentLabel,
|
||||
onClickRow,
|
||||
'data-cy': dataCy,
|
||||
}: {
|
||||
title: string;
|
||||
query: EnvironmentsQueryParams;
|
||||
emptyContentLabel: string;
|
||||
onClickRow?: (env: Environment) => void;
|
||||
} & AutomationTestingProps) {
|
||||
const tableState = useTableStateWithoutStorage('Name');
|
||||
const [page, setPage] = useState(1);
|
||||
const environmentsQuery = useEnvironmentList({
|
||||
pageLimit: tableState.pageSize,
|
||||
page,
|
||||
search: tableState.search,
|
||||
sort: tableState.sortBy.id as 'Name',
|
||||
order: tableState.sortBy.desc ? 'desc' : 'asc',
|
||||
...query,
|
||||
});
|
||||
|
||||
const { environments } = environmentsQuery;
|
||||
|
||||
return (
|
||||
<Datatable<Environment>
|
||||
title={title}
|
||||
columns={columns}
|
||||
settingsManager={tableState}
|
||||
dataset={environments}
|
||||
onPageChange={setPage}
|
||||
pageCount={Math.ceil(environmentsQuery.totalCount / tableState.pageSize)}
|
||||
renderRow={(row) => (
|
||||
<TableRow<Environment>
|
||||
cells={row.getVisibleCells()}
|
||||
onClick={onClickRow ? () => onClickRow(row.original) : undefined}
|
||||
/>
|
||||
)}
|
||||
emptyContentLabel={emptyContentLabel}
|
||||
data-cy={dataCy}
|
||||
disableSelect
|
||||
totalCount={environmentsQuery.totalCount}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -8,9 +8,11 @@ import { queryKeys } from './queries/query-keys';
|
|||
|
||||
export function useGroups<T = EnvironmentGroup[]>({
|
||||
select,
|
||||
}: { select?: (group: EnvironmentGroup[]) => T } = {}) {
|
||||
enabled = true,
|
||||
}: { select?: (group: EnvironmentGroup[]) => T; enabled?: boolean } = {}) {
|
||||
return useQuery(queryKeys.base(), getGroups, {
|
||||
select,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -60,7 +60,10 @@ export async function getEnvironments(
|
|||
query = {},
|
||||
}: GetEnvironmentsOptions = { query: {} }
|
||||
) {
|
||||
if (query.tagIds && query.tagIds.length === 0) {
|
||||
if (
|
||||
(query.tagIds && query.tagIds.length === 0) ||
|
||||
(query.endpointIds && query.endpointIds.length === 0)
|
||||
) {
|
||||
return {
|
||||
totalCount: 0,
|
||||
value: <Environment[]>[],
|
||||
|
|
|
@ -12,10 +12,16 @@ import { queryKeys } from './query-keys';
|
|||
|
||||
export const ENVIRONMENTS_POLLING_INTERVAL = 30000; // in ms
|
||||
|
||||
export const SortOptions = ['Name', 'Group', 'Status'] as const;
|
||||
export type SortType = (typeof SortOptions)[number];
|
||||
export function isSortType(value: string): value is SortType {
|
||||
return SortOptions.includes(value as SortType);
|
||||
}
|
||||
|
||||
export type Query = EnvironmentsQueryParams & {
|
||||
page?: number;
|
||||
pageLimit?: number;
|
||||
sort?: string;
|
||||
sort?: SortType;
|
||||
order?: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
|
|
Loading…
Reference in New Issue