[admin-panel] - add scroll restoration and filter persistence on config variables (#13363)

addressing
https://github.com/twentyhq/twenty/issues/13323#issuecomment-3101630375

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
nitin
2025-08-01 09:48:36 +02:00
committed by GitHub
co-authored by Félix Malfait Félix Malfait
parent ddfc68378c
commit 73f12d3504
6 changed files with 154 additions and 47 deletions
@@ -3,11 +3,13 @@ import { ConfigVariableFilterContainer } from '@/settings/admin-panel/config-var
import { ConfigVariableFilterDropdown } from '@/settings/admin-panel/config-variables/components/ConfigVariableFilterDropdown';
import { SettingsAdminConfigVariablesTable } from '@/settings/admin-panel/config-variables/components/SettingsAdminConfigVariablesTable';
import { ConfigVariableSourceOptions } from '@/settings/admin-panel/config-variables/constants/ConfigVariableSourceOptions';
import { ConfigVariableGroupFilter } from '@/settings/admin-panel/config-variables/types/ConfigVariableGroupFilter';
import { ConfigVariableSourceFilter } from '@/settings/admin-panel/config-variables/types/ConfigVariableSourceFilter';
import { configVariableGroupFilterState } from '@/settings/admin-panel/config-variables/states/configVariableGroupFilterState';
import { configVariableSourceFilterState } from '@/settings/admin-panel/config-variables/states/configVariableSourceFilterState';
import { showHiddenGroupVariablesState } from '@/settings/admin-panel/config-variables/states/showHiddenGroupVariablesState';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useMemo, useState } from 'react';
import { useRecoilState } from 'recoil';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import {
@@ -34,19 +36,17 @@ export const SettingsAdminConfigVariables = () => {
const [search, setSearch] = useState('');
const [showHiddenGroupVariables, setShowHiddenGroupVariables] =
useState(false);
const [sourceFilter, setSourceFilter] =
useState<ConfigVariableSourceFilter>('all');
const [groupFilter, setGroupFilter] =
useState<ConfigVariableGroupFilter>('all');
useRecoilState(showHiddenGroupVariablesState);
const [configVariableSourceFilter, setConfigVariableSourceFilter] =
useRecoilState(configVariableSourceFilterState);
const [configVariableGroupFilter, setConfigVariableGroupFilter] =
useRecoilState(configVariableGroupFilterState);
// Get all groups, not filtered by visibility
const allGroups = useMemo(
() => configVariables?.getConfigVariablesGrouped.groups ?? [],
[configVariables],
);
// Compute group options from all groups, not just visible ones
const groupOptions = useMemo(
() => [
{ value: 'all', label: 'All Groups' },
@@ -58,7 +58,6 @@ export const SettingsAdminConfigVariables = () => {
[allGroups],
);
// Flatten all variables for filtering, attaching isHiddenOnLoad and groupName from group
const allVariables = useMemo(
() =>
configVariables?.getConfigVariablesGrouped.groups.flatMap((group) =>
@@ -71,31 +70,23 @@ export const SettingsAdminConfigVariables = () => {
[configVariables],
);
// Filtering logic
const filteredVariables = useMemo(() => {
const isSearching = search.trim().length > 0;
const hasSelectedSpecificGroup = groupFilter !== 'all';
const hasSelectedSpecificGroup = configVariableGroupFilter !== 'all';
return allVariables.filter((v) => {
// Search filter
const matchesSearch =
v.name.toLowerCase().includes(search.toLowerCase()) ||
(v.description?.toLowerCase() || '').includes(search.toLowerCase());
if (isSearching && !matchesSearch) return false;
// Group filter
const matchesGroup = hasSelectedSpecificGroup
? v.groupName === groupFilter
? v.groupName === configVariableGroupFilter
: true;
if (hasSelectedSpecificGroup && !matchesGroup) return false;
// Hidden filter - Only apply if:
// 1. User is not searching
// 2. Show hidden is off
// 3. Item is from a hidden group
// 4. No specific group is selected (if a specific group is selected, show all its variables)
if (
!isSearching &&
!showHiddenGroupVariables &&
@@ -105,13 +96,12 @@ export const SettingsAdminConfigVariables = () => {
return false;
}
// Source filter
let matchesSource = true;
if (sourceFilter === 'database')
if (configVariableSourceFilter === 'database')
matchesSource = v.source === ConfigSource.DATABASE;
if (sourceFilter === 'environment')
if (configVariableSourceFilter === 'environment')
matchesSource = v.source === ConfigSource.ENVIRONMENT;
if (sourceFilter === 'default')
if (configVariableSourceFilter === 'default')
matchesSource = v.source === ConfigSource.DEFAULT;
return matchesSource;
@@ -120,30 +110,31 @@ export const SettingsAdminConfigVariables = () => {
allVariables,
search,
showHiddenGroupVariables,
sourceFilter,
groupFilter,
configVariableSourceFilter,
configVariableGroupFilter,
]);
// Build activeChips for current filters
const activeChips = [];
if (sourceFilter !== 'all') {
if (configVariableSourceFilter !== 'all') {
activeChips.push({
label:
ConfigVariableSourceOptions.find((o) => o.value === sourceFilter)
?.label || '',
onRemove: () => setSourceFilter('all'),
ConfigVariableSourceOptions.find(
(o) => o.value === configVariableSourceFilter,
)?.label || '',
onRemove: () => setConfigVariableSourceFilter('all'),
variant: 'default' as const,
});
}
if (groupFilter !== 'all') {
if (configVariableGroupFilter !== 'all') {
activeChips.push({
label: groupOptions.find((o) => o.value === groupFilter)?.label || '',
onRemove: () => setGroupFilter('all'),
label:
groupOptions.find((o) => o.value === configVariableGroupFilter)
?.label || '',
onRemove: () => setConfigVariableGroupFilter('all'),
variant: 'danger' as const,
});
}
// Group variables by groupName for rendering
const groupedVariables = useMemo(() => {
const groupMap = new Map();
filteredVariables.forEach((v) => {
@@ -172,18 +163,17 @@ export const SettingsAdminConfigVariables = () => {
<StyledControlsContainer>
<ConfigVariableSearchInput value={search} onChange={setSearch} />
<ConfigVariableFilterDropdown
sourceFilter={sourceFilter}
groupFilter={groupFilter}
sourceFilter={configVariableSourceFilter}
groupFilter={configVariableGroupFilter}
groupOptions={groupOptions}
showHiddenGroupVariables={showHiddenGroupVariables}
onSourceFilterChange={setSourceFilter}
onGroupFilterChange={setGroupFilter}
onSourceFilterChange={setConfigVariableSourceFilter}
onGroupFilterChange={setConfigVariableGroupFilter}
onShowHiddenChange={setShowHiddenGroupVariables}
/>
</StyledControlsContainer>
</ConfigVariableFilterContainer>
</Section>
{groupedVariables.size === 0 && (
<StyledTableContainer>
<Section>
@@ -194,7 +184,6 @@ export const SettingsAdminConfigVariables = () => {
</Section>
</StyledTableContainer>
)}
{[...groupedVariables.entries()].map(([groupName, groupData]) => (
<StyledTableContainer key={groupName}>
<H2Title title={groupName} description={groupData.description} />
@@ -0,0 +1,8 @@
import { ConfigVariableGroupFilter } from '@/settings/admin-panel/config-variables/types/ConfigVariableGroupFilter';
import { createState } from 'twenty-ui/utilities';
export const configVariableGroupFilterState =
createState<ConfigVariableGroupFilter>({
key: 'configVariableGroupFilterState',
defaultValue: 'all',
});
@@ -0,0 +1,8 @@
import { ConfigVariableSourceFilter } from '@/settings/admin-panel/config-variables/types/ConfigVariableSourceFilter';
import { createState } from 'twenty-ui/utilities';
export const configVariableSourceFilterState =
createState<ConfigVariableSourceFilter>({
key: 'configVariableSourceFilterState',
defaultValue: 'all',
});
@@ -0,0 +1,6 @@
import { createState } from 'twenty-ui/utilities';
export const showHiddenGroupVariablesState = createState<boolean>({
key: 'showHiddenGroupVariablesState',
defaultValue: false,
});
@@ -1,9 +1,13 @@
import { OBJECT_SETTINGS_WIDTH } from '@/settings/data-model/constants/ObjectSettings';
import { SettingsPath } from '@/types/SettingsPath';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useScrollRestoration } from '@/ui/utilities/scroll/hooks/useScrollRestoration';
import styled from '@emotion/styled';
import { ReactNode } from 'react';
import { ReactNode, useMemo } from 'react';
import { matchPath, useLocation } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledSettingsPageContainer = styled.div<{
width?: number;
@@ -29,8 +33,27 @@ export const SettingsPageContainer = ({
children,
}: {
children: ReactNode;
}) => (
<ScrollWrapper componentInstanceId={'scroll-wrapper-settings-page-container'}>
<StyledSettingsPageContainer>{children}</StyledSettingsPageContainer>
</ScrollWrapper>
);
}) => {
const location = useLocation();
const settingsPath = useMemo(() => {
const sortedPaths = Object.values(SettingsPath).sort(
(a, b) => b.length - a.length,
);
return sortedPaths.find((path) => {
const settingsPath = getSettingsPath(path);
const match = matchPath(settingsPath, location.pathname);
return isDefined(match);
});
}, [location.pathname]);
const componentInstanceId = `scroll-wrapper-settings-page-container-${settingsPath}`;
useScrollRestoration(componentInstanceId);
return (
<ScrollWrapper componentInstanceId={componentInstanceId}>
<StyledSettingsPageContainer>{children}</StyledSettingsPageContainer>
</ScrollWrapper>
);
};
@@ -0,0 +1,73 @@
import { scrollWrapperScrollTopComponentState } from '@/ui/utilities/scroll/states/scrollWrapperScrollTopComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useCallback, useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
export const useScrollRestoration = (componentInstanceId: string) => {
const location = useLocation();
const storageKey = `scroll-${location.pathname}`;
const [isRestoring, setIsRestoring] = useState(false);
const scrollTop = useRecoilComponentValueV2(
scrollWrapperScrollTopComponentState,
componentInstanceId,
);
const restoreScrollPosition = useCallback(
(position: number, elementId: string) => {
const attemptRestore = () => {
const element = document.getElementById(elementId);
if (!isDefined(element)) {
requestAnimationFrame(attemptRestore);
return;
}
const isScrollable = element.scrollHeight > element.clientHeight;
if (!isScrollable) {
requestAnimationFrame(attemptRestore);
return;
}
element.scrollTo({ top: position });
requestAnimationFrame(() => {
setIsRestoring(false);
});
};
requestAnimationFrame(attemptRestore);
},
[],
);
useEffect(() => {
if (scrollTop > 0 && !isRestoring) {
sessionStorage.setItem(storageKey, scrollTop.toString());
}
}, [scrollTop, storageKey, isRestoring]);
useEffect(() => {
const savedPosition = sessionStorage.getItem(storageKey);
const expectedElementId = `scroll-wrapper-${componentInstanceId}`;
if (!isDefined(savedPosition)) {
return;
}
const position = parseInt(savedPosition, 10);
if (position <= 0) {
return;
}
setIsRestoring(true);
restoreScrollPosition(position, expectedElementId);
}, [
location.pathname,
storageKey,
componentInstanceId,
restoreScrollPosition,
]);
};