Fix optimistic rendering issues on views (#2851)

* Fix optimistic rendering issues on views

* Remove virtualizer
This commit is contained in:
Charles Bochet
2023-12-06 16:55:09 +01:00
committed by GitHub
parent 93decaceab
commit 076a67b0e2
12 changed files with 229 additions and 318 deletions

View File

@@ -2,7 +2,7 @@ import { useCallback, useMemo } from 'react';
import { useQuery } from '@apollo/client';
import { isNonEmptyArray } from '@apollo/client/utilities';
import { isNonEmptyString } from '@sniptt/guards';
import { useRecoilState, useRecoilValue } from 'recoil';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
@@ -55,7 +55,7 @@ export const useFindManyRecords = <
hasNextPageFamilyState(findManyQueryStateIdentifier),
);
const [, setIsFetchingMoreObjects] = useRecoilState(
const setIsFetchingMoreObjects = useSetRecoilState(
isFetchingMoreRecordsFamilyState(findManyQueryStateIdentifier),
);
@@ -142,6 +142,18 @@ export const useFindManyRecords = <
...fetchMoreResult?.[objectMetadataItem.namePlural]?.edges,
]);
}
if (data?.[objectMetadataItem.namePlural]) {
setLastCursor(
fetchMoreResult?.[objectMetadataItem.namePlural]?.pageInfo
.endCursor,
);
setHasNextPage(
fetchMoreResult?.[objectMetadataItem.namePlural]?.pageInfo
.hasNextPage,
);
}
onCompleted?.({
__typename: `${capitalize(
objectMetadataItem.nameSingular,
@@ -151,15 +163,6 @@ export const useFindManyRecords = <
fetchMoreResult?.[objectMetadataItem.namePlural].pageInfo,
});
if (data?.[objectMetadataItem.namePlural]) {
setLastCursor(
data?.[objectMetadataItem.namePlural]?.pageInfo.endCursor,
);
setHasNextPage(
data?.[objectMetadataItem.namePlural]?.pageInfo.hasNextPage,
);
}
return Object.assign({}, prev, {
[objectMetadataItem.namePlural]: {
__typename: `${capitalize(
@@ -218,5 +221,6 @@ export const useFindManyRecords = <
loading,
error,
fetchMoreRecords,
queryStateIdentifier: findManyQueryStateIdentifier,
};
};

View File

@@ -1,4 +1,4 @@
import { useRecoilValue } from 'recoil';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural';
@@ -21,11 +21,12 @@ export const useObjectRecordTable = () => {
objectNameSingular,
},
);
const { tableFiltersState, tableSortsState } = useRecordTableScopedStates();
const { tableFiltersState, tableSortsState, tableLastRowVisibleState } =
useRecordTableScopedStates();
const tableFilters = useRecoilValue(tableFiltersState);
const tableSorts = useRecoilValue(tableSortsState);
const setLastRowVisible = useSetRecoilState(tableLastRowVisibleState);
const filter = turnFiltersIntoWhereClause(
tableFilters,
@@ -36,16 +37,21 @@ export const useObjectRecordTable = () => {
foundObjectMetadataItem?.fields ?? [],
);
const { records, loading, fetchMoreRecords } = useFindManyRecords({
objectNameSingular,
filter,
orderBy,
});
const { records, loading, fetchMoreRecords, queryStateIdentifier } =
useFindManyRecords({
objectNameSingular,
filter,
orderBy,
onCompleted: () => {
setLastRowVisible(false);
},
});
return {
records,
loading,
fetchMoreRecords,
queryStateIdentifier,
setRecordTableData,
};
};

View File

@@ -7,21 +7,16 @@ import { capitalize } from '~/utils/string/capitalize';
export const useUpdateOneRecord = <T>({
objectNameSingular,
}: ObjectMetadataItemIdentifier) => {
const {
objectMetadataItem,
updateOneRecordMutation,
getRecordFromCache,
findManyRecordsQuery,
} = useObjectMetadataItem({
objectNameSingular,
});
const { objectMetadataItem, updateOneRecordMutation, getRecordFromCache } =
useObjectMetadataItem({
objectNameSingular,
});
const apolloClient = useApolloClient();
const updateOneRecord = async ({
idToUpdate,
input,
forceRefetch,
}: {
idToUpdate: string;
input: Record<string, any>;

View File

@@ -1,92 +1,33 @@
import { useContext } from 'react';
import { useInView } from 'react-intersection-observer';
import { useRecoilCallback, useRecoilState, useRecoilValue } from 'recoil';
import { useRecoilValue } from 'recoil';
import { isFetchingMoreRecordsFamilyState } from '@/object-record/states/isFetchingMoreRecordsFamilyState';
import {
RecordTableRow,
StyledRow,
} from '@/ui/object/record-table/components/RecordTableRow';
import { RecordTableBodyFetchMoreLoader } from '@/ui/object/record-table/components/RecordTableBodyFetchMoreLoader';
import { RecordTableRow } from '@/ui/object/record-table/components/RecordTableRow';
import { RowIdContext } from '@/ui/object/record-table/contexts/RowIdContext';
import { RowIndexContext } from '@/ui/object/record-table/contexts/RowIndexContext';
import { useRecordTable } from '@/ui/object/record-table/hooks/useRecordTable';
import { isFetchingRecordTableDataState } from '@/ui/object/record-table/states/isFetchingRecordTableDataState';
import { tableRowIdsState } from '@/ui/object/record-table/states/tableRowIdsState';
import { getRecordTableScopedStates } from '@/ui/object/record-table/utils/getRecordTableScopedStates';
import { ScrollWrapperContext } from '@/ui/utilities/scroll/components/ScrollWrapper';
import RenderIfVisible from '@/ui/utilities/virtualizer/RenderIfVisible';
export const RecordTableBody = () => {
const { scopeId } = useRecordTable();
const onLastRowVisible = useRecoilCallback(
({ set }) =>
async (inView: boolean) => {
const { tableLastRowVisibleState } = getRecordTableScopedStates({
recordTableScopeId: scopeId,
});
set(tableLastRowVisibleState, inView);
},
[scopeId],
);
const { ref: lastTableRowRef } = useInView({
onChange: onLastRowVisible,
});
const tableRowIds = useRecoilValue(tableRowIdsState);
const [isFetchingMoreObjects] = useRecoilState(
isFetchingMoreRecordsFamilyState(scopeId),
);
const isFetchingRecordTableData = useRecoilValue(
isFetchingRecordTableDataState,
);
const lastRowId = tableRowIds[tableRowIds.length - 1];
const scrollWrapperRef = useContext(ScrollWrapperContext);
if (isFetchingRecordTableData) {
return <></>;
}
return (
<>
{tableRowIds.map((rowId, rowIndex) => (
{tableRowIds.slice().map((rowId, rowIndex) => (
<RowIdContext.Provider value={rowId} key={rowId}>
<RowIndexContext.Provider value={rowIndex}>
<RenderIfVisible
rootElement="tbody"
placeholderElement="tr"
defaultHeight={32}
initialVisible={rowIndex < 30}
root={scrollWrapperRef.current}
>
<RecordTableRow
key={rowId}
ref={
rowId === lastRowId && rowIndex > 30
? lastTableRowRef
: undefined
}
rowId={rowId}
/>
</RenderIfVisible>
<RecordTableRow key={rowId} rowId={rowId} />
</RowIndexContext.Provider>
</RowIdContext.Provider>
))}
<tbody>
{isFetchingMoreObjects && (
<StyledRow selected={false}>
<td style={{ height: 50 }} colSpan={1000}>
Loading more...
</td>
</StyledRow>
)}
</tbody>
<RecordTableBodyFetchMoreLoader />
</>
);
};

View File

@@ -1,28 +1,40 @@
import { useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { useRecoilState, useRecoilValue } from 'recoil';
import { useObjectRecordTable } from '@/object-record/hooks/useObjectRecordTable';
import { isFetchingMoreRecordsFamilyState } from '@/object-record/states/isFetchingMoreRecordsFamilyState';
import { useRecordTableScopedStates } from '@/ui/object/record-table/hooks/internal/useRecordTableScopedStates';
import { isDefined } from '~/utils/isDefined';
export const RecordTableBodyEffect = () => {
const {
fetchMoreRecords: fetchMoreObjects,
records,
setRecordTableData,
queryStateIdentifier,
} = useObjectRecordTable();
const { tableLastRowVisibleState } = useRecordTableScopedStates();
const tableLastRowVisible = useRecoilValue(tableLastRowVisibleState);
const [tableLastRowVisible, setTableLastRowVisible] = useRecoilState(
tableLastRowVisibleState,
);
const isFetchingMoreObjects = useRecoilValue(
isFetchingMoreRecordsFamilyState(queryStateIdentifier),
);
useEffect(() => {
setRecordTableData(records);
}, [records, setRecordTableData]);
useEffect(() => {
if (tableLastRowVisible && isDefined(fetchMoreObjects)) {
if (tableLastRowVisible && !isFetchingMoreObjects) {
fetchMoreObjects();
}
}, [fetchMoreObjects, tableLastRowVisible]);
}, [
fetchMoreObjects,
isFetchingMoreObjects,
setTableLastRowVisible,
tableLastRowVisible,
]);
return <></>;
};

View File

@@ -0,0 +1,53 @@
import { useInView } from 'react-intersection-observer';
import { useRecoilCallback, useRecoilValue } from 'recoil';
import { useObjectRecordTable } from '@/object-record/hooks/useObjectRecordTable';
import { isFetchingMoreRecordsFamilyState } from '@/object-record/states/isFetchingMoreRecordsFamilyState';
import { StyledRow } from '@/ui/object/record-table/components/RecordTableRow';
import { useRecordTable } from '@/ui/object/record-table/hooks/useRecordTable';
import { isFetchingRecordTableDataState } from '@/ui/object/record-table/states/isFetchingRecordTableDataState';
import { getRecordTableScopedStates } from '@/ui/object/record-table/utils/getRecordTableScopedStates';
export const RecordTableBodyFetchMoreLoader = () => {
const { queryStateIdentifier } = useObjectRecordTable();
const { scopeId } = useRecordTable();
const isFetchingMoreObjects = useRecoilValue(
isFetchingMoreRecordsFamilyState(queryStateIdentifier),
);
const isFetchingRecordTableData = useRecoilValue(
isFetchingRecordTableDataState,
);
const onLastRowVisible = useRecoilCallback(
({ set }) =>
async (inView: boolean) => {
const { tableLastRowVisibleState } = getRecordTableScopedStates({
recordTableScopeId: scopeId,
});
set(tableLastRowVisibleState, inView);
},
[scopeId],
);
const { ref: tbodyRef } = useInView({
onChange: onLastRowVisible,
});
if (isFetchingRecordTableData) {
return <></>;
}
return (
<tbody ref={tbodyRef}>
{isFetchingMoreObjects && (
<StyledRow selected={false}>
<td style={{ height: 50 }} colSpan={1000}>
Loading more...
</td>
</StyledRow>
)}
</tbody>
);
};

View File

@@ -1,7 +1,10 @@
import { forwardRef } from 'react';
import { useContext } from 'react';
import { useInView } from 'react-intersection-observer';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { ScrollWrapperContext } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { ColumnContext } from '../contexts/ColumnContext';
import { useRecordTableScopedStates } from '../hooks/internal/useRecordTableScopedStates';
import { useCurrentRowSelected } from '../record-table-row/hooks/useCurrentRowSelected';
@@ -18,36 +21,53 @@ type RecordTableRowProps = {
rowId: string;
};
export const RecordTableRow = forwardRef<
HTMLTableRowElement,
RecordTableRowProps
>(({ rowId }, ref) => {
const StyledPlaceholder = styled.td`
height: 30px;
`;
export const RecordTableRow = ({ rowId }: RecordTableRowProps) => {
const { visibleTableColumnsSelector } = useRecordTableScopedStates();
const visibleTableColumns = useRecoilValue(visibleTableColumnsSelector);
const { currentRowSelected } = useCurrentRowSelected();
const scrollWrapperRef = useContext(ScrollWrapperContext);
const { ref: elementRef, inView } = useInView({
root: scrollWrapperRef.current,
rootMargin: '1000px',
});
return (
<StyledRow
ref={ref}
ref={elementRef}
data-testid={`row-id-${rowId}`}
selected={currentRowSelected}
data-selectable-id={rowId}
>
<td>
<CheckboxCell />
</td>
{[...visibleTableColumns]
.sort((columnA, columnB) => columnA.position - columnB.position)
.map((column, columnIndex) => {
return (
<ColumnContext.Provider value={column} key={column.fieldMetadataId}>
<RecordTableCell cellIndex={columnIndex} />
</ColumnContext.Provider>
);
})}
<td></td>
{inView ? (
<>
<td>
<CheckboxCell />
</td>
{[...visibleTableColumns]
.sort((columnA, columnB) => columnA.position - columnB.position)
.map((column, columnIndex) => {
return (
<ColumnContext.Provider
value={column}
key={column.fieldMetadataId}
>
<RecordTableCell cellIndex={columnIndex} />
</ColumnContext.Provider>
);
})}
<td></td>
</>
) : (
<StyledPlaceholder />
)}
</StyledRow>
);
});
};

View File

@@ -1,112 +0,0 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
type RenderIfVisibleProps = {
/**
* Whether the element should be visible initially or not.
* Useful e.g. for always setting the first N items to visible.
* Default: false
*/
initialVisible?: boolean;
/** An estimate of the element's height */
defaultHeight?: number;
/** How far outside the viewport in pixels should elements be considered visible? */
visibleOffset?: number;
/** Should the element stay rendered after it becomes visible? */
stayRendered?: boolean;
root?: HTMLElement | null;
/** E.g. 'span', 'tbody'. Default = 'div' */
rootElement?: string;
rootElementClass?: string;
/** E.g. 'span', 'tr'. Default = 'div' */
placeholderElement?: string;
placeholderElementClass?: string;
children: React.ReactNode;
};
const RenderIfVisible = ({
initialVisible = false,
defaultHeight = 300,
visibleOffset = 1000,
stayRendered = false,
root = null,
rootElement = 'div',
rootElementClass = '',
placeholderElement = 'div',
placeholderElementClass = '',
children,
}: RenderIfVisibleProps) => {
const [isVisible, setIsVisible] = useState<boolean>(initialVisible);
// eslint-disable-next-line twenty/no-state-useref
const wasVisible = useRef<boolean>(initialVisible);
// eslint-disable-next-line twenty/no-state-useref
const placeholderHeight = useRef<number>(defaultHeight);
const intersectionRef = useRef<HTMLDivElement>(null);
// Set visibility with intersection observer
useEffect(() => {
if (intersectionRef.current) {
const localRef = intersectionRef.current;
const observer = new IntersectionObserver(
(entries) => {
// Before switching off `isVisible`, set the height of the placeholder
if (!entries[0].isIntersecting) {
placeholderHeight.current = localRef!.offsetHeight;
}
if (typeof window !== undefined && window.requestIdleCallback) {
window.requestIdleCallback(
() => setIsVisible(entries[0].isIntersecting),
{
timeout: 600,
},
);
} else {
setIsVisible(entries[0].isIntersecting);
}
},
{ root, rootMargin: `${visibleOffset}px 0px ${visibleOffset}px 0px` },
);
observer.observe(localRef);
return () => {
if (localRef) {
observer.unobserve(localRef);
}
};
}
return () => {};
}, [root, visibleOffset]);
useEffect(() => {
if (isVisible) {
wasVisible.current = true;
}
}, [isVisible]);
const placeholderStyle = { height: placeholderHeight.current };
const rootClasses = useMemo(
() => `renderIfVisible ${rootElementClass}`,
[rootElementClass],
);
const placeholderClasses = useMemo(
() => `renderIfVisible-placeholder ${placeholderElementClass}`,
[placeholderElementClass],
);
// eslint-disable-next-line react/no-children-prop
return React.createElement(rootElement, {
children:
isVisible || (stayRendered && wasVisible.current) ? (
<>{children}</>
) : (
React.createElement(placeholderElement, {
className: placeholderClasses,
style: placeholderStyle,
})
),
ref: intersectionRef,
className: rootClasses,
});
};
export default RenderIfVisible;

View File

@@ -1,14 +1,16 @@
import { useCallback, useState } from 'react';
import { useCallback } from 'react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
import { IconChevronDown, IconPlus } from '@/ui/display/icon';
import { Button } from '@/ui/input/button/components/Button';
import { ButtonGroup } from '@/ui/input/button/components/ButtonGroup';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { UpdateViewDropdownId } from '@/views/constants/UpdateViewDropdownId';
import { useViewBar } from '@/views/hooks/useViewBar';
import { useViewScopedStates } from '../hooks/internal/useViewScopedStates';
@@ -20,7 +22,7 @@ const StyledContainer = styled.div`
`;
export type UpdateViewButtonGroupProps = {
hotkeyScope: string;
hotkeyScope: HotkeyScope;
onViewEditModeChange?: () => void;
};
@@ -28,7 +30,6 @@ export const UpdateViewButtonGroup = ({
hotkeyScope,
onViewEditModeChange,
}: UpdateViewButtonGroupProps) => {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const { updateCurrentView, setViewEditMode } = useViewBar();
const { canPersistFiltersSelector, canPersistSortsSelector } =
useViewScopedStates();
@@ -38,53 +39,43 @@ export const UpdateViewButtonGroup = ({
const canPersistView = canPersistFilters || canPersistSorts;
const handleArrowDownButtonClick = useCallback(() => {
setIsDropdownOpen((previousIsOpen) => !previousIsOpen);
}, []);
const handleCreateViewButtonClick = useCallback(() => {
setViewEditMode('create');
onViewEditModeChange?.();
setIsDropdownOpen(false);
}, [setViewEditMode, onViewEditModeChange]);
const handleDropdownClose = useCallback(() => {
setIsDropdownOpen(false);
}, []);
const handleViewSubmit = async () => {
await updateCurrentView?.();
};
useScopedHotkeys(
[Key.Enter, Key.Escape],
handleDropdownClose,
hotkeyScope,
[],
);
if (!canPersistView) return null;
if (!canPersistView) {
return <></>;
}
return (
<StyledContainer>
<ButtonGroup size="small" accent="blue">
<Button title="Update view" onClick={handleViewSubmit} />
<Button
size="small"
Icon={IconChevronDown}
onClick={handleArrowDownButtonClick}
/>
</ButtonGroup>
{isDropdownOpen && (
<DropdownMenuItemsContainer>
<MenuItem
onClick={handleCreateViewButtonClick}
LeftIcon={IconPlus}
text="Create view"
/>
</DropdownMenuItemsContainer>
)}
</StyledContainer>
<DropdownScope dropdownScopeId={UpdateViewDropdownId}>
<Dropdown
dropdownHotkeyScope={hotkeyScope}
clickableComponent={
<StyledContainer>
<ButtonGroup size="small" accent="blue">
<Button title="Update view" onClick={handleViewSubmit} />
<Button size="small" Icon={IconChevronDown} />
</ButtonGroup>
</StyledContainer>
}
dropdownComponents={
<>
<DropdownMenuItemsContainer>
<MenuItem
onClick={handleCreateViewButtonClick}
LeftIcon={IconPlus}
text="Create view"
/>
</DropdownMenuItemsContainer>
</>
}
/>
</DropdownScope>
);
};

View File

@@ -100,7 +100,7 @@ export const ViewBar = ({
rightComponent={
<UpdateViewButtonGroup
onViewEditModeChange={openOptionsDropdownButton}
hotkeyScope={ViewsHotkeyScope.CreateDropdown}
hotkeyScope={{ scope: ViewsHotkeyScope.CreateDropdown }}
/>
}
/>

View File

@@ -1,20 +1,16 @@
import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useRecoilCallback, useRecoilValue } from 'recoil';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { PaginatedRecordTypeResults } from '@/object-record/types/PaginatedRecordTypeResults';
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
import { useViewBar } from '@/views/hooks/useViewBar';
import { GraphQLView } from '@/views/types/GraphQLView';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { useViewScopedStates } from '../hooks/internal/useViewScopedStates';
import { getViewScopedStatesFromSnapshot } from '../utils/getViewScopedStatesFromSnapshot';
export const ViewBarEffect = () => {
const {
scopeId: viewScopeId,
loadView,
changeViewInUrl,
loadViewFields,
@@ -25,58 +21,62 @@ export const ViewBarEffect = () => {
const [searchParams] = useSearchParams();
const currentViewIdFromUrl = searchParams.get('view');
const { viewTypeState, viewObjectMetadataIdState } = useViewScopedStates();
const {
viewTypeState,
viewObjectMetadataIdState,
viewsState,
currentViewIdState,
} = useViewScopedStates();
const [views, setViews] = useRecoilState(viewsState);
const viewType = useRecoilValue(viewTypeState);
const viewObjectMetadataId = useRecoilValue(viewObjectMetadataIdState);
const setCurrentViewId = useSetRecoilState(currentViewIdState);
useFindManyRecords({
const { records: newViews } = useFindManyRecords<GraphQLView>({
skip: !viewObjectMetadataId,
objectNameSingular: 'view',
filter: {
type: { eq: viewType },
objectMetadataId: { eq: viewObjectMetadataId },
},
onCompleted: useRecoilCallback(
({ snapshot, set }) =>
async (data: PaginatedRecordTypeResults<GraphQLView>) => {
const nextViews = data.edges.map(({ node }) => node);
const { viewsState, currentViewIdState } =
getViewScopedStatesFromSnapshot({
snapshot,
viewScopeId,
});
const views = getSnapshotValue(snapshot, viewsState);
if (!isDeeplyEqual(views, nextViews)) {
set(viewsState, nextViews);
}
const currentView =
data.edges
.map((view) => view.node)
.find((view) => view.id === currentViewIdFromUrl) ??
data.edges[0]?.node ??
null;
if (!currentView) return;
set(currentViewIdState, currentView.id);
if (currentView?.viewFields) {
loadViewFields(currentView.viewFields, currentView.id);
loadViewFilters(currentView.viewFilters, currentView.id);
loadViewSorts(currentView.viewSorts, currentView.id);
}
if (!nextViews.length) return;
if (!currentViewIdFromUrl) return changeViewInUrl(nextViews[0].id);
},
),
});
useEffect(() => {
if (!newViews.length) return;
if (!isDeeplyEqual(views, newViews)) {
setViews(newViews);
}
const currentView =
newViews.find((view) => view.id === currentViewIdFromUrl) ??
newViews[0] ??
null;
if (!currentView) return;
setCurrentViewId(currentView.id);
if (currentView?.viewFields) {
loadViewFields(currentView.viewFields, currentView.id);
loadViewFilters(currentView.viewFilters, currentView.id);
loadViewSorts(currentView.viewSorts, currentView.id);
}
if (!currentViewIdFromUrl) return changeViewInUrl(currentView.id);
}, [
changeViewInUrl,
currentViewIdFromUrl,
loadViewFields,
loadViewFilters,
loadViewSorts,
newViews,
setCurrentViewId,
setViews,
views,
]);
useEffect(() => {
if (!currentViewIdFromUrl) return;

View File

@@ -0,0 +1 @@
export const UpdateViewDropdownId = 'update-view';