-
Notifications
You must be signed in to change notification settings - Fork 2.7k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Refacto views #10272
Refacto views #10272
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PR Summary
This PR implements a significant refactoring of the views and context store management system. Here's a concise summary of the key changes:
- Replaces
contextStoreCurrentObjectMetadataIdComponentState
withcontextStoreCurrentObjectMetadataItemComponentState
across the application, moving from storing just IDs to complete metadata objects. - Introduces new
RecordIndexContainerGater
component to centralize record index view management and context providers. - Removes
ViewBarEffect
,QueryParamsViewIdEffect
, andViewEventContext
in favor of a simpler, more centralized state management approach. - Adds new Recoil selectors for prefetching views and managing view state, including
prefetchViewsState
and related selector families. - Changes error handling in
mapViewFiltersToFilters
to handle race conditions during view changes by returning undefined instead of throwing errors.
Key points to review:
- The removal of error throwing in
mapViewFiltersToFilters
could mask legitimate metadata issues - The TODO comment in
RecordIndexContainerGater
indicates potential technical debt around state reset logic - The new prefetch selectors assume single INDEX views per object metadata item
- The change from
null
toundefined
for certain states while keepingnull
for others suggests inconsistent null handling - The removal of direct URL parameter handling in
RecordIndexPage
might affect deep linking functionality
66 file(s) reviewed, 17 comment(s)
Edit PR Review Bot Settings | Greptile
@@ -42,7 +41,7 @@ export const RecordShowActionMenu = ({ | |||
|
|||
return ( | |||
<> | |||
{contextStoreCurrentObjectMetadataId && ( | |||
{contextStoreCurrentObjectMetadataItem && ( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
style: The conditional check here may be redundant since objectMetadataItem is already passed as a required prop to this component
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
False positive
mainContextStoreComponentInstanceId, | ||
); | ||
console.log('Provider effect'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
style: Remove debug console.log before merging
console.log('Provider effect'); |
); | ||
|
||
if (!objectMetadataItem) { | ||
throw new Error('Object metadata item is not se in context store'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
syntax: 'not se' should be 'not set'
throw new Error('Object metadata item is not se in context store'); | |
throw new Error('Object metadata item is not set in context store'); |
...ages/twenty-front/src/modules/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata.ts
Show resolved
Hide resolved
const columnDefinitions: ColumnDefinition<FieldMetadata>[] = | ||
activeFieldMetadataItems | ||
.map((field, index) => | ||
formatFieldMetadataItemAsColumnDefinition({ | ||
position: index, | ||
field, | ||
objectMetadataItem, | ||
}), | ||
) | ||
.filter(filterAvailableTableColumns) | ||
.map((column) => { | ||
const existsInFilterDefinitions = filterableFieldMetadataItems.some( | ||
(fieldMetadataItem) => | ||
fieldMetadataItem.id === column.fieldMetadataId, | ||
); | ||
const existsInSortDefinitions = sortDefinitions.some( | ||
(sort) => sort.fieldMetadataId === column.fieldMetadataId, | ||
); | ||
return { | ||
...column, | ||
isFilterable: existsInFilterDefinitions, | ||
isSortable: existsInSortDefinitions, | ||
}; | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
style: Complex computation chain (multiple maps and filters) should be memoized to prevent unnecessary recalculations on each render
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
logic: Removing this context will break any components that consume ViewEventContext.onCurrentViewChange. Ensure all consumers have been updated to use the new pattern.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
logic: This state was used by critical view management hooks like useGetCurrentView and useUpdateCurrentView. Ensure all dependencies have been updated to use the new state management approach.
throw new Error( | ||
`Field metadata item not found for view filter ${viewFilter.id} and field metadata id ${viewFilter.fieldMetadataId}`, | ||
); | ||
// Todo: we we don't throw an error yet as we have race condition on view change |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
syntax: Typo in comment: 'we we' should be 'we'
// Todo: we we don't throw an error yet as we have race condition on view change | |
// Todo: we don't throw an error yet as we have race condition on view change |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok
prefetchIsLoadedFamilyState(PrefetchKey.AllViews), | ||
); | ||
|
||
console.log('prefetchIsLoaded', prefetchIsLoaded); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
style: console.log statement should be removed before production
console.log('prefetchIsLoaded', prefetchIsLoaded); |
setContextStoreNumberOfSelectedRecords(contextStoreNumberOfSelectedRecords); | ||
setcontextStoreFiltersComponentState(contextStoreFilters); | ||
|
||
setIsLoaded(true); | ||
}, [ | ||
setContextStoreTargetedRecordsRule, | ||
setContextStoreCurrentObjectMetadataId, | ||
setContextStoreCurrentObjectMetadataItem, | ||
contextStoreTargetedRecordsRule, | ||
contextStoreCurrentObjectMetadataId, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
logic: contextStoreCurrentObjectMetadataId in dependencies array is not needed since it's not used in the effect
contextStoreCurrentObjectMetadataId, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good bot
Not wrong but out of scope a little bit
width: 100%; | ||
`; | ||
|
||
export const RecordIndexContainerGater = () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adding this "Gater" to introduce an intermediate level to avoid re-renders
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think the naming is appropriate because there's no conditionnal rendering here. Also maybe we should let backend naming for backend related stuff.
import { useRecoilValue } from 'recoil'; | ||
import { isDefined } from 'twenty-shared'; | ||
|
||
export const RecordIndexLoadBaseOnContextStoreEffect = () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have introduced this effect for now but I have voluntarily put everything into a loadRecordIndexStates hook. I think we would like to load this from the PageChangeEffect at some point. open to discussion
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seems like a good idea if it's based on the URL.
Also should we use BasedOn in the naming ?
The naming loaded, load seems to be duplicating some concept that could already be described as context store current view no ?
import { isDefined } from 'twenty-shared'; | ||
import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; | ||
|
||
export const useLoadRecordIndexStates = () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the monster, which is actually code moved from different places. This code should disappear as we remove all intermediate states
import { useRecoilCallback } from 'recoil'; | ||
import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; | ||
|
||
export const useSetTableColumns = () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
moved from useRecordTable
@@ -12,10 +11,6 @@ export const PREFETCH_CONFIG: Record< | |||
operationSignatureFactory: RecordGqlOperationSignatureFactory; | |||
} | |||
> = { | |||
ALL_VIEWS: { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
removing ALL Views from here. Prefetch was a bad idea overall
import { View } from '@/views/types/View'; | ||
import { selector } from 'recoil'; | ||
|
||
export const favoriteViewsWithMinimalDataSelector = selector< |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
selector designed for favorites that need to have minimal info on views (to avoid re-renders too)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we use viewsForMenuItem or something that tells us what the fields curation is for ?
const contextStoreCurrentObjectMetadataId = useRecoilComponentValueV2( | ||
contextStoreCurrentObjectMetadataIdComponentState, | ||
const contextStoreCurrentObjectMetadataItem = useRecoilComponentValueV2( | ||
contextStoreCurrentObjectMetadataItemComponentState, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question: Went from an atomic ID listener to the whole objectMetadataItem ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yes, because the DevXP was painful (always have to find the objectMetadataItem from the Id). Another possibility is to create a hook or a selector on top of that. Which would actually be better I believe
...nt/src/modules/prefetch/states/selector/prefetchViewsFromObjectMetadataItemFamilySelector.ts
Show resolved
Hide resolved
({ get }) => { | ||
const views = get(prefetchViewsState); | ||
return views | ||
.filter((view) => view.objectMetadataId === objectMetadataItemId) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Recoil newbie question: Could we and is it recommended to use nested recoil selectors ? ( in this case overkill but could have used prefetchViewsFromObjectMetadataItemFamilySelector
)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes we can call other selectors in selectors, as long as we have the necessary parameters available, what we cannot do is call hooks in selectors.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it make sense here to use the prefetchViewsFromObjectMetadataItemFamilySelector
selector in order to retrieve objectMetadaItem
views ? I feel like it's redundant states access even if very cheap to perform
|
||
const objectMetadataItem = objectMetadataItems.find( | ||
(item) => item.id === contextStoreCurrentObjectMetadataId, | ||
const contextStoreCurrentObjectMetadataItem = useRecoilComponentValueV2( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there any risk that this state get desynchronized from the objectMetadataItems state ? Could this be a component selector instead ? Based on the id ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
good idea!
), | ||
}); | ||
|
||
const { records } = useFindManyRecords({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: Unless I'm mistaken passing View
predicate to useFindManyRecords
could avoid having to force casting in below useEffect
const { records: views } = useFindManyRecords<View>({
//...
useEffect(() => {
if (isDefined(views) && !isPersistingViewFields) {
setPrefetchViewsState(views);
}
}, [isPersistingViewFields, records, setPrefetchViewsState]);
const view = views.find((view) => view.id === viewPickerReferenceViewId); | ||
const view = useRecoilValue( | ||
prefetchViewFromViewIdFamilySelector({ | ||
viewId: viewPickerReferenceViewId ?? '', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remark: Not the first occurence of passing an empty string to this selector
Maybe we should create a specific one or mutate exsiting for it to accept optional
viewId
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I would prefer introduce a hook useContextStoreViewIdOrThrow to force the caller to take a decision
@@ -15,7 +13,7 @@ type BooleanDisplayProps = { | |||
}; | |||
|
|||
export const BooleanDisplay = ({ value }: BooleanDisplayProps) => { | |||
if (!isDefined(value)) { | |||
if (value === null || value === undefined) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why ? Is it different than !isDefined ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
because of storybook build failing:
BooleanDisplay is in vyw + using twenty-shared, this seems to be an issue currently. We should investigate but I'm protecting the devXP
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What kind of error had you when building over this ? Emotion.styled
is not a function one ?
setViewPickerIsPersisting, | ||
setViewPickerMode, | ||
]); | ||
closeDropdown(VIEW_PICKER_KANBAN_FIELD_DROPDOWN_ID); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note: We should strictly typed as much as possible any static occurences of specificComponentId
and gather them all in a Literal type or tsc enum, as I guess there're also dynamic dropdown ids
In order to keep consistency between each declarated const like VIEW_PICKER_KANBAN_FIELD_DROPDOWN_ID
and bind them to a single TypeScript source of thruth
Something like:
const closeDropdown: (specificComponentId: DeterministicDropdownId | string) => void
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we don't want to type from the inside as the dropdown is a lower layer component that should not take dependency on the app.
In this case we could introduce an enum
ViewDropdownsInstanceId to help the devXp
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we don't want to type from the inside as the dropdown is a lower layer component that should not take dependency on the app.
We could add an abstraction layer over it scoped to the app or add a TypeScript generic to the closeDropdown
to make it as generic as possible
import { prefetchViewFromViewIdFamilySelector } from '@/prefetch/states/selector/prefetchViewFromViewIdFamilySelector'; | ||
import { useRecoilCallback } from 'recoil'; | ||
|
||
export const useGetViewFromPrefetchState = () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
useGetPrefetchedView ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For naming I would suggest :
- Prefetched when we talk about values
- Removing state suffix
@@ -53,7 +52,7 @@ export const useUpsertCombinedViewSorts = (viewBarComponentId?: string) => { | |||
return; | |||
} | |||
|
|||
const currentView = await getViewFromCache(currentViewId); | |||
const currentView = await getViewFromPrefetchState(currentViewId); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nitpick: Useless await
@@ -53,7 +53,7 @@ export const useUpsertCombinedViewFilters = (viewBarComponentId?: string) => { | |||
return; | |||
} | |||
|
|||
const currentView = await getViewFromCache(currentViewId); | |||
const currentView = await getViewFromPrefetchState(currentViewId); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nitpick: useless await
@@ -31,7 +31,7 @@ export const useSaveCurrentViewGroups = (viewBarComponentId?: string) => { | |||
return; | |||
} | |||
|
|||
const view = await getViewFromCache(currentViewId); | |||
const view = await getViewFromPrefetchState(currentViewId); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nitpick: useless await
@@ -31,7 +31,7 @@ export const useSaveCurrentViewGroups = (viewBarComponentId?: string) => { | |||
return; | |||
} | |||
|
|||
const view = await getViewFromCache(currentViewId); | |||
const view = await getViewFromPrefetchState(currentViewId); | |||
|
|||
if (isUndefinedOrNull(view)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nitpick: Could now become isDefined
@@ -77,7 +77,7 @@ export const useSaveCurrentViewFiltersAndSorts = ( | |||
unsavedToUpsertViewSortsCallbackState({ viewId }), | |||
); | |||
|
|||
const view = await getViewFromCache(viewId); | |||
const view = await getViewFromPrefetchState(viewId); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nitpick: useless await
@@ -123,7 +123,7 @@ export const useSaveCurrentViewFiltersAndSorts = ( | |||
unsavedToUpsertViewFilterGroupsCallbackState({ viewId }), | |||
); | |||
|
|||
const view = await getViewFromCache(viewId); | |||
const view = await getViewFromPrefetchState(viewId); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nitpick: useless await
|
||
const view = await getViewFromCache(currentViewId); | ||
const view = await getViewFromPrefetchState(currentViewId); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nitpick: useless await
views, | ||
const viewsOnCurrentObject = useRecoilValue( | ||
prefetchViewsFromObjectMetadataItemFamilySelector({ | ||
objectMetadataItemId: objectMetadataItem.id, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nitpick: Unfortunately id
here is typed as any
( not for too long ), would stick to ?? ''
to keep consistency and avoid future tsc errors
instanceId, | ||
const indexViewId = useRecoilValue( | ||
prefetchIndexViewIdFromObjectMetadataItemFamilySelector({ | ||
objectMetadataItemId: objectMetadataItem.id, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nitpick: Unfortunately id
here is typed as any
( not for too long ), would stick to ?? ''
to keep consistency and avoid future tsc errors
@@ -50,7 +50,7 @@ export const useDeleteCombinedViewFilters = (viewBarComponentId?: string) => { | |||
return; | |||
} | |||
|
|||
const currentView = await getViewFromCache(currentViewId); | |||
const currentView = await getViewFromPrefetchState(currentViewId); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nitpick: Useless await
getViewFromCache, | ||
isPersistingViewFieldsCallbackState, | ||
findManyRecords, | ||
objectMetadataItem?.fields, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
React newbie concerns: optional and specific property ? Could this lead to re-render issue ?
@@ -54,7 +49,7 @@ export const useSetLastVisitedViewForObjectMetadataNamePlural = () => { | |||
}); | |||
} | |||
}, | |||
[findManyRecords, views], | |||
[], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
praise: yummy
views, | ||
const views = useRecoilValue( | ||
prefetchViewsFromObjectMetadataItemFamilySelector({ | ||
objectMetadataItemId: objectMetadataItem.id, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nitpick: typed as any
would ?? ''
@@ -56,7 +55,7 @@ export const useDeleteCombinedViewFilterGroup = ( | |||
return; | |||
} | |||
|
|||
const currentView = await getViewFromCache(currentViewId); | |||
const currentView = await getViewFromPrefetchState(currentViewId); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nitpick: useless await
@@ -120,7 +113,7 @@ export const useHandleRecordGroupField = ({ | |||
return; | |||
} | |||
|
|||
const view = await getViewFromCache(currentViewId); | |||
const view = await getViewFromPrefetchState(currentViewId); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nitpick: useless await
@@ -36,7 +29,7 @@ export const useHandleRecordGroupField = ({ | |||
return; | |||
} | |||
|
|||
const view = await getViewFromCache(currentViewId); | |||
const view = await getViewFromPrefetchState(currentViewId); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nitpick: useless await
view.objectMetadataId === relationObjectMetadataItem.id, | ||
const indexViewId = useRecoilValue( | ||
prefetchIndexViewIdFromObjectMetadataItemFamilySelector({ | ||
objectMetadataItemId: relationObjectMetadataItem.id, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
praise: nice
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Gg a lot of work, re-rendering master !
I think we should merge this after fixing critical points raised by reviews, and handle nitpicks afterwards to avoid any conflicts and friction due to the PR size
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In this huge (sorry!) PR:
As a result, the performance is WAY better (I suspect the favorite implementation to trigger a lot of re-renders unfortunately).
However, we are still facing a random app freeze on view creation. I could not investigate the root cause. As this seems to be already there in the precedent release, we can move forward but this seems a urgent follow up to me ==> EDIT: I've found the root cause after a few ours of deep dive... an infinite loop in RecordTableNoRecordGroupBodyEffect...
prastoin edit: close #10253