mirror of
https://github.com/portainer/portainer.git
synced 2025-08-05 05:45:22 +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
|
@ -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 { TemplateViewModel } from './template';
|
||||
import { TemplateViewModel } from './view-model';
|
||||
import { TemplateType } from './types';
|
||||
|
||||
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 { 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);
|
||||
}
|
||||
}
|
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))
|
||||
);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue