1
0
Fork 0
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:
Chaim Lev-Ari 2023-10-23 19:04:18 +03:00 committed by GitHub
parent 1fa63f6ab7
commit 14129632a3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 393 additions and 351 deletions

View file

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

View file

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

View file

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

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

View file

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

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

View file

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

View file

@ -0,0 +1 @@
export { TemplateListSort } from './TemplateListSort';

View file

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

View file

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

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