feat(ui): store user preferences to localStorage (#21128)

* feat(ui): store user preferences to localStorage

* fix domain spec

* fix sidebar causing unwanted failures

* fix playwright
adjust expandable cards

* fix empty condition for cards

* update condition

(cherry picked from commit 3490a06ca2569bbf01f8c65948a21b7a0e59c0c8)
This commit is contained in:
Chirag Madlani 2025-05-13 17:05:51 +05:30
parent bb8673c0a7
commit 064d91b7eb
22 changed files with 170 additions and 39 deletions

View File

@ -513,6 +513,10 @@ test.describe('Domains', () => {
await domain.create(apiContext);
await page.reload();
await sidebarClick(page, SidebarItem.DOMAIN);
await page.waitForLoadState('networkidle');
await page.waitForSelector(`[data-testid="loader"]`, {
state: 'hidden',
});
await selectDomain(page, domain.data);
await addTagsAndGlossaryToDomain(page, {
@ -522,6 +526,10 @@ test.describe('Domains', () => {
await redirectToHomePage(page);
await sidebarClick(page, SidebarItem.DOMAIN);
await page.waitForLoadState('networkidle');
await page.waitForSelector(`[data-testid="loader"]`, {
state: 'hidden',
});
await selectDomain(page, domain.data);
await page.waitForLoadState('networkidle');

View File

@ -625,7 +625,13 @@ export const addTagsAndGlossaryToDomain = async (
const input = page.locator(`${container} #tagsForm_tags`);
await input.click();
await input.fill(value);
await page.getByTestId(`tag-${value}`).click();
const tag = page.getByTestId(`tag-${value}`);
if (containerType === 'glossary') {
// To avoid clicking on white space between checkbox and text
await tag.locator('.ant-select-tree-checkbox').click();
} else {
await tag.click();
}
// Save and wait for response
const updateResponse = page.waitForResponse(

View File

@ -45,5 +45,9 @@ export const loginAsAdmin = async (page: Page, admin: AdminClass) => {
await admin.logout(page);
await page.waitForURL('**/signin');
await admin.login(page);
// Close the leftside bar to run tests smoothly
await page.getByTestId('sidebar-toggle').click();
await page.waitForURL('**/my-data');
};

View File

@ -13,7 +13,7 @@
*/
import { Layout } from 'antd';
import classNames from 'classnames';
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect } from 'react';
import { useLimitStore } from '../../context/LimitsProvider/useLimitsStore';
import { LineageSettings } from '../../generated/configuration/lineageSettings';
import { SettingType } from '../../generated/settings/settings';
@ -36,7 +36,6 @@ const AppContainer = () => {
const AuthenticatedRouter = applicationRoutesClass.getRouteElements();
const ApplicationExtras = applicationsClassBase.getApplicationExtension();
const { isAuthenticated } = useApplicationStore();
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState<boolean>(true);
const { setConfig, bannerDetails } = useLimitStore();
@ -71,16 +70,13 @@ const AppContainer = () => {
['extra-banner']: Boolean(bannerDetails),
})}>
{/* Render left side navigation */}
<LeftSidebar isSidebarCollapsed={isSidebarCollapsed} />
<LeftSidebar />
{/* Render main content */}
<Layout>
{/* Render Appbar */}
{isProtectedRoute(location.pathname) && isAuthenticated ? (
<NavBar
isSidebarCollapsed={isSidebarCollapsed}
toggleSideBar={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
/>
<NavBar />
) : null}
{/* Render main content */}

View File

@ -182,7 +182,8 @@ export const DomainLabelV2 = <
{selectableList}
</div>
),
}}>
}}
isExpandDisabled={!Array.isArray(domainLink)}>
<div className="d-flex items-center gap-1 flex-wrap">
{domainLink}
</div>

View File

@ -12,6 +12,7 @@
*/
import { Typography } from 'antd';
import { t } from 'i18next';
import { isEmpty } from 'lodash';
import React, { useMemo } from 'react';
import { ReactComponent as PlusIcon } from '../../../assets/svg/plus-primary.svg';
import { TabSpecificField } from '../../../enums/entity.enum';
@ -76,7 +77,8 @@ export const OwnerLabelV2 = <
cardProps={{
title: header,
}}
dataTestId={dataTestId}>
dataTestId={dataTestId}
isExpandDisabled={isEmpty(data.owners)}>
{getOwnerVersionLabel(
data,
isVersionView ?? false,

View File

@ -100,7 +100,8 @@ export const ReviewerLabelV2 = <
cardProps={{
title: header,
}}
dataTestId="glossary-reviewer">
dataTestId="glossary-reviewer"
isExpandDisabled={!hasReviewers}>
<div data-testid="glossary-reviewer-name">
{getOwnerVersionLabel(
data,

View File

@ -13,7 +13,7 @@
import { Typography } from 'antd';
import classNames from 'classnames';
import { t } from 'i18next';
import { cloneDeep, includes, isEqual } from 'lodash';
import { cloneDeep, includes, isEmpty, isEqual } from 'lodash';
import { default as React, useMemo } from 'react';
import { ReactComponent as PlusIcon } from '../../../assets/svg/plus-primary.svg';
import { TabSpecificField } from '../../../enums/entity.enum';
@ -100,7 +100,7 @@ export const DomainExpertWidget = () => {
</div>
<div>
{editOwnerPermission && domain.experts && domain.experts.length === 0 && (
{editOwnerPermission && domain.experts?.length === 0 && (
<UserSelectableList
hasPermission={editOwnerPermission}
popoverProps={{ placement: 'topLeft' }}
@ -123,7 +123,8 @@ export const DomainExpertWidget = () => {
cardProps={{
title: header,
}}
dataTestId="domain-expert-name">
dataTestId="domain-expert-name"
isExpandDisabled={isEmpty(domain.experts)}>
{content}
</ExpandableCard>
);

View File

@ -147,7 +147,8 @@ const GlossaryTermReferences = () => {
cardProps={{
title: header,
}}
dataTestId="references-container">
dataTestId="references-container"
isExpandDisabled={isEmpty(references)}>
{isVersionView ? (
getVersionReferenceElements()
) : (

View File

@ -193,7 +193,8 @@ const GlossaryTermSynonyms = () => {
cardProps={{
title: header,
}}
dataTestId="synonyms-container">
dataTestId="synonyms-container"
isExpandDisabled={isEmpty(synonyms)}>
{isViewMode ? (
getSynonymsContainer()
) : (

View File

@ -250,7 +250,8 @@ const RelatedTerms = () => {
cardProps={{
title: header,
}}
dataTestId="related-term-container">
dataTestId="related-term-container"
isExpandDisabled={selectedOption.length === 0}>
{isIconVisible ? (
relatedTermsContainer
) : (

View File

@ -219,7 +219,8 @@ const RelatedMetrics: FC<RelatedMetricsProps> = ({
<ExpandableCard
cardProps={{
title: header,
}}>
}}
isExpandDisabled={isEmpty(relatedMetrics)}>
<Row gutter={[0, 8]}>{content}</Row>
</ExpandableCard>
);

View File

@ -24,6 +24,7 @@ import {
SIDEBAR_NESTED_KEYS,
} from '../../../constants/LeftSidebar.constants';
import { SidebarItem } from '../../../enums/sidebar.enum';
import { useCurrentUserPreferences } from '../../../hooks/currentUserStore/useCurrentUserStore';
import { useApplicationStore } from '../../../hooks/useApplicationStore';
import useCustomLocation from '../../../hooks/useCustomLocation/useCustomLocation';
import { useCustomPages } from '../../../hooks/useCustomPages';
@ -32,18 +33,16 @@ import BrandImage from '../../common/BrandImage/BrandImage';
import './left-sidebar.less';
import { LeftSidebarItem as LeftSidebarItemType } from './LeftSidebar.interface';
import LeftSidebarItem from './LeftSidebarItem.component';
const { Sider } = Layout;
const LeftSidebar = ({
isSidebarCollapsed,
}: {
isSidebarCollapsed: boolean;
}) => {
const LeftSidebar = () => {
const location = useCustomLocation();
const { t } = useTranslation();
const { onLogoutHandler } = useApplicationStore();
const [showConfirmLogoutModal, setShowConfirmLogoutModal] = useState(false);
const {
preferences: { isSidebarCollapsed },
} = useCurrentUserPreferences();
const { i18n } = useTranslation();
const isDirectionRTL = useMemo(() => i18n.dir() === 'rtl', [i18n]);

View File

@ -19,7 +19,7 @@ describe('LeftSidebar', () => {
it('renders sidebar links correctly', () => {
render(
<BrowserRouter>
<LeftSidebar isSidebarCollapsed={false} />
<LeftSidebar />
</BrowserRouter>
);

View File

@ -57,6 +57,7 @@ import { useWebSocketConnector } from '../../context/WebSocketProvider/WebSocket
import { EntityTabs, EntityType } from '../../enums/entity.enum';
import { EntityReference } from '../../generated/entity/type';
import { BackgroundJob, JobType } from '../../generated/jobs/backgroundJob';
import { useCurrentUserPreferences } from '../../hooks/currentUserStore/useCurrentUserStore';
import useCustomLocation from '../../hooks/useCustomLocation/useCustomLocation';
import { useDomainStore } from '../../hooks/useDomainStore';
import { getVersion } from '../../rest/miscAPI';
@ -96,13 +97,7 @@ import popupAlertsCardsClassBase from './PopupAlertClassBase';
const cookieStorage = new CookieStorage();
const NavBar = ({
isSidebarCollapsed = true,
toggleSideBar,
}: {
isSidebarCollapsed?: boolean;
toggleSideBar?: () => void;
}) => {
const NavBar = () => {
const { isTourOpen: isTourRoute } = useTourProvider();
const { onUpdateCSVExportJob } = useEntityExportModalProvider();
const { handleDeleteEntityWebsocketResponse } = useAsyncDeleteProvider();
@ -123,6 +118,10 @@ const NavBar = ({
const [isFeatureModalOpen, setIsFeatureModalOpen] = useState<boolean>(false);
const [version, setVersion] = useState<string>();
const [isDomainDropdownOpen, setIsDomainDropdownOpen] = useState(false);
const {
preferences: { isSidebarCollapsed },
setPreference,
} = useCurrentUserPreferences();
const fetchOMVersion = async () => {
try {
@ -423,7 +422,9 @@ const NavBar = ({
}
size="middle"
type="text"
onClick={toggleSideBar}
onClick={() =>
setPreference({ isSidebarCollapsed: !isSidebarCollapsed })
}
/>
</Tooltip>
<GlobalSearchBar />

View File

@ -427,7 +427,8 @@ const TagsContainerV2 = ({
cardProps={{
title: header,
}}
dataTestId={isGlossaryType ? 'glossary-container' : 'tags-container'}>
dataTestId={isGlossaryType ? 'glossary-container' : 'tags-container'}
isExpandDisabled={isEmpty(tags?.[tagType])}>
{suggestionDataRender ?? (
<>
{tagBody}

View File

@ -273,7 +273,7 @@ h2.rotated-header {
display: flex;
flex-direction: column;
.ant-card-head {
& > .ant-card-head {
padding: 0px 16px;
border-bottom: none;
}

View File

@ -0,0 +1,99 @@
/*
* Copyright 2024 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import { useApplicationStore } from '../useApplicationStore';
interface UserPreferences {
isSidebarCollapsed: boolean;
}
interface Store {
preferences: Record<string, UserPreferences>;
setUserPreference: (
userName: string,
preferences: Partial<UserPreferences>
) => void;
getUserPreference: (userName: string) => UserPreferences;
clearUserPreference: (userName: string) => void;
}
const defaultPreferences: UserPreferences = {
isSidebarCollapsed: false,
// Add default values for other preferences
};
export const usePersistentStorage = create<Store>()(
persist(
(set, get) => ({
preferences: {},
setUserPreference: (
userName: string,
newPreferences: Partial<UserPreferences>
) => {
set((state) => ({
preferences: {
...state.preferences,
[userName]: {
...defaultPreferences,
...state.preferences[userName],
...newPreferences,
},
},
}));
},
getUserPreference: (userName: string) => {
const state = get();
return state.preferences[userName] || defaultPreferences;
},
clearUserPreference: (userName: string) => {
set((state) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [userName]: _, ...rest } = state.preferences;
return { preferences: rest };
});
},
}),
{
name: 'user-preferences-store',
storage: createJSONStorage(() => localStorage),
}
)
);
// Hook to easily access current user's preferences
export const useCurrentUserPreferences = () => {
const currentUser = useApplicationStore((state) => state.currentUser);
const { preferences, setUserPreference } = usePersistentStorage();
if (!currentUser?.name) {
return {
preferences: defaultPreferences,
setPreference: () => {
// update the user name in the local storage
},
};
}
return {
preferences: preferences[currentUser.name] || defaultPreferences,
setPreference: (newPreferences: Partial<UserPreferences>) =>
setUserPreference(currentUser.name, newPreferences),
};
};

View File

@ -69,7 +69,8 @@ export const FrequentlyJoinedTables = () => {
cardProps={{
title: t('label.frequently-joined-table-plural'),
}}
dataTestId="frequently-joint-data-container">
dataTestId="frequently-joint-data-container"
isExpandDisabled={isEmpty(joinedTables)}>
{content}
</ExpandableCard>
);

View File

@ -84,7 +84,8 @@ export const PartitionedKeys = () => {
<ExpandableCard
cardProps={{
title: t('label.table-partition-plural'),
}}>
}}
isExpandDisabled={isEmpty(partitionColumnDetails)}>
{content}
</ExpandableCard>
);

View File

@ -178,7 +178,8 @@ const TableConstraints = () => {
<ExpandableCard
cardProps={{
title: header,
}}>
}}
isExpandDisabled={isEmpty(data?.tableConstraints)}>
{content}
</ExpandableCard>
);

View File

@ -77,7 +77,6 @@
.ant-card.new-header-border-card {
height: auto !important;
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
.ant-card-head {
background: @grey-50;
border-radius: @border-radius-sm;
@ -89,6 +88,8 @@
.ant-card-body {
height: auto !important;
max-height: none !important;
transition: all 200ms ease;
transition-property: height, left, top;
}
.expand-collapse-icon {
@ -120,5 +121,9 @@
}
}
.ant-card-extra {
margin-left: @size-sm;
}
border: 1px solid @grey-15;
}