feat(ui): Show all views in settings (#14971)

This commit is contained in:
Saketh Varma 2025-10-21 20:24:33 +05:30 committed by GitHub
parent 7628ab3fa4
commit d0bd2d50e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 294 additions and 184 deletions

View File

@ -1,51 +1,140 @@
import { Typography } from 'antd';
import React from 'react';
import { Button, PageTitle, Tabs, colors } from '@components';
import React, { useCallback, useEffect, useState } from 'react';
import { useLocation } from 'react-router';
import styled from 'styled-components';
import { Tab } from '@components/components/Tabs/Tabs';
import { ViewBuilder } from '@app/entity/view/builder/ViewBuilder';
import { ViewBuilderMode } from '@app/entity/view/builder/types';
import { ViewsList } from '@app/entityV2/view/ViewsList';
import { DataHubViewType } from '@types';
const PageContainer = styled.div`
padding-top: 20px;
padding: 16px 20px;
width: 100%;
overflow: hidden;
flex: 1;
gap: 20px;
display: flex;
flex-direction: column;
overflow: auto;
`;
const PageHeaderContainer = styled.div`
&& {
padding-left: 24px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
`;
const PageTitle = styled(Typography.Title)`
&& {
margin-bottom: 12px;
}
const TitleContainer = styled.div`
flex: 1;
`;
const HeaderActionsContainer = styled.div`
display: flex;
justify-content: flex-end;
`;
const ListContainer = styled.div`
flex: 1;
display: flex;
flex-direction: column;
&&& .ant-tabs-nav {
margin: 0;
}
color: ${colors.gray[600]};
overflow: auto;
`;
enum TabType {
Personal = 'My Views',
Global = 'Public Views',
}
const tabUrlMap = {
[TabType.Personal]: '/settings/views/personal',
[TabType.Global]: '/settings/views/public',
};
/**
* Component used for displaying the 'Manage Views' experience.
*/
export const ManageViews = () => {
const location = useLocation();
const [showViewBuilder, setShowViewBuilder] = useState(false);
const [selectedTab, setSelectedTab] = useState<TabType | undefined | null>();
const onCloseModal = () => {
setShowViewBuilder(false);
};
const tabs: Tab[] = [
{
component: <ViewsList viewType={DataHubViewType.Personal} />,
key: TabType.Personal,
name: TabType.Personal,
},
{
component: <ViewsList viewType={DataHubViewType.Global} />,
key: TabType.Global,
name: TabType.Global,
},
];
useEffect(() => {
if (selectedTab === undefined) {
const currentPath = location.pathname;
const currentTab = Object.entries(tabUrlMap).find(([, url]) => url === currentPath)?.[0] as TabType;
if (currentTab) {
setSelectedTab(currentTab);
} else {
setSelectedTab(null);
}
}
}, [selectedTab, location.pathname]);
const getCurrentUrl = useCallback(() => location.pathname, [location.pathname]);
return (
<PageContainer>
<PageHeaderContainer>
<PageTitle level={3}>Manage Views</PageTitle>
<Typography.Paragraph type="secondary">
Create, edit, and remove your Views. Views allow you to save and share sets of filters for reuse
when browsing DataHub.
</Typography.Paragraph>
<TitleContainer>
<PageTitle
title="Views"
subTitle="Create, edit, and remove your Views. Views allow you to save and share sets of filters for reuse when browsing DataHub."
/>
</TitleContainer>
<HeaderActionsContainer>
<Button
variant="filled"
id="create-new-view-button"
onClick={() => setShowViewBuilder(true)}
data-testid="create-new-view-button"
icon={{ icon: 'Plus', source: 'phosphor' }}
disabled={false}
>
Create View
</Button>
</HeaderActionsContainer>
</PageHeaderContainer>
<ListContainer>
<ViewsList />
<Tabs
tabs={tabs}
selectedTab={selectedTab as string}
onChange={(tab) => setSelectedTab(tab as TabType)}
urlMap={tabUrlMap}
defaultTab={TabType.Personal}
getCurrentUrl={getCurrentUrl}
/>
</ListContainer>
{showViewBuilder && (
<ViewBuilder mode={ViewBuilderMode.EDITOR} onSubmit={onCloseModal} onCancel={onCloseModal} />
)}
</PageContainer>
);
};

View File

@ -1,20 +1,10 @@
import { GlobalOutlined, LockOutlined } from '@ant-design/icons';
import { Icon, Tooltip } from '@components';
import { Typography } from 'antd';
import React from 'react';
import styled from 'styled-components';
import { DataHubViewType } from '@types';
const StyledLockOutlined = styled(LockOutlined)<{ color }>`
color: ${(props) => props.color};
margin-right: 4px;
`;
const StyledGlobalOutlined = styled(GlobalOutlined)<{ color }>`
color: ${(props) => props.color};
margin-right: 4px;
`;
const StyledText = styled(Typography.Text)<{ color }>`
&& {
color: ${(props) => props.color};
@ -27,31 +17,29 @@ type Props = {
onClick?: () => void;
};
const ViewNameContainer = styled.div`
display: flex;
align-items: center;
gap: 4px;
`;
/**
* Label used to describe View Types
*
* @param param0 the color of the text and iconography
*/
export const ViewTypeLabel = ({ type, color, onClick }: Props) => {
const copy =
type === DataHubViewType.Personal ? (
<>
<b>Private</b> - only visible to you.
</>
) : (
<>
<b>Public</b> - visible to everyone.
</>
);
const Icon = type === DataHubViewType.Global ? StyledGlobalOutlined : StyledLockOutlined;
const isPersonal = type === DataHubViewType.Personal;
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions
<div onClick={onClick}>
<Icon color={color} />
<StyledText color={color} type="secondary">
{copy}
</StyledText>
</div>
<Tooltip title={isPersonal ? 'Only visible to you' : 'Visible to everyone'}>
<ViewNameContainer onClick={onClick}>
{!isPersonal && <Icon source="phosphor" icon="Globe" size="md" />}
{isPersonal && <Icon source="phosphor" icon="Lock" size="md" />}
<StyledText color={color} type="secondary">
{!isPersonal ? 'Public' : 'Private'}
</StyledText>
</ViewNameContainer>
</Tooltip>
);
};

View File

@ -1,21 +1,15 @@
import { PlusOutlined } from '@ant-design/icons';
import { Button, Pagination, message } from 'antd';
import * as QueryString from 'query-string';
import React, { useEffect, useState } from 'react';
import { useLocation } from 'react-router';
import { SearchBar, Text } from '@components';
import { Pagination, message } from 'antd';
import React, { useState } from 'react';
import styled from 'styled-components';
import TabToolbar from '@app/entityV2/shared/components/styled/TabToolbar';
import { ViewsTable } from '@app/entityV2/view/ViewsTable';
import { ViewBuilder } from '@app/entityV2/view/builder/ViewBuilder';
import { ViewBuilderMode } from '@app/entityV2/view/builder/types';
import { DEFAULT_LIST_VIEWS_PAGE_SIZE, searchViews } from '@app/entityV2/view/utils';
import { SearchBar } from '@app/search/SearchBar';
import { Message } from '@app/shared/Message';
import { scrollToTop } from '@app/shared/searchUtils';
import { useEntityRegistry } from '@app/useEntityRegistry';
import { useListMyViewsQuery } from '@graphql/view.generated';
import { useListGlobalViewsQuery, useListMyViewsQuery } from '@graphql/view.generated';
import { DataHubViewType } from '@types';
const PaginationContainer = styled.div`
display: flex;
@ -26,67 +20,119 @@ const StyledPagination = styled(Pagination)`
margin: 40px;
`;
const searchBarStyle = {
maxWidth: 220,
padding: 0,
type Props = {
viewType?: DataHubViewType;
};
const searchBarInputStyle = {
height: 32,
fontSize: 12,
};
const StyledTabToolbar = styled.div`
display: flex;
justify-content: space-between;
padding: 1px 0 16px 0; // 1px at the top to prevent Select's border outline from cutting-off
height: auto;
z-index: unset;
box-shadow: none;
flex-shrink: 0;
`;
export const EmptyContainer = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
gap: 16px;
svg {
width: 160px;
height: 160px;
}
`;
const TableContainer = styled.div`
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
max-height: calc(100vh - 330px); /* Constrain to page height minus header/filters space */
overflow: auto;
/* Make table header sticky */
.ant-table-thead {
position: sticky;
top: 0;
z-index: 1;
background: white;
}
/* Ensure header cells have proper background */
.ant-table-thead > tr > th {
background: white !important;
border-bottom: 1px solid #f0f0f0;
}
`;
const SearchContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
`;
const ViewsContainer = styled.div`
display: flex;
flex-direction: column;
overflow: auto;
padding-top: 7px;
`;
const StyledSearchBar = styled(SearchBar)`
width: 300px;
`;
/**
* This component renders a paginated, searchable list of Views.
*/
export const ViewsList = () => {
/**
* Context
*/
const location = useLocation();
const entityRegistry = useEntityRegistry();
/**
* Query Params
*/
const params = QueryString.parse(location.search, { arrayFormat: 'comma' });
const paramsQuery = (params?.query as string) || undefined;
export const ViewsList = ({ viewType = DataHubViewType.Personal }: Props) => {
/**
* State
*/
const [page, setPage] = useState(1);
const [selectedViewUrn, setSelectedViewUrn] = useState<undefined | string>(undefined);
const [showViewBuilder, setShowViewBuilder] = useState<boolean>(false);
const [query, setQuery] = useState<undefined | string>(undefined);
useEffect(() => setQuery(paramsQuery), [paramsQuery]);
/**
* Queries
*/
const pageSize = DEFAULT_LIST_VIEWS_PAGE_SIZE;
const start = (page - 1) * pageSize;
const { loading, error, data } = useListMyViewsQuery({
const isPersonal = viewType === DataHubViewType.Personal;
const {
loading: loadingPersonal,
error: errorPersonal,
data: dataPersonal,
} = useListMyViewsQuery({
variables: {
start,
count: pageSize,
},
fetchPolicy: 'cache-first',
skip: !isPersonal,
});
const onClickCreateView = () => {
setShowViewBuilder(true);
};
const onClickEditView = (urn: string) => {
setShowViewBuilder(true);
setSelectedViewUrn(urn);
};
const onCloseModal = () => {
setShowViewBuilder(false);
setSelectedViewUrn(undefined);
};
const {
loading: loadingGlobal,
error: errorGlobal,
data: dataGlobal,
} = useListGlobalViewsQuery({
variables: {
start,
count: pageSize,
},
fetchPolicy: 'cache-first',
skip: isPersonal,
});
const onChangePage = (newPage: number) => {
scrollToTop();
@ -96,51 +142,48 @@ export const ViewsList = () => {
/**
* Render variables.
*/
const totalViews = data?.listMyViews?.total || 0;
const views = searchViews(data?.listMyViews?.views || [], query);
const selectedView = (selectedViewUrn && views.find((view) => view.urn === selectedViewUrn)) || undefined;
const viewsData = isPersonal ? dataPersonal?.listMyViews : dataGlobal?.listGlobalViews;
const loading = loadingPersonal || loadingGlobal;
const error = errorPersonal || errorGlobal;
const totalViews = viewsData?.total || 0;
const views = searchViews(viewsData?.views || [], query);
if (!totalViews) {
return (
<EmptyContainer>
<Text size="md" color="gray" weight="bold">
No Views yet!
</Text>
</EmptyContainer>
);
}
return (
<>
{!data && loading && <Message type="loading" content="Loading Views..." />}
{!viewsData && loading && <Message type="loading" content="Loading Views..." />}
{error && message.error({ content: `Failed to load Views! An unexpected error occurred.`, duration: 3 })}
<TabToolbar>
<Button type="text" onClick={onClickCreateView}>
<PlusOutlined /> Create new View
</Button>
<SearchBar
initialQuery=""
placeholderText="Search Views..."
suggestions={[]}
style={searchBarStyle}
inputStyle={searchBarInputStyle}
onSearch={() => null}
onQueryChange={(q) => setQuery(q.length > 0 ? q : undefined)}
entityRegistry={entityRegistry}
/>
</TabToolbar>
<ViewsTable views={views} onEditView={onClickEditView} />
{totalViews >= pageSize && (
<PaginationContainer>
<StyledPagination
current={page}
pageSize={pageSize}
total={totalViews}
showLessItems
onChange={onChangePage}
showSizeChanger={false}
/>
</PaginationContainer>
)}
{showViewBuilder && (
<ViewBuilder
mode={ViewBuilderMode.EDITOR}
urn={selectedViewUrn}
initialState={selectedView}
onSubmit={onCloseModal}
onCancel={onCloseModal}
/>
)}
<ViewsContainer>
<StyledTabToolbar>
<SearchContainer>
<StyledSearchBar placeholder="Search Views..." onChange={setQuery} value={query || ''} />
</SearchContainer>
</StyledTabToolbar>
<TableContainer>
<ViewsTable views={views} />
</TableContainer>
{totalViews >= pageSize && (
<PaginationContainer>
<StyledPagination
current={page}
pageSize={pageSize}
total={totalViews}
showLessItems
onChange={onChangePage}
showSizeChanger={false}
/>
</PaginationContainer>
)}
</ViewsContainer>
</>
);
};

View File

@ -1,7 +1,9 @@
import { Empty } from 'antd';
import { Table, Text } from '@components';
import React from 'react';
import { StyledTable } from '@app/entityV2/shared/components/styled/StyledTable';
import { AlignmentOptions } from '@components/theme/config';
import { EmptyContainer } from '@app/entityV2/view/ViewsList';
import {
ActionsColumn,
DescriptionColumn,
@ -13,7 +15,7 @@ import { DataHubView } from '@types';
type ViewsTableProps = {
views: DataHubView[];
onEditView: (urn) => void;
onEditView?: (urn) => void;
};
/**
@ -25,24 +27,28 @@ export const ViewsTable = ({ views, onEditView }: ViewsTableProps) => {
title: 'Name',
dataIndex: 'name',
key: 'name',
render: (name, record) => <NameColumn name={name} record={record} onEditView={onEditView} />,
width: '25%',
render: (record) => <NameColumn name={record.name} record={record} onEditView={onEditView} />,
},
{
title: 'Description',
dataIndex: 'description',
key: 'description',
render: (description) => <DescriptionColumn description={description} />,
render: (record) => <DescriptionColumn description={record.description} />,
},
{
title: 'Type',
dataIndex: 'viewType',
key: 'viewType',
render: (viewType) => <ViewTypeColumn viewType={viewType} />,
width: '10%',
render: (record) => <ViewTypeColumn viewType={record.viewType} />,
},
{
title: '',
dataIndex: '',
key: 'x',
width: '5%',
alignment: 'right' as AlignmentOptions,
render: (record) => <ActionsColumn record={record} />,
},
];
@ -50,19 +56,20 @@ export const ViewsTable = ({ views, onEditView }: ViewsTableProps) => {
/**
* The data for the Views List.
*/
const tableData = views.map((view) => ({
...view,
}));
const tableData =
views.map((view) => ({
...view,
})) || [];
return (
<StyledTable
columns={tableColumns}
dataSource={tableData}
rowKey="urn"
locale={{
emptyText: <Empty description="No Views found!" image={Empty.PRESENTED_IMAGE_SIMPLE} />,
}}
pagination={false}
/>
);
if (!views.length) {
return (
<EmptyContainer>
<Text size="md" color="gray" weight="bold">
No results!
</Text>
</EmptyContainer>
);
}
return <Table columns={tableColumns} data={tableData} isScrollable />;
};

View File

@ -1,4 +1,4 @@
import { Button, Typography } from 'antd';
import { Text } from '@components';
import React from 'react';
import styled from 'styled-components';
@ -15,13 +15,6 @@ const StyledDescription = styled.div`
max-width: 300px;
`;
const ActionButtonsContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
padding-right: 8px;
`;
const NameContainer = styled.span`
display: flex;
align-items: center;
@ -37,7 +30,7 @@ const IconPlaceholder = styled.span`
type NameColumnProps = {
name: string;
record: any;
onEditView: (urn) => void;
onEditView?: (urn) => void;
};
export function NameColumn({ name, record, onEditView }: NameColumnProps) {
@ -54,9 +47,9 @@ export function NameColumn({ name, record, onEditView }: NameColumnProps) {
{isUserDefault && <UserDefaultViewIcon title="Your default View." />}
{isGlobalDefault && <GlobalDefaultViewIcon title="Your organization's default View." />}
</IconPlaceholder>
<Button type="text" onClick={() => onEditView(record.urn)}>
<Typography.Text strong>{name}</Typography.Text>
</Button>
<Text size="md" weight="semiBold" onClick={() => onEditView?.(record.urn)}>
{name}
</Text>
</NameContainer>
);
}
@ -66,11 +59,7 @@ type DescriptionColumnProps = {
};
export function DescriptionColumn({ description }: DescriptionColumnProps) {
return (
<StyledDescription>
{description || <Typography.Text type="secondary">No description</Typography.Text>}
</StyledDescription>
);
return <StyledDescription>{description || '-'}</StyledDescription>;
}
type ViewTypeColumnProps = {
@ -86,9 +75,5 @@ type ActionColumnProps = {
};
export function ActionsColumn({ record }: ActionColumnProps) {
return (
<ActionButtonsContainer>
<ViewDropdownMenu view={record} visible />
</ActionButtonsContainer>
);
return <ViewDropdownMenu view={record} visible />;
}

View File

@ -121,7 +121,7 @@ export const SettingsPage = () => {
items: [
{
type: NavBarMenuItemTypes.Item,
title: 'My Views',
title: 'Views',
key: 'views',
link: `${url}/views`,
isHidden: !showViews,

View File

@ -1,7 +1,7 @@
import React from 'react';
import { ManageViews } from '@app/entity/view/ManageViews';
import { ManageOwnership } from '@app/entityV2/ownership/ManageOwnership';
import { ManageViews } from '@app/entityV2/view/ManageViews';
import { ManageIdentities } from '@app/identity/ManageIdentities';
import { ManagePermissions } from '@app/permissions/ManagePermissions';
import { ManagePolicies } from '@app/permissions/policy/ManagePolicies';

View File

@ -9,10 +9,8 @@ describe("manage views", () => {
cy.goToViewsSettings();
cy.waitTextVisible("Settings");
cy.wait(1000);
cy.clickOptionWithText("Create new View");
cy.get(".ant-input-affix-wrapper > input[type='text']")
.first()
.type(viewName);
cy.clickOptionWithText("Create View");
cy.get('[data-testid="view-name-input"]').click().type(viewName);
cy.clickOptionWithTestId("view-builder-save");
// Confirm that the test has been created.
@ -21,8 +19,8 @@ describe("manage views", () => {
// Now edit the View
cy.clickFirstOptionWithTestId("views-table-dropdown");
cy.get('[data-testid="view-dropdown-edit"]').click({ force: true });
cy.get(".ant-input-affix-wrapper > input[type='text']")
.first()
cy.get('[data-testid="view-name-input"]')
.click()
.clear()
.type("New View Name");
cy.clickOptionWithTestId("view-builder-save");

View File

@ -125,7 +125,7 @@ Cypress.Commands.add("goToDomainList", () => {
Cypress.Commands.add("goToViewsSettings", () => {
cy.visit("/settings/views");
cy.waitTextVisible("Manage Views");
cy.waitTextVisible("Views");
});
Cypress.Commands.add("goToOwnershipTypesSettings", () => {