mirror of https://github.com/portainer/portainer
refactor(app-templates): convert list to react [EE-6205] (#10439)
parent
1fa63f6ab7
commit
14129632a3
|
@ -3,8 +3,6 @@ import angular from 'angular';
|
|||
import { r2a } from '@/react-tools/react2angular';
|
||||
import { withControlledInput } from '@/react-tools/withControlledInput';
|
||||
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 { withReactQuery } from '@/react-tools/withReactQuery';
|
||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||
|
@ -39,8 +37,6 @@ const ngModule = angular
|
|||
])
|
||||
.component('dockerfileDetails', r2a(DockerfileDetails, ['image']))
|
||||
.component('dockerHealthStatus', r2a(HealthStatus, ['health']))
|
||||
.component('templateListDropdown', TemplateListDropdownAngular)
|
||||
.component('templateListSort', TemplateListSortAngular)
|
||||
.component(
|
||||
'stackContainersDatatable',
|
||||
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 { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||
import { AppTemplatesListItem } from '@/react/portainer/templates/app-templates/AppTemplatesListItem';
|
||||
import {
|
||||
CommonFields,
|
||||
validation as commonFieldsValidation,
|
||||
|
@ -15,6 +14,7 @@ import {
|
|||
import { PlatformField } from '@/react/portainer/custom-templates/components/PlatformSelector';
|
||||
import { TemplateTypeSelector } from '@/react/portainer/custom-templates/components/TemplateTypeSelector';
|
||||
import { withFormValidation } from '@/react-tools/withFormValidation';
|
||||
import { AppTemplatesList } from '@/react/portainer/templates/app-templates/AppTemplatesList';
|
||||
|
||||
import { VariablesFieldAngular } from './variables-field';
|
||||
|
||||
|
@ -47,15 +47,7 @@ export const ngModule = angular
|
|||
'isSelected',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'appTemplatesListItem',
|
||||
r2a(withUIRouter(withCurrentUser(AppTemplatesListItem)), [
|
||||
'onSelect',
|
||||
'template',
|
||||
'isSelected',
|
||||
'onDuplicate',
|
||||
])
|
||||
)
|
||||
|
||||
.component(
|
||||
'customTemplatesPlatformSelector',
|
||||
r2a(PlatformField, ['onChange', 'value'])
|
||||
|
@ -63,6 +55,15 @@ export const ngModule = angular
|
|||
.component(
|
||||
'customTemplatesTypeSelector',
|
||||
r2a(TemplateTypeSelector, ['onChange', 'value'])
|
||||
)
|
||||
.component(
|
||||
'appTemplatesList',
|
||||
r2a(withUIRouter(withCurrentUser(AppTemplatesList)), [
|
||||
'onSelect',
|
||||
'templates',
|
||||
'selectedId',
|
||||
'showSwarmStacks',
|
||||
])
|
||||
);
|
||||
|
||||
withFormValidation(
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
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';
|
||||
|
||||
angular.module('portainer.app').factory('TemplateService', TemplateServiceFactory);
|
||||
|
|
|
@ -270,18 +270,9 @@
|
|||
<!-- container-form -->
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<template-list
|
||||
ng-if="templates"
|
||||
title-text="Templates"
|
||||
title-icon="edit"
|
||||
templates="templates"
|
||||
table-key="templates"
|
||||
select-action="selectTemplate"
|
||||
is-selected="isSelected"
|
||||
show-swarm-stacks="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER' && applicationState.endpoint.apiVersion >= 1.25"
|
||||
>
|
||||
</template-list>
|
||||
</div>
|
||||
</div>
|
||||
<app-templates-list
|
||||
templates="templates"
|
||||
on-select="(selectTemplate)"
|
||||
selected-id="state.selectedTemplate.Id"
|
||||
show-swarm-stacks="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER' && applicationState.endpoint.apiVersion >= 1.25"
|
||||
></app-templates-list>
|
||||
|
|
|
@ -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 () {
|
||||
return $async(async () => {
|
||||
$scope.state.selectedTemplate = null;
|
||||
|
@ -237,7 +233,7 @@ angular.module('portainer.app').controller('TemplatesController', [
|
|||
$scope.selectTemplate = function (template) {
|
||||
return $async(async () => {
|
||||
if ($scope.state.selectedTemplate) {
|
||||
$scope.unselectTemplate($scope.state.selectedTemplate);
|
||||
await $scope.unselectTemplate($scope.state.selectedTemplate);
|
||||
}
|
||||
|
||||
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 };
|
|
@ -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 { TemplateViewModel } from './template';
|
||||
import { TemplateViewModel } from './view-model';
|
||||
import { TemplateType } from './types';
|
||||
|
||||
export function AppTemplatesListItem({
|
||||
|
|
|
@ -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 { TableHeaderSortIcons } from '@@/datatables/TableHeaderSortIcons';
|
||||
|
||||
import { TemplateListDropdown } from '../TemplateListDropdown';
|
||||
import { PortainerSelect } from '@@/form-components/PortainerSelect';
|
||||
|
||||
import styles from './TemplateListSort.module.css';
|
||||
|
||||
interface Props {
|
||||
options: string[];
|
||||
onChange: (value: string | null) => void;
|
||||
onDescending: () => void;
|
||||
options: ReadonlyArray<string>;
|
||||
onChange: (value: { id: string; desc: boolean } | undefined) => void;
|
||||
placeholder?: string;
|
||||
sortByDescending: boolean;
|
||||
sortByButton: boolean;
|
||||
value: string;
|
||||
value: { id: string; desc: boolean } | undefined;
|
||||
}
|
||||
|
||||
export function TemplateListSort({
|
||||
options,
|
||||
onChange,
|
||||
onDescending,
|
||||
placeholder,
|
||||
sortByDescending,
|
||||
sortByButton,
|
||||
value,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className={styles.sortByContainer}>
|
||||
<div className={styles.sortByElement}>
|
||||
<TemplateListDropdown
|
||||
<PortainerSelect
|
||||
placeholder={placeholder}
|
||||
options={options}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
options={options.map((id) => ({ label: id, value: id }))}
|
||||
onChange={(id) =>
|
||||
onChange(id ? { id, desc: value?.desc ?? false } : undefined)
|
||||
}
|
||||
bindToBody
|
||||
value={value?.id ?? null}
|
||||
isClearable
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.sortByElement}>
|
||||
<button
|
||||
className={clsx(styles.sortButton, 'h-[34px]')}
|
||||
type="button"
|
||||
disabled={!sortByButton || !value}
|
||||
disabled={!value?.id}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onDescending();
|
||||
onChange(value ? { id: value.id, desc: !value.desc } : undefined);
|
||||
}}
|
||||
>
|
||||
<TableHeaderSortIcons
|
||||
sorted={sortByButton && !!value}
|
||||
descending={sortByDescending}
|
||||
sorted={!!value}
|
||||
descending={value?.desc ?? false}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
|
@ -0,0 +1 @@
|
|||
export { TemplateListSort } from './TemplateListSort';
|
|
@ -1,10 +1,19 @@
|
|||
import { BasicTableSettings } from '@@/datatables/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 {
|
||||
Container = 1,
|
||||
SwarmStack = 2,
|
||||
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);
|
||||
}
|
||||
}
|
|
@ -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: {
|
||||
alias: {
|
||||
'@@': path.resolve(projectRoot, 'app/react/components'),
|
||||
|
|
Loading…
Reference in New Issue