Compare commits

...

3 Commits

Author SHA1 Message Date
ahmadshaheer
22d1b90e2a chore: add optional chaining .keys 2025-12-15 18:35:38 +04:30
ahmadshaheer
aa9a2863af chore: add tests for add/remove columns from log detail drawer 2025-12-15 15:16:06 +04:30
ahmadshaheer
c5fddb2e09 feat: add ability to add/remove columns from log detail page 2025-12-14 16:39:05 +04:30
6 changed files with 270 additions and 8 deletions

View File

@@ -80,12 +80,32 @@ function LogDetailInner({
return stagedQuery.builder.queryData.find((item) => !item.disabled) || null; return stagedQuery.builder.queryData.find((item) => !item.disabled) || null;
}, [stagedQuery]); }, [stagedQuery]);
const { options } = useOptionsMenu({ const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS, storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS, dataSource: DataSource.LOGS,
aggregateOperator: listQuery?.aggregateOperator || StringOperators.NOOP, aggregateOperator: listQuery?.aggregateOperator || StringOperators.NOOP,
}); });
const handleAddColumn = useCallback(
(fieldName: string): void => {
if (config?.addColumn?.onSelect) {
// onSelect from SelectProps has signature (value, option), but handleSelectColumns only needs value
// eslint-disable-next-line @typescript-eslint/no-explicit-any
config.addColumn.onSelect(fieldName, {} as any);
}
},
[config],
);
const handleRemoveColumn = useCallback(
(fieldName: string): void => {
if (config?.addColumn?.onRemove) {
config.addColumn.onRemove(fieldName);
}
},
[config],
);
const isDarkMode = useIsDarkMode(); const isDarkMode = useIsDarkMode();
const location = useLocation(); const location = useLocation();
const { safeNavigate } = useSafeNavigate(); const { safeNavigate } = useSafeNavigate();
@@ -369,6 +389,8 @@ function LogDetailInner({
isListViewPanel={isListViewPanel} isListViewPanel={isListViewPanel}
selectedOptions={options} selectedOptions={options}
listViewPanelSelectedFields={listViewPanelSelectedFields} listViewPanelSelectedFields={listViewPanelSelectedFields}
onAddColumn={handleAddColumn}
onRemoveColumn={handleRemoveColumn}
/> />
)} )}
{selectedView === VIEW_TYPES.JSON && <JSONView logData={log} />} {selectedView === VIEW_TYPES.JSON && <JSONView logData={log} />}

View File

@@ -30,6 +30,8 @@ interface OverviewProps {
selectedOptions: OptionsQuery; selectedOptions: OptionsQuery;
listViewPanelSelectedFields?: IField[] | null; listViewPanelSelectedFields?: IField[] | null;
onGroupByAttribute?: (fieldKey: string, dataType?: DataTypes) => Promise<void>; onGroupByAttribute?: (fieldKey: string, dataType?: DataTypes) => Promise<void>;
onAddColumn?: (fieldName: string) => void;
onRemoveColumn?: (fieldName: string) => void;
} }
type Props = OverviewProps & type Props = OverviewProps &
@@ -44,6 +46,8 @@ function Overview({
selectedOptions, selectedOptions,
onGroupByAttribute, onGroupByAttribute,
listViewPanelSelectedFields, listViewPanelSelectedFields,
onAddColumn,
onRemoveColumn,
}: Props): JSX.Element { }: Props): JSX.Element {
const [isWrapWord, setIsWrapWord] = useState<boolean>(true); const [isWrapWord, setIsWrapWord] = useState<boolean>(true);
const [isSearchVisible, setIsSearchVisible] = useState<boolean>(false); const [isSearchVisible, setIsSearchVisible] = useState<boolean>(false);
@@ -213,6 +217,8 @@ function Overview({
isListViewPanel={isListViewPanel} isListViewPanel={isListViewPanel}
selectedOptions={selectedOptions} selectedOptions={selectedOptions}
listViewPanelSelectedFields={listViewPanelSelectedFields} listViewPanelSelectedFields={listViewPanelSelectedFields}
onAddColumn={onAddColumn}
onRemoveColumn={onRemoveColumn}
/> />
</> </>
), ),
@@ -228,6 +234,8 @@ Overview.defaultProps = {
isListViewPanel: false, isListViewPanel: false,
listViewPanelSelectedFields: null, listViewPanelSelectedFields: null,
onGroupByAttribute: undefined, onGroupByAttribute: undefined,
onAddColumn: undefined,
onRemoveColumn: undefined,
}; };
export default Overview; export default Overview;

View File

@@ -48,6 +48,8 @@ interface TableViewProps {
isListViewPanel?: boolean; isListViewPanel?: boolean;
listViewPanelSelectedFields?: IField[] | null; listViewPanelSelectedFields?: IField[] | null;
onGroupByAttribute?: (fieldKey: string, dataType?: DataTypes) => Promise<void>; onGroupByAttribute?: (fieldKey: string, dataType?: DataTypes) => Promise<void>;
onAddColumn?: (fieldName: string) => void;
onRemoveColumn?: (fieldName: string) => void;
} }
type Props = TableViewProps & type Props = TableViewProps &
@@ -63,6 +65,8 @@ function TableView({
selectedOptions, selectedOptions,
onGroupByAttribute, onGroupByAttribute,
listViewPanelSelectedFields, listViewPanelSelectedFields,
onAddColumn,
onRemoveColumn,
}: Props): JSX.Element | null { }: Props): JSX.Element | null {
const dispatch = useDispatch<Dispatch<AppActions>>(); const dispatch = useDispatch<Dispatch<AppActions>>();
const [isfilterInLoading, setIsFilterInLoading] = useState<boolean>(false); const [isfilterInLoading, setIsFilterInLoading] = useState<boolean>(false);
@@ -292,6 +296,9 @@ function TableView({
isfilterOutLoading={isfilterOutLoading} isfilterOutLoading={isfilterOutLoading}
onClickHandler={onClickHandler} onClickHandler={onClickHandler}
onGroupByAttribute={onGroupByAttribute} onGroupByAttribute={onGroupByAttribute}
onAddColumn={onAddColumn}
onRemoveColumn={onRemoveColumn}
selectedOptions={selectedOptions}
/> />
), ),
}, },
@@ -335,6 +342,8 @@ TableView.defaultProps = {
isListViewPanel: false, isListViewPanel: false,
listViewPanelSelectedFields: null, listViewPanelSelectedFields: null,
onGroupByAttribute: undefined, onGroupByAttribute: undefined,
onAddColumn: undefined,
onRemoveColumn: undefined,
}; };
export interface DataType { export interface DataType {

View File

@@ -11,7 +11,14 @@ import { OPERATORS } from 'constants/queryBuilder';
import ROUTES from 'constants/routes'; import ROUTES from 'constants/routes';
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config'; import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
import { MetricsType } from 'container/MetricsApplication/constant'; import { MetricsType } from 'container/MetricsApplication/constant';
import { ArrowDownToDot, ArrowUpFromDot, Ellipsis } from 'lucide-react'; import { OptionsQuery } from 'container/OptionsMenu/types';
import {
ArrowDownToDot,
ArrowUpFromDot,
Ellipsis,
Minus,
Plus,
} from 'lucide-react';
import { useTimezone } from 'providers/Timezone'; import { useTimezone } from 'providers/Timezone';
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
@@ -34,6 +41,9 @@ interface ITableViewActionsProps {
isfilterInLoading: boolean; isfilterInLoading: boolean;
isfilterOutLoading: boolean; isfilterOutLoading: boolean;
onGroupByAttribute?: (fieldKey: string, dataType?: DataTypes) => Promise<void>; onGroupByAttribute?: (fieldKey: string, dataType?: DataTypes) => Promise<void>;
onAddColumn?: (fieldName: string) => void;
onRemoveColumn?: (fieldName: string) => void;
selectedOptions?: OptionsQuery;
onClickHandler: ( onClickHandler: (
operator: string, operator: string,
fieldKey: string, fieldKey: string,
@@ -105,6 +115,7 @@ const BodyContent: React.FC<{
BodyContent.displayName = 'BodyContent'; BodyContent.displayName = 'BodyContent';
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function TableViewActions( export default function TableViewActions(
props: ITableViewActionsProps, props: ITableViewActionsProps,
): React.ReactElement { ): React.ReactElement {
@@ -116,6 +127,9 @@ export default function TableViewActions(
isfilterOutLoading, isfilterOutLoading,
onClickHandler, onClickHandler,
onGroupByAttribute, onGroupByAttribute,
onAddColumn,
onRemoveColumn,
selectedOptions,
} = props; } = props;
const { pathname } = useLocation(); const { pathname } = useLocation();
@@ -142,6 +156,13 @@ export default function TableViewActions(
const fieldFilterKey = filterKeyForField(fieldData.field); const fieldFilterKey = filterKeyForField(fieldData.field);
const isFieldInSelectedColumns = useMemo(() => {
if (!selectedOptions?.selectColumns) return false;
return selectedOptions.selectColumns.some(
(col) => col.name === fieldFilterKey,
);
}, [selectedOptions, fieldFilterKey]);
// Memoize textToCopy computation // Memoize textToCopy computation
const textToCopy = useMemo(() => { const textToCopy = useMemo(() => {
let text = fieldData.value; let text = fieldData.value;
@@ -250,6 +271,32 @@ export default function TableViewActions(
arrow={false} arrow={false}
content={ content={
<div> <div>
{onAddColumn && !isFieldInSelectedColumns && (
<Button
className="group-by-clause"
type="text"
icon={<Plus size={14} />}
onClick={(): void => {
onAddColumn(fieldFilterKey);
setIsOpen(false);
}}
>
Add to Columns
</Button>
)}
{onRemoveColumn && isFieldInSelectedColumns && (
<Button
className="group-by-clause"
type="text"
icon={<Minus size={14} />}
onClick={(): void => {
onRemoveColumn(fieldFilterKey);
setIsOpen(false);
}}
>
Remove from Columns
</Button>
)}
<Button <Button
className="group-by-clause" className="group-by-clause"
type="text" type="text"
@@ -330,6 +377,32 @@ export default function TableViewActions(
arrow={false} arrow={false}
content={ content={
<div> <div>
{onAddColumn && !isFieldInSelectedColumns && (
<Button
className="group-by-clause"
type="text"
icon={<Plus size={14} />}
onClick={(): void => {
onAddColumn(fieldFilterKey);
setIsOpen(false);
}}
>
Add to Columns
</Button>
)}
{onRemoveColumn && isFieldInSelectedColumns && (
<Button
className="group-by-clause"
type="text"
icon={<Minus size={14} />}
onClick={(): void => {
onRemoveColumn(fieldFilterKey);
setIsOpen(false);
}}
>
Remove from Columns
</Button>
)}
<Button <Button
className="group-by-clause" className="group-by-clause"
type="text" type="text"
@@ -360,4 +433,7 @@ export default function TableViewActions(
TableViewActions.defaultProps = { TableViewActions.defaultProps = {
onGroupByAttribute: undefined, onGroupByAttribute: undefined,
onAddColumn: undefined,
onRemoveColumn: undefined,
selectedOptions: undefined,
}; };

View File

@@ -1,5 +1,7 @@
import { render, screen } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config'; import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
import { LogViewMode } from 'container/LogsTable';
import { FontSize } from 'container/OptionsMenu/types';
import TableViewActions from '../TableViewActions'; import TableViewActions from '../TableViewActions';
@@ -33,17 +35,32 @@ jest.mock('react-router-dom', () => ({
}), }),
})); }));
jest.mock('../useAsyncJSONProcessing', () => ({
__esModule: true,
default: (): {
isLoading: boolean;
treeData: unknown[] | null;
error: string | null;
} => ({
isLoading: false,
treeData: null,
error: null,
}),
}));
describe('TableViewActions', () => { describe('TableViewActions', () => {
const TEST_VALUE = 'test value'; const TEST_VALUE = 'test value';
const ACTION_BUTTON_TEST_ID = '.action-btn'; const ACTION_BUTTON_TEST_ID = '.action-btn';
const TEST_FIELD = 'test-field';
const defaultProps = { const defaultProps = {
fieldData: { fieldData: {
field: 'test-field', field: TEST_FIELD,
value: TEST_VALUE, value: TEST_VALUE,
}, },
record: { record: {
key: 'test-key', key: 'test-key',
field: 'test-field', field: TEST_FIELD,
value: TEST_VALUE, value: TEST_VALUE,
}, },
isListViewPanel: false, isListViewPanel: false,
@@ -127,4 +144,134 @@ describe('TableViewActions', () => {
container.querySelector(ACTION_BUTTON_TEST_ID), container.querySelector(ACTION_BUTTON_TEST_ID),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
describe('Add/Remove Column functionality', () => {
const ADD_TO_COLUMNS_TEXT = 'Add to Columns';
const REMOVE_FROM_COLUMNS_TEXT = 'Remove from Columns';
const getEllipsisButton = (container: HTMLElement): HTMLElement => {
const buttons = container.querySelectorAll('.filter-btn.periscope-btn');
return buttons[buttons.length - 1] as HTMLElement;
};
const defaultSelectedOptions = {
selectColumns: [],
maxLines: 1,
format: 'table' as LogViewMode,
fontSize: FontSize.MEDIUM,
};
it('shows Add to Columns button when field is not selected', async () => {
const onAddColumn = jest.fn();
const { container } = render(
<TableViewActions
fieldData={defaultProps.fieldData}
record={defaultProps.record}
isListViewPanel={defaultProps.isListViewPanel}
isfilterInLoading={defaultProps.isfilterInLoading}
isfilterOutLoading={defaultProps.isfilterOutLoading}
onClickHandler={defaultProps.onClickHandler}
onGroupByAttribute={defaultProps.onGroupByAttribute}
onAddColumn={onAddColumn}
selectedOptions={defaultSelectedOptions}
/>,
);
const ellipsisButton = getEllipsisButton(container);
fireEvent.mouseOver(ellipsisButton);
await waitFor(() => {
expect(screen.getByText(ADD_TO_COLUMNS_TEXT)).toBeInTheDocument();
});
});
it(`calls onAddColumn with correct field key when ${ADD_TO_COLUMNS_TEXT} is clicked`, async () => {
const onAddColumn = jest.fn();
const { container } = render(
<TableViewActions
fieldData={defaultProps.fieldData}
record={defaultProps.record}
isListViewPanel={defaultProps.isListViewPanel}
isfilterInLoading={defaultProps.isfilterInLoading}
isfilterOutLoading={defaultProps.isfilterOutLoading}
onClickHandler={defaultProps.onClickHandler}
onGroupByAttribute={defaultProps.onGroupByAttribute}
onAddColumn={onAddColumn}
selectedOptions={defaultSelectedOptions}
/>,
);
const ellipsisButton = getEllipsisButton(container);
fireEvent.mouseOver(ellipsisButton);
await waitFor(() => {
expect(screen.getByText(ADD_TO_COLUMNS_TEXT)).toBeInTheDocument();
});
const addButton = screen.getByText(ADD_TO_COLUMNS_TEXT);
fireEvent.click(addButton);
expect(onAddColumn).toHaveBeenCalledWith(TEST_FIELD);
});
it('shows Remove from Columns button when field is already selected', async () => {
const onRemoveColumn = jest.fn();
const { container } = render(
<TableViewActions
fieldData={defaultProps.fieldData}
record={defaultProps.record}
isListViewPanel={defaultProps.isListViewPanel}
isfilterInLoading={defaultProps.isfilterInLoading}
isfilterOutLoading={defaultProps.isfilterOutLoading}
onClickHandler={defaultProps.onClickHandler}
onGroupByAttribute={defaultProps.onGroupByAttribute}
onRemoveColumn={onRemoveColumn}
selectedOptions={{
...defaultSelectedOptions,
selectColumns: [{ name: TEST_FIELD }],
}}
/>,
);
const ellipsisButton = getEllipsisButton(container);
fireEvent.mouseOver(ellipsisButton);
await waitFor(() => {
expect(screen.getByText(REMOVE_FROM_COLUMNS_TEXT)).toBeInTheDocument();
});
expect(screen.queryByText(ADD_TO_COLUMNS_TEXT)).not.toBeInTheDocument();
});
it(`calls onRemoveColumn with correct field key when ${REMOVE_FROM_COLUMNS_TEXT} is clicked`, async () => {
const onRemoveColumn = jest.fn();
const { container } = render(
<TableViewActions
fieldData={defaultProps.fieldData}
record={defaultProps.record}
isListViewPanel={defaultProps.isListViewPanel}
isfilterInLoading={defaultProps.isfilterInLoading}
isfilterOutLoading={defaultProps.isfilterOutLoading}
onClickHandler={defaultProps.onClickHandler}
onGroupByAttribute={defaultProps.onGroupByAttribute}
onRemoveColumn={onRemoveColumn}
selectedOptions={{
...defaultSelectedOptions,
selectColumns: [{ name: TEST_FIELD }],
}}
/>,
);
const ellipsisButton = getEllipsisButton(container);
fireEvent.mouseOver(ellipsisButton);
await waitFor(() => {
expect(screen.getByText('Remove from Columns')).toBeInTheDocument();
});
const removeButton = screen.getByText(REMOVE_FROM_COLUMNS_TEXT);
fireEvent.click(removeButton);
expect(onRemoveColumn).toHaveBeenCalledWith(TEST_FIELD);
});
});
}); });

View File

@@ -170,7 +170,7 @@ const useOptionsMenu = ({
...initialQueryParamsV5, ...initialQueryParamsV5,
searchText: debouncedSearchText, searchText: debouncedSearchText,
}, },
{ queryKey: [debouncedSearchText, isFocused], enabled: isFocused }, { queryKey: [debouncedSearchText, isFocused] },
); );
// const { // const {
@@ -186,7 +186,7 @@ const useOptionsMenu = ({
const searchedAttributeKeys: TelemetryFieldKey[] = useMemo(() => { const searchedAttributeKeys: TelemetryFieldKey[] = useMemo(() => {
const searchedAttributesDataList = Object.values( const searchedAttributesDataList = Object.values(
searchedAttributesDataV5?.data.data.keys || {}, searchedAttributesDataV5?.data.data?.keys || {},
).flat(); ).flat();
if (searchedAttributesDataList.length) { if (searchedAttributesDataList.length) {
if (dataSource === DataSource.LOGS) { if (dataSource === DataSource.LOGS) {
@@ -230,7 +230,7 @@ const useOptionsMenu = ({
} }
return []; return [];
}, [dataSource, searchedAttributesDataV5?.data.data.keys]); }, [dataSource, searchedAttributesDataV5?.data.data?.keys]);
const initialOptionsQuery: OptionsQuery = useMemo(() => { const initialOptionsQuery: OptionsQuery = useMemo(() => {
let defaultColumns: TelemetryFieldKey[] = defaultOptionsQuery.selectColumns; let defaultColumns: TelemetryFieldKey[] = defaultOptionsQuery.selectColumns;