refactor(app-templates): convert list to react [EE-6205] (#10439)

pull/10519/head
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

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

View File

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

View File

@ -1,13 +0,0 @@
angular.module('portainer.app').component('templateList', {
templateUrl: './templateList.html',
controller: 'TemplateListController',
bindings: {
titleText: '@',
titleIcon: '@',
templates: '<',
tableKey: '@',
selectAction: '<',
showSwarmStacks: '<',
isSelected: '<',
},
});

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -177,6 +177,10 @@ module.exports = {
},
},
},
watchOptions: {
ignored: /node_modules/,
aggregateTimeout: 500,
},
resolve: {
alias: {
'@@': path.resolve(projectRoot, 'app/react/components'),