mirror of
https://github.com/portainer/portainer.git
synced 2025-07-19 13:29:41 +02:00
refactor(app-templates): convert list to react [EE-6205] (#10439)
This commit is contained in:
parent
1fa63f6ab7
commit
14129632a3
22 changed files with 393 additions and 351 deletions
|
@ -3,8 +3,6 @@ import angular from 'angular';
|
||||||
import { r2a } from '@/react-tools/react2angular';
|
import { r2a } from '@/react-tools/react2angular';
|
||||||
import { withControlledInput } from '@/react-tools/withControlledInput';
|
import { withControlledInput } from '@/react-tools/withControlledInput';
|
||||||
import { StackContainersDatatable } from '@/react/common/stacks/ItemView/StackContainersDatatable';
|
import { StackContainersDatatable } from '@/react/common/stacks/ItemView/StackContainersDatatable';
|
||||||
import { TemplateListDropdownAngular } from '@/react/docker/app-templates/TemplateListDropdown';
|
|
||||||
import { TemplateListSortAngular } from '@/react/docker/app-templates/TemplateListSort';
|
|
||||||
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||||
import { withReactQuery } from '@/react-tools/withReactQuery';
|
import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||||
|
@ -39,8 +37,6 @@ const ngModule = angular
|
||||||
])
|
])
|
||||||
.component('dockerfileDetails', r2a(DockerfileDetails, ['image']))
|
.component('dockerfileDetails', r2a(DockerfileDetails, ['image']))
|
||||||
.component('dockerHealthStatus', r2a(HealthStatus, ['health']))
|
.component('dockerHealthStatus', r2a(HealthStatus, ['health']))
|
||||||
.component('templateListDropdown', TemplateListDropdownAngular)
|
|
||||||
.component('templateListSort', TemplateListSortAngular)
|
|
||||||
.component(
|
.component(
|
||||||
'stackContainersDatatable',
|
'stackContainersDatatable',
|
||||||
r2a(
|
r2a(
|
||||||
|
|
|
@ -1,149 +0,0 @@
|
||||||
import _ from 'lodash-es';
|
|
||||||
|
|
||||||
angular.module('portainer.app').controller('TemplateListController', TemplateListController);
|
|
||||||
|
|
||||||
function TemplateListController($scope, $async, $state, DatatableService, Notifications, TemplateService) {
|
|
||||||
var ctrl = this;
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
textFilter: '',
|
|
||||||
selectedCategory: null,
|
|
||||||
categories: [],
|
|
||||||
typeFilters: [],
|
|
||||||
filterByType: null,
|
|
||||||
showContainerTemplates: true,
|
|
||||||
selectedOrderBy: null,
|
|
||||||
orderByFields: [],
|
|
||||||
orderDesc: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.onTextFilterChange = function () {
|
|
||||||
DatatableService.setDataTableTextFilters(this.tableKey, this.state.textFilter);
|
|
||||||
};
|
|
||||||
|
|
||||||
ctrl.filterByTemplateType = function (item) {
|
|
||||||
switch (item.Type) {
|
|
||||||
case 1: // container
|
|
||||||
return ctrl.state.showContainerTemplates;
|
|
||||||
case 2: // swarm stack
|
|
||||||
return ctrl.showSwarmStacks && !ctrl.state.showContainerTemplates;
|
|
||||||
case 3: // docker compose
|
|
||||||
return !ctrl.state.showContainerTemplates || null === ctrl.state.filterByType;
|
|
||||||
case 4: // Edge stack templates
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
ctrl.updateCategories = function () {
|
|
||||||
var availableCategories = [];
|
|
||||||
|
|
||||||
for (var i = 0; i < ctrl.templates.length; i++) {
|
|
||||||
var template = ctrl.templates[i];
|
|
||||||
if (ctrl.filterByTemplateType(template)) {
|
|
||||||
availableCategories = availableCategories.concat(template.Categories);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctrl.state.categories = _.sortBy(_.uniq(availableCategories));
|
|
||||||
};
|
|
||||||
|
|
||||||
ctrl.filterByCategory = function (item) {
|
|
||||||
if (!ctrl.state.selectedCategory) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return _.includes(item.Categories, ctrl.state.selectedCategory);
|
|
||||||
};
|
|
||||||
|
|
||||||
this.duplicateTemplate = duplicateTemplate.bind(this);
|
|
||||||
this.duplicateTemplateAsync = duplicateTemplateAsync.bind(this);
|
|
||||||
function duplicateTemplate(template) {
|
|
||||||
return $async(this.duplicateTemplateAsync, template);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function duplicateTemplateAsync(template) {
|
|
||||||
try {
|
|
||||||
const { FileContent: fileContent } = await TemplateService.templateFile(template.Repository.url, template.Repository.stackfile);
|
|
||||||
let type = 0;
|
|
||||||
if (template.Type === 2) {
|
|
||||||
type = 1;
|
|
||||||
}
|
|
||||||
if (template.Type === 3) {
|
|
||||||
type = 2;
|
|
||||||
}
|
|
||||||
$state.go('docker.templates.custom.new', { fileContent, type });
|
|
||||||
} catch (err) {
|
|
||||||
Notifications.error('Failure', err, 'Failed to duplicate template');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctrl.changeOrderBy = function (orderField) {
|
|
||||||
$scope.$evalAsync(() => {
|
|
||||||
if (null === orderField) {
|
|
||||||
ctrl.state.selectedOrderBy = null;
|
|
||||||
ctrl.templates = ctrl.initalTemplates;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctrl.state.selectedOrderBy = orderField;
|
|
||||||
ctrl.templates = _.orderBy(ctrl.templates, [getSorter(ctrl.state.selectedOrderBy)], [ctrl.state.orderDesc ? 'desc' : 'asc']);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
ctrl.applyTypeFilter = function (type) {
|
|
||||||
$scope.$evalAsync(() => {
|
|
||||||
ctrl.state.filterByType = type;
|
|
||||||
ctrl.state.showContainerTemplates = 'Container' === type || null === type;
|
|
||||||
ctrl.updateCategories();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
ctrl.invertOrder = function () {
|
|
||||||
$scope.$evalAsync(() => {
|
|
||||||
ctrl.state.orderDesc = !ctrl.state.orderDesc;
|
|
||||||
ctrl.templates = _.orderBy(ctrl.templates, [getSorter(ctrl.state.selectedOrderBy)], [ctrl.state.orderDesc ? 'desc' : 'asc']);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
ctrl.applyCategoriesFilter = function (category) {
|
|
||||||
$scope.$evalAsync(() => {
|
|
||||||
ctrl.state.selectedCategory = category;
|
|
||||||
ctrl.updateCategories();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
this.$onInit = function () {
|
|
||||||
if (this.showSwarmStacks) {
|
|
||||||
this.state.showContainerTemplates = false;
|
|
||||||
}
|
|
||||||
this.updateCategories();
|
|
||||||
|
|
||||||
var textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
|
|
||||||
if (textFilter !== null) {
|
|
||||||
this.state.textFilter = textFilter;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.initalTemplates = this.templates;
|
|
||||||
this.state.orderByFields = ['Title', 'Categories', 'Description'];
|
|
||||||
this.state.typeFilters = ['Container', 'Stack'];
|
|
||||||
};
|
|
||||||
|
|
||||||
function categorySorter(template) {
|
|
||||||
if (template.Categories && template.Categories.length > 0 && template.Categories[0] && template.Categories[0].length > 0) {
|
|
||||||
return template.Categories[0].toLowerCase();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSorter(orderBy) {
|
|
||||||
let sorter;
|
|
||||||
switch (orderBy) {
|
|
||||||
case 'Categories':
|
|
||||||
sorter = categorySorter;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
sorter = orderBy;
|
|
||||||
}
|
|
||||||
|
|
||||||
return sorter;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,13 +0,0 @@
|
||||||
angular.module('portainer.app').component('templateList', {
|
|
||||||
templateUrl: './templateList.html',
|
|
||||||
controller: 'TemplateListController',
|
|
||||||
bindings: {
|
|
||||||
titleText: '@',
|
|
||||||
titleIcon: '@',
|
|
||||||
templates: '<',
|
|
||||||
tableKey: '@',
|
|
||||||
selectAction: '<',
|
|
||||||
showSwarmStacks: '<',
|
|
||||||
isSelected: '<',
|
|
||||||
},
|
|
||||||
});
|
|
|
@ -1,76 +0,0 @@
|
||||||
<div class="datatable">
|
|
||||||
<rd-widget>
|
|
||||||
<div class="toolBar">
|
|
||||||
<div class="toolBarTitle vertical-center">
|
|
||||||
<div class="widget-icon space-right">
|
|
||||||
<pr-icon icon="$ctrl.titleIcon"></pr-icon>
|
|
||||||
</div>
|
|
||||||
{{ $ctrl.titleText }}
|
|
||||||
</div>
|
|
||||||
<div class="searchBar vertical-center">
|
|
||||||
<pr-icon icon="'search'" class-name="'searchIcon'"></pr-icon>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="searchInput"
|
|
||||||
ng-model="$ctrl.state.textFilter"
|
|
||||||
ng-change="$ctrl.onTextFilterChange()"
|
|
||||||
placeholder="Search for a template..."
|
|
||||||
auto-focus
|
|
||||||
ng-model-options="{ debounce: 300 }"
|
|
||||||
data-cy="template-searchInput"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="actionBar !gap-3" ng-if="$ctrl.showAddAction">
|
|
||||||
<button type="button" class="btn btn-sm btn-primary vertical-center !ml-0 h-fit" ui-sref="docker.templates.new" data-cy="template-addTemplateButton">
|
|
||||||
<pr-icon icon="'plus'"></pr-icon>Add template
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<rd-widget-body classes="no-padding">
|
|
||||||
<div class="actionBar">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-sm-3">
|
|
||||||
<template-list-dropdown
|
|
||||||
options="$ctrl.state.categories"
|
|
||||||
on-change="($ctrl.applyCategoriesFilter)"
|
|
||||||
placeholder="'Category'"
|
|
||||||
value="$ctrl.state.selectedCategory"
|
|
||||||
></template-list-dropdown>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-3">
|
|
||||||
<template-list-dropdown
|
|
||||||
options="$ctrl.state.typeFilters"
|
|
||||||
on-change="($ctrl.applyTypeFilter)"
|
|
||||||
placeholder="'Type'"
|
|
||||||
value="$ctrl.state.filterByType"
|
|
||||||
></template-list-dropdown>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-3 col-sm-offset-3">
|
|
||||||
<template-list-sort
|
|
||||||
on-change="($ctrl.changeOrderBy)"
|
|
||||||
on-descending="($ctrl.invertOrder)"
|
|
||||||
options="$ctrl.state.orderByFields"
|
|
||||||
sort-by-button="true"
|
|
||||||
sort-by-descending="$ctrl.state.orderDesc"
|
|
||||||
placeholder="'Sort By'"
|
|
||||||
value="$ctrl.state.selectedOrderBy"
|
|
||||||
></template-list-sort>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="blocklist gap-y-2 !px-[20px] !pb-[20px]">
|
|
||||||
<div ng-repeat="template in $ctrl.templates | filter: $ctrl.filterByTemplateType | filter:$ctrl.filterByCategory | filter:$ctrl.state.textFilter">
|
|
||||||
<app-templates-list-item template="template" on-select="($ctrl.selectAction)" on-duplicate="($ctrl.duplicateTemplate)" is-selected="$ctrl.isSelected(template)">
|
|
||||||
</app-templates-list-item>
|
|
||||||
</div>
|
|
||||||
<div ng-if="!$ctrl.templates" class="text-muted text-center"> Loading... </div>
|
|
||||||
<div
|
|
||||||
ng-if="($ctrl.templates | filter: $ctrl.filterByTemplateType | filter: $ctrl.filterByCategory | filter: $ctrl.state.textFilter).length === 0"
|
|
||||||
class="text-muted text-center"
|
|
||||||
>
|
|
||||||
No templates available.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</rd-widget-body>
|
|
||||||
</rd-widget>
|
|
||||||
</div>
|
|
|
@ -7,7 +7,6 @@ import { withControlledInput } from '@/react-tools/withControlledInput';
|
||||||
import { CustomTemplatesListItem } from '@/react/portainer/templates/custom-templates/ListView/CustomTemplatesListItem';
|
import { CustomTemplatesListItem } from '@/react/portainer/templates/custom-templates/ListView/CustomTemplatesListItem';
|
||||||
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||||
import { AppTemplatesListItem } from '@/react/portainer/templates/app-templates/AppTemplatesListItem';
|
|
||||||
import {
|
import {
|
||||||
CommonFields,
|
CommonFields,
|
||||||
validation as commonFieldsValidation,
|
validation as commonFieldsValidation,
|
||||||
|
@ -15,6 +14,7 @@ import {
|
||||||
import { PlatformField } from '@/react/portainer/custom-templates/components/PlatformSelector';
|
import { PlatformField } from '@/react/portainer/custom-templates/components/PlatformSelector';
|
||||||
import { TemplateTypeSelector } from '@/react/portainer/custom-templates/components/TemplateTypeSelector';
|
import { TemplateTypeSelector } from '@/react/portainer/custom-templates/components/TemplateTypeSelector';
|
||||||
import { withFormValidation } from '@/react-tools/withFormValidation';
|
import { withFormValidation } from '@/react-tools/withFormValidation';
|
||||||
|
import { AppTemplatesList } from '@/react/portainer/templates/app-templates/AppTemplatesList';
|
||||||
|
|
||||||
import { VariablesFieldAngular } from './variables-field';
|
import { VariablesFieldAngular } from './variables-field';
|
||||||
|
|
||||||
|
@ -47,15 +47,7 @@ export const ngModule = angular
|
||||||
'isSelected',
|
'isSelected',
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
.component(
|
|
||||||
'appTemplatesListItem',
|
|
||||||
r2a(withUIRouter(withCurrentUser(AppTemplatesListItem)), [
|
|
||||||
'onSelect',
|
|
||||||
'template',
|
|
||||||
'isSelected',
|
|
||||||
'onDuplicate',
|
|
||||||
])
|
|
||||||
)
|
|
||||||
.component(
|
.component(
|
||||||
'customTemplatesPlatformSelector',
|
'customTemplatesPlatformSelector',
|
||||||
r2a(PlatformField, ['onChange', 'value'])
|
r2a(PlatformField, ['onChange', 'value'])
|
||||||
|
@ -63,6 +55,15 @@ export const ngModule = angular
|
||||||
.component(
|
.component(
|
||||||
'customTemplatesTypeSelector',
|
'customTemplatesTypeSelector',
|
||||||
r2a(TemplateTypeSelector, ['onChange', 'value'])
|
r2a(TemplateTypeSelector, ['onChange', 'value'])
|
||||||
|
)
|
||||||
|
.component(
|
||||||
|
'appTemplatesList',
|
||||||
|
r2a(withUIRouter(withCurrentUser(AppTemplatesList)), [
|
||||||
|
'onSelect',
|
||||||
|
'templates',
|
||||||
|
'selectedId',
|
||||||
|
'showSwarmStacks',
|
||||||
|
])
|
||||||
);
|
);
|
||||||
|
|
||||||
withFormValidation(
|
withFormValidation(
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { commandStringToArray } from '@/docker/helpers/containers';
|
import { commandStringToArray } from '@/docker/helpers/containers';
|
||||||
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/template';
|
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
||||||
import { DockerHubViewModel } from 'Portainer/models/dockerhub';
|
import { DockerHubViewModel } from 'Portainer/models/dockerhub';
|
||||||
|
|
||||||
angular.module('portainer.app').factory('TemplateService', TemplateServiceFactory);
|
angular.module('portainer.app').factory('TemplateService', TemplateServiceFactory);
|
||||||
|
|
|
@ -270,18 +270,9 @@
|
||||||
<!-- container-form -->
|
<!-- container-form -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<app-templates-list
|
||||||
<div class="col-sm-12">
|
|
||||||
<template-list
|
|
||||||
ng-if="templates"
|
|
||||||
title-text="Templates"
|
|
||||||
title-icon="edit"
|
|
||||||
templates="templates"
|
templates="templates"
|
||||||
table-key="templates"
|
on-select="(selectTemplate)"
|
||||||
select-action="selectTemplate"
|
selected-id="state.selectedTemplate.Id"
|
||||||
is-selected="isSelected"
|
|
||||||
show-swarm-stacks="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER' && applicationState.endpoint.apiVersion >= 1.25"
|
show-swarm-stacks="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER' && applicationState.endpoint.apiVersion >= 1.25"
|
||||||
>
|
></app-templates-list>
|
||||||
</template-list>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
|
@ -224,10 +224,6 @@ angular.module('portainer.app').controller('TemplatesController', [
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.isSelected = function (template) {
|
|
||||||
return $scope.state.selectedTemplate && $scope.state.selectedTemplate.Id === template.Id;
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.unselectTemplate = function () {
|
$scope.unselectTemplate = function () {
|
||||||
return $async(async () => {
|
return $async(async () => {
|
||||||
$scope.state.selectedTemplate = null;
|
$scope.state.selectedTemplate = null;
|
||||||
|
@ -237,7 +233,7 @@ angular.module('portainer.app').controller('TemplatesController', [
|
||||||
$scope.selectTemplate = function (template) {
|
$scope.selectTemplate = function (template) {
|
||||||
return $async(async () => {
|
return $async(async () => {
|
||||||
if ($scope.state.selectedTemplate) {
|
if ($scope.state.selectedTemplate) {
|
||||||
$scope.unselectTemplate($scope.state.selectedTemplate);
|
await $scope.unselectTemplate($scope.state.selectedTemplate);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (template.Network) {
|
if (template.Network) {
|
||||||
|
|
|
@ -1,32 +0,0 @@
|
||||||
import { Select } from '@@/form-components/ReactSelect';
|
|
||||||
|
|
||||||
interface Filter {
|
|
||||||
label?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
options: string[];
|
|
||||||
onChange: (value: string | null) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TemplateListDropdown({
|
|
||||||
options,
|
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
value,
|
|
||||||
}: Props) {
|
|
||||||
const filterOptions: Filter[] = options.map((value) => ({ label: value }));
|
|
||||||
const filterValue: Filter | null = value ? { label: value } : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
placeholder={placeholder}
|
|
||||||
options={filterOptions}
|
|
||||||
value={filterValue}
|
|
||||||
isClearable
|
|
||||||
onChange={(option) => onChange(option?.label ?? null)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,11 +0,0 @@
|
||||||
import { react2angular } from '@/react-tools/react2angular';
|
|
||||||
|
|
||||||
import { TemplateListDropdown } from './TemplateListDropdown';
|
|
||||||
|
|
||||||
const TemplateListDropdownAngular = react2angular(TemplateListDropdown, [
|
|
||||||
'options',
|
|
||||||
'onChange',
|
|
||||||
'placeholder',
|
|
||||||
'value',
|
|
||||||
]);
|
|
||||||
export { TemplateListDropdown, TemplateListDropdownAngular };
|
|
|
@ -1,14 +0,0 @@
|
||||||
import { react2angular } from '@/react-tools/react2angular';
|
|
||||||
|
|
||||||
import { TemplateListSort } from './TemplateListSort';
|
|
||||||
|
|
||||||
const TemplateListSortAngular = react2angular(TemplateListSort, [
|
|
||||||
'options',
|
|
||||||
'onChange',
|
|
||||||
'onDescending',
|
|
||||||
'placeholder',
|
|
||||||
'sortByDescending',
|
|
||||||
'sortByButton',
|
|
||||||
'value',
|
|
||||||
]);
|
|
||||||
export { TemplateListSort, TemplateListSortAngular };
|
|
110
app/react/portainer/templates/app-templates/AppTemplatesList.tsx
Normal file
110
app/react/portainer/templates/app-templates/AppTemplatesList.tsx
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
import { Edit } from 'lucide-react';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from '@uirouter/react';
|
||||||
|
|
||||||
|
import { DatatableHeader } from '@@/datatables/DatatableHeader';
|
||||||
|
import { Table } from '@@/datatables';
|
||||||
|
import { useTableState } from '@@/datatables/useTableState';
|
||||||
|
import { createPersistedStore } from '@@/datatables/types';
|
||||||
|
import { DatatableFooter } from '@@/datatables/DatatableFooter';
|
||||||
|
|
||||||
|
import { AppTemplatesListItem } from './AppTemplatesListItem';
|
||||||
|
import { TemplateViewModel } from './view-model';
|
||||||
|
import { ListState } from './types';
|
||||||
|
import { useSortAndFilterTemplates } from './useSortAndFilter';
|
||||||
|
import { Filters } from './Filters';
|
||||||
|
import { useFetchTemplateInfoMutation } from './useFetchTemplateInfoMutation';
|
||||||
|
|
||||||
|
const tableKey = 'app-templates-list';
|
||||||
|
const store = createPersistedStore<ListState>(tableKey, undefined, (set) => ({
|
||||||
|
category: null,
|
||||||
|
setCategory: (category: ListState['category']) => set({ category }),
|
||||||
|
type: null,
|
||||||
|
setType: (type: ListState['type']) => set({ type }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function AppTemplatesList({
|
||||||
|
templates,
|
||||||
|
onSelect,
|
||||||
|
selectedId,
|
||||||
|
showSwarmStacks,
|
||||||
|
}: {
|
||||||
|
templates?: TemplateViewModel[];
|
||||||
|
onSelect: (template: TemplateViewModel) => void;
|
||||||
|
selectedId?: TemplateViewModel['Id'];
|
||||||
|
showSwarmStacks?: boolean;
|
||||||
|
}) {
|
||||||
|
const fetchTemplateInfoMutation = useFetchTemplateInfoMutation();
|
||||||
|
const router = useRouter();
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
|
||||||
|
const listState = useTableState(store, tableKey);
|
||||||
|
const filteredTemplates = useSortAndFilterTemplates(
|
||||||
|
templates || [],
|
||||||
|
listState,
|
||||||
|
showSwarmStacks
|
||||||
|
);
|
||||||
|
|
||||||
|
const pagedTemplates =
|
||||||
|
_.chunk(filteredTemplates, listState.pageSize)[page] || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.Container>
|
||||||
|
<DatatableHeader
|
||||||
|
onSearchChange={handleSearchChange}
|
||||||
|
searchValue={listState.search}
|
||||||
|
title="Templates"
|
||||||
|
titleIcon={Edit}
|
||||||
|
description={
|
||||||
|
<Filters
|
||||||
|
listState={listState}
|
||||||
|
templates={templates || []}
|
||||||
|
onChange={() => setPage(0)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="blocklist gap-y-2 !px-[20px] !pb-[20px]">
|
||||||
|
{pagedTemplates.map((template) => (
|
||||||
|
<AppTemplatesListItem
|
||||||
|
key={template.Id}
|
||||||
|
template={template}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onDuplicate={onDuplicate}
|
||||||
|
isSelected={selectedId === template.Id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{!templates && <div className="text-muted text-center">Loading...</div>}
|
||||||
|
{filteredTemplates.length === 0 && (
|
||||||
|
<div className="text-muted text-center">No templates available.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DatatableFooter
|
||||||
|
onPageChange={setPage}
|
||||||
|
page={page}
|
||||||
|
onPageSizeChange={listState.setPageSize}
|
||||||
|
pageSize={listState.pageSize}
|
||||||
|
pageCount={Math.ceil(filteredTemplates.length / listState.pageSize)}
|
||||||
|
totalSelected={0}
|
||||||
|
/>
|
||||||
|
</Table.Container>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleSearchChange(search: string) {
|
||||||
|
listState.setSearch(search);
|
||||||
|
setPage(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDuplicate(template: TemplateViewModel) {
|
||||||
|
fetchTemplateInfoMutation.mutate(template, {
|
||||||
|
onSuccess({ fileContent, type }) {
|
||||||
|
router.stateService.go('.custom.new', {
|
||||||
|
fileContent,
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,7 +2,7 @@ import { Button } from '@@/buttons';
|
||||||
|
|
||||||
import { TemplateItem } from '../components/TemplateItem';
|
import { TemplateItem } from '../components/TemplateItem';
|
||||||
|
|
||||||
import { TemplateViewModel } from './template';
|
import { TemplateViewModel } from './view-model';
|
||||||
import { TemplateType } from './types';
|
import { TemplateType } from './types';
|
||||||
|
|
||||||
export function AppTemplatesListItem({
|
export function AppTemplatesListItem({
|
||||||
|
|
69
app/react/portainer/templates/app-templates/Filters.tsx
Normal file
69
app/react/portainer/templates/app-templates/Filters.tsx
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
import _ from 'lodash';
|
||||||
|
|
||||||
|
import { PortainerSelect } from '@@/form-components/PortainerSelect';
|
||||||
|
|
||||||
|
import { ListState, TemplateType } from './types';
|
||||||
|
import { TemplateViewModel } from './view-model';
|
||||||
|
import { TemplateListSort } from './TemplateListSort';
|
||||||
|
|
||||||
|
const orderByFields = ['Title', 'Categories', 'Description'] as const;
|
||||||
|
const typeFilters = [
|
||||||
|
{ label: 'Container', value: TemplateType.Container },
|
||||||
|
{ label: 'Stack', value: TemplateType.SwarmStack },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function Filters({
|
||||||
|
templates,
|
||||||
|
listState,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
templates: TemplateViewModel[];
|
||||||
|
listState: ListState & { search: string };
|
||||||
|
onChange(): void;
|
||||||
|
}) {
|
||||||
|
const categories = _.sortBy(
|
||||||
|
_.uniq(templates?.flatMap((template) => template.Categories))
|
||||||
|
).map((category) => ({ label: category, value: category }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4 w-full">
|
||||||
|
<div className="w-1/4">
|
||||||
|
<PortainerSelect
|
||||||
|
options={categories}
|
||||||
|
onChange={(category) => {
|
||||||
|
listState.setCategory(category);
|
||||||
|
onChange();
|
||||||
|
}}
|
||||||
|
placeholder="Category"
|
||||||
|
value={listState.category}
|
||||||
|
bindToBody
|
||||||
|
isClearable
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-1/4">
|
||||||
|
<PortainerSelect
|
||||||
|
options={typeFilters}
|
||||||
|
onChange={(type) => {
|
||||||
|
listState.setType(type);
|
||||||
|
onChange();
|
||||||
|
}}
|
||||||
|
placeholder="Type"
|
||||||
|
value={listState.type}
|
||||||
|
bindToBody
|
||||||
|
isClearable
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-1/4 ml-auto">
|
||||||
|
<TemplateListSort
|
||||||
|
onChange={(value) => {
|
||||||
|
listState.setSortBy(value?.id, value?.desc ?? false);
|
||||||
|
onChange();
|
||||||
|
}}
|
||||||
|
options={orderByFields}
|
||||||
|
placeholder="Sort By"
|
||||||
|
value={listState.sortBy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,53 +1,50 @@
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
import { TableHeaderSortIcons } from '@@/datatables/TableHeaderSortIcons';
|
import { TableHeaderSortIcons } from '@@/datatables/TableHeaderSortIcons';
|
||||||
|
import { PortainerSelect } from '@@/form-components/PortainerSelect';
|
||||||
import { TemplateListDropdown } from '../TemplateListDropdown';
|
|
||||||
|
|
||||||
import styles from './TemplateListSort.module.css';
|
import styles from './TemplateListSort.module.css';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
options: string[];
|
options: ReadonlyArray<string>;
|
||||||
onChange: (value: string | null) => void;
|
onChange: (value: { id: string; desc: boolean } | undefined) => void;
|
||||||
onDescending: () => void;
|
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
sortByDescending: boolean;
|
value: { id: string; desc: boolean } | undefined;
|
||||||
sortByButton: boolean;
|
|
||||||
value: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TemplateListSort({
|
export function TemplateListSort({
|
||||||
options,
|
options,
|
||||||
onChange,
|
onChange,
|
||||||
onDescending,
|
|
||||||
placeholder,
|
placeholder,
|
||||||
sortByDescending,
|
|
||||||
sortByButton,
|
|
||||||
value,
|
value,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.sortByContainer}>
|
<div className={styles.sortByContainer}>
|
||||||
<div className={styles.sortByElement}>
|
<div className={styles.sortByElement}>
|
||||||
<TemplateListDropdown
|
<PortainerSelect
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
options={options}
|
options={options.map((id) => ({ label: id, value: id }))}
|
||||||
onChange={onChange}
|
onChange={(id) =>
|
||||||
value={value}
|
onChange(id ? { id, desc: value?.desc ?? false } : undefined)
|
||||||
|
}
|
||||||
|
bindToBody
|
||||||
|
value={value?.id ?? null}
|
||||||
|
isClearable
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.sortByElement}>
|
<div className={styles.sortByElement}>
|
||||||
<button
|
<button
|
||||||
className={clsx(styles.sortButton, 'h-[34px]')}
|
className={clsx(styles.sortButton, 'h-[34px]')}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!sortByButton || !value}
|
disabled={!value?.id}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onDescending();
|
onChange(value ? { id: value.id, desc: !value.desc } : undefined);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TableHeaderSortIcons
|
<TableHeaderSortIcons
|
||||||
sorted={sortByButton && !!value}
|
sorted={!!value}
|
||||||
descending={sortByDescending}
|
descending={value?.desc ?? false}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
|
@ -0,0 +1 @@
|
||||||
|
export { TemplateListSort } from './TemplateListSort';
|
|
@ -1,10 +1,19 @@
|
||||||
|
import { BasicTableSettings } from '@@/datatables/types';
|
||||||
|
|
||||||
import { Pair } from '../../settings/types';
|
import { Pair } from '../../settings/types';
|
||||||
|
|
||||||
|
export interface ListState extends BasicTableSettings {
|
||||||
|
category: string | null;
|
||||||
|
setCategory: (category: string | null) => void;
|
||||||
|
type: TemplateType | null;
|
||||||
|
setType: (type: TemplateType | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
export enum TemplateType {
|
export enum TemplateType {
|
||||||
Container = 1,
|
Container = 1,
|
||||||
SwarmStack = 2,
|
SwarmStack = 2,
|
||||||
ComposeStack = 3,
|
ComposeStack = 3,
|
||||||
ComposeEdgeStack = 4,
|
EdgeStack = 4,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { useMutation } from 'react-query';
|
||||||
|
|
||||||
|
import { StackType } from '@/react/common/stacks/types';
|
||||||
|
import { mutationOptions, withGlobalError } from '@/react-tools/react-query';
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
|
||||||
|
import { TemplateType } from './types';
|
||||||
|
import { TemplateViewModel } from './view-model';
|
||||||
|
|
||||||
|
export function useFetchTemplateInfoMutation() {
|
||||||
|
return useMutation(
|
||||||
|
getTemplateInfo,
|
||||||
|
mutationOptions(withGlobalError('Unable to fetch template info'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTemplateInfo(template: TemplateViewModel) {
|
||||||
|
const fileContent = await fetchFilePreview({
|
||||||
|
url: template.Repository.url,
|
||||||
|
file: template.Repository.stackfile,
|
||||||
|
});
|
||||||
|
|
||||||
|
const type = getCustomTemplateType(template.Type);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
fileContent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCustomTemplateType(type: TemplateType): StackType {
|
||||||
|
switch (type) {
|
||||||
|
case TemplateType.SwarmStack:
|
||||||
|
return StackType.DockerSwarm;
|
||||||
|
case TemplateType.ComposeStack:
|
||||||
|
return StackType.DockerCompose;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown supported template type: ${type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFilePreview({ url, file }: { url: string; file: string }) {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.post<{ FileContent: string }>(
|
||||||
|
'/templates/file',
|
||||||
|
{ repositoryUrl: url, composeFilePathInRepository: file }
|
||||||
|
);
|
||||||
|
return data.FileContent;
|
||||||
|
} catch (err) {
|
||||||
|
throw parseAxiosError(err);
|
||||||
|
}
|
||||||
|
}
|
111
app/react/portainer/templates/app-templates/useSortAndFilter.tsx
Normal file
111
app/react/portainer/templates/app-templates/useSortAndFilter.tsx
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
|
import { TemplateViewModel } from './view-model';
|
||||||
|
import { ListState, TemplateType } from './types';
|
||||||
|
|
||||||
|
export function useSortAndFilterTemplates(
|
||||||
|
templates: Array<TemplateViewModel>,
|
||||||
|
listState: ListState & { search: string },
|
||||||
|
showSwarmStacks?: boolean
|
||||||
|
) {
|
||||||
|
const filterByCategory = useCallback(
|
||||||
|
(item: TemplateViewModel) => {
|
||||||
|
if (!listState.category) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return item.Categories.includes(listState.category);
|
||||||
|
},
|
||||||
|
[listState.category]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filterBySearch = useCallback(
|
||||||
|
(item: TemplateViewModel) => {
|
||||||
|
const search = listState.search.toLowerCase();
|
||||||
|
return (
|
||||||
|
item.Title.toLowerCase().includes(search) ||
|
||||||
|
item.Description.toLowerCase().includes(search) ||
|
||||||
|
item.Categories.some((category) =>
|
||||||
|
category.toLowerCase().includes(search)
|
||||||
|
) ||
|
||||||
|
item.Note?.toLowerCase().includes(search) ||
|
||||||
|
item.Name?.toLowerCase().includes(search)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[listState.search]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filterByTemplateType = useCallback(
|
||||||
|
(item: TemplateViewModel) => {
|
||||||
|
switch (item.Type) {
|
||||||
|
case TemplateType.Container:
|
||||||
|
return (
|
||||||
|
listState.type === TemplateType.Container || listState.type === null
|
||||||
|
);
|
||||||
|
case TemplateType.SwarmStack:
|
||||||
|
return (
|
||||||
|
showSwarmStacks &&
|
||||||
|
(listState.type === TemplateType.SwarmStack ||
|
||||||
|
listState.type === null)
|
||||||
|
);
|
||||||
|
case TemplateType.ComposeStack:
|
||||||
|
return (
|
||||||
|
listState.type === TemplateType.SwarmStack ||
|
||||||
|
listState.type === null
|
||||||
|
);
|
||||||
|
case TemplateType.EdgeStack:
|
||||||
|
return listState.type === TemplateType.EdgeStack;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[listState.type, showSwarmStacks]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sort = useCallback(
|
||||||
|
(a: TemplateViewModel, b: TemplateViewModel) => {
|
||||||
|
const sortMultiplier = listState.sortBy?.desc ? -1 : 1;
|
||||||
|
switch (listState.sortBy?.id) {
|
||||||
|
case 'Categories':
|
||||||
|
return sortByCategories(a.Categories, b.Categories) * sortMultiplier;
|
||||||
|
case 'Description':
|
||||||
|
return a.Description.localeCompare(b.Description) * sortMultiplier;
|
||||||
|
case 'Title':
|
||||||
|
default:
|
||||||
|
return a.Title.localeCompare(b.Title) * sortMultiplier;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
[listState.sortBy?.desc, listState.sortBy?.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() =>
|
||||||
|
templates
|
||||||
|
?.filter(filterByTemplateType)
|
||||||
|
.filter(filterByCategory)
|
||||||
|
.filter(filterBySearch)
|
||||||
|
.sort(sort) || [],
|
||||||
|
[templates, filterByTemplateType, filterByCategory, filterBySearch, sort]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortByCategories(a: Array<string>, b: Array<string>): number {
|
||||||
|
if (a.length === 0 && b.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (a.length === 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (b.length === 0) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aCategory = a[0];
|
||||||
|
const bCategory = b[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
aCategory.localeCompare(bCategory) ||
|
||||||
|
sortByCategories(a.slice(1), b.slice(1))
|
||||||
|
);
|
||||||
|
}
|
|
@ -177,6 +177,10 @@ module.exports = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
watchOptions: {
|
||||||
|
ignored: /node_modules/,
|
||||||
|
aggregateTimeout: 500,
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@@': path.resolve(projectRoot, 'app/react/components'),
|
'@@': path.resolve(projectRoot, 'app/react/components'),
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue