[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:
co-authored by
Félix Malfait
Félix Malfait
parent
ddfc68378c
commit
73f12d3504
+30
-41
@@ -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} />
|
||||
|
||||
+8
@@ -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',
|
||||
});
|
||||
+8
@@ -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',
|
||||
});
|
||||
+6
@@ -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,
|
||||
]);
|
||||
};
|
||||
Reference in New Issue
Block a user