Compare commits

...
Author SHA1 Message Date
Weiko b364ba1c41 fix 2026-04-16 15:10:27 +02:00
Weiko 0be36105ff fix add fields to table widget 2026-04-16 14:42:10 +02:00
Weiko acf46060b4 Add field picker 2026-04-16 13:12:40 +02:00
Weiko 5dbce2c2c8 fix 2026-04-16 11:47:30 +02:00
Weiko af27e0e300 fix junction 2026-04-16 11:35:35 +02:00
Weiko 7434b722e4 fix 2026-04-16 11:24:28 +02:00
Weiko e5540e22fd Fix 2026-04-16 10:21:48 +02:00
Weiko f344984c20 various fixes 2026-04-15 19:02:41 +02:00
Weiko 4466c8f6db Add TABLE display mode for relation field widget 2026-04-15 18:06:27 +02:00
33 changed files with 1257 additions and 60 deletions
File diff suppressed because one or more lines are too long
@@ -6,6 +6,7 @@ import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/sta
import { navigationMenuItemsSelector } from '@/navigation-menu-item/common/states/navigationMenuItemsSelector';
import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/common/utils/filterWorkspaceNavigationMenuItems';
import { useSaveNavigationMenuItemsDraft } from '@/navigation-menu-item/edit/hooks/useSaveNavigationMenuItemsDraft';
import { useCreatePendingFieldWidgetTableViews } from '@/page-layout/hooks/useCreatePendingFieldWidgetTableViews';
import { useCreatePendingFieldsWidgetViews } from '@/page-layout/hooks/useCreatePendingFieldsWidgetViews';
import { useSavePageLayoutWidgetsData } from '@/page-layout/hooks/useSavePageLayoutWidgetsData';
import { useUpdatePageLayoutWithTabsAndWidgets } from '@/page-layout/hooks/useUpdatePageLayoutWithTabsAndWidgets';
@@ -41,6 +42,8 @@ export const useSaveLayoutCustomization = () => {
useUpdatePageLayoutWithTabsAndWidgets();
const { createPendingFieldsWidgetViews } =
useCreatePendingFieldsWidgetViews();
const { createPendingFieldWidgetTableViews } =
useCreatePendingFieldWidgetTableViews();
const { exitLayoutCustomizationMode } = useExitLayoutCustomizationMode();
const { savePageLayoutWidgetsData } = useSavePageLayoutWidgetsData();
@@ -108,6 +111,7 @@ export const useSaveLayoutCustomization = () => {
);
await createPendingFieldsWidgetViews(pageLayoutId);
await createPendingFieldWidgetTableViews(pageLayoutId);
if (isPageLayoutStructureDirty) {
const updateInput = convertPageLayoutDraftToUpdateInput(draft, {
@@ -180,6 +184,7 @@ export const useSaveLayoutCustomization = () => {
saveCommandMenuItemsDraft,
isCommandMenuItemsDirty,
createPendingFieldsWidgetViews,
createPendingFieldWidgetTableViews,
updatePageLayoutWithTabsAndWidgets,
savePageLayoutWidgetsData,
exitLayoutCustomizationMode,
@@ -1,9 +1,10 @@
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { useLoadRecordIndexStates } from '@/object-record/record-index/hooks/useLoadRecordIndexStates';
import { lastLoadedRecordTableWidgetViewIdComponentState } from '@/object-record/record-table-widget/states/lastLoadedRecordTableWidgetViewIdComponentState';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { viewFromViewIdFamilySelector } from '@/views/states/selectors/viewFromViewIdFamilySelector';
import { useStore } from 'jotai';
import { useEffect } from 'react';
import { isDefined } from 'twenty-shared/utils';
@@ -18,10 +19,11 @@ export const RecordTableWidgetViewLoadEffect = ({
}: RecordTableWidgetViewLoadEffectProps) => {
const { loadRecordIndexStates } = useLoadRecordIndexStates();
const [
lastLoadedRecordTableWidgetViewId,
setLastLoadedRecordTableWidgetViewId,
] = useAtomComponentState(lastLoadedRecordTableWidgetViewIdComponentState);
const store = useStore();
const lastLoadedAtom = useAtomComponentStateCallbackState(
lastLoadedRecordTableWidgetViewIdComponentState,
);
const viewFromViewId = useAtomFamilySelectorValue(
viewFromViewIdFamilySelector,
@@ -42,27 +44,28 @@ export const RecordTableWidgetViewLoadEffect = ({
return;
}
const lastLoaded = store.get(lastLoadedAtom);
if (
viewId === lastLoadedRecordTableWidgetViewId?.viewId &&
objectMetadataItem.updatedAt ===
lastLoadedRecordTableWidgetViewId?.objectMetadataItemUpdatedAt
viewId === lastLoaded?.viewId &&
objectMetadataItem.updatedAt === lastLoaded?.objectMetadataItemUpdatedAt
) {
return;
}
loadRecordIndexStates(viewFromViewId, objectMetadataItem);
setLastLoadedRecordTableWidgetViewId({
store.set(lastLoadedAtom, {
viewId,
objectMetadataItemUpdatedAt: objectMetadataItem.updatedAt,
});
loadRecordIndexStates(viewFromViewId, objectMetadataItem);
}, [
viewId,
lastLoadedRecordTableWidgetViewId,
setLastLoadedRecordTableWidgetViewId,
viewFromViewId,
viewHasFields,
objectMetadataItem,
store,
lastLoadedAtom,
loadRecordIndexStates,
]);
@@ -167,6 +167,7 @@ export const PAGE_LAYOUT_WIDGET_FRAGMENT = gql`
configurationType
fieldDisplayMode
fieldMetadataId
viewId
}
... on FieldRichTextConfiguration {
configurationType
@@ -0,0 +1,231 @@
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { fieldsWidgetUngroupedFieldsDraftComponentState } from '@/page-layout/states/fieldsWidgetUngroupedFieldsDraftComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState';
import { type FieldConfiguration } from '@/page-layout/types/FieldConfiguration';
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { resolveRelatedObjectForFieldWidget } from '@/page-layout/utils/resolveRelatedObjectForFieldWidget';
import { DEFAULT_VIEW_FIELD_SIZE } from '@/page-layout/widgets/field/constants/fieldWidgetTableDefaults';
import { buildDefaultViewFieldInputs } from '@/page-layout/widgets/field/utils/buildDefaultViewFieldInputs';
import { usePerformViewAPIPersist } from '@/views/hooks/internal/usePerformViewAPIPersist';
import { usePerformViewFieldAPIPersist } from '@/views/hooks/internal/usePerformViewFieldAPIPersist';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import {
FieldDisplayMode,
ViewType,
WidgetType,
} from '~/generated-metadata/graphql';
import { v4 } from 'uuid';
type FieldWidgetWithConfig = PageLayoutWidget & {
fieldConfig: FieldConfiguration;
};
export const useCreatePendingFieldWidgetTableViews = () => {
const { performViewAPICreate } = usePerformViewAPIPersist();
const { performViewFieldAPICreate, performViewFieldAPIUpdate } =
usePerformViewFieldAPIPersist();
const store = useStore();
const persistDraftViewFields = useCallback(
async (
widget: PageLayoutWidget,
viewId: string,
ungroupedDraft: Record<
string,
Array<{
viewFieldId?: string | null;
fieldMetadataItem: { id: string };
position: number;
isVisible: boolean;
}>
>,
) => {
const draftFields = ungroupedDraft[widget.id] ?? [];
if (!isNonEmptyArray(draftFields)) {
return;
}
const fieldsToCreate = draftFields
.filter((field) => !isDefined(field.viewFieldId))
.map((field) => ({
id: v4(),
viewId,
fieldMetadataId: field.fieldMetadataItem.id,
position: field.position,
size: DEFAULT_VIEW_FIELD_SIZE,
isVisible: field.isVisible,
}));
const fieldsToUpdate = draftFields
.filter((field): field is typeof field & { viewFieldId: string } =>
isDefined(field.viewFieldId),
)
.map((field) => ({
input: {
id: field.viewFieldId,
update: {
isVisible: field.isVisible,
position: field.position,
},
},
}));
if (isNonEmptyArray(fieldsToCreate)) {
await performViewFieldAPICreate({ inputs: fieldsToCreate });
}
if (isNonEmptyArray(fieldsToUpdate)) {
await performViewFieldAPIUpdate(fieldsToUpdate);
}
},
[performViewFieldAPICreate, performViewFieldAPIUpdate],
);
const createPendingFieldWidgetTableViews = useCallback(
async (pageLayoutId: string) => {
const draft = store.get(
pageLayoutDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
const persisted = store.get(
pageLayoutPersistedComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
const ungroupedDraft = store.get(
fieldsWidgetUngroupedFieldsDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
const persistedViewIdsByWidgetId = new Map<string, string | null>();
persisted?.tabs.forEach((tab) =>
tab.widgets.forEach((widget) => {
if (widget.configuration.configurationType === 'FIELD') {
const config = widget.configuration as FieldConfiguration;
persistedViewIdsByWidgetId.set(widget.id, config.viewId ?? null);
}
}),
);
const objectMetadataItems = store.get(objectMetadataItemsSelector.atom);
const tableFieldWidgets: FieldWidgetWithConfig[] = draft.tabs
.flatMap((tab) => tab.widgets)
.filter(
(
widget,
): widget is PageLayoutWidget & { type: typeof WidgetType.FIELD } =>
widget.type === WidgetType.FIELD,
)
.map((widget) => ({
...widget,
fieldConfig: widget.configuration as FieldConfiguration,
}))
.filter(
(widget) =>
widget.fieldConfig.fieldDisplayMode === FieldDisplayMode.VIEW &&
isDefined(widget.fieldConfig.viewId),
);
const pendingWidgets = tableFieldWidgets.filter(
(widget) =>
persistedViewIdsByWidgetId.get(widget.id) !==
widget.fieldConfig.viewId,
);
const existingWidgetsWithDraft = tableFieldWidgets.filter((widget) => {
if (
persistedViewIdsByWidgetId.get(widget.id) !==
widget.fieldConfig.viewId
) {
return false;
}
return isNonEmptyArray(ungroupedDraft[widget.id]);
});
for (const widget of pendingWidgets) {
const viewId = widget.fieldConfig.viewId;
if (!isDefined(viewId)) {
continue;
}
const parentObjectMetadataId =
widget.objectMetadataId ?? draft.objectMetadataId;
if (!isDefined(parentObjectMetadataId)) {
continue;
}
const resolved = resolveRelatedObjectForFieldWidget({
objectMetadataItems,
parentObjectMetadataId,
fieldMetadataId: widget.fieldConfig.fieldMetadataId,
});
if (!isDefined(resolved)) {
continue;
}
const { relatedObject } = resolved;
const viewResult = await performViewAPICreate(
{
input: {
id: viewId,
name: `${relatedObject.labelPlural} Table`,
icon: relatedObject.icon ?? 'IconTable',
objectMetadataId: relatedObject.id,
type: ViewType.TABLE_WIDGET,
},
},
relatedObject.id,
);
if (viewResult.status === 'failed') {
throw new Error(
`Failed to create view for FIELD table widget ${widget.id}`,
);
}
if (isNonEmptyArray(ungroupedDraft[widget.id])) {
await persistDraftViewFields(widget, viewId, ungroupedDraft);
continue;
}
const viewFieldInputs = buildDefaultViewFieldInputs({
relatedObject,
viewId,
});
await performViewFieldAPICreate({ inputs: viewFieldInputs });
}
for (const widget of existingWidgetsWithDraft) {
const viewId = widget.fieldConfig.viewId;
if (!isDefined(viewId)) {
continue;
}
await persistDraftViewFields(widget, viewId, ungroupedDraft);
}
},
[
performViewAPICreate,
performViewFieldAPICreate,
persistDraftViewFields,
store,
],
);
return { createPendingFieldWidgetTableViews };
};
@@ -6,6 +6,7 @@ import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fiel
import { fieldsWidgetGroupsPersistedComponentState } from '@/page-layout/states/fieldsWidgetGroupsPersistedComponentState';
import { fieldsWidgetUngroupedFieldsDraftComponentState } from '@/page-layout/states/fieldsWidgetUngroupedFieldsDraftComponentState';
import { fieldsWidgetUngroupedFieldsPersistedComponentState } from '@/page-layout/states/fieldsWidgetUngroupedFieldsPersistedComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { useMutation } from '@apollo/client/react';
import { useStore } from 'jotai';
import { useCallback } from 'react';
@@ -13,6 +14,7 @@ import { isDefined } from 'twenty-shared/utils';
import {
type UpsertFieldsWidgetInput,
type ViewFragmentFragment,
WidgetType,
} from '~/generated-metadata/graphql';
export const useSaveFieldsWidgetGroups = () => {
@@ -52,12 +54,29 @@ export const useSaveFieldsWidgetGroups = () => {
}),
);
const widgetIds = new Set([
const allWidgetIds = new Set([
...Object.keys(allDraftGroups),
...Object.keys(allPersistedGroups),
...Object.keys(allUngroupedFieldsDraft),
]);
const pageLayoutDraft = store.get(
pageLayoutDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
const fieldWidgetIds = new Set(
pageLayoutDraft.tabs
.flatMap((tab) => tab.widgets)
.filter((widget) => widget.type === WidgetType.FIELD)
.map((widget) => widget.id),
);
const widgetIds = [...allWidgetIds].filter(
(widgetId) => !fieldWidgetIds.has(widgetId),
);
for (const widgetId of widgetIds) {
const editorMode = allEditorModes[widgetId] ?? 'ungrouped';
@@ -1,3 +1,4 @@
import { useCreatePendingFieldWidgetTableViews } from '@/page-layout/hooks/useCreatePendingFieldWidgetTableViews';
import { useCreatePendingFieldsWidgetViews } from '@/page-layout/hooks/useCreatePendingFieldsWidgetViews';
import { useUpdatePageLayoutWithTabsAndWidgets } from '@/page-layout/hooks/useUpdatePageLayoutWithTabsAndWidgets';
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
@@ -45,6 +46,9 @@ export const useSavePageLayout = (pageLayoutIdFromProps: string) => {
const { createPendingFieldsWidgetViews } =
useCreatePendingFieldsWidgetViews();
const { createPendingFieldWidgetTableViews } =
useCreatePendingFieldWidgetTableViews();
const featureFlags = useFeatureFlagsMap();
const isRecordPageLayoutEditingEnabled =
featureFlags[FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED];
@@ -52,6 +56,7 @@ export const useSavePageLayout = (pageLayoutIdFromProps: string) => {
const savePageLayout = useCallback(async () => {
await createPendingFieldsWidgetViews(pageLayoutId);
await createPendingFieldWidgetTableViews(pageLayoutId);
const pageLayoutDraft = store.get(pageLayoutDraftCallbackState);
const updateInput = convertPageLayoutDraftToUpdateInput(pageLayoutDraft, {
@@ -91,6 +96,7 @@ export const useSavePageLayout = (pageLayoutIdFromProps: string) => {
return result;
}, [
createPendingFieldsWidgetViews,
createPendingFieldWidgetTableViews,
isRecordPageLayoutEditingEnabled,
pageLayoutCurrentLayoutsCallbackState,
pageLayoutDraftCallbackState,
@@ -0,0 +1,159 @@
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
import { type FlatView } from '@/metadata-store/types/FlatView';
import { type FlatViewField } from '@/metadata-store/types/FlatViewField';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { fieldsWidgetEditorModeDraftComponentState } from '@/page-layout/states/fieldsWidgetEditorModeDraftComponentState';
import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState';
import { fieldsWidgetUngroupedFieldsDraftComponentState } from '@/page-layout/states/fieldsWidgetUngroupedFieldsDraftComponentState';
import { hasInitializedFieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/hasInitializedFieldsWidgetGroupsDraftComponentState';
import { resolveRelatedObjectForFieldWidget } from '@/page-layout/utils/resolveRelatedObjectForFieldWidget';
import { buildDefaultViewFieldInputs } from '@/page-layout/widgets/field/utils/buildDefaultViewFieldInputs';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import {
ViewOpenRecordIn,
ViewType,
ViewVisibility,
} from '~/generated-metadata/graphql';
export const useSeedDraftViewForFieldWidgetTable = () => {
const store = useStore();
const seedDraftViewForFieldWidgetTable = useCallback(
({
viewId,
fieldMetadataId,
parentObjectMetadataId,
pageLayoutId,
widgetId,
}: {
viewId: string;
fieldMetadataId: string;
parentObjectMetadataId: string;
pageLayoutId: string;
widgetId: string;
}) => {
const objectMetadataItems = store.get(objectMetadataItemsSelector.atom);
const resolved = resolveRelatedObjectForFieldWidget({
objectMetadataItems,
parentObjectMetadataId,
fieldMetadataId,
});
if (!isDefined(resolved)) {
return;
}
const { relatedObject } = resolved;
const viewsEntry = store.get(metadataStoreState.atomFamily('views'));
const viewsCurrent = viewsEntry.current as FlatView[];
if (viewsCurrent.some((view) => view.id === viewId)) {
return;
}
const syntheticView: FlatView = {
id: viewId,
name: `${relatedObject.labelPlural} Table`,
icon: relatedObject.icon ?? 'IconTable',
objectMetadataId: relatedObject.id,
type: ViewType.TABLE_WIDGET,
isCompact: false,
shouldHideEmptyGroups: false,
position: 0,
openRecordIn: ViewOpenRecordIn.SIDE_PANEL,
visibility: ViewVisibility.UNLISTED,
key: null,
};
store.set(metadataStoreState.atomFamily('views'), {
...viewsEntry,
current: [...viewsCurrent, syntheticView],
});
const syntheticViewFields: FlatViewField[] = buildDefaultViewFieldInputs({
relatedObject,
viewId,
}).map((input) => ({
...input,
isActive: true,
}));
const viewFieldsEntry = store.get(
metadataStoreState.atomFamily('viewFields'),
);
const viewFieldsCurrent = viewFieldsEntry.current as FlatViewField[];
store.set(metadataStoreState.atomFamily('viewFields'), {
...viewFieldsEntry,
current: [...viewFieldsCurrent, ...syntheticViewFields],
});
const ungroupedDraftFields = syntheticViewFields
.map((viewField, index) => {
const fieldMetadataItem = relatedObject.fields.find(
(field) => field.id === viewField.fieldMetadataId,
);
if (!isDefined(fieldMetadataItem)) {
return undefined;
}
return {
fieldMetadataItem,
position: viewField.position,
isVisible: viewField.isVisible,
globalIndex: index,
viewFieldId: viewField.id,
};
})
.filter(isDefined);
store.set(
fieldsWidgetUngroupedFieldsDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
(prev) => ({
...prev,
[widgetId]: ungroupedDraftFields,
}),
);
store.set(
fieldsWidgetEditorModeDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
(prev) => ({
...prev,
[widgetId]: 'ungrouped',
}),
);
store.set(
fieldsWidgetGroupsDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
(prev) => ({
...prev,
[widgetId]: [],
}),
);
store.set(
hasInitializedFieldsWidgetGroupsDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
(prev) => ({
...prev,
[widgetId]: true,
}),
);
},
[store],
);
return { seedDraftViewForFieldWidgetTable };
};
@@ -8,4 +8,5 @@ export type FieldConfiguration = {
configurationType: WidgetConfigurationType.FIELD;
fieldMetadataId: string;
fieldDisplayMode: FieldDisplayMode;
viewId?: string | null;
};
@@ -0,0 +1,42 @@
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isDefined } from 'twenty-shared/utils';
export const resolveRelatedObjectForFieldWidget = ({
objectMetadataItems,
parentObjectMetadataId,
fieldMetadataId,
}: {
objectMetadataItems: EnrichedObjectMetadataItem[];
parentObjectMetadataId: string;
fieldMetadataId: string;
}):
| { field: FieldMetadataItem; relatedObject: EnrichedObjectMetadataItem }
| undefined => {
const parentObject = objectMetadataItems.find(
(item) => item.id === parentObjectMetadataId,
);
if (!isDefined(parentObject)) {
return undefined;
}
const field = parentObject.fields.find(
(fieldItem) => fieldItem.id === fieldMetadataId,
);
const relatedObjectId = field?.relation?.targetObjectMetadata.id;
if (!isDefined(field) || !isDefined(relatedObjectId)) {
return undefined;
}
const relatedObject = objectMetadataItems.find(
(item) => item.id === relatedObjectId,
);
if (!isDefined(relatedObject)) {
return undefined;
}
return { field, relatedObject };
};
@@ -1,7 +1,7 @@
import { useCurrentWidget } from '@/page-layout/widgets/hooks/useCurrentWidget';
import { type WidgetAction } from '@/page-layout/widgets/types/WidgetAction';
import { CustomError } from 'twenty-shared/utils';
import { WidgetType } from '~/generated-metadata/graphql';
import { useCurrentWidget } from '@/page-layout/widgets/hooks/useCurrentWidget';
import { WidgetActionFieldEdit } from './WidgetActionFieldEdit';
import { WidgetActionFieldSeeAll } from './WidgetActionFieldSeeAll';
@@ -16,6 +16,7 @@ import { FieldWidgetMorphRelationCard } from '@/page-layout/widgets/field/compon
import { FieldWidgetMorphRelationField } from '@/page-layout/widgets/field/components/FieldWidgetMorphRelationField';
import { FieldWidgetRelationCard } from '@/page-layout/widgets/field/components/FieldWidgetRelationCard';
import { FieldWidgetRelationField } from '@/page-layout/widgets/field/components/FieldWidgetRelationField';
import { FieldWidgetRelationTable } from '@/page-layout/widgets/field/components/FieldWidgetRelationTable';
import { assertFieldWidgetOrThrow } from '@/page-layout/widgets/field/utils/assertFieldWidgetOrThrow';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
@@ -157,6 +158,23 @@ export const FieldWidget = ({ widget }: FieldWidgetProps) => {
);
}
if (fieldDisplayMode === FieldDisplayMode.VIEW) {
const viewId = widget.configuration.viewId;
if (!isDefined(viewId)) {
return null;
}
return (
<FieldWidgetRelationTable
fieldDefinition={fieldDefinition}
viewId={viewId}
widgetId={widget.id}
targetRecordId={targetRecord.id}
/>
);
}
return (
<FieldWidgetRelationField
fieldDefinition={fieldDefinition}
@@ -0,0 +1,57 @@
import { styled } from '@linaria/react';
import { isDefined } from 'twenty-shared/utils';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { RecordTableWidgetProvider } from '@/object-record/record-table-widget/components/RecordTableWidgetProvider';
import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition';
import { type FieldRelationMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
import { FieldWidgetRelationTableGatedContent } from '@/page-layout/widgets/field/components/FieldWidgetRelationTableGatedContent';
const StyledContainer = styled.div`
max-height: 400px;
overflow: auto;
width: 100%;
`;
type FieldWidgetRelationTableProps = {
fieldDefinition: FieldDefinition<FieldRelationMetadata>;
viewId: string;
widgetId: string;
targetRecordId: string;
};
export const FieldWidgetRelationTable = ({
fieldDefinition,
viewId,
widgetId,
targetRecordId,
}: FieldWidgetRelationTableProps) => {
const relatedObjectMetadataId =
fieldDefinition.metadata.relationObjectMetadataId;
const { objectMetadataItem: relatedObjectMetadataItem } =
useObjectMetadataItemById({ objectId: relatedObjectMetadataId ?? '' });
if (!isDefined(relatedObjectMetadataItem)) {
return null;
}
return (
<StyledContainer>
<RecordTableWidgetProvider
objectNameSingular={relatedObjectMetadataItem.nameSingular}
viewId={viewId}
widgetId={widgetId}
>
<FieldWidgetRelationTableGatedContent
viewId={viewId}
widgetId={widgetId}
inverseRelationFieldMetadataId={
fieldDefinition.metadata.relationFieldMetadataId
}
targetRecordId={targetRecordId}
/>
</RecordTableWidgetProvider>
</StyledContainer>
);
};
@@ -0,0 +1,90 @@
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { RecordTableWidget } from '@/object-record/record-table-widget/components/RecordTableWidget';
import { lastLoadedRecordTableWidgetViewIdComponentState } from '@/object-record/record-table-widget/states/lastLoadedRecordTableWidgetViewIdComponentState';
import { FieldWidgetRelationTableShimmer } from '@/page-layout/widgets/field/components/FieldWidgetRelationTableShimmer';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useStore } from 'jotai';
import { useEffect, useState } from 'react';
import { ViewFilterOperand } from '~/generated-metadata/graphql';
type FieldWidgetRelationTableGatedContentProps = {
viewId: string;
widgetId: string;
inverseRelationFieldMetadataId: string;
targetRecordId: string;
};
export const FieldWidgetRelationTableGatedContent = ({
viewId,
widgetId,
inverseRelationFieldMetadataId,
targetRecordId,
}: FieldWidgetRelationTableGatedContentProps) => {
const [isScopedFilterReady, setIsScopedFilterReady] = useState(false);
const lastLoadedRecordTableWidgetViewId = useAtomComponentStateValue(
lastLoadedRecordTableWidgetViewIdComponentState,
);
const store = useStore();
const currentRecordFiltersAtom = useAtomComponentStateCallbackState(
currentRecordFiltersComponentState,
);
useEffect(() => {
if (lastLoadedRecordTableWidgetViewId?.viewId !== viewId) {
setIsScopedFilterReady(false);
return;
}
const filterId = `field-widget-relation-scope-${widgetId}`;
const existingFilters = store.get(currentRecordFiltersAtom);
const alreadyApplied = existingFilters.some(
(filter) =>
filter.id === filterId &&
filter.fieldMetadataId === inverseRelationFieldMetadataId &&
filter.value ===
JSON.stringify({
isCurrentWorkspaceMemberSelected: false,
selectedRecordIds: [targetRecordId],
}),
);
if (!alreadyApplied) {
const scopedFilter: RecordFilter = {
id: filterId,
fieldMetadataId: inverseRelationFieldMetadataId,
value: JSON.stringify({
isCurrentWorkspaceMemberSelected: false,
selectedRecordIds: [targetRecordId],
}),
displayValue: '',
label: '',
type: 'RELATION',
operand: ViewFilterOperand.IS,
};
store.set(currentRecordFiltersAtom, [scopedFilter]);
}
setIsScopedFilterReady(true);
}, [
lastLoadedRecordTableWidgetViewId,
viewId,
widgetId,
inverseRelationFieldMetadataId,
targetRecordId,
store,
currentRecordFiltersAtom,
]);
if (!isScopedFilterReady) {
return <FieldWidgetRelationTableShimmer />;
}
return <RecordTableWidget />;
};
@@ -0,0 +1,47 @@
import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { styled } from '@linaria/react';
import { useContext } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[4]};
width: 100%;
`;
const StyledRow = styled.div`
display: flex;
gap: ${themeCssVariables.spacing[3]};
`;
const SHIMMER_ROW_COUNT = 5;
const SHIMMER_COLUMN_WIDTHS = [140, 100, 120, 80];
export const FieldWidgetRelationTableShimmer = () => {
const { theme } = useContext(ThemeContext);
return (
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
<StyledContainer>
{Array.from({ length: SHIMMER_ROW_COUNT }).map((_, rowIndex) => (
<StyledRow key={rowIndex}>
{SHIMMER_COLUMN_WIDTHS.map((width, columnIndex) => (
<Skeleton
key={columnIndex}
width={width}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.m}
/>
))}
</StyledRow>
))}
</StyledContainer>
</SkeletonTheme>
);
};
@@ -0,0 +1,107 @@
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
import { type FlatViewField } from '@/metadata-store/types/FlatViewField';
import { lastLoadedRecordTableWidgetViewIdComponentState } from '@/object-record/record-table-widget/states/lastLoadedRecordTableWidgetViewIdComponentState';
import { fieldsWidgetUngroupedFieldsDraftComponentState } from '@/page-layout/states/fieldsWidgetUngroupedFieldsDraftComponentState';
import { DEFAULT_VIEW_FIELD_SIZE } from '@/page-layout/widgets/field/constants/fieldWidgetTableDefaults';
import { type FieldsWidgetGroupField } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useStore } from 'jotai';
import { useEffect } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
type FieldWidgetTableDraftToViewSyncEffectProps = {
viewId: string | null;
pageLayoutId: string;
widgetId: string;
};
export const FieldWidgetTableDraftToViewSyncEffect = ({
viewId,
pageLayoutId,
widgetId,
}: FieldWidgetTableDraftToViewSyncEffectProps) => {
const store = useStore();
const ungroupedDraft = useAtomComponentStateValue(
fieldsWidgetUngroupedFieldsDraftComponentState,
pageLayoutId,
);
const widgetDraftFields: FieldsWidgetGroupField[] =
ungroupedDraft[widgetId] ?? [];
useEffect(() => {
if (!isDefined(viewId) || widgetDraftFields.length === 0) {
return;
}
const viewFieldsEntry = store.get(
metadataStoreState.atomFamily('viewFields'),
);
const currentViewFields = viewFieldsEntry.current as FlatViewField[];
const currentViewFieldsForThisView = currentViewFields.filter(
(viewField) => viewField.viewId === viewId,
);
const hasChanges = widgetDraftFields.some((draftField) => {
const existing = currentViewFieldsForThisView.find(
(viewField) =>
viewField.id === draftField.viewFieldId ||
viewField.fieldMetadataId === draftField.fieldMetadataItem.id,
);
if (!isDefined(existing)) {
return true;
}
return (
existing.isVisible !== draftField.isVisible ||
existing.position !== draftField.position
);
});
if (!hasChanges) {
return;
}
const otherViewFields = currentViewFields.filter(
(viewField) => viewField.viewId !== viewId,
);
const draftViewFields: FlatViewField[] = widgetDraftFields.map(
(draftField) => {
const existingViewField = currentViewFieldsForThisView.find(
(viewField) =>
viewField.id === draftField.viewFieldId ||
viewField.fieldMetadataId === draftField.fieldMetadataItem.id,
);
return {
id: existingViewField?.id ?? draftField.viewFieldId ?? v4(),
viewId,
fieldMetadataId: draftField.fieldMetadataItem.id,
position: draftField.position,
size: existingViewField?.size ?? DEFAULT_VIEW_FIELD_SIZE,
isVisible: draftField.isVisible,
isActive: true,
};
},
);
store.set(metadataStoreState.atomFamily('viewFields'), {
...viewFieldsEntry,
current: [...otherViewFields, ...draftViewFields],
});
store.set(
lastLoadedRecordTableWidgetViewIdComponentState.atomFamily({
instanceId: `record-table-widget-${widgetId}`,
}),
null,
);
}, [widgetDraftFields, viewId, store, widgetId]);
return null;
};
@@ -1,11 +1,14 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { FieldDisplayMode } from '~/generated-metadata/graphql';
type FieldWidgetFieldTypeConfig = {
export type FieldWidgetFieldTypeConfig = {
availableDisplayModes: FieldDisplayMode[];
defaultDisplayMode: FieldDisplayMode;
};
// Static fallback used when only the field type is known (e.g. when the field
// has not been selected yet). Relation-type-sensitive modes like VIEW are
// resolved at runtime in getFieldWidgetDisplayModeConfig.
export const FIELD_WIDGET_CONFIG: Partial<
Record<FieldMetadataType, FieldWidgetFieldTypeConfig>
> = {
@@ -0,0 +1,2 @@
export const DEFAULT_VIEW_FIELD_SIZE = 180;
export const INITIAL_VISIBLE_FIELDS_COUNT_IN_WIDGET = 6;
@@ -0,0 +1,35 @@
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import {
DEFAULT_VIEW_FIELD_SIZE,
INITIAL_VISIBLE_FIELDS_COUNT_IN_WIDGET,
} from '@/page-layout/widgets/field/constants/fieldWidgetTableDefaults';
import { filterFieldsForRecordTableViewCreation } from '@/page-layout/widgets/record-table/utils/filterFieldsForRecordTableViewCreation';
import { sortFieldsByRelevanceForRecordTableWidget } from '@/page-layout/widgets/record-table/utils/sortFieldsByRelevanceForRecordTableWidget';
import { v4 } from 'uuid';
export const buildDefaultViewFieldInputs = ({
relatedObject,
viewId,
}: {
relatedObject: EnrichedObjectMetadataItem;
viewId: string;
}) => {
const eligibleFields = relatedObject.fields.filter(
filterFieldsForRecordTableViewCreation,
);
const sortedFields = eligibleFields.toSorted(
sortFieldsByRelevanceForRecordTableWidget(
relatedObject.labelIdentifierFieldMetadataId,
),
);
return sortedFields.map((field, index) => ({
id: v4(),
viewId,
fieldMetadataId: field.id,
position: index,
size: DEFAULT_VIEW_FIELD_SIZE,
isVisible: index < INITIAL_VISIBLE_FIELDS_COUNT_IN_WIDGET,
}));
};
@@ -1,11 +1,61 @@
import { type FieldMetadataType } from 'twenty-shared/types';
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import { FieldDisplayMode } from '~/generated-metadata/graphql';
import { FIELD_WIDGET_CONFIG } from '@/page-layout/widgets/field/constants/fieldWidgetConfig';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { hasJunctionConfig } from '@/object-record/record-field/ui/utils/junction/hasJunctionConfig';
import {
FIELD_WIDGET_CONFIG,
type FieldWidgetFieldTypeConfig,
} from '@/page-layout/widgets/field/constants/fieldWidgetConfig';
type FieldMetadataItemLike = Pick<
FieldMetadataItem,
'type' | 'settings' | 'relation'
>;
const isNonJunctionOneToManyRelation = (
fieldMetadataItem: FieldMetadataItemLike,
) => {
if (fieldMetadataItem.type !== FieldMetadataType.RELATION) {
return false;
}
if (hasJunctionConfig(fieldMetadataItem.settings)) {
return false;
}
const relationType =
fieldMetadataItem.relation?.type ??
fieldMetadataItem.settings?.relationType;
return relationType === RelationType.ONE_TO_MANY;
};
export const getFieldWidgetConfig = (fieldType: FieldMetadataType) =>
FIELD_WIDGET_CONFIG[fieldType];
export const getFieldWidgetConfigForField = (
fieldMetadataItem: FieldMetadataItemLike,
): FieldWidgetFieldTypeConfig | undefined => {
const baseConfig = FIELD_WIDGET_CONFIG[fieldMetadataItem.type];
if (baseConfig === undefined) {
return undefined;
}
if (isNonJunctionOneToManyRelation(fieldMetadataItem)) {
return {
...baseConfig,
availableDisplayModes: [
...baseConfig.availableDisplayModes,
FieldDisplayMode.VIEW,
],
};
}
return baseConfig;
};
export const getFieldWidgetDefaultDisplayMode = (
fieldType: FieldMetadataType,
) =>
@@ -18,7 +68,22 @@ export const getFieldWidgetAvailableDisplayModes = (
FieldDisplayMode.FIELD,
];
export const getFieldWidgetAvailableDisplayModesForField = (
fieldMetadataItem: FieldMetadataItemLike,
) =>
getFieldWidgetConfigForField(fieldMetadataItem)?.availableDisplayModes ?? [
FieldDisplayMode.FIELD,
];
export const isDisplayModeValidForFieldType = (
fieldType: FieldMetadataType,
displayMode: FieldDisplayMode,
) => getFieldWidgetAvailableDisplayModes(fieldType).includes(displayMode);
export const isDisplayModeValidForField = (
fieldMetadataItem: FieldMetadataItemLike,
displayMode: FieldDisplayMode,
) =>
getFieldWidgetAvailableDisplayModesForField(fieldMetadataItem).includes(
displayMode,
);
@@ -41,11 +41,13 @@ const StyledAddGroupButtonContainer = styled.div`
type FieldsConfigurationEditorProps = {
pageLayoutId: string;
widgetId: string;
hideAddGroup?: boolean;
};
export const FieldsConfigurationEditor = ({
pageLayoutId,
widgetId,
hideAddGroup = false,
}: FieldsConfigurationEditorProps) => {
const { t } = useLingui();
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
@@ -207,7 +209,7 @@ export const FieldsConfigurationEditor = ({
ungroupedFields={ungroupedFields}
onMoveField={moveUngroupedField}
onToggleFieldVisibility={toggleUngroupedFieldVisibility}
onAddGroup={() => handleAddGroup({})}
onAddGroup={hideAddGroup ? undefined : () => handleAddGroup({})}
/>
);
}
@@ -6,6 +6,7 @@ import { FieldsConfigurationFieldEditor } from '@/page-layout/widgets/fields/com
import { type FieldsWidgetGroupField } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconNewSection } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
@@ -19,7 +20,7 @@ type FieldsConfigurationUngroupedEditorProps = {
ungroupedFields: FieldsWidgetGroupField[];
onMoveField: (sourceIndex: number, destinationIndex: number) => void;
onToggleFieldVisibility: (fieldMetadataId: string) => void;
onAddGroup: () => void;
onAddGroup?: () => void;
};
export const FieldsConfigurationUngroupedEditor = ({
@@ -80,13 +81,15 @@ export const FieldsConfigurationUngroupedEditor = ({
))}
{provided.placeholder}
<MenuItem
LeftIcon={IconNewSection}
text={t`Add a Group`}
onClick={onAddGroup}
withIconContainer
withIconContainerBackground={false}
/>
{isDefined(onAddGroup) && (
<MenuItem
LeftIcon={IconNewSection}
text={t`Add a Group`}
onClick={onAddGroup}
withIconContainer
withIconContainerBackground={false}
/>
)}
</StyledFieldsDroppable>
)}
</Droppable>
@@ -7,6 +7,7 @@ import { SidePanelNewSidebarItemViewObjectPickerSubPage } from '@/navigation-men
import { SidePanelNewSidebarItemViewPickerSubPage } from '@/navigation-menu-item/edit/side-panel/components/SidePanelNewSidebarItemViewPickerSubPage';
import { SidePanelNewSidebarItemViewSystemPickerSubPage } from '@/navigation-menu-item/edit/side-panel/components/SidePanelNewSidebarItemViewSystemPickerSubPage';
import { SidePanelChartFilterSubPage } from '@/side-panel/pages/page-layout/components/SidePanelChartFilterSubPage';
import { SidePanelFieldWidgetTableFieldsSubPage } from '@/side-panel/pages/page-layout/components/SidePanelFieldWidgetTableFieldsSubPage';
import { SidePanelFieldsLayoutSubPage } from '@/side-panel/pages/page-layout/components/SidePanelFieldsLayoutSubPage';
import { SidePanelRecordTableFilterSubPage } from '@/side-panel/pages/page-layout/components/record-table-settings/SidePanelRecordTableFilterSubPage';
import { SidePanelRecordTableSortSubPage } from '@/side-panel/pages/page-layout/components/record-table-settings/SidePanelRecordTableSortSubPage';
@@ -19,6 +20,10 @@ export const SIDE_PANEL_SUB_PAGES_CONFIG = new Map<
>([
[SidePanelSubPages.PageLayoutGraphFilter, <SidePanelChartFilterSubPage />],
[SidePanelSubPages.PageLayoutFieldsLayout, <SidePanelFieldsLayoutSubPage />],
[
SidePanelSubPages.PageLayoutFieldWidgetTableFields,
<SidePanelFieldWidgetTableFieldsSubPage />,
],
[SidePanelSubPages.NewSidebarItemMainMenu, <SidePanelNewSidebarItemPage />],
[
SidePanelSubPages.PageLayoutRecordTableFilter,
@@ -0,0 +1,134 @@
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { type FieldConfiguration } from '@/page-layout/types/FieldConfiguration';
import { resolveRelatedObjectForFieldWidget } from '@/page-layout/utils/resolveRelatedObjectForFieldWidget';
import { FieldWidgetTableDraftToViewSyncEffect } from '@/page-layout/widgets/field/components/FieldWidgetTableDraftToViewSyncEffect';
import { FieldsConfigurationEditor } from '@/page-layout/widgets/fields/components/FieldsConfigurationEditor';
import { FieldsWidgetGroupsDraftInitializationEffect } from '@/page-layout/widgets/fields/components/FieldsWidgetGroupsDraftInitializationEffect';
import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdFromContextStore';
import { useWidgetInEditMode } from '@/side-panel/pages/page-layout/hooks/useWidgetInEditMode';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { styled } from '@linaria/react';
import { useEffect, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { FieldDisplayMode } from '~/generated-metadata/graphql';
const StyledFieldsLayoutContainer = styled.div`
display: flex;
flex: 1;
flex-direction: column;
gap: ${themeCssVariables.spacing[3]};
overflow-y: auto;
padding: ${themeCssVariables.spacing[2]};
`;
const FIELD_WIDGET_TABLE_FIELDS_CONTEXT_STORE_INSTANCE_ID =
'field-widget-table-fields-editor';
export const SidePanelFieldWidgetTableFieldsSubPage = () => {
const { pageLayoutId } = usePageLayoutIdFromContextStore();
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);
const pageLayoutDraft = useAtomComponentStateValue(
pageLayoutDraftComponentState,
pageLayoutId,
);
const { objectMetadataItems } = useObjectMetadataItems();
if (!isDefined(widgetInEditMode)) {
return null;
}
const fieldConfiguration =
widgetInEditMode.configuration as FieldConfiguration;
if (fieldConfiguration.fieldDisplayMode !== FieldDisplayMode.VIEW) {
return null;
}
const parentObjectMetadataId =
widgetInEditMode.objectMetadataId ?? pageLayoutDraft.objectMetadataId;
if (!isDefined(parentObjectMetadataId)) {
return null;
}
const resolved = resolveRelatedObjectForFieldWidget({
objectMetadataItems,
parentObjectMetadataId,
fieldMetadataId: fieldConfiguration.fieldMetadataId,
});
if (!isDefined(resolved)) {
return null;
}
return (
<ContextStoreComponentInstanceContext.Provider
value={{
instanceId: FIELD_WIDGET_TABLE_FIELDS_CONTEXT_STORE_INSTANCE_ID,
}}
>
<SidePanelFieldWidgetTableFieldsSubPageContent
relatedObjectMetadataItemId={resolved.relatedObject.id}
viewId={fieldConfiguration.viewId ?? null}
pageLayoutId={pageLayoutId}
widgetId={widgetInEditMode.id}
/>
</ContextStoreComponentInstanceContext.Provider>
);
};
type SidePanelFieldWidgetTableFieldsSubPageContentProps = {
relatedObjectMetadataItemId: string;
viewId: string | null;
pageLayoutId: string;
widgetId: string;
};
const SidePanelFieldWidgetTableFieldsSubPageContent = ({
relatedObjectMetadataItemId,
viewId,
pageLayoutId,
widgetId,
}: SidePanelFieldWidgetTableFieldsSubPageContentProps) => {
const setContextStoreCurrentObjectMetadataItemId = useSetAtomComponentState(
contextStoreCurrentObjectMetadataItemIdComponentState,
);
const [isContextStoreReady, setIsContextStoreReady] = useState(false);
useEffect(() => {
setContextStoreCurrentObjectMetadataItemId(relatedObjectMetadataItemId);
setIsContextStoreReady(true);
}, [relatedObjectMetadataItemId, setContextStoreCurrentObjectMetadataItemId]);
if (!isContextStoreReady) {
return null;
}
return (
<StyledFieldsLayoutContainer>
<FieldsWidgetGroupsDraftInitializationEffect
viewId={viewId}
pageLayoutId={pageLayoutId}
widgetId={widgetId}
/>
<FieldWidgetTableDraftToViewSyncEffect
viewId={viewId}
pageLayoutId={pageLayoutId}
widgetId={widgetId}
/>
<FieldsConfigurationEditor
pageLayoutId={pageLayoutId}
widgetId={widgetId}
hideAddGroup
/>
</StyledFieldsLayoutContainer>
);
};
@@ -1,10 +1,11 @@
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { isDefined } from 'twenty-shared/utils';
import { useSeedDraftViewForFieldWidgetTable } from '@/page-layout/hooks/useSeedDraftViewForFieldWidgetTable';
import { useUpdatePageLayoutWidget } from '@/page-layout/hooks/useUpdatePageLayoutWidget';
import { useFieldWidgetEligibleFields } from '@/page-layout/widgets/field/hooks/useFieldWidgetEligibleFields';
import {
getFieldWidgetDefaultDisplayMode,
isDisplayModeValidForFieldType,
isDisplayModeValidForField,
} from '@/page-layout/widgets/field/utils/getFieldWidgetDisplayModeConfig';
import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdFromContextStore';
import { useUpdateCurrentWidgetConfig } from '@/side-panel/pages/page-layout/hooks/useUpdateCurrentWidgetConfig';
@@ -23,7 +24,11 @@ import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { useIcons } from 'twenty-ui/display';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { type FieldConfiguration } from '~/generated-metadata/graphql';
import { v4 } from 'uuid';
import {
FieldDisplayMode,
type FieldConfiguration,
} from '~/generated-metadata/graphql';
import { filterBySearchQuery } from '~/utils/filterBySearchQuery';
export const FieldWidgetFieldDropdownContent = () => {
@@ -57,6 +62,11 @@ export const FieldWidgetFieldDropdownContent = () => {
const { updatePageLayoutWidget } = useUpdatePageLayoutWidget(pageLayoutId);
const { seedDraftViewForFieldWidgetTable } =
useSeedDraftViewForFieldWidgetTable();
const parentObjectMetadataId = widgetInEditMode?.objectMetadataId;
const { closeDropdown } = useCloseDropdown();
const { getIcon } = useIcons();
@@ -80,11 +90,19 @@ export const FieldWidgetFieldDropdownContent = () => {
const needsDisplayModeSwitch =
isDefined(selectedField) &&
isDefined(currentDisplayMode) &&
!isDisplayModeValidForFieldType(selectedField.type, currentDisplayMode);
!isDisplayModeValidForField(selectedField, currentDisplayMode);
const staysInTableMode =
currentDisplayMode === FieldDisplayMode.VIEW &&
isDefined(selectedField) &&
isDisplayModeValidForField(selectedField, FieldDisplayMode.VIEW);
const nextViewId = staysInTableMode ? v4() : null;
updateCurrentWidgetConfig({
configToUpdate: {
fieldMetadataId,
viewId: nextViewId,
...(needsDisplayModeSwitch && {
fieldDisplayMode: getFieldWidgetDefaultDisplayMode(
selectedField.type,
@@ -93,6 +111,23 @@ export const FieldWidgetFieldDropdownContent = () => {
},
});
const widgetId = widgetInEditMode?.id;
if (
staysInTableMode &&
isDefined(nextViewId) &&
isDefined(parentObjectMetadataId) &&
isDefined(widgetId)
) {
seedDraftViewForFieldWidgetTable({
viewId: nextViewId,
fieldMetadataId,
parentObjectMetadataId,
pageLayoutId,
widgetId,
});
}
if (widgetInEditMode && selectedField) {
updatePageLayoutWidget(widgetInEditMode.id, {
title: selectedField.label,
@@ -1,5 +1,6 @@
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { getFieldWidgetAvailableDisplayModes } from '@/page-layout/widgets/field/utils/getFieldWidgetDisplayModeConfig';
import { useSeedDraftViewForFieldWidgetTable } from '@/page-layout/hooks/useSeedDraftViewForFieldWidgetTable';
import { getFieldWidgetAvailableDisplayModesForField } from '@/page-layout/widgets/field/utils/getFieldWidgetDisplayModeConfig';
import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdFromContextStore';
import { useUpdateCurrentWidgetConfig } from '@/side-panel/pages/page-layout/hooks/useUpdateCurrentWidgetConfig';
import { useWidgetInEditMode } from '@/side-panel/pages/page-layout/hooks/useWidgetInEditMode';
@@ -18,8 +19,11 @@ import {
IconFileText,
IconLayoutKanban,
IconListDetails,
IconTable,
} from 'twenty-ui/display';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import {
FieldDisplayMode,
type FieldConfiguration,
@@ -29,7 +33,7 @@ const DISPLAY_MODE_ICONS: Record<FieldDisplayMode, IconComponent> = {
[FieldDisplayMode.FIELD]: IconListDetails,
[FieldDisplayMode.CARD]: IconLayoutKanban,
[FieldDisplayMode.EDITOR]: IconFileText,
[FieldDisplayMode.VIEW]: IconListDetails,
[FieldDisplayMode.VIEW]: IconTable,
};
export const FieldWidgetLayoutDropdownContent = () => {
@@ -45,6 +49,7 @@ export const FieldWidgetLayoutDropdownContent = () => {
const currentDisplayMode = fieldConfiguration?.fieldDisplayMode;
const currentFieldMetadataId = fieldConfiguration?.fieldMetadataId;
const currentViewId = fieldConfiguration?.viewId;
const { fieldMetadataItem } = useFieldMetadataItemById(
currentFieldMetadataId ?? '',
@@ -53,7 +58,7 @@ export const FieldWidgetLayoutDropdownContent = () => {
const layoutOptions = useMemo(
() =>
fieldMetadataItem
? getFieldWidgetAvailableDisplayModes(fieldMetadataItem.type)
? getFieldWidgetAvailableDisplayModesForField(fieldMetadataItem)
: [FieldDisplayMode.FIELD],
[fieldMetadataItem],
);
@@ -70,14 +75,49 @@ export const FieldWidgetLayoutDropdownContent = () => {
const { updateCurrentWidgetConfig } =
useUpdateCurrentWidgetConfig(pageLayoutId);
const { seedDraftViewForFieldWidgetTable } =
useSeedDraftViewForFieldWidgetTable();
const { closeDropdown } = useCloseDropdown();
const parentObjectMetadataId = widgetInEditMode?.objectMetadataId;
const handleSelectLayout = (fieldDisplayMode: FieldDisplayMode) => {
// Generate a viewId up-front for VIEW mode so the draft carries it; the
// actual TABLE_WIDGET view is created on Save. Null it on any other mode
// so an abandoned selection does not leak a pending view creation.
const nextViewId =
fieldDisplayMode === FieldDisplayMode.VIEW
? (currentViewId ?? v4())
: null;
updateCurrentWidgetConfig({
configToUpdate: {
fieldDisplayMode,
viewId: nextViewId,
},
});
// Seed a synthetic view into the client metadata store so RecordTableWidget
// can render immediately, before the real view is persisted on Save.
const widgetId = widgetInEditMode?.id;
if (
fieldDisplayMode === FieldDisplayMode.VIEW &&
isDefined(nextViewId) &&
isDefined(currentFieldMetadataId) &&
isDefined(parentObjectMetadataId) &&
isDefined(widgetId)
) {
seedDraftViewForFieldWidgetTable({
viewId: nextViewId,
fieldMetadataId: currentFieldMetadataId,
parentObjectMetadataId,
pageLayoutId,
widgetId,
});
}
closeDropdown();
};
@@ -85,6 +125,7 @@ export const FieldWidgetLayoutDropdownContent = () => {
[FieldDisplayMode.FIELD]: t`Field`,
[FieldDisplayMode.CARD]: t`Card`,
[FieldDisplayMode.EDITOR]: t`Editor`,
[FieldDisplayMode.VIEW]: t`Table`,
};
return (
@@ -1,7 +1,9 @@
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
import { CommandMenuItemDropdown } from '@/command-menu/components/CommandMenuItemDropdown';
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
import { SidePanelList } from '@/side-panel/components/SidePanelList';
import { useSidePanelSubPageHistory } from '@/side-panel/hooks/useSidePanelSubPageHistory';
import { FieldWidgetFieldDropdownContent } from '@/side-panel/pages/page-layout/components/dropdown-content/FieldWidgetFieldDropdownContent';
import { FieldWidgetLayoutDropdownContent } from '@/side-panel/pages/page-layout/components/dropdown-content/FieldWidgetLayoutDropdownContent';
import { WidgetSettingsManageSection } from '@/side-panel/pages/page-layout/components/WidgetSettingsManageSection';
@@ -10,12 +12,17 @@ import { WIDGET_SETTINGS_SELECTABLE_ITEM_IDS } from '@/side-panel/pages/page-lay
import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdFromContextStore';
import { useWidgetInEditMode } from '@/side-panel/pages/page-layout/hooks/useWidgetInEditMode';
import { useWidgetSettingsPlacementSelectableItemIds } from '@/side-panel/pages/page-layout/hooks/useWidgetSettingsPlacementSelectableItemIds';
import { SidePanelSubPages } from '@/side-panel/types/SidePanelSubPages';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconLayoutSidebarRight, IconListDetails } from 'twenty-ui/display';
import {
IconColumns,
IconLayoutSidebarRight,
IconListDetails,
} from 'twenty-ui/display';
import {
FieldDisplayMode,
type FieldConfiguration,
@@ -37,6 +44,7 @@ const StyledSidePanelContainer = styled.div`
export const SidePanelRecordPageFieldSettings = () => {
const { t } = useLingui();
const { pageLayoutId } = usePageLayoutIdFromContextStore();
const { navigateToSidePanelSubPage } = useSidePanelSubPageHistory();
const { placementSelectableItemIds } =
useWidgetSettingsPlacementSelectableItemIds(pageLayoutId);
@@ -63,15 +71,25 @@ export const SidePanelRecordPageFieldSettings = () => {
[FieldDisplayMode.FIELD]: t`Field`,
[FieldDisplayMode.CARD]: t`Card`,
[FieldDisplayMode.EDITOR]: t`Editor`,
[FieldDisplayMode.VIEW]: t`Table`,
};
const layoutLabel = isDefined(currentDisplayMode)
? (displayModeLabels[currentDisplayMode] ?? '')
: '';
const isTableDisplayMode = currentDisplayMode === FieldDisplayMode.VIEW;
const handleNavigateToFields = () => {
navigateToSidePanelSubPage(
SidePanelSubPages.PageLayoutFieldWidgetTableFields,
);
};
const selectableItemIds = [
'field',
'layout',
...(isTableDisplayMode ? ['fields'] : []),
WIDGET_SETTINGS_SELECTABLE_ITEM_IDS.VISIBILITY_RESTRICTION,
WIDGET_SETTINGS_SELECTABLE_ITEM_IDS.RESET_TO_DEFAULT,
WIDGET_SETTINGS_SELECTABLE_ITEM_IDS.REPLACE_WIDGET,
@@ -116,6 +134,20 @@ export const SidePanelRecordPageFieldSettings = () => {
contextualTextPosition="right"
/>
</SelectableListItem>
{isTableDisplayMode && (
<SelectableListItem
itemId="fields"
onEnter={handleNavigateToFields}
>
<CommandMenuItem
id="fields"
label={t`Fields`}
Icon={IconColumns}
hasSubMenu
onClick={handleNavigateToFields}
/>
</SelectableListItem>
)}
</SidePanelGroup>
<WidgetSettingsManageSection pageLayoutId={pageLayoutId} />
<WidgetSettingsPlacementSection pageLayoutId={pageLayoutId} />
@@ -3,6 +3,7 @@ export enum SidePanelSubPages {
PageLayoutFieldsLayout = 'page-layout-fields-layout',
PageLayoutRecordTableFilter = 'page-layout-record-table-filter',
PageLayoutRecordTableSort = 'page-layout-record-table-sort',
PageLayoutFieldWidgetTableFields = 'page-layout-field-widget-table-fields',
NewSidebarItemMainMenu = 'new-sidebar-item-main-menu',
NewSidebarItemViewObjectPicker = 'new-sidebar-item-view-object-picker',
NewSidebarItemViewPicker = 'new-sidebar-item-view-picker',
@@ -14,6 +14,8 @@ export const getSidePanelSubPageTitle = (
return t`Filters`;
case SidePanelSubPages.PageLayoutRecordTableSort:
return t`Sorts`;
case SidePanelSubPages.PageLayoutFieldWidgetTableFields:
return t`Fields`;
case SidePanelSubPages.NewSidebarItemMainMenu:
return t`Add menu item`;
case SidePanelSubPages.NewSidebarItemViewObjectPicker:
@@ -348,7 +348,7 @@ export const fromPageLayoutWidgetConfigurationToUniversalConfiguration = ({
}
case WidgetConfigurationType.FIELD: {
const { fieldMetadataId, fieldDisplayMode, configurationType } =
const { fieldMetadataId, fieldDisplayMode, configurationType, viewId } =
configuration;
const fieldMetadataUniversalIdentifier =
@@ -358,10 +358,27 @@ export const fromPageLayoutWidgetConfigurationToUniversalConfiguration = ({
shouldThrowOnMissingIdentifier,
});
let viewUniversalIdentifier: string | null | undefined = undefined;
if (isDefined(viewId)) {
viewUniversalIdentifier = viewUniversalIdentifierById[viewId] ?? null;
if (
!isDefined(viewUniversalIdentifier) &&
shouldThrowOnMissingIdentifier
) {
throw new FlatEntityMapsException(
`View universal identifier not found for id: ${viewId}`,
FlatEntityMapsExceptionCode.RELATION_UNIVERSAL_IDENTIFIER_NOT_FOUND,
);
}
}
return {
configurationType,
fieldMetadataId: fieldMetadataUniversalIdentifier ?? fieldMetadataId,
fieldDisplayMode,
viewId: viewUniversalIdentifier,
};
}
@@ -1,6 +1,12 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IsEnum, IsIn, IsNotEmpty, IsString } from 'class-validator';
import {
IsEnum,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
} from 'class-validator';
import { type FieldConfiguration } from 'twenty-shared/types';
@@ -23,4 +29,9 @@ export class FieldConfigurationDTO implements FieldConfiguration {
@IsEnum(FieldDisplayMode)
@IsNotEmpty()
fieldDisplayMode: FieldDisplayMode;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
viewId?: string | null;
}
@@ -22,7 +22,7 @@ const resolveFieldMetadataIdOrThrow = ({
}): string => {
if (!isDefined(fieldMetadataUniversalIdentifier)) {
throw new FlatEntityMapsException(
`Could not found any field metadata universal identifier while resolving page layout widget`,
`Could not find any field metadata universal identifier while resolving page layout widget`,
FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
);
}
@@ -337,15 +337,36 @@ export const fromUniversalConfigurationToFlatPageLayoutWidgetConfiguration = ({
}
case WidgetConfigurationType.FIELD: {
const { fieldMetadataId: fieldMetadataUniversalIdentifier, ...rest } =
universalConfiguration;
const {
fieldMetadataId: fieldMetadataUniversalIdentifier,
viewId: viewUniversalIdentifier,
...rest
} = universalConfiguration;
const fieldMetadataId = resolveFieldMetadataIdOrThrow({
fieldMetadataUniversalIdentifier,
flatFieldMetadataMaps,
});
return { ...rest, fieldMetadataId };
let viewId: string | null | undefined = undefined;
if (isDefined(viewUniversalIdentifier)) {
const flatView = findFlatEntityByUniversalIdentifier({
flatEntityMaps: flatViewMaps,
universalIdentifier: viewUniversalIdentifier,
});
if (!isDefined(flatView)) {
throw new FlatEntityMapsException(
`View not found for universal identifier: ${viewUniversalIdentifier}`,
FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
);
}
viewId = flatView.id;
}
return { ...rest, fieldMetadataId, viewId };
}
case WidgetConfigurationType.VIEW:
@@ -100,6 +100,7 @@ export type FieldConfiguration = {
configurationType: 'FIELD';
fieldMetadataId: string;
fieldDisplayMode: 'CARD' | 'EDITOR' | 'FIELD' | 'VIEW';
viewId?: string | null;
};
export type FieldsConfiguration = {