AI tools to create a demo workspace (#18236)

This PR adds the necessary tool to create a demo workspace with :
relevant custom objects and fields, mock data and a real dashboard with
graph widgets.

It is still a bit under-optimized and slow but it works.

This PR also adds an AI tool that allows to see what happens in real
time, it navigates the app and waits when necessary.

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Lucas Bordeau
2026-03-05 11:39:31 +01:00
committed by GitHub
parent a2f80d882b
commit 2a82df7073
84 changed files with 2449 additions and 315 deletions
@@ -1,11 +1,19 @@
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { PageFocusId } from '@/types/PageFocusId';
import { useResetFocusStackToFocusItem } from '@/ui/utilities/focus/hooks/useResetFocusStackToFocusItem';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useStore } from 'jotai';
export const useResetFocusStackToRecordIndex = () => {
const { resetFocusStackToFocusItem } = useResetFocusStackToFocusItem();
const store = useStore();
const resetFocusStackToRecordIndex = () => {
const isCommandMenuOpen = store.get(isCommandMenuOpenedState.atom);
const shouldEnableGlobalHotkeys = !isCommandMenuOpen;
resetFocusStackToFocusItem({
focusStackItem: {
focusId: PageFocusId.RecordIndex,
@@ -14,8 +22,8 @@ export const useResetFocusStackToRecordIndex = () => {
componentInstanceId: PageFocusId.RecordIndex,
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: true,
enableGlobalHotkeysConflictingWithKeyboard: true,
enableGlobalHotkeysWithModifiers: shouldEnableGlobalHotkeys,
enableGlobalHotkeysConflictingWithKeyboard: shouldEnableGlobalHotkeys,
},
},
});
@@ -1,11 +1,12 @@
import { RecordTableDeactivateRecordTableRowEffect } from '@/object-record/record-table/components/RecordTableDeactivateRecordTableRowEffect';
import { RecordTableEmptyHasNewRecordEffect } from '@/object-record/record-table/components/RecordTableEmptyHasNewRecordEffect';
import { RecordTableBodyEscapeHotkeyEffect } from '@/object-record/record-table/record-table-body/components/RecordTableBodyEscapeHotkeyEffect';
import { RecordTableBodyFocusClickOutsideEffect } from '@/object-record/record-table/record-table-body/components/RecordTableBodyFocusClickOutsideEffect';
import { RecordTableBodyFocusKeyboardEffect } from '@/object-record/record-table/record-table-body/components/RecordTableBodyFocusKeyboardEffect';
import { RecordTableRecordGroupBodyEffects } from '@/object-record/record-table/record-table-body/components/RecordTableRecordGroupBodyEffects';
import { RecordTableNoRecordGroupScrollToPreviousRecordEffect } from '@/object-record/record-table/virtualization/components/RecordTableNoRecordGroupScrollToPreviousRecordEffect';
import { RecordTableVirtualizedInitialDataLoadEffect } from '@/object-record/record-table/virtualization/components/RecordTableVirtualizedInitialDataLoadEffect';
import { RecordTableVirtualizedFieldMetadataUpdateEffect } from '@/object-record/record-table/virtualization/components/RecordTableVirtualizedFieldMetadataUpdateEffect';
import { RecordTableVirtualizedInitialDataLoadEffect } from '@/object-record/record-table/virtualization/components/RecordTableVirtualizedInitialDataLoadEffect';
export interface RecordTableBodyEffectsWrapperProps {
hasRecordGroups: boolean;
@@ -22,6 +23,7 @@ export const RecordTableBodyEffectsWrapper = ({
<RecordTableRecordGroupBodyEffects />
) : (
<>
<RecordTableEmptyHasNewRecordEffect />
<RecordTableNoRecordGroupScrollToPreviousRecordEffect />
<RecordTableVirtualizedInitialDataLoadEffect />
<RecordTableVirtualizedFieldMetadataUpdateEffect />
@@ -0,0 +1,103 @@
import { useListenToObjectRecordOperationBrowserEvent } from '@/browser-event/hooks/useListenToObjectRecordOperationBrowserEvent';
import { type ObjectRecordOperationBrowserEventDetail } from '@/browser-event/types/ObjectRecordOperationBrowserEventDetail';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { recordIndexHasRecordsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexHasRecordsComponentSelector';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { isRecordTableInitialLoadingComponentState } from '@/object-record/record-table/states/isRecordTableInitialLoadingComponentState';
import { recordTableWentFromEmptyToNotEmptyComponentState } from '@/object-record/record-table/states/recordTableWentFromEmptyToNotEmptyComponentState';
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useStore } from 'jotai';
import { useMemo } from 'react';
import { computeRecordGqlOperationFilter } from 'twenty-shared/utils';
export const RecordTableEmptyHasNewRecordEffect = () => {
const { objectMetadataItem } = useRecordIndexContextOrThrow();
const store = useStore();
const isRecordTableInitialLoading = useAtomComponentStateValue(
isRecordTableInitialLoadingComponentState,
);
const recordTableHasRecords = useAtomComponentSelectorValue(
recordIndexHasRecordsComponentSelector,
);
const recordTableWentFromEmptyToNotEmptyCallbackState =
useAtomComponentStateCallbackState(
recordTableWentFromEmptyToNotEmptyComponentState,
);
const { filterValueDependencies } = useFilterValueDependencies();
const currentRecordFilters = useAtomComponentStateValue(
currentRecordFiltersComponentState,
);
const currentRecordSorts = useAtomComponentStateValue(
currentRecordSortsComponentState,
);
const currentRecordFilterGroups = useAtomComponentStateValue(
currentRecordFilterGroupsComponentState,
);
const queryId = `record-table-empty-${objectMetadataItem.nameSingular}`;
const operationSignature = useMemo(
() => ({
objectNameSingular: objectMetadataItem.nameSingular,
variables: {
filter: computeRecordGqlOperationFilter({
fields: objectMetadataItem.fields,
recordFilters: currentRecordFilters,
recordFilterGroups: currentRecordFilterGroups,
filterValueDependencies,
}),
orderBy: turnSortsIntoOrderBy(objectMetadataItem, currentRecordSorts),
},
}),
[
objectMetadataItem,
currentRecordFilters,
currentRecordFilterGroups,
filterValueDependencies,
currentRecordSorts,
],
);
useListenToEventsForQuery({
queryId,
operationSignature,
});
const handleObjectRecordOperation = (
objectRecordOperationEventDetail: ObjectRecordOperationBrowserEventDetail,
) => {
const objectRecordOperation = objectRecordOperationEventDetail.operation;
if (
objectRecordOperation.type.includes('update') ||
objectRecordOperation.type.includes('create')
) {
if (!isRecordTableInitialLoading && !recordTableHasRecords) {
store.set(recordTableWentFromEmptyToNotEmptyCallbackState, true);
}
}
};
useListenToObjectRecordOperationBrowserEvent({
onObjectRecordOperationBrowserEvent: handleObjectRecordOperation,
objectMetadataItemId: objectMetadataItem.id,
});
return null;
};
@@ -0,0 +1,9 @@
import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
export const recordTableWentFromEmptyToNotEmptyComponentState =
createAtomComponentState<boolean>({
key: 'recordTableWentFromEmptyToNotEmptyComponentState',
defaultValue: false,
componentInstanceContext: RecordTableComponentInstanceContext,
});
@@ -2,15 +2,16 @@ import { useRecordIndexTableFetchMore } from '@/object-record/record-index/hooks
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { visibleRecordFieldsComponentSelector } from '@/object-record/record-field/states/visibleRecordFieldsComponentSelector';
import { recordTableWentFromEmptyToNotEmptyComponentState } from '@/object-record/record-table/states/recordTableWentFromEmptyToNotEmptyComponentState';
import { useTriggerInitialRecordTableDataLoad } from '@/object-record/record-table/virtualization/hooks/useTriggerInitialRecordTableDataLoad';
import { isInitializingVirtualTableDataLoadingComponentState } from '@/object-record/record-table/virtualization/states/isInitializingVirtualTableDataLoadingComponentState';
import { lastContextStoreVirtualizedViewIdComponentState } from '@/object-record/record-table/virtualization/states/lastContextStoreVirtualizedViewIdComponentState';
import { lastContextStoreVirtualizedVisibleRecordFieldsComponentState } from '@/object-record/record-table/virtualization/states/lastContextStoreVirtualizedVisibleRecordFieldsComponentState';
import { lastRecordTableQueryIdentifierComponentState } from '@/object-record/record-table/virtualization/states/lastRecordTableQueryIdentifierComponentState';
import { isFetchingMoreRecordsFamilyState } from '@/object-record/states/isFetchingMoreRecordsFamilyState';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
import isEmpty from 'lodash.isempty';
import { useEffect } from 'react';
@@ -31,6 +32,11 @@ export const RecordTableVirtualizedInitialDataLoadEffect = () => {
isInitializingVirtualTableDataLoadingComponentState,
);
const [
recordTableWentFromEmptyToNotEmpty,
setRecordTableWentFromEmptyToNotEmpty,
] = useAtomComponentState(recordTableWentFromEmptyToNotEmptyComponentState);
const isFetchingMoreRecords = useAtomFamilyStateValue(
isFetchingMoreRecordsFamilyState,
recordTableId,
@@ -78,6 +84,10 @@ export const RecordTableVirtualizedInitialDataLoadEffect = () => {
) {
setLastRecordTableQueryIdentifier(queryIdentifier);
await triggerInitialRecordTableDataLoad();
} else if (recordTableWentFromEmptyToNotEmpty) {
setRecordTableWentFromEmptyToNotEmpty(false);
await triggerInitialRecordTableDataLoad();
} else if (
JSON.stringify(lastContextStoreVirtualizedVisibleRecordFields) !==
@@ -98,6 +108,8 @@ export const RecordTableVirtualizedInitialDataLoadEffect = () => {
}
})();
}, [
recordTableWentFromEmptyToNotEmpty,
setRecordTableWentFromEmptyToNotEmpty,
queryIdentifier,
lastRecordTableQueryIdentifier,
triggerInitialRecordTableDataLoad,