Skip to content

Commit

Permalink
Merge pull request #478 from MytsV/feature-473-functional_rule_list
Browse files Browse the repository at this point in the history
Feature 473. Functional rule list
  • Loading branch information
maany authored Sep 22, 2024
2 parents ec312f2 + b4853db commit d2bf7f0
Show file tree
Hide file tree
Showing 71 changed files with 2,082 additions and 712 deletions.
2 changes: 1 addition & 1 deletion src/app/(rucio)/did/list/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

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

Expand Down
2 changes: 1 addition & 1 deletion src/app/(rucio)/rse/list/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { ListRSE } from '@/component-library/pages/RSE/List/ListRSE';
import { ListRSE } from '@/component-library/pages/RSE/list/ListRSE';
import { useSearchParams } from 'next/navigation';
export default function Page() {
const searchParams = useSearchParams();
Expand Down
9 changes: 2 additions & 7 deletions src/app/(rucio)/rule/list/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
'use client';

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

export default function Page() {
const comDOM = useComDOM<RuleViewModel>('list-rule-query', [], false, Infinity, 200, true);
return <ListRule comdom={comDOM} webui_host={process.env.NEXT_PUBLIC_WEBUI_HOST ?? 'http://localhost:3000'} />;
return <ListRule />;
}
16 changes: 0 additions & 16 deletions src/component-library/demos/03_2_List_Rules.stories.tsx

This file was deleted.

29 changes: 29 additions & 0 deletions src/component-library/features/badges/Rule/RuleStateBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { RuleState } from '@/lib/core/entity/rucio';
import React from 'react';
import { Badge } from '@/component-library/atoms/misc/Badge';
import { cn } from '@/component-library/utils';

const stateString: Record<RuleState, string> = {
[RuleState.REPLICATING]: 'Replicating',
[RuleState.OK]: 'OK',
[RuleState.STUCK]: 'Stuck',
[RuleState.SUSPENDED]: 'Suspended',
[RuleState.WAITING_APPROVAL]: 'Waiting',
[RuleState.INJECT]: 'Inject',
[RuleState.UNKNOWN]: 'Unknown',
};

const stateColorClasses: Record<RuleState, string> = {
[RuleState.REPLICATING]: 'bg-base-warning-400',
[RuleState.OK]: 'bg-base-success-500',
[RuleState.STUCK]: 'bg-base-error-500',
[RuleState.SUSPENDED]: 'bg-neutral-500',
[RuleState.WAITING_APPROVAL]: 'bg-extra-indigo-500',
[RuleState.INJECT]: 'bg-base-info-500',
[RuleState.UNKNOWN]: 'bg-neutral-0 dark:bg-neutral-800',
};

export const RuleStateBadge = (props: { value: RuleState; className?: string }) => {
const classes = cn(stateColorClasses[props.value], props.className);
return <Badge value={stateString[props.value]} className={classes} />;
};
37 changes: 37 additions & 0 deletions src/component-library/features/utils/filter-parameters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,40 @@ export const buildDiscreteFilterParams = (values: string[]): ITextFilterParams =
buttons: ['reset'],
};
};

/**
* Compares a date filter against a cell value excluding time.
* @param filterLocalDateAtMidnight - The date to filter against, normalized to midnight.
* @param cellValue - The value from the cell, which can be a date string or a Date object.
* @returns A number indicating the comparison result:
* - 0 if the dates are equal,
* - 1 if the cell date is greater than the filter date,
* - -1 if the cell date is less than the filter date.
*/
const dateComparator = (filterLocalDateAtMidnight: Date, cellValue: string | Date): number => {
if (cellValue == null) return -1;

let cellDate: Date;
// Convert cell value to a Date object if it's a string, otherwise use it as is.
if (typeof cellValue === 'string') {
cellDate = new Date(cellValue);
} else {
cellDate = cellValue;
}

// Normalize both the filter date and cell date to midnight.
const filterDateOnly = new Date(filterLocalDateAtMidnight.setHours(0, 0, 0, 0));
const cellDateOnly = new Date(cellDate.setHours(0, 0, 0, 0));

if (filterDateOnly.getTime() === cellDateOnly.getTime()) {
return 0;
}

return cellDateOnly.getTime() > filterDateOnly.getTime() ? 1 : -1;
};

export const DefaultDateFilterParams = {
maxNumConditions: 1,
comparator: dateComparator,
buttons: ['reset'],
};
25 changes: 23 additions & 2 deletions src/component-library/features/utils/text-formatters.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
const DEFAULT_VALUE = 'NaN';

export const formatDate = (isoString: string): string => {
const date = new Date(isoString);
if (isNaN(date.getTime())) {
return 'NaN';
return DEFAULT_VALUE;
}
// TODO: use locale from some context
return date.toLocaleDateString('en-UK', {
Expand All @@ -12,9 +14,28 @@ export const formatDate = (isoString: string): string => {
};

export const formatFileSize = (bytes: number): string => {
if (isNaN(bytes) || bytes < 0) return 'NaN';
if (isNaN(bytes) || bytes < 0) return DEFAULT_VALUE;
if (bytes === 0) return '0 Bytes';
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${parseFloat((bytes / Math.pow(1024, i)).toFixed(2))} ${sizes[i]}`;
};

export const formatSeconds = (seconds: number): string => {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;

if (days > 0) {
return `${days} day${days > 1 ? 's' : ''}`;
} else if (hours > 0) {
return `${hours} hour${hours > 1 ? 's' : ''}`;
} else if (minutes > 0) {
return `${minutes} minute${minutes > 1 ? 's' : ''}`;
} else if (seconds > 0) {
return `${remainingSeconds} second${remainingSeconds > 1 ? 's' : ''}`;
} else {
return DEFAULT_VALUE;
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectVa
import { Input } from '@/component-library/atoms/form/input';
import { useToast } from '@/lib/infrastructure/hooks/useToast';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ListDIDTable } from '@/component-library/pages/DID/List/ListDIDTable';
import { ListDIDTable } from '@/component-library/pages/DID/list/ListDIDTable';
import { BaseViewModelValidator } from '@/component-library/features/utils/BaseViewModelValidator';
import { SearchButton } from '@/component-library/features/search/SearchButton';
import { alreadyStreamingToast, noApiToast } from '@/component-library/features/utils/list-toasts';
import { ListDIDMeta } from '@/component-library/pages/DID/List/Meta/ListDIDMeta';
import { ListDIDMeta } from '@/component-library/pages/DID/list/meta/ListDIDMeta';

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

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

const res = await fetch(url);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Meta, StoryFn } from '@storybook/react';
import { ListDIDMeta } from '@/component-library/pages/DID/List/Meta/ListDIDMeta';
import { ListDIDMeta } from '@/component-library/pages/DID/list/meta/ListDIDMeta';
import { fixtureDIDMetaViewModel } from '@/test/fixtures/table-fixtures';
import { DIDType } from '@/lib/core/entity/rucio';
import { ToastedTemplate } from '@/component-library/templates/ToastedTemplate/ToastedTemplate';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const Template: StoryFn<typeof ListRSE> = args => (
);

// We don't want to generate several of these
const smallList = Array.from({ length: 20 }, fixtureRSEViewModel);
const smallList = Array.from({ length: 20 }, fixtureRSEViewModel);
const mediumList = Array.from({ length: 140 }, fixtureRSEViewModel);
const hugeList = Array.from({ length: 100000 }, fixtureRSEViewModel);
const endpointUrl = '/api/feature/list-rses';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { RSEViewModel } from '@/lib/infrastructure/data/view-model/rse';
import { ChangeEvent, useEffect, useState } from 'react';
import useChunkedStream, { StreamingStatus } from '@/lib/infrastructure/hooks/useChunkedStream';
import { ListRSETable } from '@/component-library/pages/RSE/List/ListRSETable';
import { ListRSETable } from '@/component-library/pages/RSE/list/ListRSETable';
import { useToast } from '@/lib/infrastructure/hooks/useToast';
import { GridApi, GridReadyEvent } from 'ag-grid-community';
import { Heading } from '@/component-library/atoms/misc/Heading';
Expand All @@ -16,11 +16,11 @@ type ListRSEProps = {
initialData?: RSEViewModel[];
};

const defaultExpression = '*';
const DEFAULT_EXPRESSION = '*';

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

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

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

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

const onInputChange = (event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
setExpression(value !== '' ? value : defaultExpression);
setExpression(value !== '' ? value : DEFAULT_EXPRESSION);
};

const onSearch = (event: any) => {
Expand Down Expand Up @@ -100,9 +100,9 @@ export const ListRSE = (props: ListRSEProps) => {
onChange={onInputChange}
onEnterKey={onSearch}
defaultValue={props.firstExpression ?? ''}
placeholder={defaultExpression}
placeholder={DEFAULT_EXPRESSION}
/>
<SearchButton isRunning={streamingHook.status === StreamingStatus.RUNNING} onStop={onStop} onSearch={onSearch} />
<SearchButton isRunning={isRunning} onStop={onStop} onSearch={onSearch} />
</div>
</div>
<ListRSETable streamingHook={streamingHook} onGridReady={onGridReady} />
Expand Down
32 changes: 32 additions & 0 deletions src/component-library/pages/Rule/list/ListRule.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { StoryFn, Meta } from '@storybook/react';
import { fixtureRuleViewModel } from '@/test/fixtures/table-fixtures';
import { ListRule } from '@/component-library/pages/Rule/list/ListRule';
import { ToastedTemplate } from '@/component-library/templates/ToastedTemplate/ToastedTemplate';
import { getDecoratorWithWorker } from '@/test/mocks/handlers/story-decorators';
import { getMockStreamEndpoint } from '@/test/mocks/handlers/streaming-handlers';

export default {
title: 'Components/Pages/Rule/List',
component: ListRule,
} as Meta<typeof ListRule>;

const Template: StoryFn<typeof ListRule> = args => (
<ToastedTemplate>
<ListRule {...args} />
</ToastedTemplate>
);

export const InitialDataNoEndpoint = Template.bind({});
InitialDataNoEndpoint.args = {
initialData: Array.from({ length: 50 }, () => fixtureRuleViewModel()),
};

export const RegularStreaming = Template.bind({});
RegularStreaming.decorators = [
getDecoratorWithWorker([
getMockStreamEndpoint('/api/feature/list-rules', {
data: Array.from({ length: 500 }, fixtureRuleViewModel),
delay: 1,
}),
]),
];
92 changes: 92 additions & 0 deletions src/component-library/pages/Rule/list/ListRule.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { ChangeEvent, useEffect, useState } from 'react';
import { RuleViewModel } from '@/lib/infrastructure/data/view-model/rule';
import useChunkedStream, { StreamingStatus } from '@/lib/infrastructure/hooks/useChunkedStream';
import { GridApi, GridReadyEvent } from 'ag-grid-community';
import { useToast } from '@/lib/infrastructure/hooks/useToast';
import { BaseViewModelValidator } from '@/component-library/features/utils/BaseViewModelValidator';
import { alreadyStreamingToast, noApiToast } from '@/component-library/features/utils/list-toasts';
import { Heading } from '@/component-library/atoms/misc/Heading';
import { Input } from '@/component-library/atoms/form/input';
import { SearchButton } from '@/component-library/features/search/SearchButton';
import { ListRuleTable } from '@/component-library/pages/Rule/list/ListRuleTable';

type ListRuleProps = {
initialData?: RuleViewModel[];
};

const DEFAULT_SCOPE = '*';

export const ListRule = (props: ListRuleProps) => {
const streamingHook = useChunkedStream<RuleViewModel>();
const [scope, setScope] = useState<string>(DEFAULT_SCOPE);
const [gridApi, setGridApi] = useState<GridApi<RuleViewModel> | null>(null);

const { toast, dismiss } = useToast();
const validator = new BaseViewModelValidator(toast);

const onGridReady = (event: GridReadyEvent) => {
setGridApi(event.api);
};

useEffect(() => {
if (props.initialData) {
onData(props.initialData);
}
}, [gridApi]);

const onData = (data: RuleViewModel[]) => {
const validData = data.filter(element => validator.isValid(element));
gridApi?.applyTransactionAsync({ add: validData });
};

const startStreaming = () => {
if (gridApi) {
dismiss();
gridApi.flushAsyncTransactions();
gridApi.setGridOption('rowData', []);
validator.reset();

const url = `/api/feature/list-rules?scope=${scope}`;
streamingHook.start({ url, onData });
} else {
toast(noApiToast);
}
};

const isRunning = streamingHook.status === StreamingStatus.RUNNING;

const onInputChange = (event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
setScope(value !== '' ? value : DEFAULT_SCOPE);
};

const onSearch = (event: any) => {
event.preventDefault();
if (!isRunning) {
startStreaming();
} else {
toast(alreadyStreamingToast);
}
};

const onStop = (event: any) => {
event.preventDefault();
if (isRunning) {
streamingHook.stop();
}
};

return (
<div className="flex flex-col space-y-3 w-full grow">
<Heading text="Rules" />
<div className="space-y-2">
<div className="text-neutral-900 dark:text-neutral-100">Scope</div>
<div className="flex flex-col space-y-2 sm:flex-row sm:space-y-0 sm:space-x-2 items-center sm:items-start">
<Input className="w-full sm:flex-grow" onChange={onInputChange} onEnterKey={onSearch} placeholder={DEFAULT_SCOPE} />
<SearchButton isRunning={isRunning} onStop={onStop} onSearch={onSearch} />
</div>
</div>
<ListRuleTable streamingHook={streamingHook} onGridReady={onGridReady} />
</div>
);
};
Loading

0 comments on commit d2bf7f0

Please sign in to comment.