Skip to content

Commit d2bf7f0

Browse files
authored
Merge pull request #478 from MytsV/feature-473-functional_rule_list
Feature 473. Functional rule list
2 parents ec312f2 + b4853db commit d2bf7f0

File tree

71 files changed

+2082
-712
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

71 files changed

+2082
-712
lines changed

src/app/(rucio)/did/list/page.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import { ListDID } from '@/component-library/pages/DID/List/ListDID';
3+
import { ListDID } from '@/component-library/pages/DID/list/ListDID';
44
import { didMetaQueryBase } from '../queries';
55
import { useSearchParams } from 'next/navigation';
66

src/app/(rucio)/rse/list/page.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import { ListRSE } from '@/component-library/pages/RSE/List/ListRSE';
3+
import { ListRSE } from '@/component-library/pages/RSE/list/ListRSE';
44
import { useSearchParams } from 'next/navigation';
55
export default function Page() {
66
const searchParams = useSearchParams();

src/app/(rucio)/rule/list/page.tsx

+2-7
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,7 @@
11
'use client';
22

3-
import { ListRule } from '@/component-library/pages/legacy/Rule/ListRule';
4-
import { RuleViewModel } from '@/lib/infrastructure/data/view-model/rule';
5-
import useComDOM from '@/lib/infrastructure/hooks/useComDOM';
6-
import { HTTPRequest } from '@/lib/sdk/http';
7-
import { useEffect, useState } from 'react';
3+
import { ListRule } from '@/component-library/pages/Rule/list/ListRule';
84

95
export default function Page() {
10-
const comDOM = useComDOM<RuleViewModel>('list-rule-query', [], false, Infinity, 200, true);
11-
return <ListRule comdom={comDOM} webui_host={process.env.NEXT_PUBLIC_WEBUI_HOST ?? 'http://localhost:3000'} />;
6+
return <ListRule />;
127
}

src/component-library/demos/03_2_List_Rules.stories.tsx

-16
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { RuleState } from '@/lib/core/entity/rucio';
2+
import React from 'react';
3+
import { Badge } from '@/component-library/atoms/misc/Badge';
4+
import { cn } from '@/component-library/utils';
5+
6+
const stateString: Record<RuleState, string> = {
7+
[RuleState.REPLICATING]: 'Replicating',
8+
[RuleState.OK]: 'OK',
9+
[RuleState.STUCK]: 'Stuck',
10+
[RuleState.SUSPENDED]: 'Suspended',
11+
[RuleState.WAITING_APPROVAL]: 'Waiting',
12+
[RuleState.INJECT]: 'Inject',
13+
[RuleState.UNKNOWN]: 'Unknown',
14+
};
15+
16+
const stateColorClasses: Record<RuleState, string> = {
17+
[RuleState.REPLICATING]: 'bg-base-warning-400',
18+
[RuleState.OK]: 'bg-base-success-500',
19+
[RuleState.STUCK]: 'bg-base-error-500',
20+
[RuleState.SUSPENDED]: 'bg-neutral-500',
21+
[RuleState.WAITING_APPROVAL]: 'bg-extra-indigo-500',
22+
[RuleState.INJECT]: 'bg-base-info-500',
23+
[RuleState.UNKNOWN]: 'bg-neutral-0 dark:bg-neutral-800',
24+
};
25+
26+
export const RuleStateBadge = (props: { value: RuleState; className?: string }) => {
27+
const classes = cn(stateColorClasses[props.value], props.className);
28+
return <Badge value={stateString[props.value]} className={classes} />;
29+
};

src/component-library/features/utils/filter-parameters.ts

+37
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,40 @@ export const buildDiscreteFilterParams = (values: string[]): ITextFilterParams =
4242
buttons: ['reset'],
4343
};
4444
};
45+
46+
/**
47+
* Compares a date filter against a cell value excluding time.
48+
* @param filterLocalDateAtMidnight - The date to filter against, normalized to midnight.
49+
* @param cellValue - The value from the cell, which can be a date string or a Date object.
50+
* @returns A number indicating the comparison result:
51+
* - 0 if the dates are equal,
52+
* - 1 if the cell date is greater than the filter date,
53+
* - -1 if the cell date is less than the filter date.
54+
*/
55+
const dateComparator = (filterLocalDateAtMidnight: Date, cellValue: string | Date): number => {
56+
if (cellValue == null) return -1;
57+
58+
let cellDate: Date;
59+
// Convert cell value to a Date object if it's a string, otherwise use it as is.
60+
if (typeof cellValue === 'string') {
61+
cellDate = new Date(cellValue);
62+
} else {
63+
cellDate = cellValue;
64+
}
65+
66+
// Normalize both the filter date and cell date to midnight.
67+
const filterDateOnly = new Date(filterLocalDateAtMidnight.setHours(0, 0, 0, 0));
68+
const cellDateOnly = new Date(cellDate.setHours(0, 0, 0, 0));
69+
70+
if (filterDateOnly.getTime() === cellDateOnly.getTime()) {
71+
return 0;
72+
}
73+
74+
return cellDateOnly.getTime() > filterDateOnly.getTime() ? 1 : -1;
75+
};
76+
77+
export const DefaultDateFilterParams = {
78+
maxNumConditions: 1,
79+
comparator: dateComparator,
80+
buttons: ['reset'],
81+
};

src/component-library/features/utils/text-formatters.ts

+23-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
const DEFAULT_VALUE = 'NaN';
2+
13
export const formatDate = (isoString: string): string => {
24
const date = new Date(isoString);
35
if (isNaN(date.getTime())) {
4-
return 'NaN';
6+
return DEFAULT_VALUE;
57
}
68
// TODO: use locale from some context
79
return date.toLocaleDateString('en-UK', {
@@ -12,9 +14,28 @@ export const formatDate = (isoString: string): string => {
1214
};
1315

1416
export const formatFileSize = (bytes: number): string => {
15-
if (isNaN(bytes) || bytes < 0) return 'NaN';
17+
if (isNaN(bytes) || bytes < 0) return DEFAULT_VALUE;
1618
if (bytes === 0) return '0 Bytes';
1719
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
1820
const i = Math.floor(Math.log(bytes) / Math.log(1024));
1921
return `${parseFloat((bytes / Math.pow(1024, i)).toFixed(2))} ${sizes[i]}`;
2022
};
23+
24+
export const formatSeconds = (seconds: number): string => {
25+
const days = Math.floor(seconds / 86400);
26+
const hours = Math.floor((seconds % 86400) / 3600);
27+
const minutes = Math.floor((seconds % 3600) / 60);
28+
const remainingSeconds = seconds % 60;
29+
30+
if (days > 0) {
31+
return `${days} day${days > 1 ? 's' : ''}`;
32+
} else if (hours > 0) {
33+
return `${hours} hour${hours > 1 ? 's' : ''}`;
34+
} else if (minutes > 0) {
35+
return `${minutes} minute${minutes > 1 ? 's' : ''}`;
36+
} else if (seconds > 0) {
37+
return `${remainingSeconds} second${remainingSeconds > 1 ? 's' : ''}`;
38+
} else {
39+
return DEFAULT_VALUE;
40+
}
41+
};

src/component-library/pages/DID/List/ListDID.tsx src/component-library/pages/DID/list/ListDID.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectVa
88
import { Input } from '@/component-library/atoms/form/input';
99
import { useToast } from '@/lib/infrastructure/hooks/useToast';
1010
import { useQuery, useQueryClient } from '@tanstack/react-query';
11-
import { ListDIDTable } from '@/component-library/pages/DID/List/ListDIDTable';
11+
import { ListDIDTable } from '@/component-library/pages/DID/list/ListDIDTable';
1212
import { BaseViewModelValidator } from '@/component-library/features/utils/BaseViewModelValidator';
1313
import { SearchButton } from '@/component-library/features/search/SearchButton';
1414
import { alreadyStreamingToast, noApiToast } from '@/component-library/features/utils/list-toasts';
15-
import { ListDIDMeta } from '@/component-library/pages/DID/List/Meta/ListDIDMeta';
15+
import { ListDIDMeta } from '@/component-library/pages/DID/list/meta/ListDIDMeta';
1616

1717
const SCOPE_DELIMITER = ':';
1818
const emptyToastMessage = 'Please specify both scope and name before the search.';
@@ -219,7 +219,7 @@ export const ListDID = (props: ListDIDProps) => {
219219

220220
const queryMeta = async () => {
221221
if (selectedItem !== null) {
222-
const params = new URLSearchParams({scope: selectedItem.scope, name: selectedItem.name});
222+
const params = new URLSearchParams({ scope: selectedItem.scope, name: selectedItem.name });
223223
const url = '/api/feature/get-did-meta?' + params;
224224

225225
const res = await fetch(url);

src/component-library/pages/DID/List/Meta/ListDIDMeta.stories.tsx src/component-library/pages/DID/list/meta/ListDIDMeta.stories.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Meta, StoryFn } from '@storybook/react';
2-
import { ListDIDMeta } from '@/component-library/pages/DID/List/Meta/ListDIDMeta';
2+
import { ListDIDMeta } from '@/component-library/pages/DID/list/meta/ListDIDMeta';
33
import { fixtureDIDMetaViewModel } from '@/test/fixtures/table-fixtures';
44
import { DIDType } from '@/lib/core/entity/rucio';
55
import { ToastedTemplate } from '@/component-library/templates/ToastedTemplate/ToastedTemplate';

src/component-library/pages/RSE/List/ListRSE.stories.tsx src/component-library/pages/RSE/list/ListRSE.stories.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const Template: StoryFn<typeof ListRSE> = args => (
2828
);
2929

3030
// We don't want to generate several of these
31-
const smallList = Array.from({ length: 20 }, fixtureRSEViewModel);
31+
const smallList = Array.from({ length: 20 }, fixtureRSEViewModel);
3232
const mediumList = Array.from({ length: 140 }, fixtureRSEViewModel);
3333
const hugeList = Array.from({ length: 100000 }, fixtureRSEViewModel);
3434
const endpointUrl = '/api/feature/list-rses';

src/component-library/pages/RSE/List/ListRSE.tsx src/component-library/pages/RSE/list/ListRSE.tsx

+7-7
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { RSEViewModel } from '@/lib/infrastructure/data/view-model/rse';
22
import { ChangeEvent, useEffect, useState } from 'react';
33
import useChunkedStream, { StreamingStatus } from '@/lib/infrastructure/hooks/useChunkedStream';
4-
import { ListRSETable } from '@/component-library/pages/RSE/List/ListRSETable';
4+
import { ListRSETable } from '@/component-library/pages/RSE/list/ListRSETable';
55
import { useToast } from '@/lib/infrastructure/hooks/useToast';
66
import { GridApi, GridReadyEvent } from 'ag-grid-community';
77
import { Heading } from '@/component-library/atoms/misc/Heading';
@@ -16,11 +16,11 @@ type ListRSEProps = {
1616
initialData?: RSEViewModel[];
1717
};
1818

19-
const defaultExpression = '*';
19+
const DEFAULT_EXPRESSION = '*';
2020

2121
export const ListRSE = (props: ListRSEProps) => {
2222
const streamingHook = useChunkedStream<RSEViewModel>();
23-
const [expression, setExpression] = useState<string | null>(props.firstExpression ?? defaultExpression);
23+
const [expression, setExpression] = useState<string | null>(props.firstExpression ?? DEFAULT_EXPRESSION);
2424

2525
const [gridApi, setGridApi] = useState<GridApi<RSEViewModel> | null>(null);
2626

@@ -56,7 +56,7 @@ export const ListRSE = (props: ListRSEProps) => {
5656
// Reset the validator
5757
validator.reset();
5858

59-
const url = `/api/feature/list-rses?rseExpression=${expression ?? defaultExpression}`;
59+
const url = `/api/feature/list-rses?rseExpression=${expression ?? DEFAULT_EXPRESSION}`;
6060
streamingHook.start({ url, onData });
6161
} else {
6262
toast(noApiToast);
@@ -67,7 +67,7 @@ export const ListRSE = (props: ListRSEProps) => {
6767

6868
const onInputChange = (event: ChangeEvent<HTMLInputElement>) => {
6969
const value = event.target.value;
70-
setExpression(value !== '' ? value : defaultExpression);
70+
setExpression(value !== '' ? value : DEFAULT_EXPRESSION);
7171
};
7272

7373
const onSearch = (event: any) => {
@@ -100,9 +100,9 @@ export const ListRSE = (props: ListRSEProps) => {
100100
onChange={onInputChange}
101101
onEnterKey={onSearch}
102102
defaultValue={props.firstExpression ?? ''}
103-
placeholder={defaultExpression}
103+
placeholder={DEFAULT_EXPRESSION}
104104
/>
105-
<SearchButton isRunning={streamingHook.status === StreamingStatus.RUNNING} onStop={onStop} onSearch={onSearch} />
105+
<SearchButton isRunning={isRunning} onStop={onStop} onSearch={onSearch} />
106106
</div>
107107
</div>
108108
<ListRSETable streamingHook={streamingHook} onGridReady={onGridReady} />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { StoryFn, Meta } from '@storybook/react';
2+
import { fixtureRuleViewModel } from '@/test/fixtures/table-fixtures';
3+
import { ListRule } from '@/component-library/pages/Rule/list/ListRule';
4+
import { ToastedTemplate } from '@/component-library/templates/ToastedTemplate/ToastedTemplate';
5+
import { getDecoratorWithWorker } from '@/test/mocks/handlers/story-decorators';
6+
import { getMockStreamEndpoint } from '@/test/mocks/handlers/streaming-handlers';
7+
8+
export default {
9+
title: 'Components/Pages/Rule/List',
10+
component: ListRule,
11+
} as Meta<typeof ListRule>;
12+
13+
const Template: StoryFn<typeof ListRule> = args => (
14+
<ToastedTemplate>
15+
<ListRule {...args} />
16+
</ToastedTemplate>
17+
);
18+
19+
export const InitialDataNoEndpoint = Template.bind({});
20+
InitialDataNoEndpoint.args = {
21+
initialData: Array.from({ length: 50 }, () => fixtureRuleViewModel()),
22+
};
23+
24+
export const RegularStreaming = Template.bind({});
25+
RegularStreaming.decorators = [
26+
getDecoratorWithWorker([
27+
getMockStreamEndpoint('/api/feature/list-rules', {
28+
data: Array.from({ length: 500 }, fixtureRuleViewModel),
29+
delay: 1,
30+
}),
31+
]),
32+
];
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { ChangeEvent, useEffect, useState } from 'react';
2+
import { RuleViewModel } from '@/lib/infrastructure/data/view-model/rule';
3+
import useChunkedStream, { StreamingStatus } from '@/lib/infrastructure/hooks/useChunkedStream';
4+
import { GridApi, GridReadyEvent } from 'ag-grid-community';
5+
import { useToast } from '@/lib/infrastructure/hooks/useToast';
6+
import { BaseViewModelValidator } from '@/component-library/features/utils/BaseViewModelValidator';
7+
import { alreadyStreamingToast, noApiToast } from '@/component-library/features/utils/list-toasts';
8+
import { Heading } from '@/component-library/atoms/misc/Heading';
9+
import { Input } from '@/component-library/atoms/form/input';
10+
import { SearchButton } from '@/component-library/features/search/SearchButton';
11+
import { ListRuleTable } from '@/component-library/pages/Rule/list/ListRuleTable';
12+
13+
type ListRuleProps = {
14+
initialData?: RuleViewModel[];
15+
};
16+
17+
const DEFAULT_SCOPE = '*';
18+
19+
export const ListRule = (props: ListRuleProps) => {
20+
const streamingHook = useChunkedStream<RuleViewModel>();
21+
const [scope, setScope] = useState<string>(DEFAULT_SCOPE);
22+
const [gridApi, setGridApi] = useState<GridApi<RuleViewModel> | null>(null);
23+
24+
const { toast, dismiss } = useToast();
25+
const validator = new BaseViewModelValidator(toast);
26+
27+
const onGridReady = (event: GridReadyEvent) => {
28+
setGridApi(event.api);
29+
};
30+
31+
useEffect(() => {
32+
if (props.initialData) {
33+
onData(props.initialData);
34+
}
35+
}, [gridApi]);
36+
37+
const onData = (data: RuleViewModel[]) => {
38+
const validData = data.filter(element => validator.isValid(element));
39+
gridApi?.applyTransactionAsync({ add: validData });
40+
};
41+
42+
const startStreaming = () => {
43+
if (gridApi) {
44+
dismiss();
45+
gridApi.flushAsyncTransactions();
46+
gridApi.setGridOption('rowData', []);
47+
validator.reset();
48+
49+
const url = `/api/feature/list-rules?scope=${scope}`;
50+
streamingHook.start({ url, onData });
51+
} else {
52+
toast(noApiToast);
53+
}
54+
};
55+
56+
const isRunning = streamingHook.status === StreamingStatus.RUNNING;
57+
58+
const onInputChange = (event: ChangeEvent<HTMLInputElement>) => {
59+
const value = event.target.value;
60+
setScope(value !== '' ? value : DEFAULT_SCOPE);
61+
};
62+
63+
const onSearch = (event: any) => {
64+
event.preventDefault();
65+
if (!isRunning) {
66+
startStreaming();
67+
} else {
68+
toast(alreadyStreamingToast);
69+
}
70+
};
71+
72+
const onStop = (event: any) => {
73+
event.preventDefault();
74+
if (isRunning) {
75+
streamingHook.stop();
76+
}
77+
};
78+
79+
return (
80+
<div className="flex flex-col space-y-3 w-full grow">
81+
<Heading text="Rules" />
82+
<div className="space-y-2">
83+
<div className="text-neutral-900 dark:text-neutral-100">Scope</div>
84+
<div className="flex flex-col space-y-2 sm:flex-row sm:space-y-0 sm:space-x-2 items-center sm:items-start">
85+
<Input className="w-full sm:flex-grow" onChange={onInputChange} onEnterKey={onSearch} placeholder={DEFAULT_SCOPE} />
86+
<SearchButton isRunning={isRunning} onStop={onStop} onSearch={onSearch} />
87+
</div>
88+
</div>
89+
<ListRuleTable streamingHook={streamingHook} onGridReady={onGridReady} />
90+
</div>
91+
);
92+
};

0 commit comments

Comments
 (0)