refactor(groups): migrate groups selectors to react [EE-3842] (#8936)

pull/9115/head
Chaim Lev-Ari 2023-06-22 21:11:10 +07:00 committed by GitHub
parent 2018529add
commit e91b4f5c83
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 543 additions and 627 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -7,7 +7,6 @@ angular.module('portainer.edge').component('edgeGroupForm', {
controller: EdgeGroupFormController,
bindings: {
model: '<',
groups: '<',
formActionLabel: '@',
formAction: '<',
actionInProgress: '<',

View File

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

View File

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

View File

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

View File

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

View File

@ -6,14 +6,12 @@ angular.module('portainer.app').component('groupForm', {
controller: GroupFormController,
bindings: {
loaded: '<',
pageType: '@',
model: '=',
availableEndpoints: '=',
associatedEndpoints: '=',
addLabelAction: '<',
removeLabelAction: '<',
formAction: '<',
formActionLabel: '@',
actionInProgress: '<',
onChangeEnvironments: '<',
},
});

View File

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

View File

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

View File

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

View File

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

View File

@ -2,6 +2,7 @@ export function EndpointGroupDefaultModel() {
this.Name = '';
this.Description = '';
this.TagIds = [];
this.AssociatedEndpoints = [];
}
export function EndpointGroupModel(data) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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[]>[],

View File

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