Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b8d0db0be |
@@ -615,7 +615,7 @@
|
||||
"RequiredPasswordLength": 12
|
||||
},
|
||||
"KubeconfigExpiry": "0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.41.1",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.41.0",
|
||||
"LDAPSettings": {
|
||||
"AnonymousMode": true,
|
||||
"AutoCreateUsers": true,
|
||||
@@ -947,7 +947,7 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.41.1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.41.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
},
|
||||
"webhooks": null
|
||||
}
|
||||
@@ -81,7 +81,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.41.1
|
||||
// @version 2.41.0
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -1945,7 +1945,7 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.41.1"
|
||||
APIVersion = "2.41.0"
|
||||
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
|
||||
APIVersionSupport = "STS"
|
||||
// Edition is what this edition of Portainer is called
|
||||
|
||||
@@ -117,7 +117,6 @@ export function GroupSortTable<TItem extends object>({
|
||||
<Widget className="overflow-clip [&_table]:bg-transparent" data-cy={dataCy}>
|
||||
<GroupSortTableHeader
|
||||
sortBy={sortBy}
|
||||
sortDesc={tableState.sortBy?.desc ?? false}
|
||||
onSortChange={handleSortChange}
|
||||
searchTerm={tableState.search}
|
||||
onSearchChange={(value) => {
|
||||
@@ -186,15 +185,11 @@ export function GroupSortTable<TItem extends object>({
|
||||
return renderRow(row);
|
||||
}
|
||||
|
||||
const header = renderGroupHeader(groupKey, groupCountByKey[groupKey] ?? 0);
|
||||
if (header == null) {
|
||||
return renderRow(row);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<tr>
|
||||
<td colSpan={Number.MAX_SAFE_INTEGER} className="!p-0">
|
||||
{header}
|
||||
{renderGroupHeader(groupKey, groupCountByKey[groupKey] ?? 0)}
|
||||
</td>
|
||||
</tr>
|
||||
{renderRow(row)}
|
||||
@@ -204,9 +199,7 @@ export function GroupSortTable<TItem extends object>({
|
||||
|
||||
function handleSortChange(key: string) {
|
||||
tableState.setPage(1);
|
||||
const newDesc =
|
||||
tableState.sortBy?.id === key ? !tableState.sortBy.desc : false;
|
||||
tableState.setSortBy(key, newDesc);
|
||||
tableState.setSortBy(key, false);
|
||||
}
|
||||
|
||||
function handleGroupFilterChange(value: string | null) {
|
||||
|
||||
@@ -29,20 +29,13 @@ type SortKey = (typeof sortOptions)[number]['key'];
|
||||
|
||||
export function Interactive() {
|
||||
const [sortBy, setSortBy] = useState<SortKey>('name');
|
||||
const [sortDesc, setSortDesc] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [groupFilter, setGroupFilter] = useState<string | null>(null);
|
||||
|
||||
function handleSortChange(key: SortKey) {
|
||||
setSortDesc((prev) => (sortBy === key ? !prev : false));
|
||||
setSortBy(key);
|
||||
}
|
||||
|
||||
return (
|
||||
<GroupSortTableHeader
|
||||
sortBy={sortBy}
|
||||
sortDesc={sortDesc}
|
||||
onSortChange={handleSortChange}
|
||||
onSortChange={setSortBy}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
sortOptions={[...sortOptions]}
|
||||
@@ -57,19 +50,12 @@ export function Interactive() {
|
||||
|
||||
export function WithGroupFilter() {
|
||||
const [sortBy, setSortBy] = useState<SortKey>('group');
|
||||
const [sortDesc, setSortDesc] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
function handleSortChange(key: SortKey) {
|
||||
setSortDesc((prev) => (sortBy === key ? !prev : false));
|
||||
setSortBy(key);
|
||||
}
|
||||
|
||||
return (
|
||||
<GroupSortTableHeader
|
||||
sortBy={sortBy}
|
||||
sortDesc={sortDesc}
|
||||
onSortChange={handleSortChange}
|
||||
onSortChange={setSortBy}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
sortOptions={[...sortOptions]}
|
||||
@@ -84,20 +70,13 @@ export function WithGroupFilter() {
|
||||
|
||||
export function WithActionButton() {
|
||||
const [sortBy, setSortBy] = useState<SortKey>('name');
|
||||
const [sortDesc, setSortDesc] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [groupFilter, setGroupFilter] = useState<string | null>(null);
|
||||
|
||||
function handleSortChange(key: SortKey) {
|
||||
setSortDesc((prev) => (sortBy === key ? !prev : false));
|
||||
setSortBy(key);
|
||||
}
|
||||
|
||||
return (
|
||||
<GroupSortTableHeader
|
||||
sortBy={sortBy}
|
||||
sortDesc={sortDesc}
|
||||
onSortChange={handleSortChange}
|
||||
onSortChange={setSortBy}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
sortOptions={[...sortOptions]}
|
||||
|
||||
@@ -21,7 +21,6 @@ function renderHeader(
|
||||
) {
|
||||
const props = {
|
||||
sortBy: 'Group' as string,
|
||||
sortDesc: false,
|
||||
onSortChange: vi.fn(),
|
||||
searchTerm: '',
|
||||
onSearchChange: vi.fn(),
|
||||
|
||||
@@ -13,7 +13,6 @@ export type { SortOption };
|
||||
|
||||
interface Props<TSortKey extends string> {
|
||||
sortBy: TSortKey;
|
||||
sortDesc: boolean;
|
||||
onSortChange: (key: TSortKey) => void;
|
||||
searchTerm: string;
|
||||
onSearchChange: (term: string) => void;
|
||||
@@ -28,7 +27,6 @@ interface Props<TSortKey extends string> {
|
||||
|
||||
export function GroupSortTableHeader<TSortKey extends string>({
|
||||
sortBy,
|
||||
sortDesc,
|
||||
onSortChange,
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
@@ -45,12 +43,12 @@ export function GroupSortTableHeader<TSortKey extends string>({
|
||||
<div
|
||||
className={clsx(
|
||||
'flex flex-wrap items-center justify-between gap-3 px-5 py-3',
|
||||
'bg-gray-2 th-highcontrast:bg-black th-dark:bg-gray-iron-10'
|
||||
'bg-gray-2 th-highcontrast:bg-black th-dark:bg-gray-iron-10',
|
||||
'border-0 border-b border-solid border-gray-5 th-dark:border-gray-9'
|
||||
)}
|
||||
>
|
||||
<SortByGroup
|
||||
sortBy={sortBy}
|
||||
sortDesc={sortDesc}
|
||||
onSortChange={onSortChange}
|
||||
sortOptions={sortOptions}
|
||||
groupFilter={groupFilter}
|
||||
|
||||
@@ -139,7 +139,6 @@ function renderComponent({
|
||||
withTestRouter(() => (
|
||||
<SortByGroup
|
||||
sortBy={sortBy}
|
||||
sortDesc={false}
|
||||
onSortChange={onSortChange}
|
||||
sortOptions={sortOptions}
|
||||
groupFilter={groupFilter}
|
||||
@@ -163,13 +162,13 @@ describe('SortByGroup', () => {
|
||||
expect(onSortChange).toHaveBeenCalledExactlyOnceWith('Name');
|
||||
});
|
||||
|
||||
test('clicking the already-active non-grouped button calls onSortChange to toggle sort order', async () => {
|
||||
test('clicking the already-active button does not call onSortChange', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSortChange } = renderComponent({ sortBy: 'Name' });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^Name Asc/i }));
|
||||
await user.click(screen.getByRole('button', { name: /^Name$/i }));
|
||||
|
||||
expect(onSortChange).toHaveBeenCalledExactlyOnceWith('Name');
|
||||
expect(onSortChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,13 +6,10 @@ export interface SortOption<TSortKey extends string = string> {
|
||||
key: TSortKey;
|
||||
label: string;
|
||||
grouped?: boolean;
|
||||
descendingLabel?: string;
|
||||
ascendingLabel?: string;
|
||||
}
|
||||
|
||||
export interface SortByGroupProps<TSortKey extends string> {
|
||||
sortBy: TSortKey;
|
||||
sortDesc: boolean;
|
||||
onSortChange: (key: TSortKey) => void;
|
||||
sortOptions: SortOption<TSortKey>[];
|
||||
groupFilter: string | null;
|
||||
@@ -23,7 +20,6 @@ export interface SortByGroupProps<TSortKey extends string> {
|
||||
|
||||
export function SortByGroup<TSortKey extends string>({
|
||||
sortBy,
|
||||
sortDesc,
|
||||
onSortChange,
|
||||
sortOptions,
|
||||
groupFilter,
|
||||
@@ -53,7 +49,6 @@ export function SortByGroup<TSortKey extends string>({
|
||||
key={option.key}
|
||||
option={option}
|
||||
isActive={sortBy === option.key}
|
||||
sortDesc={sortDesc}
|
||||
isFirst={index === 0}
|
||||
isLast={index === sortOptions.length - 1}
|
||||
onSortChange={onSortChange}
|
||||
@@ -87,7 +82,6 @@ const inactiveBtn = clsx(
|
||||
interface SortOptionItemProps<TSortKey extends string> {
|
||||
option: SortOption<TSortKey>;
|
||||
isActive: boolean;
|
||||
sortDesc: boolean;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
onSortChange: (key: TSortKey) => void;
|
||||
@@ -100,7 +94,6 @@ interface SortOptionItemProps<TSortKey extends string> {
|
||||
function SortOptionItem<TSortKey extends string>({
|
||||
option,
|
||||
isActive,
|
||||
sortDesc,
|
||||
isFirst,
|
||||
isLast,
|
||||
onSortChange,
|
||||
@@ -139,30 +132,19 @@ function SortOptionItem<TSortKey extends string>({
|
||||
);
|
||||
}
|
||||
|
||||
const badge = isActive
|
||||
? sortDesc
|
||||
? option.descendingLabel || 'Desc'
|
||||
: option.ascendingLabel || 'Asc'
|
||||
: null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
onClick={() => {
|
||||
onSortChange(option.key);
|
||||
if (!isActive) {
|
||||
onSortChange(option.key);
|
||||
onGroupFilterChange(null);
|
||||
}
|
||||
}}
|
||||
data-cy={`${dataCy}-sort-by-${option.key.toLowerCase()}-button`}
|
||||
>
|
||||
{option.label}
|
||||
{badge && (
|
||||
<span className="py-0.2 ml-1 rounded-md bg-blue-7 px-1 text-[10px] font-normal text-white">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,11 +58,8 @@ export function SortableList<T>({
|
||||
<SortableListCard>
|
||||
<GroupSortTableHeader
|
||||
sortBy={activeSortKey}
|
||||
sortDesc={tableState.sortBy?.desc ?? false}
|
||||
onSortChange={(key) => {
|
||||
const newDesc =
|
||||
tableState.sortBy?.id === key ? !tableState.sortBy.desc : false;
|
||||
tableState.setSortBy(key, newDesc);
|
||||
tableState.setSortBy(key, false);
|
||||
}}
|
||||
searchTerm={tableState.search}
|
||||
onSearchChange={(value) => {
|
||||
|
||||
@@ -521,7 +521,7 @@ test('URL param groupBy=platform&filter=docker activates Docker platform filter
|
||||
});
|
||||
});
|
||||
|
||||
test('URL param filter without groupBy is ignored (default Age sort used)', async () => {
|
||||
test('URL param filter without groupBy is ignored (default Group sort used)', async () => {
|
||||
let capturedParams: URLSearchParams | null = null;
|
||||
|
||||
// filter present but no groupBy — the component should bail out early
|
||||
@@ -540,12 +540,12 @@ test('URL param filter without groupBy is ignored (default Age sort used)', asyn
|
||||
]
|
||||
);
|
||||
|
||||
// status[] should not be sent; sort defaults to Age
|
||||
// status[] should not be sent; sort defaults to Group
|
||||
await waitFor(() => {
|
||||
expect(capturedParams).not.toBeNull();
|
||||
});
|
||||
expect(capturedParams!.getAll('status[]')).toHaveLength(0);
|
||||
expect(capturedParams!.get('sort')).toBe('Age');
|
||||
expect(capturedParams!.get('sort')).toBe('Group');
|
||||
});
|
||||
|
||||
test('selecting a sort/filter updates URL via stateService.go', async () => {
|
||||
|
||||
@@ -33,7 +33,6 @@ import { KubeconfigButton } from '@/react/portainer/HomeView/EnvironmentList/Kub
|
||||
import { EnvironmentCard } from '@/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EnvironmentCard';
|
||||
|
||||
import { GroupSortTable } from '@@/GroupSortTable/GroupSortTable';
|
||||
import { SortOption } from '@@/GroupSortTable/SortByGroup';
|
||||
import { GroupSortTableGroupRow } from '@@/GroupSortTable/GroupSortTableGroupRow';
|
||||
import { useGroupSortTableState } from '@@/GroupSortTable/useGroupSortTableState';
|
||||
|
||||
@@ -58,7 +57,6 @@ const HEALTH_SORT_ORDER: Record<string, number> = {
|
||||
};
|
||||
|
||||
const columns: ColumnDef<EnvironmentRow>[] = [
|
||||
{ id: 'Age', accessorKey: 'age' },
|
||||
{ id: 'Platform', accessorKey: 'platformName' },
|
||||
{ id: 'Group', accessorKey: 'groupName' },
|
||||
{
|
||||
@@ -71,13 +69,7 @@ const columns: ColumnDef<EnvironmentRow>[] = [
|
||||
{ id: 'Name', accessorKey: 'Name' },
|
||||
];
|
||||
|
||||
const SORT_OPTIONS: SortOption[] = [
|
||||
{
|
||||
key: 'Age',
|
||||
label: 'Age',
|
||||
descendingLabel: 'Newest',
|
||||
ascendingLabel: 'Oldest',
|
||||
},
|
||||
const SORT_OPTIONS = [
|
||||
{ key: 'Group', label: 'Group', grouped: true },
|
||||
{ key: 'Platform', label: 'Platform', grouped: true },
|
||||
{ key: 'Health', label: 'Health', grouped: true },
|
||||
@@ -137,7 +129,7 @@ export function EnvironmentList({
|
||||
|
||||
const tableState = useGroupSortTableState(
|
||||
storageKey,
|
||||
'Age',
|
||||
'Group',
|
||||
DEFAULT_PAGE_LIMIT
|
||||
);
|
||||
|
||||
@@ -196,8 +188,6 @@ export function EnvironmentList({
|
||||
const environmentRows = useMemo<EnvironmentRow[]>(() => {
|
||||
const rows = environments.map((env) => ({
|
||||
...env,
|
||||
// Use Environment ID to sort age as lower ID = older environment
|
||||
age: env.Id,
|
||||
groupName: groupNameById.get(env.GroupId) ?? 'Unassigned',
|
||||
platformName:
|
||||
PlatformType[getPlatformType(env.Type, env.ContainerEngine)],
|
||||
@@ -369,8 +359,6 @@ export function EnvironmentList({
|
||||
} else if (sortId === 'Health' && healthDetails[groupKey]) {
|
||||
icon = getHealthIcon(healthDetails[groupKey].type, 'md');
|
||||
description = healthDetails[groupKey].description;
|
||||
} else if (sortId === 'Age') {
|
||||
return null;
|
||||
} else {
|
||||
icon = getGroupIcon('md');
|
||||
}
|
||||
@@ -389,7 +377,6 @@ export function EnvironmentList({
|
||||
}
|
||||
|
||||
type EnvironmentRow = Environment & {
|
||||
age: number;
|
||||
groupName: string;
|
||||
platformName: string;
|
||||
healthLabel: string;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Portainer.io",
|
||||
"name": "@portainer/ce",
|
||||
"homepage": "http://portainer.io",
|
||||
"version": "2.41.1",
|
||||
"version": "2.41.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git@github.com:portainer/portainer.git"
|
||||
|
||||
@@ -100,7 +100,7 @@ func TestValidateHelmRepositoryURL(t *testing.T) {
|
||||
func Test_ValidateSeedsCacheAndSearchUsesCache(t *testing.T) {
|
||||
const indexYAML = "apiVersion: v1\nentries: {}\ngenerated: \"2020-01-01T00:00:00Z\"\n"
|
||||
|
||||
var requestCount atomic.Int32
|
||||
var requestCount int32
|
||||
var fail bool
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -109,7 +109,7 @@ func Test_ValidateSeedsCacheAndSearchUsesCache(t *testing.T) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
requestCount.Add(1)
|
||||
atomic.AddInt32(&requestCount, 1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(indexYAML))
|
||||
return
|
||||
@@ -128,5 +128,5 @@ func Test_ValidateSeedsCacheAndSearchUsesCache(t *testing.T) {
|
||||
// validate cache is used
|
||||
err := ValidateHelmRepositoryURL(srv.URL, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int32(1), requestCount.Load())
|
||||
require.Equal(t, int32(1), atomic.LoadInt32(&requestCount))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user