diff --git a/apps/web/core/hooks/use-search.ts b/apps/web/core/hooks/use-search.ts index bdb4b0c55b..984d42587f 100644 --- a/apps/web/core/hooks/use-search.ts +++ b/apps/web/core/hooks/use-search.ts @@ -61,6 +61,12 @@ export function useSearch({ const { store } = useSyncEngine(); const cache = useQueryClient(); const [query, setQuery] = React.useState(initialQuery ?? ''); + + React.useEffect(() => { + const next = initialQuery ?? ''; + setQuery(prev => (prev === next ? prev : next)); + }, [initialQuery]); + const debouncedQuery = useDebouncedValue(query); const maybeEntityId = debouncedQuery.trim(); diff --git a/apps/web/design-system/reorderable-relation-chips-dnd.tsx b/apps/web/design-system/reorderable-relation-chips-dnd.tsx index 287d35c748..4aa7ddb3fd 100644 --- a/apps/web/design-system/reorderable-relation-chips-dnd.tsx +++ b/apps/web/design-system/reorderable-relation-chips-dnd.tsx @@ -117,7 +117,7 @@ export default function ReorderableRelationChipsDnd({ onDragEnd={handleDragEnd} > r.id)} strategy={horizontalListSortingStrategy}> -
+
{sortedRelations.map(relation => ( ))} diff --git a/apps/web/design-system/select-entity-dialog.tsx b/apps/web/design-system/select-entity-dialog.tsx index 282bfe99a8..2daaa9e0c8 100644 --- a/apps/web/design-system/select-entity-dialog.tsx +++ b/apps/web/design-system/select-entity-dialog.tsx @@ -30,6 +30,10 @@ type SelectEntityAsPopoverProps = { showIDs?: boolean; initialQuery?: string; selectedEntityId?: string; + clearQueryAfterPick?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; + onSearchQueryChange?: (query: string) => void; }; export function SelectEntityAsPopover({ @@ -43,8 +47,25 @@ export function SelectEntityAsPopover({ showIDs = true, initialQuery, selectedEntityId, + clearQueryAfterPick = true, + open: openProp, + onOpenChange: onOpenChangeProp, + onSearchQueryChange, }: SelectEntityAsPopoverProps) { - const [open, setOpen] = useState(false); + const [uncontrolledOpen, setUncontrolledOpen] = useState(false); + const isControlled = openProp !== undefined; + const open = isControlled ? openProp : uncontrolledOpen; + + const setOpen = React.useCallback( + (next: boolean) => { + if (isControlled) { + onOpenChangeProp?.(next); + } else { + setUncontrolledOpen(next); + } + }, + [isControlled, onOpenChangeProp] + ); useKey('Escape', () => { if (!open) return; @@ -57,7 +78,23 @@ export function SelectEntityAsPopover({ {trigger} - + { + if ((event.target as Element | null)?.closest?.('[data-select-entity-dropdown]')) { + event.preventDefault(); + } + }} + onInteractOutside={event => { + if ((event.target as Element | null)?.closest?.('[data-select-entity-dropdown]')) { + event.preventDefault(); + } + }} + > diff --git a/apps/web/design-system/select-entity.tsx b/apps/web/design-system/select-entity.tsx index 721299e33c..ec763d1774 100644 --- a/apps/web/design-system/select-entity.tsx +++ b/apps/web/design-system/select-entity.tsx @@ -76,6 +76,9 @@ type SelectEntityProps = { initialQuery?: string; /** When set, the result with this ID gets a "Currently selected" indicator */ selectedEntityId?: string; + /** The results popover stays open for back-to-back picks.*/ + clearQueryAfterPick?: boolean; + onSearchQueryChange?: (query: string) => void; }; type SpaceFilter = { spaceId: string; spaceName: string | null }; @@ -99,6 +102,8 @@ export const SelectEntity = ({ showIDs = true, initialQuery, selectedEntityId, + clearQueryAfterPick = true, + onSearchQueryChange, }: SelectEntityProps) => { const [isShowingIds, setIsShowingIds] = useAtom(showingIdsAtom); const { storage } = useMutate(); @@ -130,7 +135,7 @@ export const SelectEntity = ({ ? allowedTypes.map(r => r.id) : undefined; - const { query, onQueryChange, isLoading, isEmpty, results } = useSearch({ + const { query, onQueryChange: setSearchQuery, isLoading, isEmpty, results } = useSearch({ filterByTypes, filterBySpace, initialQuery, @@ -150,6 +155,14 @@ export const SelectEntity = ({ } }, [autoFocus]); + const onQueryChange = React.useCallback( + (value: string) => { + setSearchQuery(value); + onSearchQueryChange?.(value); + }, + [setSearchQuery, onSearchQueryChange] + ); + if (query === '' && result !== null) { startTransition(() => { setResult(null); @@ -191,8 +204,10 @@ export const SelectEntity = ({ // Create new entity with name and types using internal id storage.entities.name.set(newEntityId, spaceId, query); onDone?.({ id: newEntityId, name: query, space: spaceId }, true); - onQueryChange(''); - setSelectedIndex(0); + if (clearQueryAfterPick) { + onQueryChange(''); + setSelectedIndex(0); + } setToast(); }; @@ -212,7 +227,9 @@ export const SelectEntity = ({ name: result.name, primarySpace: result.spaces?.[0]?.spaceId ? result.spaces[0].spaceId : undefined, }); - onQueryChange(''); + if (clearQueryAfterPick) { + onQueryChange(''); + } } }); @@ -314,6 +331,7 @@ export const SelectEntity = ({ { event.preventDefault(); event.stopPropagation(); @@ -495,8 +513,10 @@ export const SelectEntity = ({ ? result.spaces[0].spaceId : undefined, }); - onQueryChange(''); - setSelectedIndex(0); + if (clearQueryAfterPick) { + onQueryChange(''); + setSelectedIndex(0); + } }} id={`select-entity-result-${index}`} className={cx( diff --git a/apps/web/partials/entity-page/editable-entity-page.tsx b/apps/web/partials/entity-page/editable-entity-page.tsx index a44531ba0d..edb4788a40 100644 --- a/apps/web/partials/entity-page/editable-entity-page.tsx +++ b/apps/web/partials/entity-page/editable-entity-page.tsx @@ -25,7 +25,7 @@ import { } from '~/core/state/entity-page-store/entity-store'; import { Mutator, useMutate } from '~/core/sync/use-mutate'; import { useQueryProperty, useRelations, useValue, useValues } from '~/core/sync/use-store'; -import { Property, Relation, ValueOptions } from '~/core/types'; +import { Property, Relation, SwitchableRenderableType, ValueOptions } from '~/core/types'; import { mapPropertyType } from '~/core/utils/property/properties'; import { isUrlTemplate, resolveUrlTemplate } from '~/core/utils/url-template'; import { useImageUrlFromEntity, useVideoUrlFromEntity } from '~/core/utils/use-entity-media'; @@ -80,6 +80,8 @@ export function EditableEntityPage({ id, spaceId }: EditableEntityPageProps) { const showPanel = shouldShowPanel || isRelationPage; + const [propertyAddPopoverOpen, setPropertyAddPopoverOpen] = React.useState(false); + return ( {showPanel && ( @@ -134,6 +136,9 @@ export function EditableEntityPage({ id, spaceId }: EditableEntityPageProps) {
} />} spaceId={spaceId} relationValueTypes={[{ id: SystemIds.PROPERTY, name: 'Property' }]} @@ -147,7 +152,6 @@ export function EditableEntityPage({ id, spaceId }: EditableEntityPageProps) { space: result.space, }); - // Immediately add the property to the entity addPropertyToEntity({ entityId: id, propertyId: createdPropertyId, @@ -155,10 +159,12 @@ export function EditableEntityPage({ id, spaceId }: EditableEntityPageProps) { entityName: name || undefined, }); + setPropertyAddPopoverOpen(true); + return createdPropertyId; }} - onDone={result => { - if (result) { + onDone={(result, fromCreateFn) => { + if (result && !fromCreateFn) { addPropertyToEntity({ entityId: id, propertyId: result.id, @@ -166,6 +172,7 @@ export function EditableEntityPage({ id, spaceId }: EditableEntityPageProps) { entityName: name || undefined, }); } + setPropertyAddPopoverOpen(true); }} placeholder="Find or create property..." advanced={false} @@ -283,6 +290,38 @@ export function RelationsGroup({ propertyId, id, spaceId }: RelationsGroupProps) // Use the efficient hook to get only the video URL for this specific entity const videoSrc = useVideoUrlFromEntity(videoEntityId, spaceId); + const isGenericRelationField = React.useMemo( + () => + Boolean( + property && + property.renderableTypeStrict !== 'IMAGE' && + property.renderableTypeStrict !== 'VIDEO' && + propertyId !== VENUE_PROPERTY + ), + [property, propertyId] + ); + + const [relationPickerOpen, setRelationPickerOpen] = React.useState(false); + /** Keeps popover search text in sync with the inline "Find or create..." field. */ + const [relationSearchMirror, setRelationSearchMirror] = React.useState(''); + const prevRelationCountRef = React.useRef(relations.length); + + React.useEffect(() => { + if (!isGenericRelationField) { + prevRelationCountRef.current = relations.length; + return; + } + const prev = prevRelationCountRef.current; + if (prev === 0 && relations.length > 0) { + setRelationPickerOpen(true); + } + if (relations.length === 0) { + setRelationPickerOpen(false); + setRelationSearchMirror(''); + } + prevRelationCountRef.current = relations.length; + }, [relations.length, isGenericRelationField]); + if (!property) { return null; } @@ -302,6 +341,131 @@ export function RelationsGroup({ propertyId, id, spaceId }: RelationsGroupProps) const valueType = relationValueTypes?.[0]; const isEmpty = relations.length === 0; + const relationFieldOnCreateEntity = (result: { + id: string; + name: string | null; + space?: string; + verified?: boolean; + renderableType?: SwitchableRenderableType; + }) => { + const isCreatingProperty = valueType?.id === SystemIds.PROPERTY; + + if (isCreatingProperty) { + const renderableType = result.renderableType || 'TEXT'; + const { baseDataType, renderableTypeId } = mapPropertyType(renderableType); + storage.properties.create({ + entityId: result.id, + spaceId, + name: result.name ?? '', + dataType: baseDataType, + renderableTypeId, + verified: result.verified, + toSpaceId: result.space, + }); + } else { + storage.values.set({ + id: ID.createValueId({ + entityId: result.id, + propertyId: SystemIds.NAME_PROPERTY, + spaceId, + }), + entity: { + id: result.id, + name: result.name, + }, + property: { + id: SystemIds.NAME_PROPERTY, + name: 'Name', + dataType: 'TEXT', + }, + spaceId, + value: result.name ?? '', + }); + + if (valueType) { + storage.relations.set({ + id: IdUtils.generate(), + entityId: IdUtils.generate(), + spaceId, + renderableType: 'RELATION', + verified: result.verified, + toSpaceId: valueType.spaceId, + type: { + id: SystemIds.TYPES_PROPERTY, + name: 'Types', + }, + fromEntity: { + id: result.id, + name: result.name, + }, + toEntity: { + id: valueType.id, + name: valueType.name, + value: valueType.id, + }, + }); + } + } + }; + + const relationFieldOnDone = async (result: { + id: string; + name: string | null; + space?: string; + primarySpace?: string; + verified?: boolean; + }) => { + if (relations.some(r => r.toEntity.id === result.id)) { + return; + } + + const newRelationId = ID.createEntityId(); + const newEntityId = ID.createEntityId(); + + const newRelation: Relation = { + id: newRelationId, + spaceId: spaceId, + position: Position.generate(), + renderableType: 'RELATION', + verified: false, + entityId: newEntityId, + type: { + id: propertyId, + name: property.name, + }, + fromEntity: { + id: id, + name: name, + }, + toEntity: { + id: result.id, + name: result.name, + value: result.id, + }, + }; + + const toSpaceId = result.space ?? result.primarySpace; + if (toSpaceId) { + newRelation.toSpaceId = toSpaceId; + } + + if (result.verified) { + newRelation.verified = true; + } + + storage.relations.set(newRelation); + + for (const relationType of property.relationEntityTypes ?? []) { + createRelationEntityTypeRelation(storage, spaceId, newEntityId, relationType); + } + + await applyTemplate({ ...templateOptions, propertyId: typeOfId, typeId: result.id }); + + if (isGenericRelationField) { + setRelationPickerOpen(true); + } + }; + // Handler for file upload (images and videos) const handleFileUpload = async (file: File) => { setIsUploading(true); @@ -342,205 +506,97 @@ export function RelationsGroup({ propertyId, id, spaceId }: RelationsGroupProps) fileInputRef.current?.click(); }; - if (isEmpty) { + if (isEmpty && property.renderableTypeStrict === 'IMAGE') { return (
- {property.renderableTypeStrict === 'IMAGE' ? ( -
- { - // Use the consolidated helper to create and link the image - await storage.images.createAndLink({ - file, - fromEntityId: id, - fromEntityName: name, - relationPropertyId: propertyId, - relationPropertyName: typeOfName, - spaceId, - }); - }} - /> -
- ) : property.renderableTypeStrict === 'VIDEO' ? ( -
- { - // Use the consolidated helper to create and link the video - await storage.videos.createAndLink({ - file, - fromEntityId: id, - fromEntityName: name, - relationPropertyId: propertyId, - relationPropertyName: typeOfName, - spaceId, - }); - }} - /> -
- ) : propertyId === VENUE_PROPERTY ? ( -
- { - const newRelationId = ID.createEntityId(); - const newEntityId = ID.createEntityId(); - - const newRelation: Relation = { - id: newRelationId, - spaceId: spaceId, - position: Position.generate(), - renderableType: 'RELATION', - verified: false, - entityId: newEntityId, - type: { - id: propertyId, - name: property.name, - }, - fromEntity: { - id: id, - name: name, - }, - toEntity: { - id: result.id, - name: result.name, - value: result.id, - }, - }; - - if (result.space) { - newRelation.toSpaceId = result.space; - } - - if (result.verified) { - newRelation.verified = true; - } +
+ { + await storage.images.createAndLink({ + file, + fromEntityId: id, + fromEntityName: name, + relationPropertyId: propertyId, + relationPropertyName: typeOfName, + spaceId, + }); + }} + /> +
+
+ ); + } - storage.relations.set(newRelation); + if (isEmpty && property.renderableTypeStrict === 'VIDEO') { + return ( +
+
+ { + await storage.videos.createAndLink({ + file, + fromEntityId: id, + fromEntityName: name, + relationPropertyId: propertyId, + relationPropertyName: typeOfName, + spaceId, + }); + }} + /> +
+
+ ); + } - await applyTemplate({ ...templateOptions, typeId: result.id }); - }} - /> -
- ) : ( -
- { - // Check if we're creating a Property entity - const isCreatingProperty = valueType?.id === SystemIds.PROPERTY; - - if (isCreatingProperty) { - // Use the proper property creation flow which sets dataType correctly - const renderableType = result.renderableType || 'TEXT'; - const { baseDataType, renderableTypeId } = mapPropertyType(renderableType); - storage.properties.create({ - entityId: result.id, - spaceId, - name: result.name ?? '', - dataType: baseDataType, - renderableTypeId, - verified: result.verified, - toSpaceId: result.space, - }); - } else { - // Standard entity creation - storage.values.set({ - id: ID.createValueId({ - entityId: result.id, - propertyId: SystemIds.NAME_PROPERTY, - spaceId, - }), - entity: { - id: result.id, - name: result.name, - }, - property: { - id: SystemIds.NAME_PROPERTY, - name: 'Name', - dataType: 'TEXT', - }, - spaceId, - value: result.name ?? '', - }); + if (isEmpty && propertyId === VENUE_PROPERTY) { + return ( +
+
+ { + const newRelationId = ID.createEntityId(); + const newEntityId = ID.createEntityId(); - if (valueType) { - storage.relations.set({ - id: IdUtils.generate(), - entityId: IdUtils.generate(), - spaceId, - renderableType: 'RELATION', - verified: result.verified, - toSpaceId: valueType.spaceId, - type: { - id: SystemIds.TYPES_PROPERTY, - name: 'Types', - }, - fromEntity: { - id: result.id, - name: result.name, - }, - toEntity: { - id: valueType.id, - name: valueType.name, - value: valueType.id, - }, - }); - } - } - }} - onDone={async result => { - const newRelationId = ID.createEntityId(); - // @TODO(migration): lightweight relation pointing to entity id - const newEntityId = ID.createEntityId(); - - const newRelation: Relation = { - id: newRelationId, - spaceId: spaceId, - position: Position.generate(), - renderableType: 'RELATION', - verified: false, - entityId: newEntityId, - type: { - id: propertyId, - name: property.name, - }, - fromEntity: { - id: id, - name: name, - }, - toEntity: { - id: result.id, - name: result.name, - value: result.id, - }, - }; - - const toSpaceId = result.space ?? result.primarySpace; - if (toSpaceId) { - newRelation.toSpaceId = toSpaceId; - } + const newRelation: Relation = { + id: newRelationId, + spaceId: spaceId, + position: Position.generate(), + renderableType: 'RELATION', + verified: false, + entityId: newEntityId, + type: { + id: propertyId, + name: property.name, + }, + fromEntity: { + id: id, + name: name, + }, + toEntity: { + id: result.id, + name: result.name, + value: result.id, + }, + }; - if (result.verified) { - newRelation.verified = true; - } + if (result.space) { + newRelation.toSpaceId = result.space; + } - storage.relations.set(newRelation); + if (result.verified) { + newRelation.verified = true; + } - for (const relationType of property.relationEntityTypes ?? []) { - createRelationEntityTypeRelation(storage, spaceId, newEntityId, relationType); - } + storage.relations.set(newRelation); - await applyTemplate({ ...templateOptions, typeId: result.id }); - }} - variant="fixed" - /> -
- )} + await applyTemplate({ ...templateOptions, typeId: result.id }); + }} + /> +
); } @@ -604,140 +660,89 @@ export function RelationsGroup({ propertyId, id, spaceId }: RelationsGroupProps) /> ); }) - ) : ( - { - storage.relations.update(relation, draft => { - if (newPosition) draft.position = newPosition; - }); - }} - /> - )} - - {property.renderableType !== SystemIds.IMAGE && property.renderableTypeStrict !== 'VIDEO' && ( -
- } label="type" /> - ) : ( - } /> - ) - } - placeholder={isType ? 'Find or create type...' : undefined} - relationValueTypes={relationValueTypes ? relationValueTypes : undefined} - onCreateEntity={result => { - // Check if we're creating a Property entity - const isCreatingProperty = valueType?.id === SystemIds.PROPERTY; - - if (isCreatingProperty) { - // Use the proper property creation flow which sets dataType correctly - const renderableType = result.renderableType || 'TEXT'; - const { baseDataType, renderableTypeId } = mapPropertyType(renderableType); - storage.properties.create({ - entityId: result.id, - spaceId, - name: result.name ?? '', - dataType: baseDataType, - renderableTypeId, - verified: result.verified, - toSpaceId: result.space, - }); - } else { - // Standard entity creation - storage.values.set({ - id: ID.createValueId({ - entityId: result.id, - propertyId: SystemIds.NAME_PROPERTY, - spaceId, - }), - entity: { - id: result.id, - name: result.name, - }, - property: { - id: SystemIds.NAME_PROPERTY, - name: 'Name', - dataType: 'TEXT', - }, - spaceId, - value: result.name ?? '', - }); - - if (valueType) { - storage.relations.set({ - id: IdUtils.generate(), - entityId: IdUtils.generate(), - spaceId, - renderableType: 'RELATION', - toSpaceId: valueType.spaceId, - type: { - id: SystemIds.TYPES_PROPERTY, - name: 'Types', - }, - fromEntity: { - id: result.id, - name: result.name, - }, - toEntity: { - id: valueType.id, - name: valueType.name, - value: valueType.id, - }, - }); - } - } - }} - onDone={async result => { - const newRelationId = ID.createEntityId(); - // @TODO(migration): lightweight relation pointing to entity id - const newEntityId = ID.createEntityId(); - - const newRelation: Relation = { - id: newRelationId, - spaceId: spaceId, - position: Position.generate(), - renderableType: 'RELATION', - verified: false, - entityId: newEntityId, - type: { - id: typeOfId, - name: typeOfName, - }, - fromEntity: { - id: id, - name: name, - }, - toEntity: { - id: result.id, - name: result.name, - value: result.id, - }, - }; - - if (result.space) { - newRelation.toSpaceId = result.space; - } - - if (result.verified) { - newRelation.verified = true; - } - - storage.relations.set(newRelation); - - for (const relationType of property.relationEntityTypes ?? []) { - createRelationEntityTypeRelation(storage, spaceId, newEntityId, relationType); - } - - await applyTemplate({ ...templateOptions, propertyId: typeOfId, typeId: result.id }); + ) : isGenericRelationField && relations.length === 0 ? ( +
+ { + storage.relations.update(relation, draft => { + if (newPosition) draft.position = newPosition; + }); }} + /> +
+ +
+
+ ) : ( +
+ { + storage.relations.update(relation, draft => { + if (newPosition) draft.position = newPosition; + }); + }} /> + {isGenericRelationField && relations.length > 0 && ( +
+ } label="type" /> + ) : ( + } /> + ) + } + placeholder={isType ? 'Find or create type...' : 'Find or create...'} + relationValueTypes={relationValueTypes ? relationValueTypes : undefined} + onCreateEntity={relationFieldOnCreateEntity} + onDone={relationFieldOnDone} + spaceId={spaceId} + /> +
+ )}
)} + {property.renderableType !== SystemIds.IMAGE && + property.renderableTypeStrict !== 'VIDEO' && + !isGenericRelationField && ( +
+ } label="type" /> + ) : ( + } /> + ) + } + placeholder={isType ? 'Find or create type...' : undefined} + relationValueTypes={relationValueTypes ? relationValueTypes : undefined} + onCreateEntity={relationFieldOnCreateEntity} + onDone={relationFieldOnDone} + spaceId={spaceId} + /> +
+ )} + {/* Show geo location map for the first Address or Venue relation */} {shouldShowMap && firstRelation && ( dedupedRelations.some(r => r.toEntity.id === targetEntityId); + const [relationSearchMirror, setRelationSearchMirror] = React.useState(''); + const [relationPickerOpen, setRelationPickerOpen] = React.useState(false); + const prevRelationCountRef = React.useRef(dedupedRelations.length); + + React.useEffect(() => { + const prev = prevRelationCountRef.current; + if (prev === 0 && dedupedRelations.length > 0) { + setRelationPickerOpen(true); + } + if (dedupedRelations.length === 0) { + setRelationPickerOpen(false); + setRelationSearchMirror(''); + } + prevRelationCountRef.current = dedupedRelations.length; + }, [dedupedRelations.length]); + if (dedupedRelations.length === 0) { // For IMAGE type properties, show an image upload field instead of SelectEntity if (property.renderableTypeStrict === 'IMAGE') { @@ -223,10 +240,13 @@ function RelationsGroup({ spaceId={spaceId} relationValueTypes={filterSearchByTypes} width="full" + clearQueryAfterPick={false} + onSearchQueryChange={setRelationSearchMirror} onCreateEntity={result => { if (firstRelationValueType) { createTypeRelationForNewEntity(storage, spaceId, result, firstRelationValueType); } + setRelationPickerOpen(true); }} onDone={result => { if (hasRelationToEntity(result.id)) { @@ -234,6 +254,7 @@ function RelationsGroup({ } createPropertyRelation(storage, spaceId, entityId, property, result); + setRelationPickerOpen(true); }} variant="tableCell" /> @@ -293,14 +314,20 @@ function RelationsGroup({ ); })} -
+
} />} relationValueTypes={filterSearchByTypes} onCreateEntity={result => { if (firstRelationValueType) { createTypeRelationForNewEntity(storage, spaceId, result, firstRelationValueType); } + setRelationPickerOpen(true); }} onDone={result => { if (hasRelationToEntity(result.id)) { @@ -308,6 +335,7 @@ function RelationsGroup({ } createPropertyRelation(storage, spaceId, entityId, property, result); + setRelationPickerOpen(true); }} spaceId={spaceId} />