Search (#7237)
Steps to test 1. Run metadata migrations 2. Run sync-metadata on your workspace 3. Enable the following feature flags: IS_SEARCH_ENABLED IS_QUERY_RUNNER_TWENTY_ORM_ENABLED IS_WORKSPACE_MIGRATED_FOR_SEARCH 4. Type Cmd + K and search anything
This commit is contained in:
@ -16,7 +16,9 @@ import { useKeyboardShortcutMenu } from '@/keyboard-shortcut-menu/hooks/useKeybo
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { getCompanyDomainName } from '@/object-metadata/utils/getCompanyDomainName';
|
||||
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
|
||||
import { useSearchRecords } from '@/object-record/hooks/useSearchRecords';
|
||||
import { makeOrFilterVariables } from '@/object-record/utils/makeOrFilterVariables';
|
||||
import { Opportunity } from '@/opportunities/Opportunity';
|
||||
import { Person } from '@/people/types/Person';
|
||||
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
|
||||
import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem';
|
||||
@ -165,8 +167,21 @@ export const CommandMenu = () => {
|
||||
[closeCommandMenu],
|
||||
);
|
||||
|
||||
const { records: people } = useFindManyRecords<Person>({
|
||||
skip: !isCommandMenuOpened,
|
||||
const isTwentyOrmEnabled = useIsFeatureEnabled(
|
||||
'IS_QUERY_RUNNER_TWENTY_ORM_ENABLED',
|
||||
);
|
||||
|
||||
const isWorkspaceMigratedForSearch = useIsFeatureEnabled(
|
||||
'IS_WORKSPACE_MIGRATED_FOR_SEARCH',
|
||||
);
|
||||
|
||||
const isSearchEnabled =
|
||||
useIsFeatureEnabled('IS_SEARCH_ENABLED') &&
|
||||
isTwentyOrmEnabled &&
|
||||
isWorkspaceMigratedForSearch;
|
||||
|
||||
const { records: peopleFromFindMany } = useFindManyRecords<Person>({
|
||||
skip: !isCommandMenuOpened || isSearchEnabled,
|
||||
objectNameSingular: CoreObjectNameSingular.Person,
|
||||
filter: commandMenuSearch
|
||||
? makeOrFilterVariables([
|
||||
@ -183,9 +198,24 @@ export const CommandMenu = () => {
|
||||
: undefined,
|
||||
limit: 3,
|
||||
});
|
||||
const { records: peopleFromSearch } = useSearchRecords<Person>({
|
||||
skip: !isCommandMenuOpened || !isSearchEnabled,
|
||||
objectNameSingular: CoreObjectNameSingular.Person,
|
||||
limit: 3,
|
||||
searchInput: commandMenuSearch ?? undefined,
|
||||
});
|
||||
|
||||
const { records: companies } = useFindManyRecords<Company>({
|
||||
skip: !isCommandMenuOpened,
|
||||
const people = isSearchEnabled ? peopleFromSearch : peopleFromFindMany;
|
||||
|
||||
const { records: companiesFromSearch } = useSearchRecords<Company>({
|
||||
skip: !isCommandMenuOpened || !isSearchEnabled,
|
||||
objectNameSingular: CoreObjectNameSingular.Company,
|
||||
limit: 3,
|
||||
searchInput: commandMenuSearch ?? undefined,
|
||||
});
|
||||
|
||||
const { records: companiesFromFindMany } = useFindManyRecords<Company>({
|
||||
skip: !isCommandMenuOpened || isSearchEnabled,
|
||||
objectNameSingular: CoreObjectNameSingular.Company,
|
||||
filter: commandMenuSearch
|
||||
? {
|
||||
@ -195,6 +225,10 @@ export const CommandMenu = () => {
|
||||
limit: 3,
|
||||
});
|
||||
|
||||
const companies = isSearchEnabled
|
||||
? companiesFromSearch
|
||||
: companiesFromFindMany;
|
||||
|
||||
const { records: notes } = useFindManyRecords<Note>({
|
||||
skip: !isCommandMenuOpened,
|
||||
objectNameSingular: CoreObjectNameSingular.Note,
|
||||
@ -207,8 +241,8 @@ export const CommandMenu = () => {
|
||||
limit: 3,
|
||||
});
|
||||
|
||||
const { records: opportunities } = useFindManyRecords({
|
||||
skip: !isCommandMenuOpened,
|
||||
const { records: opportunitiesFromFindMany } = useFindManyRecords({
|
||||
skip: !isCommandMenuOpened || isSearchEnabled,
|
||||
objectNameSingular: CoreObjectNameSingular.Opportunity,
|
||||
filter: commandMenuSearch
|
||||
? {
|
||||
@ -218,6 +252,17 @@ export const CommandMenu = () => {
|
||||
limit: 3,
|
||||
});
|
||||
|
||||
const { records: opportunitiesFromSearch } = useSearchRecords<Opportunity>({
|
||||
skip: !isCommandMenuOpened || !isSearchEnabled,
|
||||
objectNameSingular: CoreObjectNameSingular.Opportunity,
|
||||
limit: 3,
|
||||
searchInput: commandMenuSearch ?? undefined,
|
||||
});
|
||||
|
||||
const opportunities = isSearchEnabled
|
||||
? opportunitiesFromSearch
|
||||
: opportunitiesFromFindMany;
|
||||
|
||||
const peopleCommands = useMemo(
|
||||
() =>
|
||||
people.map(({ id, name: { firstName, lastName } }) => ({
|
||||
|
||||
@ -1,16 +1,29 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { useRecoilCallback, useRecoilValue } from 'recoil';
|
||||
|
||||
import { useIsLogged } from '@/auth/hooks/useIsLogged';
|
||||
import { currentUserState } from '@/auth/states/currentUserState';
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { useFindManyObjectMetadataItems } from '@/object-metadata/hooks/useFindManyObjectMetadataItems';
|
||||
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
import { WorkspaceActivationStatus } from '~/generated/graphql';
|
||||
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/objectMetadataItems';
|
||||
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
|
||||
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
|
||||
|
||||
const filterTsVectorFields = (
|
||||
objectMetadataItems: ObjectMetadataItem[],
|
||||
): ObjectMetadataItem[] => {
|
||||
return objectMetadataItems.map((item) => ({
|
||||
...item,
|
||||
fields: item.fields.filter(
|
||||
(field) => field.type !== FieldMetadataType.TsVector,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
export const ObjectMetadataItemsLoadEffect = () => {
|
||||
const currentUser = useRecoilValue(currentUserState);
|
||||
const currentWorkspace = useRecoilValue(currentWorkspaceState);
|
||||
@ -21,26 +34,32 @@ export const ObjectMetadataItemsLoadEffect = () => {
|
||||
skip: !isLoggedIn,
|
||||
});
|
||||
|
||||
const [objectMetadataItems, setObjectMetadataItems] = useRecoilState(
|
||||
objectMetadataItemsState,
|
||||
const updateObjectMetadataItems = useRecoilCallback(
|
||||
({ set, snapshot }) =>
|
||||
() => {
|
||||
const filteredFields = filterTsVectorFields(newObjectMetadataItems);
|
||||
const toSetObjectMetadataItems =
|
||||
isUndefinedOrNull(currentUser) ||
|
||||
currentWorkspace?.activationStatus !==
|
||||
WorkspaceActivationStatus.Active
|
||||
? generatedMockObjectMetadataItems
|
||||
: filteredFields;
|
||||
|
||||
if (
|
||||
!isDeeplyEqual(
|
||||
snapshot.getLoadable(objectMetadataItemsState).getValue(),
|
||||
toSetObjectMetadataItems,
|
||||
)
|
||||
) {
|
||||
set(objectMetadataItemsState, toSetObjectMetadataItems);
|
||||
}
|
||||
},
|
||||
[currentUser, currentWorkspace?.activationStatus, newObjectMetadataItems],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const toSetObjectMetadataItems =
|
||||
isUndefinedOrNull(currentUser) ||
|
||||
currentWorkspace?.activationStatus !== WorkspaceActivationStatus.Active
|
||||
? generatedMockObjectMetadataItems
|
||||
: newObjectMetadataItems;
|
||||
if (!isDeeplyEqual(objectMetadataItems, toSetObjectMetadataItems)) {
|
||||
setObjectMetadataItems(toSetObjectMetadataItems);
|
||||
}
|
||||
}, [
|
||||
currentUser,
|
||||
currentWorkspace?.activationStatus,
|
||||
newObjectMetadataItems,
|
||||
objectMetadataItems,
|
||||
setObjectMetadataItems,
|
||||
]);
|
||||
updateObjectMetadataItems();
|
||||
}, [updateObjectMetadataItems]);
|
||||
|
||||
return <></>;
|
||||
};
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
import { RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
|
||||
|
||||
export type RecordGqlOperationSearchResult = {
|
||||
[objectNamePlural: string]: RecordGqlConnection;
|
||||
};
|
||||
@ -0,0 +1,94 @@
|
||||
import { useQuery, WatchQueryFetchPolicy } from '@apollo/client';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
|
||||
import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection';
|
||||
import { RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields';
|
||||
import { RecordGqlOperationSearchResult } from '@/object-record/graphql/types/RecordGqlOperationSearchResult';
|
||||
import { RecordGqlOperationVariables } from '@/object-record/graphql/types/RecordGqlOperationVariables';
|
||||
import { useSearchRecordsQuery } from '@/object-record/hooks/useSearchRecordsQuery';
|
||||
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { getSearchRecordsQueryResponseField } from '@/object-record/utils/getSearchRecordsQueryResponseField';
|
||||
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useMemo } from 'react';
|
||||
import { logError } from '~/utils/logError';
|
||||
|
||||
export type UseSearchRecordsParams = ObjectMetadataItemIdentifier &
|
||||
RecordGqlOperationVariables & {
|
||||
onError?: (error?: Error) => void;
|
||||
skip?: boolean;
|
||||
recordGqlFields?: RecordGqlOperationGqlRecordFields;
|
||||
fetchPolicy?: WatchQueryFetchPolicy;
|
||||
searchInput?: string;
|
||||
};
|
||||
|
||||
export const useSearchRecords = <T extends ObjectRecord = ObjectRecord>({
|
||||
objectNameSingular,
|
||||
searchInput,
|
||||
limit,
|
||||
skip,
|
||||
recordGqlFields,
|
||||
fetchPolicy,
|
||||
}: UseSearchRecordsParams) => {
|
||||
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
|
||||
const { objectMetadataItem } = useObjectMetadataItem({
|
||||
objectNameSingular,
|
||||
});
|
||||
const { searchRecordsQuery } = useSearchRecordsQuery({
|
||||
objectNameSingular,
|
||||
recordGqlFields,
|
||||
});
|
||||
|
||||
const { enqueueSnackBar } = useSnackBar();
|
||||
|
||||
const { data, loading, error } = useQuery<RecordGqlOperationSearchResult>(
|
||||
searchRecordsQuery,
|
||||
{
|
||||
skip:
|
||||
skip || !objectMetadataItem || !currentWorkspaceMember || !searchInput,
|
||||
variables: {
|
||||
search: searchInput,
|
||||
limit: limit,
|
||||
},
|
||||
fetchPolicy: fetchPolicy,
|
||||
onError: (error) => {
|
||||
logError(
|
||||
`useSearchRecords for "${objectMetadataItem.namePlural}" error : ` +
|
||||
error,
|
||||
);
|
||||
enqueueSnackBar(
|
||||
`Error during useSearchRecords for "${objectMetadataItem.namePlural}", ${error.message}`,
|
||||
{
|
||||
variant: SnackBarVariant.Error,
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const queryResponseField = getSearchRecordsQueryResponseField(
|
||||
objectMetadataItem.namePlural,
|
||||
);
|
||||
|
||||
const result = data?.[queryResponseField];
|
||||
|
||||
const records = useMemo(
|
||||
() =>
|
||||
result
|
||||
? (getRecordsFromRecordConnection({
|
||||
recordConnection: result,
|
||||
}) as T[])
|
||||
: [],
|
||||
[result],
|
||||
);
|
||||
|
||||
return {
|
||||
objectMetadataItem,
|
||||
records: records,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,33 @@
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
||||
import { RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields';
|
||||
import { generateSearchRecordsQuery } from '@/object-record/utils/generateSearchRecordsQuery';
|
||||
|
||||
export const useSearchRecordsQuery = ({
|
||||
objectNameSingular,
|
||||
recordGqlFields,
|
||||
computeReferences,
|
||||
}: {
|
||||
objectNameSingular: string;
|
||||
recordGqlFields?: RecordGqlOperationGqlRecordFields;
|
||||
computeReferences?: boolean;
|
||||
}) => {
|
||||
const { objectMetadataItem } = useObjectMetadataItem({
|
||||
objectNameSingular,
|
||||
});
|
||||
|
||||
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
|
||||
|
||||
const searchRecordsQuery = generateSearchRecordsQuery({
|
||||
objectMetadataItem,
|
||||
objectMetadataItems,
|
||||
recordGqlFields,
|
||||
computeReferences,
|
||||
});
|
||||
|
||||
return {
|
||||
searchRecordsQuery,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,40 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery';
|
||||
import { RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields';
|
||||
import { getSearchRecordsQueryResponseField } from '@/object-record/utils/getSearchRecordsQueryResponseField';
|
||||
import { capitalize } from '~/utils/string/capitalize';
|
||||
|
||||
export type QueryCursorDirection = 'before' | 'after';
|
||||
|
||||
export const generateSearchRecordsQuery = ({
|
||||
objectMetadataItem,
|
||||
objectMetadataItems,
|
||||
recordGqlFields,
|
||||
computeReferences,
|
||||
}: {
|
||||
objectMetadataItem: ObjectMetadataItem;
|
||||
objectMetadataItems: ObjectMetadataItem[]; // TODO - what is this used for?
|
||||
recordGqlFields?: RecordGqlOperationGqlRecordFields;
|
||||
computeReferences?: boolean;
|
||||
}) => gql`
|
||||
query Search${capitalize(objectMetadataItem.namePlural)}($search: String, $limit: Int) {
|
||||
${getSearchRecordsQueryResponseField(objectMetadataItem.namePlural)}(searchInput: $search, limit: $limit){
|
||||
edges {
|
||||
node ${mapObjectMetadataToGraphQLQuery({
|
||||
objectMetadataItems,
|
||||
objectMetadataItem,
|
||||
recordGqlFields,
|
||||
computeReferences,
|
||||
})}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,4 @@
|
||||
import { capitalize } from '~/utils/string/capitalize';
|
||||
|
||||
export const getSearchRecordsQueryResponseField = (objectNamePlural: string) =>
|
||||
`search${capitalize(objectNamePlural)}`;
|
||||
@ -0,0 +1,8 @@
|
||||
export type Opportunity = {
|
||||
__typename: 'Opportunity';
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
deletedAt?: string | null;
|
||||
name: string | null;
|
||||
};
|
||||
@ -2,5 +2,5 @@ import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
|
||||
export type SettingsSupportedFieldType = Exclude<
|
||||
FieldMetadataType,
|
||||
FieldMetadataType.Position
|
||||
FieldMetadataType.Position | FieldMetadataType.TsVector
|
||||
>;
|
||||
|
||||
@ -9,4 +9,7 @@ export type FeatureFlagKey =
|
||||
| 'IS_FREE_ACCESS_ENABLED'
|
||||
| 'IS_MESSAGE_THREAD_SUBSCRIBER_ENABLED'
|
||||
| 'IS_WORKFLOW_ENABLED'
|
||||
| 'IS_WORKSPACE_FAVORITE_ENABLED';
|
||||
| 'IS_WORKSPACE_FAVORITE_ENABLED'
|
||||
| 'IS_SEARCH_ENABLED'
|
||||
| 'IS_QUERY_RUNNER_TWENTY_ORM_ENABLED'
|
||||
| 'IS_WORKSPACE_MIGRATED_FOR_SEARCH';
|
||||
|
||||
Reference in New Issue
Block a user