feat(dashboard): Add edit button to dashboard native filters filter cards (#22364)

Co-authored-by: Herbert Gainor <herbert.gainor@preset.io>
This commit is contained in:
Cody Leff 2022-12-09 07:00:15 -07:00 committed by GitHub
parent d41cb66737
commit 3b45ad8b97
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 173 additions and 88 deletions

View File

@ -27,6 +27,8 @@ import { getFilterBarTestId } from '../utils';
export interface FCBProps { export interface FCBProps {
createNewOnOpen?: boolean; createNewOnOpen?: boolean;
dashboardId?: number; dashboardId?: number;
initialFilterId?: string;
onClick?: () => void;
children?: React.ReactNode; children?: React.ReactNode;
} }
@ -37,6 +39,8 @@ const HeaderButton = styled(Button)`
export const FilterConfigurationLink: React.FC<FCBProps> = ({ export const FilterConfigurationLink: React.FC<FCBProps> = ({
createNewOnOpen, createNewOnOpen,
dashboardId, dashboardId,
initialFilterId,
onClick,
children, children,
}) => { }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
@ -56,7 +60,10 @@ export const FilterConfigurationLink: React.FC<FCBProps> = ({
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
setOpen(true); setOpen(true);
}, [setOpen]); if (onClick) {
onClick();
}
}, [setOpen, onClick]);
return ( return (
<> <>
@ -73,6 +80,7 @@ export const FilterConfigurationLink: React.FC<FCBProps> = ({
isOpen={isOpen} isOpen={isOpen}
onSave={submit} onSave={submit}
onCancel={close} onCancel={close}
initialFilterId={initialFilterId}
createNewOnOpen={createNewOnOpen} createNewOnOpen={createNewOnOpen}
key={`filters-for-${dashboardId}`} key={`filters-for-${dashboardId}`}
/> />

View File

@ -106,6 +106,7 @@ export const DependenciesRow = React.memo(({ filter }: FilterCardRowProps) => {
<RowValue ref={dependenciesRef}> <RowValue ref={dependenciesRef}>
{dependencies.map((dependency, index) => ( {dependencies.map((dependency, index) => (
<DependencyValue <DependencyValue
key={dependency.id}
dependency={dependency} dependency={dependency}
hasSeparator={index !== 0} hasSeparator={index !== 0}
/> />

View File

@ -27,6 +27,7 @@ import { SET_FOCUSED_NATIVE_FILTER } from 'src/dashboard/actions/nativeFilters';
import { FilterCardContent } from './FilterCardContent'; import { FilterCardContent } from './FilterCardContent';
const baseInitialState = { const baseInitialState = {
dashboardInfo: {},
nativeFilters: { nativeFilters: {
filters: { filters: {
'NATIVE_FILTER-1': { 'NATIVE_FILTER-1': {
@ -204,6 +205,13 @@ jest.mock('@superset-ui/core', () => ({
}), }),
})); }));
jest.mock(
'src/components/Icons/Icon',
() =>
({ fileName }: { fileName: string }) =>
<span role="img" aria-label={fileName.replace('_', '-')} />,
);
// extract text from embedded html tags // extract text from embedded html tags
// source: https://polvara.me/posts/five-things-you-didnt-know-about-testing-library // source: https://polvara.me/posts/five-things-you-didnt-know-about-testing-library
const getTextInHTMLTags = const getTextInHTMLTags =
@ -217,90 +225,115 @@ const getTextInHTMLTags =
return nodeHasText && childrenDontHaveText; return nodeHasText && childrenDontHaveText;
}; };
const hidePopover = jest.fn();
const renderContent = (filter = baseFilter, initialState = baseInitialState) => const renderContent = (filter = baseFilter, initialState = baseInitialState) =>
render(<FilterCardContent filter={filter} />, { render(<FilterCardContent filter={filter} hidePopover={hidePopover} />, {
useRedux: true, useRedux: true,
initialState, initialState,
}); });
describe('Filter Card', () => { test('filter card title, type, scope, dependencies', () => {
it('Basic', () => { renderContent();
renderContent(); expect(screen.getByText('Native filter 1')).toBeVisible();
expect(screen.getByText('Native filter 1')).toBeVisible(); expect(screen.getByLabelText('filter-small')).toBeVisible();
expect(screen.getByLabelText('filter-small')).toBeVisible();
expect(screen.getByText('Filter type')).toBeVisible(); expect(screen.getByText('Filter type')).toBeVisible();
expect(screen.getByText('Select filter')).toBeVisible(); expect(screen.getByText('Select filter')).toBeVisible();
expect(screen.getByText('Scope')).toBeVisible(); expect(screen.getByText('Scope')).toBeVisible();
expect(screen.getByText('All charts')).toBeVisible(); expect(screen.getByText('All charts')).toBeVisible();
expect(screen.queryByText('Dependencies')).not.toBeInTheDocument(); expect(screen.queryByText('Dependencies')).not.toBeInTheDocument();
}); });
describe('Scope', () => { test('filter card scope with excluded', () => {
it('Scope with excluded', () => { const filter = {
const filter = { ...baseFilter,
...baseFilter, scope: { rootPath: [DASHBOARD_ROOT_ID], excluded: [1, 4] },
scope: { rootPath: [DASHBOARD_ROOT_ID], excluded: [1, 4] }, };
}; renderContent(filter);
renderContent(filter); expect(screen.getByText('Scope')).toBeVisible();
expect(screen.getByText('Scope')).toBeVisible(); expect(
expect( screen.getByText(getTextInHTMLTags('Test chart 2, Test chart 3')),
screen.getByText(getTextInHTMLTags('Test chart 2, Test chart 3')), ).toBeVisible();
).toBeVisible(); });
});
it('Scope with top level tab as root', () => { test('filter card scope with top level tab as root', () => {
const filter = { const filter = {
...baseFilter, ...baseFilter,
scope: { rootPath: ['TAB-1', 'TAB-2'], excluded: [1, 2] }, scope: { rootPath: ['TAB-1', 'TAB-2'], excluded: [1, 2] },
}; };
renderContent(filter); renderContent(filter);
expect(screen.getByText('Scope')).toBeVisible(); expect(screen.getByText('Scope')).toBeVisible();
expect( expect(
screen.getByText(getTextInHTMLTags('Tab 2, Test chart 3')), screen.getByText(getTextInHTMLTags('Tab 2, Test chart 3')),
).toBeVisible(); ).toBeVisible();
}); });
it('Empty scope', () => { test('filter card empty scope', () => {
const filter = { const filter = {
...baseFilter, ...baseFilter,
scope: { rootPath: [], excluded: [1, 2, 3, 4] }, scope: { rootPath: [], excluded: [1, 2, 3, 4] },
}; };
renderContent(filter); renderContent(filter);
expect(screen.getByText('Scope')).toBeVisible(); expect(screen.getByText('Scope')).toBeVisible();
expect(screen.getByText('None')).toBeVisible(); expect(screen.getByText('None')).toBeVisible();
}); });
});
describe('Dependencies', () => { test('filter card with dependency', () => {
it('Has dependency', () => { const filter = {
const filter = { ...baseFilter,
...baseFilter, cascadeParentIds: ['NATIVE_FILTER-2'],
cascadeParentIds: ['NATIVE_FILTER-2'], };
}; renderContent(filter);
renderContent(filter); expect(screen.getByText('Dependent on')).toBeVisible();
expect(screen.getByText('Dependent on')).toBeVisible(); expect(screen.getByText('Native filter 2')).toBeVisible();
expect(screen.getByText('Native filter 2')).toBeVisible(); });
});
it('Focus filter on dependency click', () => { test('focus filter on filter card dependency click', () => {
const useDispatchMock = jest.spyOn(reactRedux, 'useDispatch'); const useDispatchMock = jest.spyOn(reactRedux, 'useDispatch');
const dummyDispatch = jest.fn(); const dummyDispatch = jest.fn();
useDispatchMock.mockReturnValue(dummyDispatch); useDispatchMock.mockReturnValue(dummyDispatch);
const filter = { const filter = {
...baseFilter, ...baseFilter,
cascadeParentIds: ['NATIVE_FILTER-2'], cascadeParentIds: ['NATIVE_FILTER-2'],
}; };
renderContent(filter); renderContent(filter);
userEvent.click(screen.getByText('Native filter 2')); userEvent.click(screen.getByText('Native filter 2'));
expect(dummyDispatch).toHaveBeenCalledWith({ expect(dummyDispatch).toHaveBeenCalledWith({
type: SET_FOCUSED_NATIVE_FILTER, type: SET_FOCUSED_NATIVE_FILTER,
id: 'NATIVE_FILTER-2', id: 'NATIVE_FILTER-2',
});
});
}); });
}); });
test('edit filter button for dashboard viewer', () => {
renderContent();
expect(
screen.queryByRole('button', { name: /edit/i }),
).not.toBeInTheDocument();
});
test('edit filter button for dashboard editor', () => {
renderContent(baseFilter, {
...baseInitialState,
dashboardInfo: { dash_edit_perm: true },
});
expect(screen.getByRole('button', { name: /edit/i })).toBeVisible();
});
test('open modal on edit filter button click', async () => {
renderContent(baseFilter, {
...baseInitialState,
dashboardInfo: { dash_edit_perm: true },
});
const editButton = screen.getByRole('button', { name: /edit/i });
userEvent.click(editButton);
expect(
await screen.findByRole('dialog', { name: /add and edit filters/i }),
).toBeVisible();
});

View File

@ -24,9 +24,17 @@ import { DependenciesRow } from './DependenciesRow';
import { NameRow } from './NameRow'; import { NameRow } from './NameRow';
import { TypeRow } from './TypeRow'; import { TypeRow } from './TypeRow';
export const FilterCardContent = ({ filter }: { filter: Filter }) => ( interface FilterCardContentProps {
filter: Filter;
hidePopover: () => void;
}
export const FilterCardContent = ({
filter,
hidePopover,
}: FilterCardContentProps) => (
<div> <div>
<NameRow filter={filter} /> <NameRow filter={filter} hidePopover={hidePopover} />
<TypeRow filter={filter} /> <TypeRow filter={filter} />
<ScopeRow filter={filter} /> <ScopeRow filter={filter} />
<DependenciesRow filter={filter} /> <DependenciesRow filter={filter} />

View File

@ -17,34 +17,61 @@
* under the License. * under the License.
*/ */
import React, { useRef } from 'react'; import React, { useRef } from 'react';
import { css, SupersetTheme } from '@superset-ui/core'; import { useSelector } from 'react-redux';
import { css, SupersetTheme, useTheme } from '@superset-ui/core';
import Icons from 'src/components/Icons'; import Icons from 'src/components/Icons';
import { useTruncation } from 'src/hooks/useTruncation'; import { useTruncation } from 'src/hooks/useTruncation';
import { Row, FilterName } from './Styles'; import { RootState } from 'src/dashboard/types';
import { Row, FilterName, InternalRow } from './Styles';
import { FilterCardRowProps } from './types'; import { FilterCardRowProps } from './types';
import { FilterConfigurationLink } from '../FilterBar/FilterConfigurationLink';
import { TooltipWithTruncation } from './TooltipWithTruncation'; import { TooltipWithTruncation } from './TooltipWithTruncation';
export const NameRow = ({ filter }: FilterCardRowProps) => { export const NameRow = ({
filter,
hidePopover,
}: FilterCardRowProps & { hidePopover: () => void }) => {
const theme = useTheme();
const filterNameRef = useRef<HTMLElement>(null); const filterNameRef = useRef<HTMLElement>(null);
const [elementsTruncated] = useTruncation(filterNameRef); const [elementsTruncated] = useTruncation(filterNameRef);
const dashboardId = useSelector<RootState, number>(
({ dashboardInfo }) => dashboardInfo.id,
);
const canEdit = useSelector<RootState, boolean>(
({ dashboardInfo }) => dashboardInfo.dash_edit_perm,
);
return ( return (
<Row <Row
css={(theme: SupersetTheme) => css={(theme: SupersetTheme) =>
css` css`
margin-bottom: ${theme.gridUnit * 3}px; margin-bottom: ${theme.gridUnit * 3}px;
justify-content: space-between;
` `
} }
> >
<Icons.FilterSmall <InternalRow>
css={(theme: SupersetTheme) => <Icons.FilterSmall
css` css={(theme: SupersetTheme) =>
margin-right: ${theme.gridUnit}px; css`
` margin-right: ${theme.gridUnit}px;
} `
/> }
<TooltipWithTruncation title={elementsTruncated ? filter.name : null}> />
<FilterName ref={filterNameRef}>{filter.name}</FilterName> <TooltipWithTruncation title={elementsTruncated ? filter.name : null}>
</TooltipWithTruncation> <FilterName ref={filterNameRef}>{filter.name}</FilterName>
</TooltipWithTruncation>
</InternalRow>
{canEdit && (
<FilterConfigurationLink
dashboardId={dashboardId}
onClick={hidePopover}
initialFilterId={filter.id}
>
<Icons.Edit iconSize="l" iconColor={theme.colors.grayscale.light1} />
</FilterConfigurationLink>
)}
</Row> </Row>
); );
}; };

View File

@ -92,3 +92,8 @@ export const TooltipTrigger = styled.div`
display: inline-flex; display: inline-flex;
white-space: nowrap; white-space: nowrap;
`; `;
export const InternalRow = styled.div`
display: flex;
align-items: center;
`;

View File

@ -31,6 +31,10 @@ export const FilterCard = ({
}: FilterCardProps) => { }: FilterCardProps) => {
const [internalIsVisible, setInternalIsVisible] = useState(false); const [internalIsVisible, setInternalIsVisible] = useState(false);
const hidePopover = () => {
setInternalIsVisible(false);
};
useEffect(() => { useEffect(() => {
if (!externalIsVisible) { if (!externalIsVisible) {
setInternalIsVisible(false); setInternalIsVisible(false);
@ -46,9 +50,8 @@ export const FilterCard = ({
setInternalIsVisible(externalIsVisible && visible); setInternalIsVisible(externalIsVisible && visible);
}} }}
visible={externalIsVisible && internalIsVisible} visible={externalIsVisible && internalIsVisible}
content={<FilterCardContent filter={filter} />} content={<FilterCardContent filter={filter} hidePopover={hidePopover} />}
getPopupContainer={getPopupContainer ?? (() => document.body)} getPopupContainer={getPopupContainer ?? (() => document.body)}
destroyTooltipOnHide
> >
{children} {children}
</Popover> </Popover>