diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities/write-google-sheet-action/convert-subgraph-to-sheet-requests.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities/write-google-sheet-action/convert-subgraph-to-sheet-requests.ts index 3f5d134d89a..8fa8a51a479 100644 --- a/apps/hash-ai-worker-ts/src/activities/flow-activities/write-google-sheet-action/convert-subgraph-to-sheet-requests.ts +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities/write-google-sheet-action/convert-subgraph-to-sheet-requests.ts @@ -542,6 +542,12 @@ export const convertSubgraphToSheetRequests = ({ const { sheetId, rowIndex } = entityPosition; const linkedEntity = getEntityRevision(subgraph, leftEntityId); + if (!linkedEntity) { + throw new Error( + `Entity ${leftEntityId} not found in subgraph when processing link entity ${entity.metadata.recordId.entityId}`, + ); + } + /** Create the link from this sheet to the source entity */ entityCells.push( createHyperlinkCell({ @@ -599,6 +605,12 @@ export const convertSubgraphToSheetRequests = ({ const linkedEntity = getEntityRevision(subgraph, rightEntityId); + if (!linkedEntity) { + throw new Error( + `Entity ${rightEntityId} not found in subgraph when processing link entity ${entity.metadata.recordId.entityId}`, + ); + } + entityCells.push( createHyperlinkCell({ label: generateEntityLabel(subgraph, linkedEntity), diff --git a/apps/hash-api/src/generate-ontology-type-ids.ts b/apps/hash-api/src/generate-ontology-type-ids.ts index f4054c66a8e..7aca3bbf2b6 100644 --- a/apps/hash-api/src/generate-ontology-type-ids.ts +++ b/apps/hash-api/src/generate-ontology-type-ids.ts @@ -200,7 +200,6 @@ const generateOntologyIds = async () => { }, }, graphApi, - temporalClient: null, }; const [hashOrg, googleOrg, linearOrg] = await Promise.all([ diff --git a/apps/hash-api/src/graph/context-types.ts b/apps/hash-api/src/graph/context-types.ts index 32efca2d504..8f8216fb93c 100644 --- a/apps/hash-api/src/graph/context-types.ts +++ b/apps/hash-api/src/graph/context-types.ts @@ -8,25 +8,25 @@ import type { DataSource } from "apollo-datasource"; export type GraphApi = GraphApiClient & DataSource; export type ImpureGraphContext< - WithUpload extends boolean = false, - WithTemporal extends boolean = false, + RequiresUpload extends boolean = false, + RequiresTemporal extends boolean = false, > = { graphApi: GraphApi; provenance: EnforcedEntityEditionProvenance; -} & (WithUpload extends true +} & (RequiresUpload extends true ? { uploadProvider: UploadableStorageProvider } - : Record) & - (WithTemporal extends true + : { uploadProvider?: UploadableStorageProvider }) & + (RequiresTemporal extends true ? { temporalClient: TemporalClient } - : Record); + : { temporalClient?: TemporalClient }); export type ImpureGraphFunction< Parameters, ReturnType, - WithUpload extends boolean = false, - WithTemporal extends boolean = false, + RequiresUpload extends boolean = false, + RequiresTemporal extends boolean = false, > = ( - context: ImpureGraphContext, + context: ImpureGraphContext, authentication: AuthenticationContext, params: Parameters, ) => ReturnType; diff --git a/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util/upgrade-entities.ts b/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util/upgrade-entities.ts index 1337bb7bde2..af5bb737856 100644 --- a/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util/upgrade-entities.ts +++ b/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util/upgrade-entities.ts @@ -30,7 +30,7 @@ import isEqual from "lodash/isEqual"; import type { ImpureGraphContext } from "../../../context-types"; import { - getEntitySubgraph, + getEntitySubgraphResponse, updateEntity, } from "../../../knowledge/primitive/entity"; import { systemAccountId } from "../../../system-account"; @@ -62,44 +62,48 @@ export const upgradeWebEntities = async ({ const webBotAuthentication = { actorId: webBotAccountId }; - const subgraph = await getEntitySubgraph(context, webBotAuthentication, { - filter: { - all: [ - { - any: entityTypeBaseUrls.map((baseUrl) => ({ - all: [ - { - equal: [ - { path: ["type(inheritanceDepth = 0)", "baseUrl"] }, - { parameter: baseUrl }, - ], - }, + const { subgraph } = await getEntitySubgraphResponse( + context, + webBotAuthentication, + { + filter: { + all: [ + { + any: entityTypeBaseUrls.map((baseUrl) => ({ + all: [ + { + equal: [ + { path: ["type(inheritanceDepth = 0)", "baseUrl"] }, + { parameter: baseUrl }, + ], + }, + { + less: [ + { path: ["type(inheritanceDepth = 0)", "version"] }, + { parameter: migrationState.entityTypeVersions[baseUrl] }, + ], + }, + ], + })), + }, + { + equal: [ + { path: ["ownedById"] }, { - less: [ - { path: ["type(inheritanceDepth = 0)", "version"] }, - { parameter: migrationState.entityTypeVersions[baseUrl] }, - ], + parameter: webOwnedById, }, ], - })), - }, - { - equal: [ - { path: ["ownedById"] }, - { - parameter: webOwnedById, - }, - ], - }, - ], + }, + ], + }, + graphResolveDepths: { + ...zeroedGraphResolveDepths, + ...fullOntologyResolveDepths, + }, + includeDrafts: true, + temporalAxes: currentTimeInstantTemporalAxes, }, - graphResolveDepths: { - ...zeroedGraphResolveDepths, - ...fullOntologyResolveDepths, - }, - includeDrafts: true, - temporalAxes: currentTimeInstantTemporalAxes, - }); + ); const existingEntities = getRoots(subgraph); diff --git a/apps/hash-api/src/graph/knowledge/primitive/entity.ts b/apps/hash-api/src/graph/knowledge/primitive/entity.ts index 28642a95116..7ee8f66d90e 100644 --- a/apps/hash-api/src/graph/knowledge/primitive/entity.ts +++ b/apps/hash-api/src/graph/knowledge/primitive/entity.ts @@ -34,6 +34,7 @@ import { import { systemEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids"; import { mapGraphApiEntityToEntity, + mapGraphApiEntityTypeResolveDefinitionsToEntityTypeResolveDefinitions, mapGraphApiSubgraphToSubgraph, } from "@local/hash-isomorphic-utils/subgraph-mapping"; import type { @@ -59,6 +60,7 @@ import { ApolloError } from "apollo-server-errors"; import type { EntityDefinition, + GetEntitySubgraphResponse, LinkedEntityDefinition, } from "../../../graphql/api-types.gen"; import { isTestEnv } from "../../../lib/env-config"; @@ -160,7 +162,7 @@ export const createEntity = async ( export const getEntities: ImpureGraphFunction< GetEntitiesRequest & { temporalClient?: TemporalClient }, Promise -> = async ({ graphApi }, { actorId }, { temporalClient, ...params }) => { +> = async ({ graphApi, temporalClient }, { actorId }, params) => { await rewriteSemanticFilter(params.filter, temporalClient); const isRequesterAdmin = isTestEnv @@ -185,12 +187,15 @@ export const getEntities: ImpureGraphFunction< * * @param params.query the structural query to filter entities by. */ -export const getEntitySubgraph: ImpureGraphFunction< - GetEntitySubgraphRequest & { - temporalClient?: TemporalClient; - }, - Promise> -> = async ({ graphApi }, { actorId }, { temporalClient, ...params }) => { +export const getEntitySubgraphResponse: ImpureGraphFunction< + GetEntitySubgraphRequest, + Promise< + Omit< + GetEntitySubgraphResponse, + "userPermissionsOnEntities" | "subgraph" + > & { subgraph: Subgraph } + > +> = async ({ graphApi, temporalClient }, { actorId }, params) => { await rewriteSemanticFilter(params.filter, temporalClient); const isRequesterAdmin = isTestEnv @@ -202,8 +207,15 @@ export const getEntitySubgraph: ImpureGraphFunction< ); return await graphApi.getEntitySubgraph(actorId, params).then(({ data }) => { + const { + subgraph: unfilteredSubgraph, + definitions, + closedMultiEntityTypes, + ...rest + } = data; + const subgraph = mapGraphApiSubgraphToSubgraph( - data.subgraph, + unfilteredSubgraph, actorId, isRequesterAdmin, ); @@ -223,7 +235,16 @@ export const getEntitySubgraph: ImpureGraphFunction< } } - return subgraph; + return { + closedMultiEntityTypes, + definitions: definitions + ? mapGraphApiEntityTypeResolveDefinitionsToEntityTypeResolveDefinitions( + definitions, + ) + : undefined, + subgraph, + ...rest, + }; }); }; @@ -239,7 +260,7 @@ export const countEntities: ImpureGraphFunction< * This function does NOT implement: * 1. The ability to get the latest draft version without knowing its id. * 2. The ability to get ALL versions of an entity at a given timestamp, i.e. if there is a live and one or more drafts - * – use {@link getEntitySubgraph} instead, includeDrafts, and match on its ownedById and uuid + * – use {@link getEntitySubgraphResponse} instead, includeDrafts, and match on its ownedById and uuid * * @param params.entityId the id of the entity, in one of the following formats: * - `[webUuid]~[entityUuid]` for the 'live', non-draft version of the entity @@ -750,39 +771,45 @@ export const getLatestEntityRootedSubgraph: ImpureGraphFunction< > = async (context, authentication, params) => { const { entity, graphResolveDepths } = params; - return await getEntitySubgraph(context, authentication, { - filter: { - all: [ - { - equal: [ - { path: ["uuid"] }, - { - parameter: extractEntityUuidFromEntityId( - entity.metadata.recordId.entityId, - ), - }, - ], - }, - { - equal: [ - { path: ["ownedById"] }, - { - parameter: extractOwnedByIdFromEntityId( - entity.metadata.recordId.entityId, - ), - }, - ], - }, - { equal: [{ path: ["archived"] }, { parameter: false }] }, - ], - }, - graphResolveDepths: { - ...zeroedGraphResolveDepths, - ...graphResolveDepths, + const { subgraph } = await getEntitySubgraphResponse( + context, + authentication, + { + filter: { + all: [ + { + equal: [ + { path: ["uuid"] }, + { + parameter: extractEntityUuidFromEntityId( + entity.metadata.recordId.entityId, + ), + }, + ], + }, + { + equal: [ + { path: ["ownedById"] }, + { + parameter: extractOwnedByIdFromEntityId( + entity.metadata.recordId.entityId, + ), + }, + ], + }, + { equal: [{ path: ["archived"] }, { parameter: false }] }, + ], + }, + graphResolveDepths: { + ...zeroedGraphResolveDepths, + ...graphResolveDepths, + }, + temporalAxes: currentTimeInstantTemporalAxes, + includeDrafts: false, }, - temporalAxes: currentTimeInstantTemporalAxes, - includeDrafts: false, - }); + ); + + return subgraph; }; export const modifyEntityAuthorizationRelationships: ImpureGraphFunction< diff --git a/apps/hash-api/src/graph/knowledge/system-types/notification.ts b/apps/hash-api/src/graph/knowledge/system-types/notification.ts index 4467172905f..d6a24c0988e 100644 --- a/apps/hash-api/src/graph/knowledge/system-types/notification.ts +++ b/apps/hash-api/src/graph/knowledge/system-types/notification.ts @@ -44,7 +44,7 @@ import type { } from "../../context-types"; import { createEntity, - getEntitySubgraph, + getEntitySubgraphResponse, updateEntity, } from "../primitive/entity"; import { createLinkEntity } from "../primitive/link-entity"; @@ -249,27 +249,34 @@ export const getMentionNotification: ImpureGraphFunction< includeDrafts = false, } = params; - const entitiesSubgraph = await getEntitySubgraph(context, authentication, { - filter: { - all: [ - generateVersionedUrlMatchingFilter( - systemEntityTypes.mentionNotification.entityTypeId, - { ignoreParents: true }, - ), - { - equal: [{ path: ["ownedById"] }, { parameter: recipient.accountId }], - }, - pageOrNotificationNotArchivedFilter, - ], - }, - graphResolveDepths: { - ...zeroedGraphResolveDepths, - // Get the outgoing links of the entities - hasLeftEntity: { outgoing: 0, incoming: 1 }, + const { subgraph: entitiesSubgraph } = await getEntitySubgraphResponse( + context, + authentication, + { + filter: { + all: [ + generateVersionedUrlMatchingFilter( + systemEntityTypes.mentionNotification.entityTypeId, + { ignoreParents: true }, + ), + { + equal: [ + { path: ["ownedById"] }, + { parameter: recipient.accountId }, + ], + }, + pageOrNotificationNotArchivedFilter, + ], + }, + graphResolveDepths: { + ...zeroedGraphResolveDepths, + // Get the outgoing links of the entities + hasLeftEntity: { outgoing: 0, incoming: 1 }, + }, + temporalAxes: currentTimeInstantTemporalAxes, + includeDrafts, }, - temporalAxes: currentTimeInstantTemporalAxes, - includeDrafts, - }); + ); /** * @todo: move these filters into the query when it is possible to filter @@ -500,55 +507,62 @@ export const getCommentNotification: ImpureGraphFunction< includeDrafts = false, } = params; - const entitiesSubgraph = await getEntitySubgraph(context, authentication, { - filter: { - all: [ - generateVersionedUrlMatchingFilter( - systemEntityTypes.commentNotification.entityTypeId, - { ignoreParents: true }, - ), - { - equal: [{ path: ["ownedById"] }, { parameter: recipient.accountId }], - }, - /** @todo: enforce the type of these links somehow */ - { - any: [ - { - equal: [ - { - path: [ - "properties", - systemPropertyTypes.archived.propertyTypeBaseUrl, - ], - }, - // @ts-expect-error -- We need to update the type definition of `EntityStructuralQuery` to allow for this - // @see https://linear.app/hash/issue/H-1207 - null, - ], - }, - { - equal: [ - { - path: [ - "properties", - systemPropertyTypes.archived.propertyTypeBaseUrl, - ], - }, - { parameter: false }, - ], - }, - ], - }, - ], - }, - graphResolveDepths: { - ...zeroedGraphResolveDepths, - // Get the outgoing links of the entities - hasLeftEntity: { outgoing: 0, incoming: 1 }, + const { subgraph: entitiesSubgraph } = await getEntitySubgraphResponse( + context, + authentication, + { + filter: { + all: [ + generateVersionedUrlMatchingFilter( + systemEntityTypes.commentNotification.entityTypeId, + { ignoreParents: true }, + ), + { + equal: [ + { path: ["ownedById"] }, + { parameter: recipient.accountId }, + ], + }, + /** @todo: enforce the type of these links somehow */ + { + any: [ + { + equal: [ + { + path: [ + "properties", + systemPropertyTypes.archived.propertyTypeBaseUrl, + ], + }, + // @ts-expect-error -- We need to update the type definition of `EntityStructuralQuery` to allow for this + // @see https://linear.app/hash/issue/H-1207 + null, + ], + }, + { + equal: [ + { + path: [ + "properties", + systemPropertyTypes.archived.propertyTypeBaseUrl, + ], + }, + { parameter: false }, + ], + }, + ], + }, + ], + }, + graphResolveDepths: { + ...zeroedGraphResolveDepths, + // Get the outgoing links of the entities + hasLeftEntity: { outgoing: 0, incoming: 1 }, + }, + temporalAxes: currentTimeInstantTemporalAxes, + includeDrafts, }, - temporalAxes: currentTimeInstantTemporalAxes, - includeDrafts, - }); + ); /** * @todo: move these filters into the query when it is possible to filter diff --git a/apps/hash-api/src/graph/ontology/primitive/entity-type.ts b/apps/hash-api/src/graph/ontology/primitive/entity-type.ts index f02f1a3d48f..9e06b57779e 100644 --- a/apps/hash-api/src/graph/ontology/primitive/entity-type.ts +++ b/apps/hash-api/src/graph/ontology/primitive/entity-type.ts @@ -1,14 +1,10 @@ -import type { - ClosedMultiEntityType, - VersionedUrl, -} from "@blockprotocol/type-system"; +import type { EntityType, VersionedUrl } from "@blockprotocol/type-system"; import { ENTITY_TYPE_META_SCHEMA } from "@blockprotocol/type-system"; import { NotFoundError } from "@local/hash-backend-utils/error"; import { publicUserAccountId } from "@local/hash-backend-utils/public-user-account-id"; import type { TemporalClient } from "@local/hash-backend-utils/temporal"; import type { ArchiveEntityTypeParams, - EntityType, EntityTypePermission, GetClosedMultiEntityTypeParams, GetClosedMultiEntityTypeResponse as GetClosedMultiEntityTypeResponseGraphApi, @@ -22,6 +18,7 @@ import type { } from "@local/hash-graph-client"; import type { ClosedEntityTypeWithMetadata, + ClosedMultiEntityType, EntityTypeMetadata, EntityTypeResolveDefinitions, EntityTypeWithMetadata, @@ -133,7 +130,8 @@ export const checkPermissionsOnEntityType: ImpureGraphFunction< * Create an entity type. * * @param params.ownedById - the id of the account who owns the entity type - * @param [params.webShortname] – the shortname of the web that owns the entity type, if the web entity does not yet exist. + * @param [params.webShortname] – the shortname of the web that owns the entity type, if the web entity does not yet + * exist. * - Only for seeding purposes. Caller is responsible for ensuring the webShortname is correct for the ownedById. * @param params.schema - the `EntityType` * @param params.actorId - the id of the account that is creating the entity type @@ -190,11 +188,9 @@ export const createEntityType: ImpureGraphFunction< * @param params.query the structural query to filter entity types by. */ export const getEntityTypeSubgraph: ImpureGraphFunction< - Omit & { - temporalClient?: TemporalClient; - }, + Omit, Promise> -> = async ({ graphApi }, { actorId }, { temporalClient, ...request }) => { +> = async ({ graphApi, temporalClient }, { actorId }, request) => { await rewriteSemanticFilter(request.filter, temporalClient); return await graphApi @@ -210,11 +206,9 @@ export const getEntityTypeSubgraph: ImpureGraphFunction< }; export const getEntityTypes: ImpureGraphFunction< - Omit & { - temporalClient?: TemporalClient; - }, + Omit, Promise -> = async ({ graphApi }, { actorId }, { temporalClient, ...request }) => { +> = async ({ graphApi, temporalClient }, { actorId }, request) => { await rewriteSemanticFilter(request.filter, temporalClient); return await graphApi @@ -232,7 +226,7 @@ export const getClosedEntityTypes: ImpureGraphFunction< temporalClient?: TemporalClient; }, Promise -> = async ({ graphApi }, { actorId }, { temporalClient, ...request }) => { +> = async ({ graphApi, temporalClient }, { actorId }, request) => { await rewriteSemanticFilter(request.filter, temporalClient); const { data: response } = await graphApi.getEntityTypes(actorId, { @@ -259,11 +253,9 @@ export type GetClosedMultiEntityTypeResponse = Omit< }; export const getClosedMultiEntityType: ImpureGraphFunction< - GetClosedMultiEntityTypeParams & { - temporalClient?: TemporalClient; - }, + GetClosedMultiEntityTypeParams, Promise -> = async ({ graphApi }, { actorId }, { temporalClient: _, ...request }) => +> = async ({ graphApi }, { actorId }, request) => graphApi .getClosedMultiEntityType(actorId, request) .then(({ data: response }) => ({ @@ -309,7 +301,8 @@ export const getEntityTypeById: ImpureGraphFunction< /** * Get an entity type rooted subgraph by its versioned URL. * - * If the type does not already exist within the Graph, and is an externally-hosted type, this will also load the type into the Graph. + * If the type does not already exist within the Graph, and is an externally-hosted type, this will also load the type + * into the Graph. */ export const getEntityTypeSubgraphById: ImpureGraphFunction< Omit & { @@ -403,7 +396,7 @@ export const isEntityTypeLinkEntityType: ImpureGraphFunction< const parentTypes = await Promise.all( (allOf ?? []).map(async ({ $ref }) => getEntityTypeById(context, authentication, { - entityTypeId: $ref as VersionedUrl, + entityTypeId: $ref, }), ), ); diff --git a/apps/hash-api/src/graphql/resolvers/index.ts b/apps/hash-api/src/graphql/resolvers/index.ts index b33ac3bf50c..18fe1851f53 100644 --- a/apps/hash-api/src/graphql/resolvers/index.ts +++ b/apps/hash-api/src/graphql/resolvers/index.ts @@ -83,6 +83,7 @@ import { archiveEntityTypeResolver, checkUserPermissionsOnEntityTypeResolver, createEntityTypeResolver, + getClosedMultiEntityTypeResolver, getEntityTypeResolver, queryEntityTypesResolver, unarchiveEntityTypeResolver, @@ -119,6 +120,7 @@ export const resolvers: Omit & { getPropertyType: getPropertyTypeResolver, queryEntityTypes: queryEntityTypesResolver, getEntityType: getEntityTypeResolver, + getClosedMultiEntityType: getClosedMultiEntityTypeResolver, // Knowledge pageComments: loggedInAndSignedUpMiddleware(pageCommentsResolver), blocks: loggedInAndSignedUpMiddleware(blocksResolver), diff --git a/apps/hash-api/src/graphql/resolvers/knowledge/entity/entity.ts b/apps/hash-api/src/graphql/resolvers/knowledge/entity/entity.ts index bb94c96b108..4a72d9cdfec 100644 --- a/apps/hash-api/src/graphql/resolvers/knowledge/entity/entity.ts +++ b/apps/hash-api/src/graphql/resolvers/knowledge/entity/entity.ts @@ -18,6 +18,7 @@ import { zeroedGraphResolveDepths, } from "@local/hash-isomorphic-utils/graph-queries"; import type { MutationArchiveEntitiesArgs } from "@local/hash-isomorphic-utils/graphql/api-types.gen"; +import { serializeSubgraph } from "@local/hash-isomorphic-utils/subgraph-mapping"; import { splitEntityId } from "@local/hash-subgraph"; import { ApolloError, @@ -32,7 +33,7 @@ import { checkEntityPermission, createEntityWithLinks, getEntityAuthorizationRelationships, - getEntitySubgraph, + getEntitySubgraphResponse, getLatestEntityById, modifyEntityAuthorizationRelationships, removeEntityAdministrator, @@ -71,7 +72,7 @@ import { } from "../../../api-types.gen"; import type { GraphQLContext, LoggedInGraphQLContext } from "../../../context"; import { graphQLContextToImpureGraphContext } from "../../util"; -import { createSubgraphAndPermissionsReturn } from "../shared/create-subgraph-and-permissions-return"; +import { getUserPermissionsOnSubgraph } from "../shared/get-user-permissions-on-subgraph"; export const createEntityResolver: ResolverFn< Promise, @@ -181,28 +182,37 @@ export const queryEntitiesResolver: NonNullable< ); } - const entitySubgraph = await getEntitySubgraph(context, authentication, { - filter, - graphResolveDepths: { - ...zeroedGraphResolveDepths, - constrainsValuesOn, - constrainsPropertiesOn, - constrainsLinksOn, - constrainsLinkDestinationsOn, - inheritsFrom, - isOfType, - hasLeftEntity, - hasRightEntity, + const { subgraph: entitySubgraph } = await getEntitySubgraphResponse( + context, + authentication, + { + filter, + graphResolveDepths: { + ...zeroedGraphResolveDepths, + constrainsValuesOn, + constrainsPropertiesOn, + constrainsLinksOn, + constrainsLinkDestinationsOn, + inheritsFrom, + isOfType, + hasLeftEntity, + hasRightEntity, + }, + temporalAxes: currentTimeInstantTemporalAxes, + includeDrafts: includeDrafts ?? false, }, - temporalAxes: currentTimeInstantTemporalAxes, - includeDrafts: includeDrafts ?? false, - }); + ); - return createSubgraphAndPermissionsReturn( + const userPermissionsOnEntities = await getUserPermissionsOnSubgraph( graphQLContext, info, entitySubgraph, ); + + return { + subgraph: serializeSubgraph(entitySubgraph), + userPermissionsOnEntities, + }; }; export const getEntitySubgraphResolver: ResolverFn< @@ -211,18 +221,23 @@ export const getEntitySubgraphResolver: ResolverFn< GraphQLContext, QueryGetEntitySubgraphArgs > = async (_, { request }, graphQLContext, info) => { - const context = graphQLContextToImpureGraphContext(graphQLContext); - - const subgraph = await getEntitySubgraph( + const { subgraph, ...rest } = await getEntitySubgraphResponse( graphQLContextToImpureGraphContext(graphQLContext), graphQLContext.authentication, - { - temporalClient: context.temporalClient, - ...request, - }, + request, ); - return createSubgraphAndPermissionsReturn(graphQLContext, info, subgraph); + const userPermissionsOnEntities = await getUserPermissionsOnSubgraph( + graphQLContext, + info, + subgraph, + ); + + return { + subgraph: serializeSubgraph(subgraph), + userPermissionsOnEntities, + ...rest, + }; }; export const getEntityResolver: ResolverFn< @@ -284,7 +299,7 @@ export const getEntityResolver: ResolverFn< } : currentTimeInstantTemporalAxes; - const entitySubgraph = await getEntitySubgraph( + const { subgraph: entitySubgraph } = await getEntitySubgraphResponse( graphQLContextToImpureGraphContext(graphQLContext), graphQLContext.authentication, { @@ -305,11 +320,16 @@ export const getEntityResolver: ResolverFn< }, ); - return createSubgraphAndPermissionsReturn( + const userPermissionsOnEntities = await getUserPermissionsOnSubgraph( graphQLContext, info, entitySubgraph, ); + + return { + subgraph: serializeSubgraph(entitySubgraph), + userPermissionsOnEntities, + }; }; export const updateEntityResolver: ResolverFn< diff --git a/apps/hash-api/src/graphql/resolvers/knowledge/shared/create-subgraph-and-permissions-return.ts b/apps/hash-api/src/graphql/resolvers/knowledge/shared/get-user-permissions-on-subgraph.ts similarity index 87% rename from apps/hash-api/src/graphql/resolvers/knowledge/shared/create-subgraph-and-permissions-return.ts rename to apps/hash-api/src/graphql/resolvers/knowledge/shared/get-user-permissions-on-subgraph.ts index 8cb0eec8c4d..b6493beede3 100644 --- a/apps/hash-api/src/graphql/resolvers/knowledge/shared/create-subgraph-and-permissions-return.ts +++ b/apps/hash-api/src/graphql/resolvers/knowledge/shared/get-user-permissions-on-subgraph.ts @@ -1,4 +1,3 @@ -import { serializeSubgraph } from "@local/hash-isomorphic-utils/subgraph-mapping"; import type { UserPermissionsOnEntities } from "@local/hash-isomorphic-utils/types"; import type { Subgraph } from "@local/hash-subgraph"; import type { GraphQLResolveInfo } from "graphql"; @@ -14,7 +13,8 @@ const werePermissionsRequested = (info: GraphQLResolveInfo) => { const parsedResolveInfoFragment = parseResolveInfo(info); const requestedFieldsOnSubgraph = - parsedResolveInfoFragment?.fieldsByTypeName.SubgraphAndPermissions; + parsedResolveInfoFragment?.fieldsByTypeName.SubgraphAndPermissions || + parsedResolveInfoFragment?.fieldsByTypeName.GetEntitySubgraphResponse; if (!requestedFieldsOnSubgraph) { throw new Error(`No Subgraph in parsed resolve info fragment`); @@ -30,11 +30,11 @@ const werePermissionsRequested = (info: GraphQLResolveInfo) => { }; }; -export const createSubgraphAndPermissionsReturn = async ( +export const getUserPermissionsOnSubgraph = async ( graphQLContext: GraphQLContext, resolveInfo: GraphQLResolveInfo, subgraph: Subgraph, -): Promise => { +): Promise => { const { authentication } = graphQLContext; const userPermissionsOnEntities = werePermissionsRequested(resolveInfo) @@ -53,8 +53,5 @@ export const createSubgraphAndPermissionsReturn = async ( */ (null as unknown as UserPermissionsOnEntities); - return { - subgraph: serializeSubgraph(subgraph), - userPermissionsOnEntities, - }; + return userPermissionsOnEntities; }; diff --git a/apps/hash-api/src/graphql/resolvers/knowledge/user/me.ts b/apps/hash-api/src/graphql/resolvers/knowledge/user/me.ts index d5df4aacfd2..303fecc8a94 100644 --- a/apps/hash-api/src/graphql/resolvers/knowledge/user/me.ts +++ b/apps/hash-api/src/graphql/resolvers/knowledge/user/me.ts @@ -1,8 +1,10 @@ +import { serializeSubgraph } from "@local/hash-isomorphic-utils/subgraph-mapping"; + import { getLatestEntityRootedSubgraph } from "../../../../graph/knowledge/primitive/entity"; import type { Query, QueryMeArgs, ResolverFn } from "../../../api-types.gen"; import type { LoggedInGraphQLContext } from "../../../context"; import { graphQLContextToImpureGraphContext } from "../../util"; -import { createSubgraphAndPermissionsReturn } from "../shared/create-subgraph-and-permissions-return"; +import { getUserPermissionsOnSubgraph } from "../shared/get-user-permissions-on-subgraph"; export const meResolver: ResolverFn< Query["me"], @@ -26,5 +28,14 @@ export const meResolver: ResolverFn< }, ); - return createSubgraphAndPermissionsReturn(graphQLContext, info, userSubgraph); + const userPermissionsOnEntities = await getUserPermissionsOnSubgraph( + graphQLContext, + info, + userSubgraph, + ); + + return { + subgraph: serializeSubgraph(userSubgraph), + userPermissionsOnEntities, + }; }; diff --git a/apps/hash-api/src/graphql/resolvers/ontology/entity-type.ts b/apps/hash-api/src/graphql/resolvers/ontology/entity-type.ts index 9f031ba15ef..694fb51c890 100644 --- a/apps/hash-api/src/graphql/resolvers/ontology/entity-type.ts +++ b/apps/hash-api/src/graphql/resolvers/ontology/entity-type.ts @@ -10,22 +10,26 @@ import { import { serializeSubgraph } from "@local/hash-isomorphic-utils/subgraph-mapping"; import type { UserPermissionsOnEntityType } from "@local/hash-isomorphic-utils/types"; import type { SerializedSubgraph } from "@local/hash-subgraph"; +import { ApolloError } from "apollo-server-express"; import { archiveEntityType, checkPermissionsOnEntityType, createEntityType, + getClosedMultiEntityType, getEntityTypeSubgraph, getEntityTypeSubgraphById, unarchiveEntityType, updateEntityType, } from "../../../graph/ontology/primitive/entity-type"; import type { + GetClosedMultiEntityTypeResponse, MutationArchiveEntityTypeArgs, MutationCreateEntityTypeArgs, MutationUnarchiveEntityTypeArgs, MutationUpdateEntityTypeArgs, QueryCheckUserPermissionsOnEntityTypeArgs, + QueryGetClosedMultiEntityTypeArgs, QueryGetEntityTypeArgs, QueryQueryEntityTypesArgs, ResolverFn, @@ -80,25 +84,28 @@ export const queryEntityTypesResolver: ResolverFn< }; return serializeSubgraph( - await getEntityTypeSubgraph({ graphApi, provenance }, authentication, { - filter: latestOnly - ? filter - ? { all: [filter, latestOnlyFilter] } - : latestOnlyFilter - : { all: [] }, - graphResolveDepths: { - ...zeroedGraphResolveDepths, - constrainsValuesOn, - constrainsPropertiesOn, - constrainsLinksOn, - constrainsLinkDestinationsOn, - inheritsFrom, + await getEntityTypeSubgraph( + { graphApi, provenance, temporalClient: temporal }, + authentication, + { + filter: latestOnly + ? filter + ? { all: [filter, latestOnlyFilter] } + : latestOnlyFilter + : { all: [] }, + graphResolveDepths: { + ...zeroedGraphResolveDepths, + constrainsValuesOn, + constrainsPropertiesOn, + constrainsLinksOn, + constrainsLinkDestinationsOn, + inheritsFrom, + }, + temporalAxes: includeArchived + ? fullTransactionTimeAxis + : currentTimeInstantTemporalAxes, }, - temporalAxes: includeArchived - ? fullTransactionTimeAxis - : currentTimeInstantTemporalAxes, - temporalClient: temporal, - }), + ), ); }; @@ -142,6 +149,38 @@ export const getEntityTypeResolver: ResolverFn< ), ); +export const getClosedMultiEntityTypeResolver: ResolverFn< + Promise, + Record, + GraphQLContext, + QueryGetClosedMultiEntityTypeArgs +> = async (_, args, graphQLContext) => { + const { entityTypeIds, includeDrafts, includeArchived } = args; + + const { entityType, definitions } = await getClosedMultiEntityType( + graphQLContextToImpureGraphContext(graphQLContext), + graphQLContext.authentication, + { + entityTypeIds, + // All references to other types are resolved, and those types provided under 'definitions' in the response + includeResolved: true, + includeDrafts: includeDrafts ?? false, + temporalAxes: includeArchived + ? fullTransactionTimeAxis + : currentTimeInstantTemporalAxes, + }, + ); + + if (!definitions) { + throw new ApolloError("No definitions found for closed multi entity type"); + } + + return { + closedMultiEntityType: entityType, + definitions, + }; +}; + export const updateEntityTypeResolver: ResolverFn< Promise, Record, diff --git a/apps/hash-api/src/integrations/linear/sync-back.ts b/apps/hash-api/src/integrations/linear/sync-back.ts index e11b72f842d..b5b8c5c6d25 100644 --- a/apps/hash-api/src/integrations/linear/sync-back.ts +++ b/apps/hash-api/src/integrations/linear/sync-back.ts @@ -83,7 +83,6 @@ export const processEntityChange = async ( type: "flow", }, }, - temporalClient: null, }; const linearEntityToUpdate = entityTypeIds.some( diff --git a/apps/hash-frontend/src/graphql/queries/knowledge/entity.queries.ts b/apps/hash-frontend/src/graphql/queries/knowledge/entity.queries.ts index 779ff2e2c23..3fbba0c3a9d 100644 --- a/apps/hash-frontend/src/graphql/queries/knowledge/entity.queries.ts +++ b/apps/hash-frontend/src/graphql/queries/knowledge/entity.queries.ts @@ -61,6 +61,8 @@ export const getEntitySubgraphQuery = gql` $includePermissions: Boolean! ) { getEntitySubgraph(request: $request) { + closedMultiEntityTypes + definitions userPermissionsOnEntities @include(if: $includePermissions) subgraph { ...SubgraphFields diff --git a/apps/hash-frontend/src/graphql/queries/ontology/entity-type.queries.ts b/apps/hash-frontend/src/graphql/queries/ontology/entity-type.queries.ts index 71d25301e0a..75363ed169e 100644 --- a/apps/hash-frontend/src/graphql/queries/ontology/entity-type.queries.ts +++ b/apps/hash-frontend/src/graphql/queries/ontology/entity-type.queries.ts @@ -53,6 +53,23 @@ export const queryEntityTypesQuery = gql` ${subgraphFieldsFragment} `; +export const getClosedMultiEntityTypeQuery = gql` + query getClosedMultiEntityType( + $entityTypeIds: [VersionedUrl!]! + $includeArchived: Boolean = false + $includeDrafts: Boolean = false + ) { + getClosedMultiEntityType( + entityTypeIds: $entityTypeIds + includeArchived: $includeArchived + includeDrafts: $includeDrafts + ) { + closedMultiEntityType + definitions + } + } +`; + export const createEntityTypeMutation = gql` mutation createEntityType( $ownedById: OwnedById! diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page.tsx b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page.tsx index 7dc7e71f8a1..695d470c4b7 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page.tsx +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page.tsx @@ -1,6 +1,7 @@ import { useLazyQuery, useMutation } from "@apollo/client"; import type { Entity } from "@local/hash-graph-sdk/entity"; import { + getClosedMultiEntityTypeFromMap, mergePropertyObjectAndMetadata, patchesFromPropertyObjects, } from "@local/hash-graph-sdk/entity"; @@ -13,8 +14,12 @@ import type { import type { OwnedById } from "@local/hash-graph-types/web"; import { generateEntityPath } from "@local/hash-isomorphic-utils/frontend-paths"; import { generateEntityLabel } from "@local/hash-isomorphic-utils/generate-entity-label"; -import { mapGqlSubgraphFieldsFragmentToSubgraph } from "@local/hash-isomorphic-utils/graph-queries"; -import { getEntityQuery } from "@local/hash-isomorphic-utils/graphql/queries/entity.queries"; +import { + currentTimeInstantTemporalAxes, + generateEntityIdFilter, + mapGqlSubgraphFieldsFragmentToSubgraph, + zeroedGraphResolveDepths, +} from "@local/hash-isomorphic-utils/graph-queries"; import { blockProtocolEntityTypes, systemEntityTypes, @@ -35,21 +40,26 @@ import { useCallback, useEffect, useState } from "react"; import { useBlockProtocolGetEntityType } from "../../../components/hooks/block-protocol-functions/ontology/use-block-protocol-get-entity-type"; import { PageErrorState } from "../../../components/page-error-state"; import type { - GetEntityQuery, - GetEntityQueryVariables, + GetEntitySubgraphQuery, + GetEntitySubgraphQueryVariables, UpdateEntityMutation, UpdateEntityMutationVariables, } from "../../../graphql/api-types.gen"; -import { updateEntityMutation } from "../../../graphql/queries/knowledge/entity.queries"; +import { + getEntitySubgraphQuery, + updateEntityMutation, +} from "../../../graphql/queries/knowledge/entity.queries"; import type { NextPageWithLayout } from "../../../shared/layout"; import { getLayoutWithSidebar } from "../../../shared/layout"; import { EditBar } from "../shared/edit-bar"; import { useRouteNamespace } from "../shared/use-route-namespace"; +import type { EntityEditorProps } from "./[entity-uuid].page/entity-editor"; import { EntityEditorPage } from "./[entity-uuid].page/entity-editor-page"; import { EntityPageLoadingState } from "./[entity-uuid].page/entity-page-loading-state"; -import { updateEntitySubgraphStateByEntity } from "./[entity-uuid].page/shared/update-entity-subgraph-state-by-entity"; +import { updateDraftEntitySubgraph } from "./[entity-uuid].page/shared/update-draft-entity-subgraph"; import { useApplyDraftLinkEntityChanges } from "./[entity-uuid].page/shared/use-apply-draft-link-entity-changes"; import { useDraftLinkState } from "./[entity-uuid].page/shared/use-draft-link-state"; +import { useGetClosedMultiEntityType } from "./[entity-uuid].page/shared/use-get-closed-multi-entity-type"; const Page: NextPageWithLayout = () => { const router = useRouter(); @@ -58,10 +68,10 @@ const Page: NextPageWithLayout = () => { const { routeNamespace } = useRouteNamespace(); - const [lazyGetEntity] = useLazyQuery( - getEntityQuery, - { fetchPolicy: "cache-and-network" }, - ); + const [lazyGetEntity] = useLazyQuery< + GetEntitySubgraphQuery, + GetEntitySubgraphQueryVariables + >(getEntitySubgraphQuery, { fetchPolicy: "cache-and-network" }); const { getEntityType } = useBlockProtocolGetEntityType(); const [updateEntity] = useMutation< @@ -71,14 +81,34 @@ const Page: NextPageWithLayout = () => { const applyDraftLinkEntityChanges = useApplyDraftLinkEntityChanges(); - const [entitySubgraphFromDb, setEntitySubgraphFromDb] = - useState>(); + const [draftEntityTypesDetails, setDraftEntityTypesDetails] = + useState< + Pick< + EntityEditorProps, + "closedMultiEntityType" | "closedMultiEntityTypesDefinitions" + > + >(); + + const { getClosedMultiEntityType, loading: closedMultiEntityTypeLoading } = + useGetClosedMultiEntityType(); + + const [dataFromDb, setDataFromDb] = + useState< + Pick< + EntityEditorProps, + | "closedMultiEntityType" + | "closedMultiEntityTypesDefinitions" + | "closedMultiEntityTypesMap" + | "entitySubgraph" + > + >(); + const [draftEntitySubgraph, setDraftEntitySubgraph] = useState>(); const [isReadOnly, setIsReadOnly] = useState(true); const entityFromDb = - entitySubgraphFromDb && getRoots(entitySubgraphFromDb)[0]; + dataFromDb?.entitySubgraph && getRoots(dataFromDb.entitySubgraph)[0]; /** * If the user is viewing a `User` entity, redirect to its profile page. @@ -110,17 +140,19 @@ const Page: NextPageWithLayout = () => { (entityId: EntityId) => lazyGetEntity({ variables: { - entityId, - constrainsValuesOn: { outgoing: 255 }, - constrainsPropertiesOn: { outgoing: 255 }, - constrainsLinksOn: { outgoing: 1 }, - constrainsLinkDestinationsOn: { outgoing: 1 }, includePermissions: true, - inheritsFrom: { outgoing: 255 }, - isOfType: { outgoing: 1 }, - hasLeftEntity: { outgoing: 1, incoming: 1 }, - hasRightEntity: { outgoing: 1, incoming: 1 }, - includeDrafts: !!draftId, + request: { + filter: generateEntityIdFilter({ entityId, includeArchived: true }), + graphResolveDepths: { + ...zeroedGraphResolveDepths, + isOfType: { outgoing: 1 }, + hasLeftEntity: { outgoing: 1, incoming: 1 }, + hasRightEntity: { outgoing: 1, incoming: 1 }, + }, + includeEntityTypes: "resolved", + includeDrafts: !!draftId, + temporalAxes: currentTimeInstantTemporalAxes, + }, }, }), [draftId, lazyGetEntity], @@ -133,6 +165,60 @@ const Page: NextPageWithLayout = () => { setDraftLinksToArchive, ] = useDraftLinkState(); + const setStateFromGetEntityResponse = useCallback( + (data?: GetEntitySubgraphQuery) => { + if (data?.getEntitySubgraph) { + const subgraph = mapGqlSubgraphFieldsFragmentToSubgraph( + data.getEntitySubgraph.subgraph, + ); + + try { + const { closedMultiEntityTypes, definitions } = + data.getEntitySubgraph; + + if (!closedMultiEntityTypes || !definitions) { + throw new Error( + "closedMultiEntityTypes and definitions are required", + ); + } + + const entity = getRoots(subgraph)[0]; + + if (!entity) { + throw new Error("No root entity found in entity subgraph"); + } + + const closedMultiEntityType = getClosedMultiEntityTypeFromMap( + closedMultiEntityTypes, + entity.metadata.entityTypeIds, + ); + + setDataFromDb({ + closedMultiEntityTypesMap: closedMultiEntityTypes, + closedMultiEntityType, + closedMultiEntityTypesDefinitions: definitions, + entitySubgraph: subgraph, + }); + + setDraftEntityTypesDetails({ + closedMultiEntityType, + closedMultiEntityTypesDefinitions: definitions, + }); + setDraftEntitySubgraph(subgraph); + setIsReadOnly( + !data.getEntitySubgraph.userPermissionsOnEntities?.[entity.entityId] + ?.edit, + ); + } catch { + setDataFromDb(undefined); + setDraftEntitySubgraph(undefined); + setIsReadOnly(true); + } + } + }, + [], + ); + useEffect(() => { if (routeNamespace) { const init = async () => { @@ -145,25 +231,7 @@ const Page: NextPageWithLayout = () => { const { data } = await getEntity(entityId); - const subgraph = data - ? mapGqlSubgraphFieldsFragmentToSubgraph( - data.getEntity.subgraph, - ) - : undefined; - - if (data?.getEntity) { - try { - setEntitySubgraphFromDb(subgraph); - setDraftEntitySubgraph(subgraph); - setIsReadOnly( - !data.getEntity.userPermissionsOnEntities?.[entityId]?.edit, - ); - } catch { - setEntitySubgraphFromDb(undefined); - setDraftEntitySubgraph(undefined); - setIsReadOnly(true); - } - } + setStateFromGetEntityResponse(data); } finally { setLoading(false); } @@ -171,7 +239,14 @@ const Page: NextPageWithLayout = () => { void init(); } - }, [draftId, entityUuid, getEntity, getEntityType, routeNamespace]); + }, [ + draftId, + entityUuid, + getEntity, + getEntityType, + routeNamespace, + setStateFromGetEntityResponse, + ]); const refetch = useCallback(async () => { if (!routeNamespace || !draftEntitySubgraph) { @@ -186,15 +261,15 @@ const Page: NextPageWithLayout = () => { const { data } = await getEntity(entityId); - const subgraph = data - ? mapGqlSubgraphFieldsFragmentToSubgraph( - data.getEntity.subgraph, - ) - : undefined; - - setEntitySubgraphFromDb(subgraph); - setDraftEntitySubgraph(subgraph); - }, [draftEntitySubgraph, draftId, entityUuid, getEntity, routeNamespace]); + setStateFromGetEntityResponse(data); + }, [ + draftEntitySubgraph, + draftId, + entityUuid, + getEntity, + routeNamespace, + setStateFromGetEntityResponse, + ]); const resetDraftState = () => { setIsDirty(false); @@ -204,12 +279,27 @@ const Page: NextPageWithLayout = () => { const discardChanges = () => { resetDraftState(); - setDraftEntitySubgraph(entitySubgraphFromDb); + + const { + entitySubgraph, + closedMultiEntityType, + closedMultiEntityTypesDefinitions, + } = dataFromDb ?? {}; + + setDraftEntityTypesDetails( + closedMultiEntityType && closedMultiEntityTypesDefinitions + ? { + closedMultiEntityType, + closedMultiEntityTypesDefinitions, + } + : undefined, + ); + setDraftEntitySubgraph(entitySubgraph); }; const [savingChanges, setSavingChanges] = useState(false); const handleSaveChanges = async (overrideProperties?: PropertyObject) => { - if (!entitySubgraphFromDb || !draftEntitySubgraph) { + if (!dataFromDb || !draftEntitySubgraph) { return; } @@ -222,7 +312,7 @@ const Page: NextPageWithLayout = () => { try { setSavingChanges(true); - const entity = getRoots(entitySubgraphFromDb)[0]; + const entity = getRoots(dataFromDb.entitySubgraph)[0]; if (!entity) { throw new Error(`entity not found in subgraph`); } @@ -289,11 +379,11 @@ const Page: NextPageWithLayout = () => { [draftId, refetch, router, routeNamespace], ); - if (loading) { + if (loading || (!draftEntityTypesDetails && closedMultiEntityTypeLoading)) { return ; } - if (!draftEntitySubgraph) { + if (!draftEntitySubgraph || !draftEntityTypesDetails) { return ; } @@ -302,7 +392,7 @@ const Page: NextPageWithLayout = () => { return ; } - const entityLabel = generateEntityLabel(draftEntitySubgraph); + const entityLabel = generateEntityLabel(draftEntitySubgraph, draftEntity); const isModifyingEntity = isDirty || !!draftLinksToCreate.length || !!draftLinksToArchive.length; @@ -313,6 +403,8 @@ const Page: NextPageWithLayout = () => { return ( { entitySubgraph={draftEntitySubgraph} readonly={isReadOnly} onEntityUpdated={(entity) => onEntityUpdated(entity)} + setEntityTypes={async (newEntityTypeIds) => { + const newDetails = await getClosedMultiEntityType(newEntityTypeIds); + setDraftEntityTypesDetails(newDetails); + + const newSubgraph = updateDraftEntitySubgraph( + draftEntity, + newEntityTypeIds, + draftEntitySubgraph, + ); + + setIsDirty( + JSON.stringify(newEntityTypeIds.sort()) !== + JSON.stringify(entityFromDb?.metadata.entityTypeIds.sort()), + ); + + setDraftEntitySubgraph(newSubgraph); + }} setEntity={(changedEntity) => { - setIsDirty(true); - updateEntitySubgraphStateByEntity( + const newSubgraph = updateDraftEntitySubgraph( changedEntity, - setDraftEntitySubgraph, + changedEntity.metadata.entityTypeIds, + draftEntitySubgraph, ); + + setDraftEntitySubgraph(newSubgraph); }} /> ); diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/create-entity-page.tsx b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/create-entity-page.tsx index 9f49ff7715e..b1cdf3dc374 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/create-entity-page.tsx +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/create-entity-page.tsx @@ -7,19 +7,21 @@ import { extractEntityUuidFromEntityId } from "@local/hash-subgraph"; import { getRoots } from "@local/hash-subgraph/stdlib"; import { Typography } from "@mui/material"; import { useRouter } from "next/router"; -import { useContext, useState } from "react"; +import { useCallback, useContext, useEffect, useState } from "react"; import { useBlockProtocolCreateEntity } from "../../../../components/hooks/block-protocol-functions/knowledge/use-block-protocol-create-entity"; import { PageErrorState } from "../../../../components/page-error-state"; import { Link } from "../../../../shared/ui/link"; import { WorkspaceContext } from "../../../shared/workspace-context"; import { EditBar } from "../../shared/edit-bar"; +import { createDraftEntitySubgraph } from "./create-entity-page/create-draft-entity-subgraph"; +import type { EntityEditorProps } from "./entity-editor"; import { EntityEditorPage } from "./entity-editor-page"; import { EntityPageLoadingState } from "./entity-page-loading-state"; -import { updateEntitySubgraphStateByEntity } from "./shared/update-entity-subgraph-state-by-entity"; +import { updateDraftEntitySubgraph } from "./shared/update-draft-entity-subgraph"; import { useApplyDraftLinkEntityChanges } from "./shared/use-apply-draft-link-entity-changes"; -import { useDraftEntitySubgraph } from "./shared/use-draft-entity-subgraph"; import { useDraftLinkState } from "./shared/use-draft-link-state"; +import { useGetClosedMultiEntityType } from "./shared/use-get-closed-multi-entity-type"; interface CreateEntityPageProps { entityTypeId: VersionedUrl; @@ -38,8 +40,33 @@ export const CreateEntityPage = ({ entityTypeId }: CreateEntityPageProps) => { setDraftLinksToArchive, ] = useDraftLinkState(); - const [draftEntitySubgraph, setDraftEntitySubgraph, loading] = - useDraftEntitySubgraph(entityTypeId); + const { getClosedMultiEntityType, loading: closedTypeLoading } = + useGetClosedMultiEntityType(); + + const [draftEntityTypesDetails, setDraftEntityTypesDetails] = + useState< + Pick< + EntityEditorProps, + "closedMultiEntityType" | "closedMultiEntityTypesDefinitions" + > + >(); + + const [draftEntitySubgraph, setDraftEntitySubgraph] = useState(() => + createDraftEntitySubgraph([entityTypeId]), + ); + + const fetchAndSetTypeDetails = useCallback( + async (entityTypeIds: VersionedUrl[]) => { + await getClosedMultiEntityType(entityTypeIds).then((result) => { + setDraftEntityTypesDetails(result); + }); + }, + [getClosedMultiEntityType], + ); + + useEffect(() => { + void fetchAndSetTypeDetails([entityTypeId]); + }, [entityTypeId, fetchAndSetTypeDetails]); const { activeWorkspace, activeWorkspaceOwnedById } = useContext(WorkspaceContext); @@ -49,6 +76,8 @@ export const CreateEntityPage = ({ entityTypeId }: CreateEntityPageProps) => { const [creating, setCreating] = useState(false); + const entity = getRoots(draftEntitySubgraph)[0]!; + /** * `overrideProperties` is a quick hack to bypass the setting draftEntity state * I did this, because I was having trouble with the `setDraftEntitySubgraph` function, @@ -56,7 +85,7 @@ export const CreateEntityPage = ({ entityTypeId }: CreateEntityPageProps) => { * @todo find a better way to do this */ const handleCreateEntity = async (overrideProperties?: PropertyObject) => { - if (!draftEntitySubgraph || !activeWorkspace) { + if (!activeWorkspace) { return; } @@ -68,25 +97,25 @@ export const CreateEntityPage = ({ entityTypeId }: CreateEntityPageProps) => { try { setCreating(true); - const { data: entity } = await createEntity({ + const { data: createdEntity } = await createEntity({ data: { - entityTypeIds: [entityTypeId], + entityTypeIds: entity.metadata.entityTypeIds, properties: overrideProperties ?? draftEntity.properties, }, }); - if (!entity) { + if (!createdEntity) { return; } await applyDraftLinkEntityChanges( - entity, + createdEntity, draftLinksToCreate, draftLinksToArchive, ); const entityId = extractEntityUuidFromEntityId( - entity.metadata.recordId.entityId, + createdEntity.metadata.recordId.entityId, ); void router.push(`/@${activeWorkspace.shortname}/entities/${entityId}`); @@ -97,15 +126,18 @@ export const CreateEntityPage = ({ entityTypeId }: CreateEntityPageProps) => { } }; - if (loading) { + if (!draftEntityTypesDetails && !closedTypeLoading) { return ; } - if (!draftEntitySubgraph) { + if (!draftEntityTypesDetails) { return ; } - const entityLabel = generateEntityLabel(draftEntitySubgraph); + const entityLabel = generateEntityLabel( + draftEntityTypesDetails.closedMultiEntityType, + entity, + ); const isQueryEntity = entityTypeId === blockProtocolEntityTypes.query.entityTypeId; @@ -126,6 +158,8 @@ export const CreateEntityPage = ({ entityTypeId }: CreateEntityPageProps) => { )} { isDirty isDraft handleSaveChanges={handleCreateEntity} - setEntity={(entity) => { - updateEntitySubgraphStateByEntity(entity, setDraftEntitySubgraph); + setEntityTypes={async (newEntityTypeIds) => { + const newDetails = await getClosedMultiEntityType(newEntityTypeIds); + setDraftEntityTypesDetails(newDetails); + + const newSubgraph = updateDraftEntitySubgraph( + entity, + newEntityTypeIds, + draftEntitySubgraph, + ); + + setDraftEntitySubgraph(newSubgraph); + }} + setEntity={(changedEntity) => { + const newSubgraph = updateDraftEntitySubgraph( + changedEntity, + changedEntity.metadata.entityTypeIds, + draftEntitySubgraph, + ); + setDraftEntitySubgraph(newSubgraph); }} draftLinksToCreate={draftLinksToCreate} setDraftLinksToCreate={setDraftLinksToCreate} diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/create-entity-page/create-draft-entity-subgraph.ts b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/create-entity-page/create-draft-entity-subgraph.ts new file mode 100644 index 00000000000..dbfb48d57bc --- /dev/null +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/create-entity-page/create-draft-entity-subgraph.ts @@ -0,0 +1,107 @@ +import type { VersionedUrl } from "@blockprotocol/type-system"; +import type { Entity as GraphApiEntity } from "@local/hash-graph-client"; +import { Entity } from "@local/hash-graph-sdk/entity"; +import type { EntityId } from "@local/hash-graph-types/entity"; +import type { Timestamp } from "@local/hash-graph-types/temporal-versioning"; +import { + currentTimeInstantTemporalAxes, + zeroedGraphResolveDepths, +} from "@local/hash-isomorphic-utils/graph-queries"; +import type { + EntityRevisionId, + EntityRootType, + EntityVertex, + EntityVertexId, + KnowledgeGraphVertices, + Subgraph, +} from "@local/hash-subgraph"; +import { extractOwnedByIdFromEntityId } from "@local/hash-subgraph"; + +export const createDraftEntitySubgraph = ( + entityTypeIds: [VersionedUrl, ...VersionedUrl[]], + previousDraft?: Entity, +): Subgraph => { + const now = new Date().toISOString() as Timestamp; + + const draftEntityVertexId: EntityVertexId = { + baseId: "draft~draft" as EntityId, + revisionId: now as EntityRevisionId, + }; + const creator = extractOwnedByIdFromEntityId(draftEntityVertexId.baseId); + + const serializedEntity: GraphApiEntity = { + properties: previousDraft?.properties ?? {}, + metadata: { + recordId: { + entityId: draftEntityVertexId.baseId, + editionId: now, + }, + entityTypeIds, + temporalVersioning: { + decisionTime: { + start: { + kind: "inclusive", + limit: now, + }, + end: { + kind: "unbounded", + }, + }, + transactionTime: { + start: { + kind: "inclusive", + limit: now, + }, + end: { + kind: "unbounded", + }, + }, + }, + archived: false, + provenance: { + createdAtDecisionTime: now, + createdAtTransactionTime: now, + createdById: creator, + edition: { + createdById: creator, + }, + }, + }, + }; + + const entity = new Entity(serializedEntity); + + return { + depths: zeroedGraphResolveDepths, + edges: {}, + roots: [draftEntityVertexId], + temporalAxes: { + initial: currentTimeInstantTemporalAxes, + resolved: { + pinned: { + axis: "transactionTime", + timestamp: now, + }, + variable: { + axis: "decisionTime", + interval: { + start: { + kind: "inclusive", + limit: new Date(0).toISOString() as Timestamp, + }, + end: { kind: "inclusive", limit: now }, + }, + }, + }, + }, + // @ts-expect-error -- Vertices expects OntologyVertices to be present. @todo overhaul subgraph + vertices: { + [draftEntityVertexId.baseId]: { + [draftEntityVertexId.revisionId]: { + kind: "entity", + inner: entity, + } as const satisfies EntityVertex, + }, + } satisfies KnowledgeGraphVertices, + }; +}; diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/edit-entity-slide-over.tsx b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/edit-entity-slide-over.tsx index 85b31105f3e..75c7eed9dd2 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/edit-entity-slide-over.tsx +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/edit-entity-slide-over.tsx @@ -5,27 +5,33 @@ import { Skeleton, } from "@hashintel/design-system"; import { + getClosedMultiEntityTypeFromMap, + getDisplayFieldsForClosedEntityType, + isClosedMultiEntityTypeForEntityTypeIds, mergePropertyObjectAndMetadata, patchesFromPropertyObjects, } from "@local/hash-graph-sdk/entity"; import type { EntityId } from "@local/hash-graph-types/entity"; +import type { + ClosedMultiEntityTypesDefinitions, + ClosedMultiEntityTypesRootMap, +} from "@local/hash-graph-types/ontology"; import { generateEntityPath } from "@local/hash-isomorphic-utils/frontend-paths"; import { generateEntityLabel } from "@local/hash-isomorphic-utils/generate-entity-label"; import { currentTimeInstantTemporalAxes, - fullOntologyResolveDepths, mapGqlSubgraphFieldsFragmentToSubgraph, + zeroedGraphResolveDepths, } from "@local/hash-isomorphic-utils/graph-queries"; import type { EntityRootType, Subgraph } from "@local/hash-subgraph"; import { extractOwnedByIdFromEntityId, - linkEntityTypeUrl, splitEntityId, } from "@local/hash-subgraph"; -import { getEntityTypeById, getRoots } from "@local/hash-subgraph/stdlib"; +import { getRoots } from "@local/hash-subgraph/stdlib"; import { Box, Drawer, Stack, Typography } from "@mui/material"; import type { RefObject } from "react"; -import { memo, useCallback, useMemo, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useState } from "react"; import { useUserOrOrgShortnameByOwnedById } from "../../../../components/hooks/use-user-or-org-shortname-by-owned-by-id"; import type { @@ -42,11 +48,14 @@ import { Button, Link } from "../../../../shared/ui"; import { SlideBackForwardCloseBar } from "../../../shared/shared/slide-back-forward-close-bar"; import type { EntityEditorProps } from "./entity-editor"; import { EntityEditor } from "./entity-editor"; -import { updateEntitySubgraphStateByEntity } from "./shared/update-entity-subgraph-state-by-entity"; +import { updateDraftEntitySubgraph } from "./shared/update-draft-entity-subgraph"; import { useApplyDraftLinkEntityChanges } from "./shared/use-apply-draft-link-entity-changes"; import { useDraftLinkState } from "./shared/use-draft-link-state"; +import { useGetClosedMultiEntityType } from "./shared/use-get-closed-multi-entity-type"; export interface EditEntitySlideOverProps { + closedMultiEntityTypesMap?: ClosedMultiEntityTypesRootMap; + closedMultiEntityTypesDefinitions?: ClosedMultiEntityTypesDefinitions; customColumns?: EntityEditorProps["customColumns"]; defaultOutgoingLinkFilters?: EntityEditorProps["defaultOutgoingLinkFilters"]; /** @@ -98,6 +107,9 @@ export interface EditEntitySlideOverProps { */ const EditEntitySlideOver = memo( ({ + closedMultiEntityTypesMap: providedClosedMultiEntityTypesMap, + closedMultiEntityTypesDefinitions: + providedClosedMultiEntityTypesDefinitions, customColumns, defaultOutgoingLinkFilters, disableTypeClick, @@ -126,6 +138,56 @@ const EditEntitySlideOver = memo( const [ownedByIdFromProvidedEntityId, entityUuid, draftId] = providedEntityId ? splitEntityId(providedEntityId) : []; + const entity = localEntitySubgraph + ? getRoots(localEntitySubgraph)[0] + : null; + + const providedTypeDetails = useMemo(() => { + if ( + providedClosedMultiEntityTypesMap && + providedClosedMultiEntityTypesDefinitions && + entity?.metadata.entityTypeIds + ) { + const closedMultiEntityType = getClosedMultiEntityTypeFromMap( + providedClosedMultiEntityTypesMap, + entity.metadata.entityTypeIds, + ); + + return { + closedMultiEntityType, + closedMultiEntityTypesDefinitions: + providedClosedMultiEntityTypesDefinitions, + }; + } + }, [ + providedClosedMultiEntityTypesMap, + providedClosedMultiEntityTypesDefinitions, + entity?.metadata.entityTypeIds, + ]); + + const [draftEntityTypesDetails, setDraftEntityTypesDetails] = useState< + | Pick< + EntityEditorProps, + "closedMultiEntityType" | "closedMultiEntityTypesDefinitions" + > + | undefined + >(providedTypeDetails); + + useEffect(() => { + if ( + entity?.metadata.entityTypeIds && + providedTypeDetails && + !isClosedMultiEntityTypeForEntityTypeIds( + providedTypeDetails.closedMultiEntityType, + entity.metadata.entityTypeIds, + ) + ) { + setDraftEntityTypesDetails(providedTypeDetails); + } + }, [entity?.metadata.entityTypeIds, providedTypeDetails]); + + const { getClosedMultiEntityType } = useGetClosedMultiEntityType(); + const [animateOut, setAnimateOut] = useState(false); const handleBackClick = useCallback(() => { @@ -136,11 +198,24 @@ const EditEntitySlideOver = memo( }, 300); }, [setAnimateOut, onBack]); - const subgraphHasLinkedEntities = useMemo(() => { - if (!localEntitySubgraph) { + const entityWithLinksAndTypesAvailableLocally = useMemo(() => { + if (!localEntitySubgraph || !draftEntityTypesDetails || !entity) { return false; } + if ( + !isClosedMultiEntityTypeForEntityTypeIds( + draftEntityTypesDetails.closedMultiEntityType, + entity.metadata.entityTypeIds, + ) + ) { + return false; + } + + /** + * If the provided subgraph doesn't have a depth of 1 for these traversal options, + * it doesn't contain the incoming and outgoing links from the entity. + */ if ( localEntitySubgraph.depths.hasLeftEntity.incoming === 0 || localEntitySubgraph.depths.hasLeftEntity.outgoing === 0 || @@ -150,13 +225,21 @@ const EditEntitySlideOver = memo( return false; } + /** + * If the entity isn't in the subgraph roots, it may not have the links to/from it. + */ const roots = getRoots(localEntitySubgraph); const containsRequestedEntity = roots.some( (root) => root.entityId === providedEntityId, ); return containsRequestedEntity; - }, [localEntitySubgraph, providedEntityId]); + }, [ + entity, + draftEntityTypesDetails, + localEntitySubgraph, + providedEntityId, + ]); /** * If the parent component didn't have the entitySubgraph already available, @@ -164,7 +247,7 @@ const EditEntitySlideOver = memo( * we need to fetch it and set it in the local state (from where it will be updated if the user uses the editor * form). */ - const { data: fetchedEntitySubgraph } = useQuery< + const { data: fetchedEntityData } = useQuery< GetEntitySubgraphQuery, GetEntitySubgraphQueryVariables >(getEntitySubgraphQuery, { @@ -175,8 +258,34 @@ const EditEntitySlideOver = memo( ); setLocalEntitySubgraph(subgraph); + + const { definitions, closedMultiEntityTypes } = data.getEntitySubgraph; + + if (!definitions || !closedMultiEntityTypes) { + throw new Error( + "definitions and closedMultiEntityTypes must be present in entitySubgraph", + ); + } + + const returnedEntity = getRoots(subgraph)[0]; + + if (!returnedEntity) { + throw new Error("No entity found in entitySubgraph"); + } + + const closedMultiEntityType = getClosedMultiEntityTypeFromMap( + closedMultiEntityTypes, + returnedEntity.metadata.entityTypeIds, + ); + + setDraftEntityTypesDetails({ + closedMultiEntityType, + closedMultiEntityTypesDefinitions: definitions, + }); }, - skip: subgraphHasLinkedEntities, + skip: + entityWithLinksAndTypesAvailableLocally && + !!providedClosedMultiEntityTypesDefinitions, variables: { request: { filter: { @@ -201,20 +310,21 @@ const EditEntitySlideOver = memo( }, temporalAxes: currentTimeInstantTemporalAxes, graphResolveDepths: { - ...fullOntologyResolveDepths, + ...zeroedGraphResolveDepths, hasLeftEntity: { incoming: 1, outgoing: 1 }, hasRightEntity: { incoming: 1, outgoing: 1 }, }, includeDrafts: !!draftId, + includeEntityTypes: "resolved", }, includePermissions: false, }, }); const originalEntitySubgraph = useMemo(() => { - if (fetchedEntitySubgraph) { + if (fetchedEntityData) { return mapGqlSubgraphFieldsFragmentToSubgraph( - fetchedEntitySubgraph.getEntitySubgraph.subgraph, + fetchedEntityData.getEntitySubgraph.subgraph, ); } @@ -223,7 +333,7 @@ const EditEntitySlideOver = memo( } return null; - }, [providedEntitySubgraph, fetchedEntitySubgraph]); + }, [providedEntitySubgraph, fetchedEntityData]); const [savingChanges, setSavingChanges] = useState(false); const [isDirty, setIsDirty] = useState(false); @@ -258,8 +368,13 @@ const EditEntitySlideOver = memo( const entityLabel = useMemo( () => - localEntitySubgraph ? generateEntityLabel(localEntitySubgraph) : "", - [localEntitySubgraph], + draftEntityTypesDetails?.closedMultiEntityType && entity + ? generateEntityLabel( + draftEntityTypesDetails.closedMultiEntityType, + entity, + ) + : "", + [entity, draftEntityTypesDetails], ); const resetEntityEditor = useCallback(() => { @@ -273,10 +388,6 @@ const EditEntitySlideOver = memo( onClose(); }, [onClose, resetEntityEditor]); - const entity = localEntitySubgraph - ? getRoots(localEntitySubgraph)[0] - : null; - const ownedById = ownedByIdFromProvidedEntityId ?? (entity @@ -359,27 +470,11 @@ const EditEntitySlideOver = memo( [incompleteOnEntityClick, slideContainerRef], ); - /** - * @todo H-3363 use the closed schema to get the first icon - */ - const entityTypes = useMemo( - () => - entity && localEntitySubgraph - ? entity.metadata.entityTypeIds.toSorted().map((entityTypeId) => { - const entityType = getEntityTypeById( - localEntitySubgraph, - entityTypeId, - ); - - if (!entityType) { - throw new Error(`Cannot find entity type ${entityTypeId}`); - } - - return entityType; - }) - : [], - [entity, localEntitySubgraph], - ); + const { icon, isLink } = draftEntityTypesDetails + ? getDisplayFieldsForClosedEntityType( + draftEntityTypesDetails.closedMultiEntityType, + ) + : { icon: null, isLink: false }; return ( {!entity || !localEntitySubgraph || - !subgraphHasLinkedEntities || + !entityWithLinksAndTypesAvailableLocally || + !draftEntityTypesDetails || entity.entityId !== providedEntityId ? ( @@ -428,13 +524,8 @@ const EditEntitySlideOver = memo( allOf.$ref === linkEntityTypeUrl, - ) - } + icon={icon} + isLink={isLink} fill={({ palette }) => palette.gray[50]} fontSize={40} /> @@ -468,32 +559,40 @@ const EditEntitySlideOver = memo( { - setIsDirty(true); - updateEntitySubgraphStateByEntity( - newEntity, - (updatedEntitySubgraphOrFunction) => { - setLocalEntitySubgraph((prev) => { - if (!prev) { - throw new Error(`No previous subgraph to update`); - } - - const updatedEntitySubgraph = - typeof updatedEntitySubgraphOrFunction === "function" - ? updatedEntitySubgraphOrFunction(prev) - : updatedEntitySubgraphOrFunction; - - return updatedEntitySubgraph ?? prev; - }); - }, + setEntityTypes={async (newEntityTypeIds) => { + const newDetails = + await getClosedMultiEntityType(newEntityTypeIds); + setDraftEntityTypesDetails(newDetails); + + const newSubgraph = updateDraftEntitySubgraph( + entity, + newEntityTypeIds, + localEntitySubgraph, + ); + + setLocalEntitySubgraph(newSubgraph); + }} + setEntity={(changedEntity) => { + const newSubgraph = updateDraftEntitySubgraph( + changedEntity, + changedEntity.metadata.entityTypeIds, + localEntitySubgraph, ); + + setLocalEntitySubgraph(newSubgraph); }} isDirty={isDirty} onEntityClick={onEntityClick} diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor.tsx b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor.tsx index 6b71b872d88..12807d19b6f 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor.tsx +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor.tsx @@ -1,5 +1,11 @@ +import type { VersionedUrl } from "@blockprotocol/type-system"; import type { Entity } from "@local/hash-graph-sdk/entity"; import type { EntityId } from "@local/hash-graph-types/entity"; +import type { + ClosedMultiEntityType, + ClosedMultiEntityTypesDefinitions, + ClosedMultiEntityTypesRootMap, +} from "@local/hash-graph-types/ontology"; import type { EntityRootType, Subgraph } from "@local/hash-subgraph"; import { getRoots } from "@local/hash-subgraph/stdlib"; import { Box } from "@mui/material"; @@ -22,6 +28,9 @@ import type { DraftLinkState } from "./shared/use-draft-link-state"; export type { CustomColumn }; export interface EntityEditorProps extends DraftLinkState { + closedMultiEntityType: ClosedMultiEntityType; + closedMultiEntityTypesDefinitions: ClosedMultiEntityTypesDefinitions; + closedMultiEntityTypesMap: ClosedMultiEntityTypesRootMap | null; customColumns?: CustomColumn[]; defaultOutgoingLinkFilters?: Partial; disableTypeClick?: boolean; @@ -30,6 +39,9 @@ export interface EntityEditorProps extends DraftLinkState { entitySubgraph: Subgraph; onEntityClick: (entityId: EntityId) => void; setEntity: (entity: Entity) => void; + setEntityTypes: ( + entityTypeIds: [VersionedUrl, ...VersionedUrl[]], + ) => Promise; readonly: boolean; onEntityUpdated: ((entity: Entity) => void) | null; /** diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/entity-editor-context.tsx b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/entity-editor-context.tsx index c0bd5c958b3..65f138b4f5a 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/entity-editor-context.tsx +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/entity-editor-context.tsx @@ -21,6 +21,9 @@ const EntityEditorContext = createContext(null); export const EntityEditorContextProvider = ({ children, + closedMultiEntityType, + closedMultiEntityTypesDefinitions, + closedMultiEntityTypesMap, customColumns, defaultOutgoingLinkFilters, disableTypeClick, @@ -35,6 +38,7 @@ export const EntityEditorContextProvider = ({ setDraftLinksToArchive, setDraftLinksToCreate, setEntity, + setEntityTypes, slideContainerRef, }: PropsWithChildren) => { const [propertyExpandStatus, setPropertyExpandStatus] = @@ -69,6 +73,9 @@ export const EntityEditorContextProvider = ({ const state = useMemo( () => ({ + closedMultiEntityType, + closedMultiEntityTypesDefinitions, + closedMultiEntityTypesMap, customColumns, defaultOutgoingLinkFilters, disableTypeClick, @@ -84,10 +91,14 @@ export const EntityEditorContextProvider = ({ setDraftLinksToArchive, setDraftLinksToCreate, setEntity, + setEntityTypes, slideContainerRef, togglePropertyExpand, }), [ + closedMultiEntityType, + closedMultiEntityTypesDefinitions, + closedMultiEntityTypesMap, customColumns, defaultOutgoingLinkFilters, disableTypeClick, @@ -103,6 +114,7 @@ export const EntityEditorContextProvider = ({ setDraftLinksToArchive, setDraftLinksToCreate, setEntity, + setEntityTypes, slideContainerRef, togglePropertyExpand, ], diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/file-preview-section.tsx b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/file-preview-section.tsx index be824a1f930..ff4fbc4281b 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/file-preview-section.tsx +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/file-preview-section.tsx @@ -184,7 +184,7 @@ export const FilePreviewSection = () => { entity.properties as FileProperties, ); - const title = displayName ?? generateEntityLabel(entitySubgraph); + const title = displayName ?? generateEntityLabel(entitySubgraph, entity); const alt = description ?? title; diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/link-section.tsx b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/link-section.tsx index 954c43f4480..16b49d625fd 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/link-section.tsx +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/link-section.tsx @@ -8,7 +8,12 @@ import { SectionWrapper } from "../../../../shared/section-wrapper"; import { useEntityEditor } from "./entity-editor-context"; export const LinkSection: FunctionComponent = () => { - const { entitySubgraph, onEntityClick } = useEntityEditor(); + const { + closedMultiEntityTypesMap, + closedMultiEntityTypesDefinitions, + entitySubgraph, + onEntityClick, + } = useEntityEditor(); const linkEntity = useMemo(() => { const [rootEntity] = getRoots(entitySubgraph); @@ -23,6 +28,8 @@ export const LinkSection: FunctionComponent = () => { return ( []; linkEntity: Entity; linkEntityLabel: string; linkEntityProperties: { [propertyTitle: string]: string }; - linkEntityTypes: EntityType[]; + linkEntityTypes: Pick< + PartialEntityType, + "icon" | "$id" | "inverse" | "title" + >[]; onEntityClick: (entityId: EntityId) => void; customFields: { [fieldId: string]: string | number }; }; @@ -259,7 +266,13 @@ export const IncomingLinksTable = memo( direction: "asc", }); - const { customColumns, entitySubgraph, onEntityClick } = useEntityEditor(); + const { + closedMultiEntityTypesMap, + closedMultiEntityTypesDefinitions, + customColumns, + entitySubgraph, + onEntityClick, + } = useEntityEditor(); const outputContainerRef = useRef(null); const [outputContainerHeight, setOutputContainerHeight] = useState(400); @@ -310,8 +323,6 @@ export const IncomingLinksTable = memo( }, } as const satisfies VirtualizedTableFilterDefinitionsByFieldId; - const entityTypesByVersionedUrl: Record = {}; - for (const { leftEntity: leftEntityRevisions, linkEntity: linkEntityRevisions, @@ -333,33 +344,30 @@ export const IncomingLinksTable = memo( } } - const linkEntityTypes: EntityType[] = []; - for (const linkEntityTypeId of linkEntityTypeIds) { - let linkEntityType = entityTypesByVersionedUrl[linkEntityTypeId]; + if (!closedMultiEntityTypesMap) { + throw new Error("Expected closedMultiEntityTypesMap to be defined"); + } - if (!linkEntityType) { - const foundType = getEntityTypeById( - entitySubgraph, - linkEntityTypeId, - ); + const linkEntityClosedMultiType = getClosedMultiEntityTypeFromMap( + closedMultiEntityTypesMap, + linkEntity.metadata.entityTypeIds, + ); - if (!foundType) { - throw new Error( - `Could not find linkEntityType with id ${linkEntityTypeId} in subgraph`, - ); - } + const linkEntityLabel = generateEntityLabel( + linkEntityClosedMultiType, + linkEntity, + ); - linkEntityType = foundType.schema; - linkEntityTypes.push(linkEntityType); + for (const linkType of linkEntityClosedMultiType.allOf) { + const linkEntityTypeId = linkType.$id; - filterDefs.linkTypes.options[linkEntityTypeId] ??= { - label: linkEntityType.title, - count: 0, - value: linkEntityTypeId, - }; - } + filterDefs.linkTypes.options[linkEntityTypeId] ??= { + label: linkType.title, + count: 0, + value: linkEntityTypeId, + }; - filterDefs.linkTypes.options[linkEntityTypeId]!.count++; + filterDefs.linkTypes.options[linkEntityTypeId].count++; filterDefs.linkTypes.initialValue.add(linkEntityTypeId); } @@ -368,9 +376,19 @@ export const IncomingLinksTable = memo( throw new Error("Expected at least one left entity revision"); } + const leftEntityClosedMultiType = getClosedMultiEntityTypeFromMap( + closedMultiEntityTypesMap, + leftEntity.metadata.entityTypeIds, + ); + const leftEntityLabel = leftEntity.linkData - ? generateLinkEntityLabel(entitySubgraph, leftEntity) - : generateEntityLabel(entitySubgraph, leftEntity); + ? generateLinkEntityLabel( + entitySubgraph, + leftEntity, + leftEntityClosedMultiType, + closedMultiEntityTypesDefinitions, + ) + : generateEntityLabel(leftEntityClosedMultiType, leftEntity); filterDefs.linkedFrom.options[leftEntity.metadata.recordId.entityId] ??= { @@ -384,38 +402,19 @@ export const IncomingLinksTable = memo( leftEntity.metadata.recordId.entityId, ); - const leftEntityTypeIds = leftEntity.metadata.entityTypeIds; - const leftEntityTypes: EntityType[] = []; - for (const leftEntityTypeId of leftEntityTypeIds) { - let leftEntityType = entityTypesByVersionedUrl[leftEntityTypeId]; - - if (!leftEntityType) { - const foundType = getEntityTypeById( - entitySubgraph, - leftEntityTypeId, - ); - - if (!foundType) { - throw new Error( - `Could not find leftEntityType with id ${leftEntityTypeId} in subgraph`, - ); - } + for (const leftType of leftEntityClosedMultiType.allOf) { + const leftEntityTypeId = leftType.$id; - leftEntityType = foundType.schema; - leftEntityTypes.push(leftEntityType); - - filterDefs.linkedFromTypes.options[leftEntityTypeId] ??= { - label: leftEntityType.title, - count: 0, - value: leftEntityTypeId, - }; - } + filterDefs.linkedFromTypes.options[leftEntityTypeId] ??= { + label: leftType.title, + count: 0, + value: leftEntityTypeId, + }; - filterDefs.linkedFromTypes.options[leftEntityTypeId]!.count++; + filterDefs.linkedFromTypes.options[leftEntityTypeId].count++; filterDefs.linkedFromTypes.initialValue.add(leftEntityTypeId); } - const linkEntityLabel = generateEntityLabel(entitySubgraph, linkEntity); filterDefs.link.options[linkEntity.metadata.recordId.entityId] ??= { label: linkEntityLabel, count: 0, @@ -429,11 +428,12 @@ export const IncomingLinksTable = memo( for (const [propertyBaseUrl, propertyValue] of typedEntries( linkEntity.properties, )) { - const { propertyType } = getPropertyTypeForEntity( - entitySubgraph, - linkEntityTypeIds, + const propertyType = getPropertyTypeForClosedMultiEntityType( + linkEntityClosedMultiType, propertyBaseUrl, + closedMultiEntityTypesDefinitions, ); + linkEntityProperties[propertyType.title] = stringifyPropertyValue(propertyValue); } @@ -443,11 +443,12 @@ export const IncomingLinksTable = memo( for (const [propertyBaseUrl, propertyValue] of typedEntries( leftEntity.properties, )) { - const { propertyType } = getPropertyTypeForEntity( - entitySubgraph, - leftEntityTypeIds, + const propertyType = getPropertyTypeForClosedMultiEntityType( + leftEntityClosedMultiType, propertyBaseUrl, + closedMultiEntityTypesDefinitions, ); + sourceEntityProperties[propertyType.title] = stringifyPropertyValue(propertyValue); } @@ -456,7 +457,16 @@ export const IncomingLinksTable = memo( id: linkEntity.metadata.recordId.entityId, data: { customFields, - linkEntityTypes, + linkEntityTypes: linkEntityClosedMultiType.allOf.map((type) => { + const { icon } = getDisplayFieldsForClosedEntityType(type); + + return { + $id: type.$id, + title: type.title, + icon, + inverse: type.inverse, + }; + }), linkEntity, linkEntityLabel, linkEntityProperties, @@ -464,7 +474,16 @@ export const IncomingLinksTable = memo( sourceEntity: leftEntity, sourceEntityLabel: leftEntityLabel, sourceEntityProperties, - sourceEntityTypes: leftEntityTypes, + sourceEntityTypes: leftEntityClosedMultiType.allOf.map((type) => { + const { icon } = getDisplayFieldsForClosedEntityType(type); + + return { + $id: type.$id, + title: type.title, + icon, + inverse: type.inverse, + }; + }), }, }); } @@ -482,7 +501,14 @@ export const IncomingLinksTable = memo( ) as VirtualizedTableFilterValuesByFieldId, unsortedRows: rowData, }; - }, [customColumns, entitySubgraph, incomingLinksAndSources, onEntityClick]); + }, [ + closedMultiEntityTypesDefinitions, + closedMultiEntityTypesMap, + customColumns, + entitySubgraph, + incomingLinksAndSources, + onEntityClick, + ]); const [filterValues, setFilterValues] = useVirtualizedTableFilterState({ defaultFilterValues: initialFilterValues, diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/links-section/outgoing-links-section/cells/linked-with-cell.ts b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/links-section/outgoing-links-section/cells/linked-with-cell.ts index 8c622a75493..8e42672489a 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/links-section/outgoing-links-section/cells/linked-with-cell.ts +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/links-section/outgoing-links-section/cells/linked-with-cell.ts @@ -2,7 +2,6 @@ import type { CustomCell, CustomRenderer } from "@glideapps/glide-data-grid"; import { GridCellKind } from "@glideapps/glide-data-grid"; import { customColors } from "@hashintel/design-system/theme"; import type { EntityId } from "@local/hash-graph-types/entity"; -import { generateEntityLabel } from "@local/hash-isomorphic-utils/generate-entity-label"; import { getCellHorizontalPadding, @@ -135,9 +134,7 @@ export const renderLinkedWithCell: CustomRenderer = { const entityChipInteractables: Interactable[] = []; // draw linked entity chips - for (const { rightEntity, sourceSubgraph } of sortedLinkedEntities) { - const label = generateEntityLabel(sourceSubgraph, rightEntity); - + for (const { rightEntity, rightEntityLabel } of sortedLinkedEntities) { const imageSrc = getImageUrlFromEntityProperties(rightEntity.properties); const { width: chipWidth } = drawChipWithIcon({ @@ -147,7 +144,7 @@ export const renderLinkedWithCell: CustomRenderer = { * @todo H-1978 use entity type icon if present in its schema */ icon: imageSrc ? { imageSrc } : { inbuiltIcon: "bpAsterisk" }, - text: label, + text: rightEntityLabel, left: accumulatedLeft, }); diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/links-section/outgoing-links-section/cells/linked-with-cell/linked-entity-list-editor.tsx b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/links-section/outgoing-links-section/cells/linked-with-cell/linked-entity-list-editor.tsx index 3985d228e46..7ea7446439d 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/links-section/outgoing-links-section/cells/linked-with-cell/linked-entity-list-editor.tsx +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/links-section/outgoing-links-section/cells/linked-with-cell/linked-entity-list-editor.tsx @@ -11,8 +11,6 @@ import type { CreatedAtTransactionTime, Timestamp, } from "@local/hash-graph-types/temporal-versioning"; -import { generateEntityLabel } from "@local/hash-isomorphic-utils/generate-entity-label"; -import type { EntityRootType, Subgraph } from "@local/hash-subgraph"; import { extractDraftIdFromEntityId } from "@local/hash-subgraph"; import { getRoots } from "@local/hash-subgraph/stdlib"; import { Box } from "@mui/material"; @@ -92,6 +90,7 @@ export const LinkedEntityListEditor: ProvideEditorComponent = ( expectedEntityTypes, linkAndTargetEntities, linkEntityTypeId, + linkTitle, maxItems, } = cell.data.linkRow; @@ -99,10 +98,7 @@ export const LinkedEntityListEditor: ProvideEditorComponent = ( const entity = useMemo(() => getRoots(entitySubgraph)[0]!, [entitySubgraph]); - const onSelect = ( - selectedEntity: Entity, - sourceSubgraph: Subgraph | null, - ) => { + const onSelect = (selectedEntity: Entity, entityLabel: string) => { const alreadyLinked = linkAndTargetEntities.find( ({ rightEntity }) => rightEntity.metadata.recordId.entityId === @@ -126,7 +122,8 @@ export const LinkedEntityListEditor: ProvideEditorComponent = ( const newLinkAndTargetEntity = { linkEntity, rightEntity: selectedEntity, - sourceSubgraph, + rightEntityLabel: entityLabel, + linkEntityLabel: linkTitle, }; setDraftLinksToCreate((prev) => [...prev, newLinkAndTargetEntity]); @@ -163,7 +160,7 @@ export const LinkedEntityListEditor: ProvideEditorComponent = ( {sortedLinkAndTargetEntities.map( - ({ rightEntity, linkEntity, sourceSubgraph }) => { + ({ rightEntity, linkEntity, rightEntityLabel }) => { const linkEntityId = linkEntity.metadata.recordId.entityId; return ( = ( imageSrc={getImageUrlFromEntityProperties( rightEntity.properties, )} - title={generateEntityLabel(sourceSubgraph, rightEntity)} + title={rightEntityLabel} onDelete={() => { const newCell = produce(cell, (draftCell) => { draftCell.data.linkRow.linkAndTargetEntities = diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/links-section/outgoing-links-section/cells/linked-with-cell/linked-entity-selector.tsx b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/links-section/outgoing-links-section/cells/linked-with-cell/linked-entity-selector.tsx index a9d5c3bc928..944fbefa977 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/links-section/outgoing-links-section/cells/linked-with-cell/linked-entity-selector.tsx +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/links-section/outgoing-links-section/cells/linked-with-cell/linked-entity-selector.tsx @@ -1,10 +1,10 @@ -import type { VersionedUrl } from "@blockprotocol/type-system/slim"; +import type { EntityType, VersionedUrl } from "@blockprotocol/type-system/slim"; import { ArrowLeftIcon, AutocompleteDropdown } from "@hashintel/design-system"; import { GRID_CLICK_IGNORE_CLASS } from "@hashintel/design-system/constants"; import type { Entity } from "@local/hash-graph-sdk/entity"; +import { getClosedMultiEntityTypeFromMap } from "@local/hash-graph-sdk/entity"; import type { EntityId } from "@local/hash-graph-types/entity"; -import type { EntityTypeWithMetadata } from "@local/hash-graph-types/ontology"; -import type { EntityRootType, Subgraph } from "@local/hash-subgraph"; +import { generateEntityLabel } from "@local/hash-isomorphic-utils/generate-entity-label"; import { getRoots } from "@local/hash-subgraph/stdlib"; import type { PaperProps } from "@mui/material"; import { Stack, Typography } from "@mui/material"; @@ -25,14 +25,11 @@ import { EntitySelector } from "../../../../../../../../shared/entity-selector"; import { WorkspaceContext } from "../../../../../../../../shared/workspace-context"; import { useEntityEditor } from "../../../../entity-editor-context"; -interface EntitySelectorProps { +interface LinkedEntitySelectorProps { includeDrafts: boolean; - onSelect: ( - option: Entity, - sourceSubgraph: Subgraph | null, - ) => void; + onSelect: (option: Entity, entityLabel: string) => void; onFinishedEditing: () => void; - expectedEntityTypes: EntityTypeWithMetadata[]; + expectedEntityTypes: Pick[]; entityIdsToFilterOut?: EntityId[]; linkEntityTypeId: VersionedUrl; } @@ -71,7 +68,7 @@ export const LinkedEntitySelector = ({ expectedEntityTypes, entityIdsToFilterOut, linkEntityTypeId, -}: EntitySelectorProps) => { +}: LinkedEntitySelectorProps) => { const { entitySubgraph, readonly } = useEntityEditor(); const entityId = getRoots(entitySubgraph)[0]?.metadata.recordId @@ -82,14 +79,12 @@ export const LinkedEntitySelector = ({ const { isSpecialEntityTypeLookup } = useEntityTypesContextRequired(); const isFileType = expectedEntityTypes.some( - (expectedType) => - isSpecialEntityTypeLookup?.[expectedType.schema.$id]?.isFile, + (expectedType) => isSpecialEntityTypeLookup?.[expectedType.$id]?.isFile, ); const isImage = isFileType && expectedEntityTypes.some( - (expectedType) => - isSpecialEntityTypeLookup?.[expectedType.schema.$id]?.isImage, + (expectedType) => isSpecialEntityTypeLookup?.[expectedType.$id]?.isImage, ); const onCreateNew = () => { @@ -105,7 +100,7 @@ export const LinkedEntitySelector = ({ /** @todo this should be replaced with a "new entity modal" or something else */ void window.open( `/new/entity?entity-type-id=${encodeURIComponent( - expectedEntityTypes[0].schema.$id, + expectedEntityTypes[0].$id, )}`, "_blank", ); @@ -127,17 +122,24 @@ export const LinkedEntitySelector = ({ fileData: { file, fileEntityCreationInput: { - entityTypeId: expectedEntityTypes[0]?.schema.$id, + entityTypeId: expectedEntityTypes[0]?.$id, }, }, makePublic: false, - onComplete: (upload) => - onSelect( - upload.createdEntities.fileEntity as Entity, - // the entity's subgraph should mostly contain the file's type, since we're choosing it based on the expected type - // it will not if the expected type is File and we automatically choose a narrower type of e.g. Image based on the upload - entitySubgraph, - ), + onComplete: (upload) => { + const entity = upload.createdEntities.fileEntity; + + const label = + entity.properties[ + "https://blockprotocol.org/@blockprotocol/types/property-type/display-name/" + ] ?? + entity.properties[ + "https://blockprotocol.org/@blockprotocol/types/property-type/file-name/" + ] ?? + "File"; + + onSelect(upload.createdEntities.fileEntity as Entity, label); + }, ownedById: activeWorkspaceOwnedById, /** * Link creation is handled in the onSelect, since we might need to manage drafts, @@ -155,7 +157,6 @@ export const LinkedEntitySelector = ({ [ activeWorkspaceOwnedById, entityId, - entitySubgraph, expectedEntityTypes, linkEntityTypeId, onFinishedEditing, @@ -182,7 +183,16 @@ export const LinkedEntitySelector = ({ expectedEntityTypes={expectedEntityTypes} includeDrafts={includeDrafts} multiple={false} - onSelect={onSelect} + onSelect={(entity, closedMultiEntityTypeMap) => { + const closedType = getClosedMultiEntityTypeFromMap( + closedMultiEntityTypeMap, + entity.metadata.entityTypeIds, + ); + + const label = generateEntityLabel(closedType, entity); + + onSelect(entity, label); + }} className={GRID_CLICK_IGNORE_CLASS} open readOnly={readonly} diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/links-section/outgoing-links-section/cells/linked-with-cell/linked-with-cell-editor.tsx b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/links-section/outgoing-links-section/cells/linked-with-cell/linked-with-cell-editor.tsx index 2b80e0b0636..61bd0da2d93 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/links-section/outgoing-links-section/cells/linked-with-cell/linked-with-cell-editor.tsx +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/links-section/outgoing-links-section/cells/linked-with-cell/linked-with-cell-editor.tsx @@ -1,6 +1,5 @@ import type { ProvideEditorComponent } from "@glideapps/glide-data-grid"; import type { Entity } from "@local/hash-graph-sdk/entity"; -import type { EntityRootType, Subgraph } from "@local/hash-subgraph"; import { extractDraftIdFromEntityId } from "@local/hash-subgraph"; import { getRoots } from "@local/hash-subgraph/stdlib"; import { useMemo } from "react"; @@ -25,6 +24,7 @@ export const LinkedWithCellEditor: ProvideEditorComponent = ( expectedEntityTypes, linkAndTargetEntities, linkEntityTypeId, + linkTitle, maxItems, } = cell.data.linkRow; @@ -32,7 +32,7 @@ export const LinkedWithCellEditor: ProvideEditorComponent = ( const onSelectForSingleLink = ( selectedEntity: Entity, - sourceSubgraph: Subgraph | null, + selectedEntityLabel: string, ) => { const { linkEntity: currentLink, rightEntity: currentLinkedEntity } = linkAndTargetEntities[0] ?? {}; @@ -60,8 +60,9 @@ export const LinkedWithCellEditor: ProvideEditorComponent = ( const newLinkAndTargetEntity = { linkEntity, + linkEntityLabel: linkTitle, rightEntity: selectedEntity, - sourceSubgraph, + rightEntityLabel: selectedEntityLabel, }; setDraftLinksToCreate((prev) => [...prev, newLinkAndTargetEntity]); diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx index 3b42af63ae8..fa33b541fee 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx @@ -1,18 +1,19 @@ -import type { EntityType, VersionedUrl } from "@blockprotocol/type-system"; import { EntityOrTypeIcon } from "@hashintel/design-system"; import { typedEntries } from "@local/advanced-types/typed-entries"; import type { Entity } from "@local/hash-graph-sdk/entity"; +import { + getClosedMultiEntityTypeFromMap, + getDisplayFieldsForClosedEntityType, + getPropertyTypeForClosedMultiEntityType, +} from "@local/hash-graph-sdk/entity"; import type { EntityId } from "@local/hash-graph-types/entity"; +import type { PartialEntityType } from "@local/hash-graph-types/ontology"; import { generateEntityLabel, generateLinkEntityLabel, } from "@local/hash-isomorphic-utils/generate-entity-label"; import { stringifyPropertyValue } from "@local/hash-isomorphic-utils/stringify-property-value"; import type { LinkEntityAndRightEntity } from "@local/hash-subgraph"; -import { - getEntityTypeById, - getPropertyTypeForEntity, -} from "@local/hash-subgraph/stdlib"; import { Box, Stack, TableCell, Typography } from "@mui/material"; import type { ReactElement } from "react"; import { @@ -105,11 +106,17 @@ type OutgoingLinkRow = { targetEntity: Entity; targetEntityLabel: string; targetEntityProperties: { [propertyTitle: string]: string }; - targetEntityTypes: EntityType[]; + targetEntityTypes: Pick< + PartialEntityType, + "icon" | "$id" | "inverse" | "title" + >[]; linkEntity: Entity; linkEntityLabel: string; linkEntityProperties: { [propertyTitle: string]: string }; - linkEntityTypes: EntityType[]; + linkEntityTypes: Pick< + PartialEntityType, + "icon" | "$id" | "inverse" | "title" + >[]; onEntityClick: (entityId: EntityId) => void; customFields: { [fieldId: string]: string | number }; }; @@ -137,7 +144,6 @@ const TableRow = memo(({ row }: { row: OutgoingLinkRow }) => { fontSize={linksTableFontSize} fill={({ palette }) => palette.blue[70]} icon={linkEntityType.icon} - /* @todo H-3363 use closed entity type schema to check link status */ isLink /> } @@ -247,9 +253,11 @@ export const OutgoingLinksTable = memo( }); const { + closedMultiEntityTypesDefinitions, + closedMultiEntityTypesMap, + entitySubgraph, customColumns, defaultOutgoingLinkFilters, - entitySubgraph, onEntityClick, } = useEntityEditor(); @@ -302,8 +310,6 @@ export const OutgoingLinksTable = memo( }, } as const satisfies VirtualizedTableFilterDefinitionsByFieldId; - const entityTypesByVersionedUrl: Record = {}; - for (const { rightEntity: rightEntityRevisions, linkEntity: linkEntityRevisions, @@ -328,35 +334,30 @@ export const OutgoingLinksTable = memo( } } - const linkEntityTypeIds = linkEntity.metadata.entityTypeIds; - const linkEntityTypes: EntityType[] = []; - - for (const linkEntityTypeId of linkEntityTypeIds) { - let linkEntityType = entityTypesByVersionedUrl[linkEntityTypeId]; + if (!closedMultiEntityTypesMap) { + throw new Error("Expected closedMultiEntityTypesMap to be defined"); + } - if (!linkEntityType) { - const foundType = getEntityTypeById( - entitySubgraph, - linkEntityTypeId, - ); + const linkEntityClosedMultiType = getClosedMultiEntityTypeFromMap( + closedMultiEntityTypesMap, + linkEntity.metadata.entityTypeIds, + ); - if (!foundType) { - throw new Error( - `Could not find linkEntityType with id ${linkEntityTypeId} in subgraph`, - ); - } + const linkEntityLabel = generateEntityLabel( + linkEntityClosedMultiType, + linkEntity, + ); - linkEntityType = foundType.schema; - linkEntityTypes.push(linkEntityType); + for (const linkType of linkEntityClosedMultiType.allOf) { + const linkEntityTypeId = linkType.$id; - filterDefs.linkTypes.options[linkEntityTypeId] ??= { - label: linkEntityType.title, - count: 0, - value: linkEntityTypeId, - }; - } + filterDefs.linkTypes.options[linkEntityTypeId] ??= { + label: linkType.title, + count: 0, + value: linkEntityTypeId, + }; - filterDefs.linkTypes.options[linkEntityTypeId]!.count++; + filterDefs.linkTypes.options[linkEntityTypeId].count++; filterDefs.linkTypes.initialValue.add(linkEntityTypeId); } @@ -365,9 +366,19 @@ export const OutgoingLinksTable = memo( throw new Error("Expected at least one right entity revision"); } + const rightEntityClosedMultiType = getClosedMultiEntityTypeFromMap( + closedMultiEntityTypesMap, + rightEntity.metadata.entityTypeIds, + ); + const rightEntityLabel = rightEntity.linkData - ? generateLinkEntityLabel(entitySubgraph, rightEntity) - : generateEntityLabel(entitySubgraph, rightEntity); + ? generateLinkEntityLabel( + entitySubgraph, + rightEntity, + rightEntityClosedMultiType, + closedMultiEntityTypesDefinitions, + ) + : generateEntityLabel(rightEntityClosedMultiType, rightEntity); filterDefs.linkedTo.options[rightEntity.metadata.recordId.entityId] ??= { @@ -381,39 +392,19 @@ export const OutgoingLinksTable = memo( rightEntity.metadata.recordId.entityId, ); - const rightEntityTypeIds = rightEntity.metadata.entityTypeIds; - const rightEntityTypes: EntityType[] = []; - - for (const rightEntityTypeId of rightEntityTypeIds) { - let rightEntityType = entityTypesByVersionedUrl[rightEntityTypeId]; - - if (!rightEntityType) { - const foundType = getEntityTypeById( - entitySubgraph, - rightEntityTypeId, - ); - - if (!foundType) { - throw new Error( - `Could not find rightEntityType with id ${rightEntityTypeId} in subgraph`, - ); - } - - rightEntityType = foundType.schema; - rightEntityTypes.push(rightEntityType); + for (const rightType of rightEntityClosedMultiType.allOf) { + const rightEntityTypeId = rightType.$id; - filterDefs.linkedToTypes.options[rightEntityTypeId] ??= { - label: rightEntityType.title, - count: 0, - value: rightEntityTypeId, - }; - } + filterDefs.linkedToTypes.options[rightEntityTypeId] ??= { + label: rightType.title, + count: 0, + value: rightEntityTypeId, + }; - filterDefs.linkedToTypes.options[rightEntityTypeId]!.count++; + filterDefs.linkedToTypes.options[rightEntityTypeId].count++; filterDefs.linkedToTypes.initialValue.add(rightEntityTypeId); } - const linkEntityLabel = generateEntityLabel(entitySubgraph, linkEntity); filterDefs.link.options[linkEntity.metadata.recordId.entityId] ??= { label: linkEntityLabel, count: 0, @@ -427,11 +418,12 @@ export const OutgoingLinksTable = memo( for (const [propertyBaseUrl, propertyValue] of typedEntries( linkEntity.properties, )) { - const { propertyType } = getPropertyTypeForEntity( - entitySubgraph, - linkEntityTypeIds, + const propertyType = getPropertyTypeForClosedMultiEntityType( + linkEntityClosedMultiType, propertyBaseUrl, + closedMultiEntityTypesDefinitions, ); + linkEntityProperties[propertyType.title] = stringifyPropertyValue(propertyValue); } @@ -441,11 +433,12 @@ export const OutgoingLinksTable = memo( for (const [propertyBaseUrl, propertyValue] of typedEntries( rightEntity.properties, )) { - const { propertyType } = getPropertyTypeForEntity( - entitySubgraph, - rightEntityTypeIds, + const propertyType = getPropertyTypeForClosedMultiEntityType( + rightEntityClosedMultiType, propertyBaseUrl, + closedMultiEntityTypesDefinitions, ); + targetEntityProperties[propertyType.title] = stringifyPropertyValue(propertyValue); } @@ -454,7 +447,16 @@ export const OutgoingLinksTable = memo( id: linkEntity.metadata.recordId.entityId, data: { customFields, - linkEntityTypes, + linkEntityTypes: linkEntityClosedMultiType.allOf.map((type) => { + const { icon } = getDisplayFieldsForClosedEntityType(type); + + return { + $id: type.$id, + title: type.title, + icon, + inverse: type.inverse, + }; + }), linkEntity, linkEntityLabel, linkEntityProperties, @@ -462,7 +464,16 @@ export const OutgoingLinksTable = memo( targetEntity: rightEntity, targetEntityLabel: rightEntityLabel, targetEntityProperties, - targetEntityTypes: rightEntityTypes, + targetEntityTypes: rightEntityClosedMultiType.allOf.map((type) => { + const { icon } = getDisplayFieldsForClosedEntityType(type); + + return { + $id: type.$id, + title: type.title, + icon, + inverse: type.inverse, + }; + }), }, }); } @@ -480,7 +491,14 @@ export const OutgoingLinksTable = memo( ) as OutgoingLinksFilterValues, unsortedRows: rowData, }; - }, [customColumns, entitySubgraph, outgoingLinksAndTargets, onEntityClick]); + }, [ + closedMultiEntityTypesMap, + closedMultiEntityTypesDefinitions, + customColumns, + entitySubgraph, + outgoingLinksAndTargets, + onEntityClick, + ]); const [highlightOutgoingLinks, setHighlightOutgoingLinks] = useState( !!defaultOutgoingLinkFilters, diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/links-section/outgoing-links-section/types.ts b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/links-section/outgoing-links-section/types.ts index 09b05ed6b20..6d410f96263 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/links-section/outgoing-links-section/types.ts +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/links-section/outgoing-links-section/types.ts @@ -2,7 +2,7 @@ import type { VersionedUrl } from "@blockprotocol/type-system"; import type { SizedGridColumn } from "@glideapps/glide-data-grid"; import type { Entity } from "@local/hash-graph-sdk/entity"; import type { EntityId } from "@local/hash-graph-types/entity"; -import type { EntityTypeWithMetadata } from "@local/hash-graph-types/ontology"; +import type { PartialEntityType } from "@local/hash-graph-types/ontology"; import type { EntityRootType, Subgraph } from "@local/hash-subgraph"; export type LinkAndTargetEntity = { @@ -19,10 +19,10 @@ export type LinkRow = { isFile: boolean; isList: boolean; isUploading: boolean; - expectedEntityTypes: EntityTypeWithMetadata[]; + expectedEntityTypes: PartialEntityType[]; linkAndTargetEntities: (LinkAndTargetEntity & { - // Adding the subgraph we found these in makes it easy to retrieve their type(s), e.g. for labelling - sourceSubgraph: Subgraph | null; + linkEntityLabel: string; + rightEntityLabel: string; })[]; entitySubgraph: Subgraph; markLinkAsArchived: (linkEntityId: EntityId) => void; diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/links-section/outgoing-links-section/use-create-get-cell-content.ts b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/links-section/outgoing-links-section/use-create-get-cell-content.ts index 7a7572a25c3..2a40ef817b4 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/links-section/outgoing-links-section/use-create-get-cell-content.ts +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/links-section/outgoing-links-section/use-create-get-cell-content.ts @@ -1,6 +1,5 @@ import type { Item } from "@glideapps/glide-data-grid"; import { GridCellKind } from "@glideapps/glide-data-grid"; -import { linkEntityTypeUrl } from "@local/hash-subgraph"; import { useTheme } from "@mui/material"; import { useCallback } from "react"; @@ -66,7 +65,7 @@ export const useCreateGetCellContent = () => { }; case "expectedEntityTypes": { const expectedEntityTypeTitles = row.expectedEntityTypes.map( - (type) => type.schema.title, + (type) => type.title, ); return { kind: GridCellKind.Custom, @@ -77,19 +76,12 @@ export const useCreateGetCellContent = () => { kind: "chip-cell", chips: expectsAnything ? [{ text: "Anything" }] - : row.expectedEntityTypes.map(({ schema }) => ({ - text: schema.title, - icon: schema.icon - ? { entityTypeIcon: schema.icon } + : row.expectedEntityTypes.map(({ title, icon }) => ({ + text: title, + icon: icon + ? { entityTypeIcon: icon } : { - /** - * @todo H-3363 use closed schema to take account of indirect inheritance links - */ - inbuiltIcon: schema.allOf?.some( - (allOf) => allOf.$ref === linkEntityTypeUrl, - ) - ? "bpLink" - : "bpAsterisk", + inbuiltIcon: "bpAsterisk", }, iconFill: theme.palette.blue[70], })), diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/links-section/outgoing-links-section/use-rows.ts b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/links-section/outgoing-links-section/use-rows.ts index 4c83040ce55..d1ce2ae1780 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/links-section/outgoing-links-section/use-rows.ts +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/links-section/outgoing-links-section/use-rows.ts @@ -1,9 +1,9 @@ import type { VersionedUrl } from "@blockprotocol/type-system"; import { typedEntries } from "@local/advanced-types/typed-entries"; -import type { EntityTypeWithMetadata } from "@local/hash-graph-types/ontology"; +import { getClosedMultiEntityTypeFromMap } from "@local/hash-graph-sdk/entity"; +import type { PartialEntityType } from "@local/hash-graph-types/ontology"; +import { generateEntityLabel } from "@local/hash-isomorphic-utils/generate-entity-label"; import { - getBreadthFirstEntityTypesAndParents, - getEntityTypeById, getOutgoingLinkAndTargetEntities, getRoots, intervalCompareWithInterval, @@ -18,6 +18,9 @@ import type { LinkRow } from "./types"; export const useRows = () => { const { + closedMultiEntityType, + closedMultiEntityTypesDefinitions, + closedMultiEntityTypesMap, entitySubgraph, draftLinksToArchive, draftLinksToCreate, @@ -32,10 +35,6 @@ export const useRows = () => { const rows = useMemo(() => { const entity = getRoots(entitySubgraph)[0]!; - const entityTypesAndAncestors = getBreadthFirstEntityTypesAndParents( - entitySubgraph, - entity.metadata.entityTypeIds, - ); const variableAxis = entitySubgraph.temporalAxes.resolved.variable.axis; const entityInterval = entity.metadata.temporalVersioning[variableAxis]; @@ -48,149 +47,169 @@ export const useRows = () => { const processedLinkEntityTypeIds = new Set(); - return entityTypesAndAncestors.flatMap((entityType) => - typedEntries(entityType.schema.links ?? {}).flatMap( - ([linkEntityTypeId, linkSchema]) => { - if (processedLinkEntityTypeIds.has(linkEntityTypeId)) { - return []; - } + return typedEntries(closedMultiEntityType.links ?? {}).flatMap( + ([linkEntityTypeId, linkSchema]) => { + if (processedLinkEntityTypeIds.has(linkEntityTypeId)) { + return []; + } - const linkEntityType = getEntityTypeById( - entitySubgraph, - linkEntityTypeId, + const linkEntityType = + closedMultiEntityTypesDefinitions.entityTypes[linkEntityTypeId]; + + if (!linkEntityType) { + throw new Error( + `Could not find link entity type with id ${linkEntityTypeId} in definitions`, ); + } - if (!linkEntityType) { - throw new Error( - `Could not find link entity type with id ${linkEntityTypeId} in subgraph`, - ); - } + const relevantUpload = uploads.find( + (upload) => + upload.status !== "complete" && + upload.linkedEntityData?.linkedEntityId === + entity.metadata.recordId.entityId && + upload.linkedEntityData.linkEntityTypeId === linkEntityTypeId, + ); - const relevantUpload = uploads.find( - (upload) => - upload.status !== "complete" && - upload.linkedEntityData?.linkedEntityId === - entity.metadata.recordId.entityId && - upload.linkedEntityData.linkEntityTypeId === linkEntityTypeId, - ); + const isLoading = + !!relevantUpload && + relevantUpload.status !== "complete" && + relevantUpload.status !== "error"; - const isLoading = - !!relevantUpload && - relevantUpload.status !== "complete" && - relevantUpload.status !== "error"; + const isErroredUpload = relevantUpload?.status === "error"; - const isErroredUpload = relevantUpload?.status === "error"; + let expectedEntityTypes: PartialEntityType[] = []; - let expectedEntityTypes: EntityTypeWithMetadata[] = []; + if ("oneOf" in linkSchema.items) { + expectedEntityTypes = linkSchema.items.oneOf.map(({ $ref }) => { + const expectedEntityType = + closedMultiEntityTypesDefinitions.entityTypes[$ref]; - if ("oneOf" in linkSchema.items) { - expectedEntityTypes = linkSchema.items.oneOf.map(({ $ref }) => { - const expectedEntityType = getEntityTypeById( - entitySubgraph, - $ref, - ); + if (!expectedEntityType) { + throw new Error(`entity type ${$ref} not found in definitions`); + } - if (!expectedEntityType) { - throw new Error("entity type not found"); - } + return expectedEntityType; + }); + } - return expectedEntityType; - }); - } + const additions = draftLinksToCreate.filter((draftToCreate) => + draftToCreate.linkEntity.metadata.entityTypeIds.includes( + linkEntityTypeId, + ), + ); - const additions = draftLinksToCreate.filter((draftToCreate) => - draftToCreate.linkEntity.metadata.entityTypeIds.includes( - linkEntityTypeId, + const linkAndTargetEntities: LinkRow["linkAndTargetEntities"] = []; + + for (const entities of outgoingLinkAndTargetEntities) { + const linkEntityRevisions = [...entities.linkEntity]; + linkEntityRevisions.sort((entityA, entityB) => + intervalCompareWithInterval( + entityA.metadata.temporalVersioning[variableAxis], + entityB.metadata.temporalVersioning[variableAxis], ), ); - const linkAndTargetEntities = []; + const latestLinkEntityRevision = linkEntityRevisions.at(-1); - for (const entities of outgoingLinkAndTargetEntities) { - const linkEntityRevisions = [...entities.linkEntity]; - linkEntityRevisions.sort((entityA, entityB) => - intervalCompareWithInterval( - entityA.metadata.temporalVersioning[variableAxis], - entityB.metadata.temporalVersioning[variableAxis], - ), + if (!latestLinkEntityRevision) { + throw new Error( + `Couldn't find a latest link entity revision from ${entity.metadata.recordId.entityId}, this is likely an implementation bug in the stdlib`, ); + } - const latestLinkEntityRevision = linkEntityRevisions.at(-1); + const targetEntityRevisions = [...entities.rightEntity]; + targetEntityRevisions.sort((entityA, entityB) => + intervalCompareWithInterval( + entityA.metadata.temporalVersioning[variableAxis], + entityB.metadata.temporalVersioning[variableAxis], + ), + ); - if (!latestLinkEntityRevision) { - throw new Error( - `Couldn't find a latest link entity revision from ${entity.metadata.recordId.entityId}, this is likely an implementation bug in the stdlib`, - ); - } + const latestTargetEntityRevision = targetEntityRevisions.at(-1); - const targetEntityRevisions = [...entities.rightEntity]; - targetEntityRevisions.sort((entityA, entityB) => - intervalCompareWithInterval( - entityA.metadata.temporalVersioning[variableAxis], - entityB.metadata.temporalVersioning[variableAxis], - ), + if (!latestTargetEntityRevision) { + throw new Error( + `Couldn't find a target link entity revision from ${entity.metadata.recordId.entityId}, this is likely an implementation bug in the stdlib`, ); + } - const latestTargetEntityRevision = targetEntityRevisions.at(-1); - - if (!latestTargetEntityRevision) { - throw new Error( - `Couldn't find a target link entity revision from ${entity.metadata.recordId.entityId}, this is likely an implementation bug in the stdlib`, - ); - } - - const { entityTypeIds, recordId } = - latestLinkEntityRevision.metadata; + const { entityTypeIds, recordId } = latestLinkEntityRevision.metadata; - const isMatching = entityTypeIds.includes(linkEntityTypeId); - const isMarkedToArchive = draftLinksToArchive.some( - (markedLinkId) => markedLinkId === recordId.entityId, - ); + const isMatching = entityTypeIds.includes(linkEntityTypeId); + const isMarkedToArchive = draftLinksToArchive.some( + (markedLinkId) => markedLinkId === recordId.entityId, + ); - if (isMatching && !isMarkedToArchive) { - linkAndTargetEntities.push({ - linkEntity: latestLinkEntityRevision, - rightEntity: latestTargetEntityRevision, - sourceSubgraph: entitySubgraph, - }); - } + if (!closedMultiEntityTypesMap) { + throw new Error("Expected closedMultiEntityTypesMap to be defined"); } - linkAndTargetEntities.push(...additions); + const targetEntityClosedType = getClosedMultiEntityTypeFromMap( + closedMultiEntityTypesMap, + latestTargetEntityRevision.metadata.entityTypeIds, + ); - const isFile = expectedEntityTypes.some( - (expectedType) => - isSpecialEntityTypeLookup?.[expectedType.schema.$id]?.isFile, + const rightEntityLabel = generateEntityLabel( + targetEntityClosedType, + latestTargetEntityRevision, ); - const retryErroredUpload = - relevantUpload?.status === "error" - ? () => uploadFile(relevantUpload) - : undefined; + const linkEntityClosedType = getClosedMultiEntityTypeFromMap( + closedMultiEntityTypesMap, + latestLinkEntityRevision.metadata.entityTypeIds, + ); - processedLinkEntityTypeIds.add(linkEntityTypeId); + const linkEntityLabel = generateEntityLabel( + linkEntityClosedType, + latestLinkEntityRevision, + ); - return { - rowId: linkEntityTypeId, - linkEntityTypeId, - linkTitle: linkEntityType.schema.title, - linkAndTargetEntities, - maxItems: linkSchema.maxItems, - isErroredUpload, - isFile, - isUploading: isLoading, - isList: - linkSchema.maxItems === undefined || linkSchema.maxItems > 1, - expectedEntityTypes, - entitySubgraph, - markLinkAsArchived: markLinkEntityToArchive, - onEntityClick, - retryErroredUpload, - }; - }, - ), + if (isMatching && !isMarkedToArchive) { + linkAndTargetEntities.push({ + linkEntity: latestLinkEntityRevision, + linkEntityLabel, + rightEntity: latestTargetEntityRevision, + rightEntityLabel, + }); + } + } + + linkAndTargetEntities.push(...additions); + + const isFile = expectedEntityTypes.some( + (expectedType) => + isSpecialEntityTypeLookup?.[expectedType.$id]?.isFile, + ); + + const retryErroredUpload = + relevantUpload?.status === "error" + ? () => uploadFile(relevantUpload) + : undefined; + + processedLinkEntityTypeIds.add(linkEntityTypeId); + + return { + rowId: linkEntityTypeId, + linkEntityTypeId, + linkTitle: linkEntityType.title, + linkAndTargetEntities, + maxItems: linkSchema.maxItems, + isErroredUpload, + isFile, + isUploading: isLoading, + isList: linkSchema.maxItems === undefined || linkSchema.maxItems > 1, + expectedEntityTypes, + entitySubgraph, + markLinkAsArchived: markLinkEntityToArchive, + onEntityClick, + retryErroredUpload, + }; + }, ); }, [ + closedMultiEntityType, + closedMultiEntityTypesMap, + closedMultiEntityTypesDefinitions, entitySubgraph, draftLinksToArchive, draftLinksToCreate, diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/change-type-cell.tsx b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/change-type-cell.tsx index 78786727a12..a88865de296 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/change-type-cell.tsx +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/change-type-cell.tsx @@ -1,3 +1,4 @@ +import type { ClosedDataType } from "@blockprotocol/type-system"; import type { CustomCell, CustomRenderer, @@ -5,7 +6,6 @@ import type { } from "@glideapps/glide-data-grid"; import { GridCellKind } from "@glideapps/glide-data-grid"; import { customColors } from "@hashintel/design-system/theme"; -import type { DataTypeWithMetadata } from "@local/hash-graph-types/ontology"; import produce from "immer"; import type { RefObject } from "react"; @@ -21,7 +21,7 @@ import { guessEditorTypeFromExpectedType } from "./value-cell/utils"; export interface ChangeTypeCellProps { readonly kind: "change-type-cell"; - currentType: DataTypeWithMetadata["schema"]; + currentType: ClosedDataType; propertyRow: PropertyRow; valueCellOfThisRow: ValueCell; } diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell.tsx b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell.tsx index 0e58941128e..3fa17f3cc17 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell.tsx +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell.tsx @@ -1,8 +1,8 @@ import type { JsonValue } from "@blockprotocol/core"; +import type { ClosedDataType } from "@blockprotocol/type-system"; import type { CustomCell, CustomRenderer } from "@glideapps/glide-data-grid"; import { GridCellKind } from "@glideapps/glide-data-grid"; import { customColors } from "@hashintel/design-system/theme"; -import type { DataTypeWithMetadata } from "@local/hash-graph-types/ontology"; import type { FormattedValuePart } from "@local/hash-isomorphic-utils/data-types"; import { formatDataValue } from "@local/hash-isomorphic-utils/data-types"; @@ -25,19 +25,22 @@ import { guessEditorTypeFromValue } from "./value-cell/utils"; const guessDataTypeFromValue = ( value: JsonValue, - expectedTypes: DataTypeWithMetadata["schema"][], + expectedTypes: ClosedDataType[], ) => { const editorType = guessEditorTypeFromValue(value, expectedTypes); - const expectedType = expectedTypes.find((type) => - "type" in type - ? type.type === editorType - : /** - * @todo H-3374 support anyOf in expected types. also don't need to guess the value any more, use dataTypeId - * from property metadata - */ - type.anyOf.some((subType) => subType.type === editorType), + const expectedType = expectedTypes.find(({ allOf }) => + allOf.some((constraint) => + "type" in constraint + ? constraint.type === editorType + : /** + * @todo H-3374 support anyOf in expected types. also don't need to guess the value any more, use dataTypeId + * from property metadata + */ + constraint.anyOf.some((subType) => subType.type === editorType), + ), ); + if (!expectedType) { throw new Error( `Could not find guessed editor type ${editorType} among expected types ${expectedTypes @@ -59,7 +62,7 @@ export const renderValueCell: CustomRenderer = { const { readonly } = cell.data; - const { value, expectedTypes, isArray, isSingleUrl } = + const { value, permittedDataTypes, isArray, isSingleUrl } = cell.data.propertyRow; ctx.fillStyle = theme.textHeader; @@ -68,15 +71,17 @@ export const renderValueCell: CustomRenderer = { const yCenter = getYCenter(args); const left = rect.x + getCellHorizontalPadding(); - const editorType = guessEditorTypeFromValue(value, expectedTypes); - const relevantType = expectedTypes.find((type) => - "type" in type - ? type.type === editorType - : /** - * @todo H-3374 support anyOf in expected types. also don't need to guess the value any more, use dataTypeId - * from property metadata - */ - type.anyOf.some((subType) => subType.type === editorType), + const editorType = guessEditorTypeFromValue(value, permittedDataTypes); + const relevantType = permittedDataTypes.find(({ allOf }) => + allOf.some((constraint) => + "type" in constraint + ? constraint.type === editorType + : /** + * @todo H-3374 support anyOf in expected types. also don't need to guess the value any more, use dataTypeId + * from property metadata + */ + constraint.anyOf.some((subType) => subType.type === editorType), + ), ); const editorSpec = getEditorSpecs(editorType, relevantType); @@ -90,7 +95,7 @@ export const renderValueCell: CustomRenderer = { } else if (!isArray && editorSpec.shouldBeDrawnAsAChip) { const expectedType = guessDataTypeFromValue( value as JsonValue, - expectedTypes, + permittedDataTypes, ); drawChipWithText({ @@ -103,7 +108,7 @@ export const renderValueCell: CustomRenderer = { } else if (editorType === "boolean") { const expectedType = guessDataTypeFromValue( value as JsonValue, - expectedTypes, + permittedDataTypes, ); // draw boolean @@ -125,7 +130,7 @@ export const renderValueCell: CustomRenderer = { for (const [index, entry] of value.entries()) { const expectedType = guessDataTypeFromValue( entry as JsonValue, - expectedTypes, + permittedDataTypes, ); valueParts.push(...formatDataValue(entry as JsonValue, expectedType)); if (index < value.length - 1) { @@ -139,7 +144,7 @@ export const renderValueCell: CustomRenderer = { } else { const expectedType = guessDataTypeFromValue( value as JsonValue, - expectedTypes, + permittedDataTypes, ); valueParts.push(...formatDataValue(value as JsonValue, expectedType)); } diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/array-editor.tsx b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/array-editor.tsx index 5d247ede460..e44bc419081 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/array-editor.tsx +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/array-editor.tsx @@ -50,7 +50,7 @@ export const ArrayEditor: ValueCellEditorComponent = ({ const listWrapperRef = useRef(null); const { value: propertyValue, - expectedTypes, + permittedDataTypes, maxItems, minItems, } = cell.data.propertyRow; @@ -74,8 +74,10 @@ export const ArrayEditor: ValueCellEditorComponent = ({ return ""; } - if (expectedTypes.length === 1) { - const expectedType = guessEditorTypeFromExpectedType(expectedTypes[0]!); + if (permittedDataTypes.length === 1) { + const expectedType = guessEditorTypeFromExpectedType( + permittedDataTypes[0]!, + ); if (getEditorSpecs(expectedType).arrayEditException === "no-edit-mode") { return ""; @@ -159,8 +161,8 @@ export const ArrayEditor: ValueCellEditorComponent = ({ const handleAddAnotherClick = () => { setSelectedRow(""); - const onlyOneExpectedType = expectedTypes.length === 1; - const expectedType = expectedTypes[0]!; + const onlyOneExpectedType = permittedDataTypes.length === 1; + const expectedType = permittedDataTypes[0]!; const editorType = guessEditorTypeFromExpectedType(expectedType); const editorSpec = getEditorSpecs(editorType, expectedType); const noEditMode = editorSpec.arrayEditException === "no-edit-mode"; @@ -207,7 +209,7 @@ export const ArrayEditor: ValueCellEditorComponent = ({ editing={editingRow === item.id} selected={selectedRow === item.id} onSelect={toggleSelectedRow} - expectedTypes={expectedTypes} + expectedTypes={permittedDataTypes} /> ))} @@ -224,7 +226,7 @@ export const ArrayEditor: ValueCellEditorComponent = ({ {isAddingDraft && ( setEditingRow("")} /> diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/array-editor/draft-row.tsx b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/array-editor/draft-row.tsx index ed015ec99f3..7bd37779d02 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/array-editor/draft-row.tsx +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/array-editor/draft-row.tsx @@ -1,4 +1,4 @@ -import type { DataTypeWithMetadata } from "@local/hash-graph-types/ontology"; +import type { ClosedDataType } from "@blockprotocol/type-system"; import { useState } from "react"; import { DRAFT_ROW_KEY } from "../array-editor"; @@ -12,7 +12,7 @@ import { import { SortableRow } from "./sortable-row"; interface DraftRowProps { - expectedTypes: DataTypeWithMetadata["schema"][]; + expectedTypes: ClosedDataType[]; existingItemCount: number; onDraftSaved: (value: unknown) => void; onDraftDiscarded: () => void; diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/array-editor/sortable-row.tsx b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/array-editor/sortable-row.tsx index c3bfb0d19c2..234fe7b46c4 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/array-editor/sortable-row.tsx +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/array-editor/sortable-row.tsx @@ -1,4 +1,8 @@ import type { JsonValue } from "@blockprotocol/core"; +import type { + ClosedDataType, + ValueConstraints, +} from "@blockprotocol/type-system"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { @@ -7,7 +11,6 @@ import { faPencil, faTrash, } from "@fortawesome/free-solid-svg-icons"; -import type { DataTypeWithMetadata } from "@local/hash-graph-types/ontology"; import { formatDataValue } from "@local/hash-isomorphic-utils/data-types"; import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; import { Box, Divider, Typography } from "@mui/material"; @@ -29,7 +32,7 @@ interface SortableRowProps { onSelect?: (id: string) => void; onEditClicked?: (id: string) => void; editing: boolean; - expectedTypes: DataTypeWithMetadata["schema"][]; + expectedTypes: ClosedDataType[]; onSaveChanges: (index: number, value: unknown) => void; onDiscardChanges: () => void; } @@ -66,13 +69,40 @@ export const SortableRow = ({ const editorType = overriddenEditorType ?? guessEditorTypeFromValue(value, expectedTypes); + let valueConstraints: ValueConstraints; + + /** @todo H-3374 don't guess the type, take it from the data type metadata */ + /* eslint-disable no-labels */ + outerLoop: for (const expectedType of expectedTypes) { + for (const constraint of expectedType.allOf) { + if ("type" in constraint) { + if (constraint.type === editorType) { + valueConstraints = constraint; + break outerLoop; + } + } else { + for (const innerConstraint of constraint.anyOf) { + if ("type" in innerConstraint) { + if (innerConstraint.type === editorType) { + valueConstraints = innerConstraint; + break outerLoop; + } + } + } + } + } + } + /* eslint-enable no-labels */ + const expectedType = expectedTypes.find((type) => - "type" in type - ? type.type === editorType - : /** - * @todo H-3374 support multiple expected data types - */ - type.anyOf.some((subType) => subType.type === editorType), + type.allOf.some((constraint) => + "type" in constraint + ? constraint.type === editorType + : /** + * @todo H-3374 support multiple expected data types + */ + constraint.anyOf.some((subType) => subType.type === editorType), + ), ); const editorSpec = getEditorSpecs(editorType, expectedType); @@ -136,11 +166,11 @@ export const SortableRow = ({ return ( ); diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/editor-specs.ts b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/editor-specs.ts index 31cf429a8cf..95b470e48be 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/editor-specs.ts +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/editor-specs.ts @@ -1,4 +1,4 @@ -import type { DataType } from "@blockprotocol/type-system"; +import type { ClosedDataType } from "@blockprotocol/type-system"; import type { IconDefinition } from "@fortawesome/free-solid-svg-icons"; import { fa100, @@ -87,7 +87,7 @@ const identifierTypeTitles = ["URL", "URI"]; export const getEditorSpecs = ( editorType: EditorType, - dataType?: DataType, + dataType?: ClosedDataType, ): EditorSpec => { switch (editorType) { case "boolean": diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/editor-type-picker.tsx b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/editor-type-picker.tsx index 216ebdd70c7..69fe6f12d8e 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/editor-type-picker.tsx +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/editor-type-picker.tsx @@ -1,5 +1,5 @@ +import type { ClosedDataType } from "@blockprotocol/type-system/slim"; import { FontAwesomeIcon } from "@hashintel/design-system"; -import type { DataTypeWithMetadata } from "@local/hash-graph-types/ontology"; import { Box, ButtonBase, Typography } from "@mui/material"; import { getEditorSpecs } from "./editor-specs"; @@ -11,7 +11,7 @@ const ExpectedTypeButton = ({ expectedType, }: { onClick: () => void; - expectedType: DataTypeWithMetadata["schema"]; + expectedType: ClosedDataType; }) => { const editorSpec = getEditorSpecs( guessEditorTypeFromExpectedType(expectedType), @@ -51,7 +51,7 @@ const ExpectedTypeButton = ({ }; interface EditorTypePickerProps { - expectedTypes: DataTypeWithMetadata["schema"][]; + expectedTypes: ClosedDataType[]; onTypeChange: OnTypeChange; } diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/inputs/number-or-text-input.tsx b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/inputs/number-or-text-input.tsx index b6e41eabc65..3deecef176c 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/inputs/number-or-text-input.tsx +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/inputs/number-or-text-input.tsx @@ -1,6 +1,6 @@ +import type { ValueConstraints } from "@blockprotocol/type-system-rs/pkg/type-system"; import type { TextFieldProps } from "@hashintel/design-system"; import { TextField } from "@hashintel/design-system"; -import type { DataTypeWithMetadata } from "@local/hash-graph-types/ontology"; import { format, formatISO, parseISO } from "date-fns"; import type { CellInputProps } from "./types"; @@ -33,50 +33,53 @@ const convertDateTimeToLocalRFC3339 = (dateTimeStringWithoutOffset: string) => { }; export const NumberOrTextInput = ({ - expectedType, + isNumber, onBlur, onChange, onEnterPressed, value: uncheckedValue, - isNumber, + valueConstraints, }: CellInputProps & { - onBlur?: TextFieldProps["onBlur"]; - expectedType: DataTypeWithMetadata["schema"]; isNumber: boolean; + onBlur?: TextFieldProps["onBlur"]; onEnterPressed?: () => void; + valueConstraints: ValueConstraints; }) => { const minLength = - "minLength" in expectedType ? expectedType.minLength : undefined; + "minLength" in valueConstraints ? valueConstraints.minLength : undefined; const maxLength = - "maxLength" in expectedType ? expectedType.maxLength : undefined; + "maxLength" in valueConstraints ? valueConstraints.maxLength : undefined; const step = - "multipleOf" in expectedType && expectedType.multipleOf !== undefined - ? expectedType.multipleOf + "multipleOf" in valueConstraints && + valueConstraints.multipleOf !== undefined + ? valueConstraints.multipleOf : 0.01; const exclusiveMinimum = - "exclusiveMinimum" in expectedType && - typeof expectedType.exclusiveMinimum === "boolean" - ? expectedType.exclusiveMinimum + "exclusiveMinimum" in valueConstraints && + typeof valueConstraints.exclusiveMinimum === "boolean" + ? valueConstraints.exclusiveMinimum : false; const minimum = - "minimum" in expectedType && typeof expectedType.minimum === "number" - ? expectedType.minimum + (exclusiveMinimum ? step : 0) + "minimum" in valueConstraints && + typeof valueConstraints.minimum === "number" + ? valueConstraints.minimum + (exclusiveMinimum ? step : 0) : undefined; const exclusiveMaximum = - "exclusiveMaximum" in expectedType && - typeof expectedType.exclusiveMaximum === "boolean" - ? expectedType.exclusiveMaximum + "exclusiveMaximum" in valueConstraints && + typeof valueConstraints.exclusiveMaximum === "boolean" + ? valueConstraints.exclusiveMaximum : false; const maximum = - "maximum" in expectedType && typeof expectedType.maximum === "number" - ? expectedType.maximum - (exclusiveMaximum ? step : 0) + "maximum" in valueConstraints && + typeof valueConstraints.maximum === "number" + ? valueConstraints.maximum - (exclusiveMaximum ? step : 0) : undefined; const jsonStringFormat = - "format" in expectedType ? expectedType.format : undefined; + "format" in valueConstraints ? valueConstraints.format : undefined; let inputType: TextFieldProps["type"] = isNumber ? "number" : "text"; let value = uncheckedValue; @@ -127,7 +130,7 @@ export const NumberOrTextInput = ({ value={value} type={inputType} inputMode={isNumber ? "numeric" : "text"} - placeholder="Start typing..." + placeholder={isNumber ? "Enter a number" : "Start typing..."} onBlur={onBlur} onChange={({ target }) => { const isEmptyString = target.value === ""; diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/single-value-editor.tsx b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/single-value-editor.tsx index 9bbb093bb4b..b1c4d16af88 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/single-value-editor.tsx +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/single-value-editor.tsx @@ -1,3 +1,4 @@ +import type { ValueConstraints } from "@blockprotocol/type-system-rs/pkg/type-system"; import { Chip } from "@hashintel/design-system"; import { GRID_CLICK_IGNORE_CLASS } from "@hashintel/design-system/constants"; import { Box } from "@mui/material"; @@ -19,15 +20,18 @@ import { export const SingleValueEditor: ValueCellEditorComponent = (props) => { const { value: cell, onChange, onFinishedEditing } = props; - const { expectedTypes, value } = cell.data.propertyRow; + const { permittedDataTypes, value } = cell.data.propertyRow; const textInputFormRef = useRef(null); const [editorType, setEditorType] = useState(() => { // if there are multiple expected types - if (expectedTypes.length > 1) { + if (permittedDataTypes.length > 1) { // show type picker if value is empty, guess editor type using value if it's not - const guessedEditorType = guessEditorTypeFromValue(value, expectedTypes); + const guessedEditorType = guessEditorTypeFromValue( + value, + permittedDataTypes, + ); if (guessedEditorType === "null" || guessedEditorType === "emptyList") { return guessedEditorType; @@ -35,10 +39,10 @@ export const SingleValueEditor: ValueCellEditorComponent = (props) => { return isValueEmpty(value) ? null - : guessEditorTypeFromValue(value, expectedTypes); + : guessEditorTypeFromValue(value, permittedDataTypes); } - const expectedType = expectedTypes[0]; + const expectedType = permittedDataTypes[0]; if (!expectedType) { throw new Error("there is no expectedType found on property type"); @@ -50,7 +54,7 @@ export const SingleValueEditor: ValueCellEditorComponent = (props) => { } // if the value is not empty, guess the editor type using value - return guessEditorTypeFromValue(value, expectedTypes); + return guessEditorTypeFromValue(value, permittedDataTypes); }); const latestValueCellRef = useRef(cell); @@ -62,7 +66,7 @@ export const SingleValueEditor: ValueCellEditorComponent = (props) => { return ( { const editorSpec = getEditorSpecs(type); @@ -144,18 +148,33 @@ export const SingleValueEditor: ValueCellEditorComponent = (props) => { ); } - const expectedType = expectedTypes.find((type) => - "type" in type - ? type.type === editorType - : /** - * @todo H-3374 support anyOf in expected types. also don't need to guess the value any more, use dataTypeId from property metadata - */ - type.anyOf.some((subType) => subType.type === editorType), - ); + let valueConstraints: ValueConstraints | undefined; + /** @todo H-3374 don't guess the type, take it from the data type metadata */ + /* eslint-disable no-labels */ + outerLoop: for (const expectedType of permittedDataTypes) { + for (const constraint of expectedType.allOf) { + if ("type" in constraint) { + if (constraint.type === editorType) { + valueConstraints = constraint; + break outerLoop; + } + } else { + for (const innerConstraint of constraint.anyOf) { + if ("type" in innerConstraint) { + if (innerConstraint.type === editorType) { + valueConstraints = innerConstraint; + break outerLoop; + } + } + } + } + } + } + /* eslint-enable no-labels */ - if (!expectedType) { + if (!valueConstraints) { throw new Error( - `Could not find guessed editor type ${editorType} among expected types ${expectedTypes + `Could not find guessed editor type ${editorType} among expected types ${permittedDataTypes .map((opt) => opt.$id) .join(", ")}`, ); @@ -215,24 +234,26 @@ export const SingleValueEditor: ValueCellEditorComponent = (props) => { ref={textInputFormRef} > { if ( - ("format" in expectedType && - expectedType.format && + ("format" in valueConstraints && + valueConstraints.format && /** * We use the native browser date/time inputs which handle validation for us, * and the validation click handler assumes there will be a click outside after a change * - which there won't for those inputs, because clicking to select a value closes the input. */ - !["date", "date-time", "time"].includes(expectedType.format)) || - "minLength" in expectedType || - "maxLength" in expectedType || - "minimum" in expectedType || - "maximum" in expectedType || - "step" in expectedType + !["date", "date-time", "time"].includes( + valueConstraints.format, + )) || + "minLength" in valueConstraints || + "maxLength" in valueConstraints || + "minimum" in valueConstraints || + "maximum" in valueConstraints || + "step" in valueConstraints ) { /** * Add the validation enforcer if there are any validation rules. diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/utils.ts b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/utils.ts index b5c3b288d78..910680f5a5b 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/utils.ts +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/utils.ts @@ -1,5 +1,4 @@ -import type { DataType } from "@blockprotocol/type-system/slim"; -import type { DataTypeWithMetadata } from "@local/hash-graph-types/ontology"; +import type { ClosedDataType } from "@blockprotocol/type-system/slim"; import isPlainObject from "lodash/isPlainObject"; import type { EditorType } from "./types"; @@ -8,12 +7,14 @@ const isEmptyArray = (value: unknown) => Array.isArray(value) && !value.length; const isValidTypeForSchemas = ( type: "string" | "boolean" | "number" | "object" | "null", - expectedTypes: DataType[], + expectedTypes: ClosedDataType[], ) => - expectedTypes.some((dataType) => - "type" in dataType - ? dataType.type === type - : dataType.anyOf.some((subType) => subType.type === type), + expectedTypes.some(({ allOf }) => + allOf.some((constraint) => + "type" in constraint + ? constraint.type === type + : constraint.anyOf.some((subType) => subType.type === type), + ), ); /** @@ -21,7 +22,7 @@ const isValidTypeForSchemas = ( */ export const guessEditorTypeFromValue = ( value: unknown, - expectedTypes: DataTypeWithMetadata["schema"][], + expectedTypes: ClosedDataType[], ): EditorType => { if ( typeof value === "string" && @@ -63,7 +64,7 @@ export const guessEditorTypeFromValue = ( }; export const guessEditorTypeFromExpectedType = ( - dataType: DataTypeWithMetadata["schema"], + dataType: ClosedDataType, ): EditorType => { if (dataType.title === "Empty List") { return "emptyList"; @@ -71,13 +72,15 @@ export const guessEditorTypeFromExpectedType = ( let type: "string" | "number" | "boolean" | "object" | "null" | "array"; - if ("anyOf" in dataType) { + const firstConstraint = dataType.allOf[0]; + + if ("anyOf" in firstConstraint) { /** * @todo H-3374 support multiple expected data types */ - type = dataType.anyOf[0].type; + type = firstConstraint.anyOf[0].type; } else { - type = dataType.type; + type = firstConstraint.type; } if (type === "array") { diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/constants.ts b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/constants.ts index 981f8cd29df..1adaa6eb049 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/constants.ts +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/constants.ts @@ -13,7 +13,7 @@ export const propertyGridColumns: PropertyColumn[] = [ }, { title: "Expected type", - id: "expectedTypes", + id: "permittedDataTypes", width: 250, grow: 1, }, @@ -22,5 +22,5 @@ export const propertyGridColumns: PropertyColumn[] = [ export const propertyGridIndexes: PropertyColumnKey[] = [ "title", "value", - "expectedTypes", + "permittedDataTypes", ]; diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/types.ts b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/types.ts index c7c6aed6f6f..005408063e2 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/types.ts +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/types.ts @@ -1,28 +1,30 @@ +import type { ClosedDataType } from "@blockprotocol/type-system"; import type { SizedGridColumn } from "@glideapps/glide-data-grid"; -import type { DataTypeWithMetadata } from "@local/hash-graph-types/ontology"; +import type { PropertyMetadata } from "@local/hash-graph-types/entity"; import type { VerticalIndentationLineDir } from "../../../../../../../components/grid/utils/draw-vertical-indentation-line"; export type PropertyRow = { - title: string; - rowId: string; - value: unknown; - expectedTypes: DataTypeWithMetadata["schema"][]; - isArray: boolean; - isSingleUrl: boolean; - required: boolean; children: PropertyRow[]; depth: number; indent: number; - verticalLinesForEachIndent: VerticalIndentationLineDir[]; - propertyKeyChain: string[]; + isArray: boolean; + isSingleUrl: boolean; maxItems?: number; minItems?: number; + permittedDataTypes: ClosedDataType[]; + propertyKeyChain: string[]; + required: boolean; + rowId: string; + title: string; + value: unknown; + valueMetadata: PropertyMetadata["metadata"]; + verticalLinesForEachIndent: VerticalIndentationLineDir[]; }; export type PropertyColumnKey = Extract< keyof PropertyRow, - "title" | "value" | "expectedTypes" + "title" | "value" | "permittedDataTypes" >; export interface PropertyColumn extends SizedGridColumn { diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/use-create-get-cell-content.ts b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/use-create-get-cell-content.ts index dae854ded3c..159a3b1f1ca 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/use-create-get-cell-content.ts +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/use-create-get-cell-content.ts @@ -69,7 +69,7 @@ export const useCreateGetCellContent = ( const guessedType = guessEditorTypeFromValue( row.value, - row.expectedTypes, + row.permittedDataTypes, ); const isEmptyValue = @@ -78,7 +78,7 @@ export const useCreateGetCellContent = ( guessedType !== "emptyList"; const shouldShowChangeTypeCell = - row.expectedTypes.length > 1 && + row.permittedDataTypes.length > 1 && !row.isArray && !isEmptyValue && !readonly; @@ -119,19 +119,23 @@ export const useCreateGetCellContent = ( return valueCell; - case "expectedTypes": + case "permittedDataTypes": if (hasChild) { return blankCell; } if (shouldShowChangeTypeCell) { - const currentType = row.expectedTypes.find((opt) => - "type" in opt - ? opt.type === guessedType - : /** - * @todo H-3374 support anyOf in expected types. also don't need to guess the value any more, use dataTypeId from property metadata - */ - opt.anyOf.some((subType) => subType.type === guessedType), + const currentType = row.permittedDataTypes.find(({ allOf }) => + allOf.some((constraint) => + "type" in constraint + ? constraint.type === guessedType + : /** + * @todo H-3374 support anyOf in expected types. also don't need to guess the value any more, use dataTypeId from property metadata + */ + constraint.anyOf.some( + (subType) => subType.type === guessedType, + ), + ), ); if (!currentType) { throw new Error( @@ -158,10 +162,10 @@ export const useCreateGetCellContent = ( kind: GridCellKind.Custom, allowOverlay: true, readonly: true, - copyData: String(row.expectedTypes), + copyData: String(row.permittedDataTypes), data: { kind: "chip-cell", - chips: row.expectedTypes.map((type) => { + chips: row.permittedDataTypes.map((type) => { const editorSpec = getEditorSpecs( guessEditorTypeFromExpectedType(type), type, diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/use-rows.ts b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/use-rows.ts index 93568fbd7a3..f8fceebb754 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/use-rows.ts +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/use-rows.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from "react"; +import { useCallback } from "react"; import type { ColumnSort } from "../../../../../../../components/grid/utils/sorting"; import { defaultSortRows } from "../../../../../../../components/grid/utils/sorting"; @@ -6,14 +6,12 @@ import { useEntityEditor } from "../../entity-editor-context"; import { fillRowIndentCalculations } from "./fill-row-indent-calculations"; import { flattenExpandedItemsOfTree } from "./flatten"; import type { PropertyRow } from "./types"; -import { generatePropertyRowsFromEntity } from "./use-rows/generate-property-rows-from-entity"; +import { usePropertyRowsFromEntity } from "./use-rows/use-property-rows-from-entity"; export const useRows = () => { - const { entitySubgraph, propertyExpandStatus } = useEntityEditor(); + const { propertyExpandStatus } = useEntityEditor(); - const rows = useMemo(() => { - return generatePropertyRowsFromEntity(entitySubgraph); - }, [entitySubgraph]); + const rows = usePropertyRowsFromEntity(); const sortAndFlattenRows = useCallback( (_rows: PropertyRow[], sort: ColumnSort) => { diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/use-rows/generate-property-rows-from-entity.ts b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/use-rows/generate-property-rows-from-entity.ts deleted file mode 100644 index b6b8dec774e..00000000000 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/use-rows/generate-property-rows-from-entity.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { BaseUrl } from "@local/hash-graph-types/ontology"; -import type { EntityRootType, Subgraph } from "@local/hash-subgraph"; -import { - getBreadthFirstEntityTypesAndParents, - getRoots, -} from "@local/hash-subgraph/stdlib"; - -import type { PropertyRow } from "../types"; -import { generatePropertyRowRecursively } from "./generate-property-rows-from-entity/generate-property-row-recursively"; - -export const generatePropertyRowsFromEntity = ( - entitySubgraph: Subgraph, -): PropertyRow[] => { - const entity = getRoots(entitySubgraph)[0]!; - - const entityTypeAndAncestors = getBreadthFirstEntityTypesAndParents( - entitySubgraph, - entity.metadata.entityTypeIds, - ); - - const requiredPropertyTypes = entityTypeAndAncestors.flatMap( - (type) => (type.schema.required ?? []) as BaseUrl[], - ); - - const processedPropertyTypes = new Set(); - - return entityTypeAndAncestors.flatMap((entityType) => - Object.keys(entityType.schema.properties).flatMap((propertyTypeBaseUrl) => { - if (processedPropertyTypes.has(propertyTypeBaseUrl as BaseUrl)) { - return []; - } - - const propertyRefSchema = - entityType.schema.properties[propertyTypeBaseUrl]; - - if (!propertyRefSchema) { - throw new Error("Property not found"); - } - - processedPropertyTypes.add(propertyTypeBaseUrl as BaseUrl); - - return generatePropertyRowRecursively({ - propertyTypeBaseUrl: propertyTypeBaseUrl as BaseUrl, - propertyKeyChain: [propertyTypeBaseUrl as BaseUrl], - entity, - entitySubgraph, - requiredPropertyTypes, - propertyRefSchema, - }); - }), - ); -}; diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/use-rows/generate-property-rows-from-entity/generate-property-row-recursively.ts b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/use-rows/generate-property-rows-from-entity/generate-property-row-recursively.ts index 4b8a6a7860f..466d304bf04 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/use-rows/generate-property-rows-from-entity/generate-property-row-recursively.ts +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/use-rows/generate-property-rows-from-entity/generate-property-row-recursively.ts @@ -3,9 +3,11 @@ import type { ValueOrArray, } from "@blockprotocol/type-system"; import type { Entity } from "@local/hash-graph-sdk/entity"; -import type { BaseUrl } from "@local/hash-graph-types/ontology"; -import type { EntityRootType, Subgraph } from "@local/hash-subgraph"; -import { getPropertyTypeById } from "@local/hash-subgraph/stdlib"; +import type { + BaseUrl, + ClosedMultiEntityType, + ClosedMultiEntityTypesDefinitions, +} from "@local/hash-graph-types/ontology"; import get from "lodash/get"; import { @@ -49,18 +51,20 @@ import { getExpectedTypesOfPropertyType } from "./get-expected-types-of-property * @returns property row (and nested rows as `children` if it's a nested property) */ export const generatePropertyRowRecursively = ({ + closedMultiEntityType, + closedMultiEntityTypesDefinitions, propertyTypeBaseUrl, propertyKeyChain, entity, - entitySubgraph, requiredPropertyTypes, depth = 0, propertyRefSchema, }: { + closedMultiEntityType: ClosedMultiEntityType; + closedMultiEntityTypesDefinitions: ClosedMultiEntityTypesDefinitions; propertyTypeBaseUrl: BaseUrl; propertyKeyChain: BaseUrl[]; entity: Entity; - entitySubgraph: Subgraph; requiredPropertyTypes: BaseUrl[]; depth?: number; propertyRefSchema: ValueOrArray; @@ -70,16 +74,18 @@ export const generatePropertyRowRecursively = ({ ? propertyRefSchema.$ref : propertyRefSchema.items.$ref; - const propertyType = getPropertyTypeById( - entitySubgraph, - propertyTypeId, - )?.schema; + const propertyType = + closedMultiEntityTypesDefinitions.propertyTypes[propertyTypeId]; + if (!propertyType) { - throw new Error(`Property type ${propertyTypeId} not found in subgraph`); + throw new Error(`Property type ${propertyTypeId} not found in definitions`); } const { isArray: isPropertyTypeArray, expectedTypes } = - getExpectedTypesOfPropertyType(propertyType, entitySubgraph); + getExpectedTypesOfPropertyType( + propertyType, + closedMultiEntityTypesDefinitions, + ); const isAllowMultiple = "type" in propertyRefSchema; @@ -88,6 +94,7 @@ export const generatePropertyRowRecursively = ({ const required = requiredPropertyTypes.includes(propertyTypeBaseUrl); const value = get(entity.properties, propertyKeyChain); + const valueMetadata = entity.propertyMetadata(propertyKeyChain); const children: PropertyRow[] = []; @@ -102,6 +109,8 @@ export const generatePropertyRowRecursively = ({ )) { children.push( generatePropertyRowRecursively({ + closedMultiEntityType, + closedMultiEntityTypesDefinitions, propertyTypeBaseUrl: subPropertyTypeBaseUrl as BaseUrl, propertyKeyChain: [ ...propertyKeyChain, @@ -110,7 +119,6 @@ export const generatePropertyRowRecursively = ({ subPropertyTypeBaseUrl, ] as BaseUrl[], entity, - entitySubgraph, requiredPropertyTypes, depth: depth + 1, propertyRefSchema: subPropertyRefSchema, @@ -153,23 +161,24 @@ export const generatePropertyRowRecursively = ({ } return { - rowId, - title: propertyType.title, - value, - expectedTypes, - isArray, - isSingleUrl, ...minMaxConfig, - required, - depth, children, + depth, indent, + isArray, + isSingleUrl, + permittedDataTypes: expectedTypes, + propertyKeyChain, + required, + rowId, + title: propertyType.title, + value, + valueMetadata, /** * this will be filled by `fillRowIndentCalculations` * this is not filled here, because we'll use the whole flattened tree, * and check some values of prev-next items on the flattened tree while calculating this */ verticalLinesForEachIndent: [], - propertyKeyChain, }; }; diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/use-rows/generate-property-rows-from-entity/get-expected-types-of-property-type.ts b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/use-rows/generate-property-rows-from-entity/get-expected-types-of-property-type.ts index f3b2a17f6f2..b9cc4008b07 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/use-rows/generate-property-rows-from-entity/get-expected-types-of-property-type.ts +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/use-rows/generate-property-rows-from-entity/get-expected-types-of-property-type.ts @@ -1,37 +1,36 @@ import type { + ClosedDataType, DataTypeReference, PropertyType, PropertyValues, } from "@blockprotocol/type-system"; -import type { DataTypeWithMetadata } from "@local/hash-graph-types/ontology"; -import type { Subgraph } from "@local/hash-subgraph"; -import { getDataTypeById } from "@local/hash-subgraph/stdlib"; +import type { ClosedMultiEntityTypesDefinitions } from "@local/hash-graph-types/ontology"; import { isPropertyValueArray } from "../../../../../../../../../lib/typeguards"; const getDataType = ( dataTypeReference: DataTypeReference, - subgraph: Subgraph, + definitions: ClosedMultiEntityTypesDefinitions, ) => { const dataTypeId = dataTypeReference.$ref; - const dataType = getDataTypeById(subgraph, dataTypeId); + const dataType = definitions.dataTypes[dataTypeId]; if (!dataType) { throw new Error(`Could not find data type with id ${dataTypeId}`); } - return dataType.schema; + return dataType; }; const getReferencedDataTypes = ( propertyValues: PropertyValues[], - subgraph: Subgraph, + definitions: ClosedMultiEntityTypesDefinitions, ) => { - const types: DataTypeWithMetadata["schema"][] = []; + const types: ClosedDataType[] = []; for (const value of propertyValues) { if ("$ref" in value) { - types.push(getDataType(value, subgraph)); + types.push(getDataType(value, definitions)); } } @@ -40,13 +39,13 @@ const getReferencedDataTypes = ( export const getExpectedTypesOfPropertyType = ( propertyType: PropertyType, - subgraph: Subgraph, + definitions: ClosedMultiEntityTypesDefinitions, ): { - expectedTypes: DataTypeWithMetadata["schema"][]; + expectedTypes: ClosedDataType[]; isArray: boolean; } => { let isArray = false; - let expectedTypes: DataTypeWithMetadata["schema"][] = []; + let expectedTypes: ClosedDataType[] = []; /** * @todo handle property types with multiple expected values -- H-2257 @@ -62,9 +61,9 @@ export const getExpectedTypesOfPropertyType = ( if (isPropertyValueArray(firstType)) { isArray = true; - expectedTypes = getReferencedDataTypes(firstType.items.oneOf, subgraph); + expectedTypes = getReferencedDataTypes(firstType.items.oneOf, definitions); } else { - expectedTypes = getReferencedDataTypes(propertyType.oneOf, subgraph); + expectedTypes = getReferencedDataTypes(propertyType.oneOf, definitions); } return { diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/use-rows/use-property-rows-from-entity.ts b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/use-rows/use-property-rows-from-entity.ts new file mode 100644 index 00000000000..16892f0179a --- /dev/null +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/use-rows/use-property-rows-from-entity.ts @@ -0,0 +1,54 @@ +import { typedKeys } from "@local/advanced-types/typed-entries"; +import type { BaseUrl } from "@local/hash-graph-types/ontology"; +import { getRoots } from "@local/hash-subgraph/stdlib"; +import { useMemo } from "react"; + +import { useEntityEditor } from "../../../entity-editor-context"; +import type { PropertyRow } from "../types"; +import { generatePropertyRowRecursively } from "./generate-property-rows-from-entity/generate-property-row-recursively"; + +export const usePropertyRowsFromEntity = (): PropertyRow[] => { + const { + entitySubgraph, + closedMultiEntityType, + closedMultiEntityTypesDefinitions, + } = useEntityEditor(); + + return useMemo(() => { + const entity = getRoots(entitySubgraph)[0]!; + + const processedPropertyTypes = new Set(); + + return typedKeys(closedMultiEntityType.properties).flatMap( + (propertyTypeBaseUrl) => { + if (processedPropertyTypes.has(propertyTypeBaseUrl as BaseUrl)) { + return []; + } + + const propertyRefSchema = + closedMultiEntityType.properties[propertyTypeBaseUrl]; + + if (!propertyRefSchema) { + throw new Error(`Property ${propertyTypeBaseUrl} not found`); + } + + processedPropertyTypes.add(propertyTypeBaseUrl as BaseUrl); + + return generatePropertyRowRecursively({ + closedMultiEntityType, + closedMultiEntityTypesDefinitions, + propertyTypeBaseUrl: propertyTypeBaseUrl as BaseUrl, + propertyKeyChain: [propertyTypeBaseUrl as BaseUrl], + entity, + requiredPropertyTypes: + (closedMultiEntityType.required as BaseUrl[] | undefined) ?? [], + propertyRefSchema, + }); + }, + ); + }, [ + entitySubgraph, + closedMultiEntityType, + closedMultiEntityTypesDefinitions, + ]); +}; diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/types-section.tsx b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/types-section.tsx index f33d16cb8ed..d884758e7dc 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/types-section.tsx +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/types-section.tsx @@ -1,25 +1,24 @@ -import { useMutation, useQuery } from "@apollo/client"; -import type { VersionedUrl } from "@blockprotocol/type-system-rs/pkg/type-system"; +import { useQuery } from "@apollo/client"; +import { mustHaveAtLeastOne } from "@blockprotocol/type-system"; +import type { VersionedUrl } from "@blockprotocol/type-system/slim"; import { PlusIcon, TypeCard } from "@hashintel/design-system"; -import { Entity } from "@local/hash-graph-sdk/entity"; -import type { EntityTypeWithMetadata } from "@local/hash-graph-types/ontology"; +import type { Entity } from "@local/hash-graph-sdk/entity"; +import { getDisplayFieldsForClosedEntityType } from "@local/hash-graph-sdk/entity"; import { mapGqlSubgraphFieldsFragmentToSubgraph, zeroedGraphResolveDepths, } from "@local/hash-isomorphic-utils/graph-queries"; import type { EntityTypeRootType } from "@local/hash-subgraph"; import { linkEntityTypeUrl } from "@local/hash-subgraph"; -import { getEntityTypeById, getRoots } from "@local/hash-subgraph/stdlib"; +import { getRoots } from "@local/hash-subgraph/stdlib"; +import { componentsFromVersionedUrl } from "@local/hash-subgraph/type-system-patch"; import { Box, Stack } from "@mui/material"; import { useMemo, useState } from "react"; import type { QueryEntityTypesQuery, QueryEntityTypesQueryVariables, - UpdateEntityMutation, - UpdateEntityMutationVariables, } from "../../../../../graphql/api-types.gen"; -import { updateEntityMutation } from "../../../../../graphql/queries/knowledge/entity.queries"; import { queryEntityTypesQuery } from "../../../../../graphql/queries/ontology/entity-type.queries"; import { Button } from "../../../../../shared/ui/button"; import { Link } from "../../../../../shared/ui/link"; @@ -29,135 +28,117 @@ import { SectionWrapper } from "../../../../shared/section-wrapper"; import { useEntityEditor } from "./entity-editor-context"; import type { EntityTypeChangeDetails } from "./types-section/entity-type-change-modal"; import { EntityTypeChangeModal } from "./types-section/entity-type-change-modal"; +import { useGetTypeChangeDetails } from "./types-section/use-get-type-change-details"; + +type MinimalTypeData = { + entityTypeId: VersionedUrl; + entityTypeTitle: string; + icon?: string; + isLink: boolean; + version: number; +}; export const TypeButton = ({ + allowDelete, entity, currentEntityType, newerEntityType, }: { + allowDelete: boolean; entity: Entity; - currentEntityType: EntityTypeWithMetadata; - newerEntityType?: EntityTypeWithMetadata; + currentEntityType: MinimalTypeData; + newerEntityType?: Pick; }) => { - const { disableTypeClick, onEntityUpdated, readonly } = useEntityEditor(); + const { disableTypeClick, readonly, setEntityTypes } = useEntityEditor(); - const newVersion = newerEntityType?.metadata.recordId.version; + const newVersion = newerEntityType?.version; const [changeDetails, setChangeDetails] = useState(null); const [updatingTypes, setUpdatingTypes] = useState(false); - const [updateEntity] = useMutation< - UpdateEntityMutation, - UpdateEntityMutationVariables - >(updateEntityMutation); + const getTypeDetails = useGetTypeChangeDetails(); const handleUpdateTypes = async (newEntityTypeIds: VersionedUrl[]) => { try { - setUpdatingTypes(true); - - const res = await updateEntity({ - variables: { - entityUpdate: { - entityTypeIds: newEntityTypeIds, - entityId: entity.metadata.recordId.entityId, - propertyPatches: [], - }, - }, - }); - - if (res.data) { - onEntityUpdated?.(new Entity(res.data.updateEntity)); - } - } finally { + await setEntityTypes(mustHaveAtLeastOne(newEntityTypeIds)); setChangeDetails(null); + } finally { setUpdatingTypes(false); } }; - const onUpgradeClicked = () => { + const onUpgradeClicked = async () => { if (!newerEntityType) { throw new Error(`No newer entity type to upgrade to`); } const newEntityTypeIds = entity.metadata.entityTypeIds.map( (entityTypeId) => { - if (entityTypeId === currentEntityType.schema.$id) { - return newerEntityType.schema.$id; + if (entityTypeId === currentEntityType.entityTypeId) { + return newerEntityType.entityTypeId; } return entityTypeId; }, ); + const { linkChanges, propertyChanges } = + await getTypeDetails(newEntityTypeIds); + setChangeDetails({ onAccept: () => handleUpdateTypes(newEntityTypeIds), proposedChange: { type: "Update", - entityTypeTitle: currentEntityType.schema.title, - currentVersion: currentEntityType.metadata.recordId.version, - newVersion: newerEntityType.metadata.recordId.version, + entityTypeTitle: currentEntityType.entityTypeTitle, + currentVersion: currentEntityType.version, + newVersion: newerEntityType.version, }, - /** - * @todo H-3408: Calculate and show property/link changes when upgrading entity types - */ - linkChanges: [], - propertyChanges: [], + linkChanges, + propertyChanges, }); }; - const onDeleteClicked = () => { + const onDeleteClicked = async () => { const newEntityTypeIds = entity.metadata.entityTypeIds.filter( - (entityTypeId) => entityTypeId !== currentEntityType.schema.$id, + (entityTypeId) => entityTypeId !== currentEntityType.entityTypeId, ); + const { linkChanges, propertyChanges } = + await getTypeDetails(newEntityTypeIds); + setChangeDetails({ onAccept: () => handleUpdateTypes(newEntityTypeIds), proposedChange: { type: "Remove", - entityTypeTitle: currentEntityType.schema.title, - currentVersion: currentEntityType.metadata.recordId.version, + entityTypeTitle: currentEntityType.entityTypeTitle, + currentVersion: currentEntityType.version, }, - /** - * @todo H-3408: Calculate and show property/link changes when removing entity types - */ - linkChanges: [], - propertyChanges: [], + linkChanges, + propertyChanges, }); }; const closeModal = () => setChangeDetails(null); - const entityTypeId = currentEntityType.schema.$id; - const entityTypeTitle = currentEntityType.schema.title; - const currentVersion = currentEntityType.metadata.recordId.version; - - /** - * @todo H-3379 bring changes to types into the same 'local draft changes' system as properties/links changes, - * which enables the user to make type changes before the entity is persisted to the db. - */ - const isNotYetInDb = entity.metadata.recordId.entityId.includes("draft"); - const canChangeTypes = !readonly && !isNotYetInDb; - - const isLink = !!currentEntityType.schema.allOf?.some( - (allOf) => allOf.$ref === linkEntityTypeUrl, - ); + const entityTypeId = currentEntityType.entityTypeId; + const entityTypeTitle = currentEntityType.entityTypeTitle; + const currentVersion = currentEntityType.version; - /** @todo H-3363 take account of inherited icons */ return ( <> { - const { entitySubgraph, readonly, onEntityUpdated } = useEntityEditor(); + const { entitySubgraph, closedMultiEntityType, readonly, setEntityTypes } = + useEntityEditor(); const entity = getRoots(entitySubgraph)[0]!; - const { - metadata: { entityTypeIds }, - } = entity; - const { data: latestEntityTypesData } = useQuery< QueryEntityTypesQuery, QueryEntityTypesQueryVariables @@ -206,8 +184,8 @@ export const TypesSection = () => { const entityTypes = useMemo< { - currentEntityType: EntityTypeWithMetadata; - newerEntityType?: EntityTypeWithMetadata; + currentEntityType: MinimalTypeData; + newerEntityType?: Pick; }[] >(() => { const typedSubgraph = latestEntityTypesData @@ -220,63 +198,58 @@ export const TypesSection = () => { ? getRoots(typedSubgraph) : []; - return entityTypeIds.map((entityTypeId) => { - const currentEntityType = getEntityTypeById(entitySubgraph, entityTypeId); - - if (!currentEntityType) { - throw new Error( - `Could not find entity type with id ${entityTypeId} in subgraph`, - ); - } + return closedMultiEntityType.allOf.map((currentTypeMetadata) => { + const { baseUrl, version } = componentsFromVersionedUrl( + currentTypeMetadata.$id, + ); const newerEntityType = latestEntityTypes.find( (type) => - type.metadata.recordId.baseUrl === - currentEntityType.metadata.recordId.baseUrl && - type.metadata.recordId.version > - currentEntityType.metadata.recordId.version, + type.metadata.recordId.baseUrl === baseUrl && + type.metadata.recordId.version > version, ); - return { currentEntityType, newerEntityType }; + const { icon } = getDisplayFieldsForClosedEntityType(currentTypeMetadata); + + const currentEntityType: MinimalTypeData = { + entityTypeId: currentTypeMetadata.$id, + entityTypeTitle: currentTypeMetadata.title, + icon, + isLink: !!currentTypeMetadata.allOf?.some( + (parent) => parent.$id === linkEntityTypeUrl, + ), + version, + }; + + return { + currentEntityType, + newerEntityType: newerEntityType + ? { + entityTypeId: newerEntityType.schema.$id, + version: newerEntityType.metadata.recordId.version, + } + : undefined, + }; }); - }, [entitySubgraph, entityTypeIds, latestEntityTypesData]); - - const [updateEntity] = useMutation< - UpdateEntityMutation, - UpdateEntityMutationVariables - >(updateEntityMutation); + }, [closedMultiEntityType, latestEntityTypesData]); const [addingType, setAddingType] = useState(false); const onNewTypeSelected = async (entityTypeId: VersionedUrl) => { + const newEntityTypeIds = mustHaveAtLeastOne([ + ...entity.metadata.entityTypeIds, + entityTypeId, + ]); + try { setAddingType(true); - const res = await updateEntity({ - variables: { - entityUpdate: { - entityTypeIds: [...entityTypeIds, entityTypeId], - entityId: entity.metadata.recordId.entityId, - propertyPatches: [], - }, - }, - }); - - if (res.data) { - onEntityUpdated?.(new Entity(res.data.updateEntity)); - } + await setEntityTypes(newEntityTypeIds); } finally { setAddingType(false); } }; - /** - * @todo H-3379 bring changes to types into the same 'local draft changes' system as properties/links changes, - * which enables the user to make type changes before the entity is persisted to the db. - */ - const isNotYetInDb = entity.metadata.recordId.entityId.includes("draft"); - const canChangeTypes = !readonly && !isNotYetInDb; - return ( { {entityTypes.map(({ currentEntityType, newerEntityType }) => ( ))} - {canChangeTypes && + {!readonly && (addingType ? ( { ...entityTypes.flatMap( ({ currentEntityType, newerEntityType }) => [ - currentEntityType.schema.$id, - newerEntityType?.schema.$id ?? [], + currentEntityType.entityTypeId, + newerEntityType?.entityTypeId ?? [], ].flat(), ), ...nonAssignableTypes, diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/types-section/entity-type-change-modal.tsx b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/types-section/entity-type-change-modal.tsx index 97899a97a67..ac0f96915a3 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/types-section/entity-type-change-modal.tsx +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/types-section/entity-type-change-modal.tsx @@ -1,6 +1,16 @@ import { faArrowRight } from "@fortawesome/free-solid-svg-icons"; import { AlertModal, FontAwesomeIcon } from "@hashintel/design-system"; -import { Box, Stack, Typography } from "@mui/material"; +import { + Box, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Tooltip, + Typography, +} from "@mui/material"; import { useMemo } from "react"; export type EntityTypeChangeDetails = { @@ -20,42 +30,49 @@ export type EntityTypeChangeDetails = { linkChanges: { linkTitle: string; change: - | "Added" - | "Removed" + | "Added (optional)" + | "Added (required)" | "Now required" - | "Now array" - | "No longer array" - | "Target changed"; - blocking: boolean; + | "Min items changed" + | "Max items changed" + | "Removed" + | "Target type(s) changed"; }[]; propertyChanges: { propertyTitle: string; change: - | "Added" - | "Removed" + | "Added (optional)" + | "Added (required)" + | "No longer a list" + | "Now a list" | "Now required" - | "Type changed" + | "Min items changed" | "Max items changed" - | "Min items changed"; - blocking: boolean; + | "Removed" + | "Value type changed"; }[]; }; type ChangeSummary = { - blockCount: number; shouldWarn: boolean; totalChangeCount: number; linkChangeCount: { added: number; changedDestination: number; - madeRequired: number; + minItemsChanged: number; + maxItemsChanged: number; + nowRequired: number; removed: number; }; propertyChangeCount: { added: number; - changedType: number; - madeRequired: number; + minItemsChanged: number; + maxItemsChanged: number; + noLongerList: number; + nowList: number; + nowRequired: number; removed: number; + typeChanged: number; }; }; @@ -64,11 +81,8 @@ const generateCountString = (count: number, type: "property" | "link") => ? `${count} ${type === "property" ? "properties" : "links"}` : `${count} ${type}`; -// @todo H-3408 – use this when changes are calculated by the parent component -// eslint-disable-next-line @typescript-eslint/no-unused-vars const CalloutMessage = ({ changeSummary: { - blockCount, shouldWarn, totalChangeCount, linkChangeCount, @@ -79,37 +93,72 @@ const CalloutMessage = ({ changeSummary: ChangeSummary; type: "Update" | "Remove"; }) => { - const prefix = `This ${type === "Update" ? "update" : "removal"} `; + const prefix = `This `; const messages: string[] = []; if (totalChangeCount === 0) { messages.push( - "does not make any changes to properties or links for the entity.", + "does not make any changes to properties or links for the entity", ); } else { { - const { added, madeRequired, changedType, removed } = propertyChangeCount; + const { + added, + minItemsChanged, + maxItemsChanged, + noLongerList, + nowRequired, + nowList, + typeChanged, + removed, + } = propertyChangeCount; if (added > 0) { messages.push(`adds ${generateCountString(added, "property")}`); } if (removed > 0) { messages.push(`removes ${generateCountString(removed, "property")}`); } - if (changedType > 0) { + if (typeChanged > 0) { + messages.push( + `changes the expected type of ${generateCountString(typeChanged, "property")}`, + ); + } + if (nowRequired > 0) { + messages.push( + `makes ${generateCountString(nowRequired, "property")} required`, + ); + } + if (nowList > 0) { + messages.push( + `makes ${generateCountString(nowList, "property")} a list`, + ); + } + if (noLongerList > 0) { + messages.push( + `makes ${generateCountString(noLongerList, "property")} no longer a list`, + ); + } + if (minItemsChanged > 0) { messages.push( - `changes the type of ${generateCountString(changedType, "property")}`, + `changes the minimum number of items for ${generateCountString(minItemsChanged, "property")}`, ); } - if (madeRequired > 0) { + if (maxItemsChanged > 0) { messages.push( - `makes ${generateCountString(madeRequired, "property")} required`, + `changes the maximum number of items for ${generateCountString(maxItemsChanged, "property")}`, ); } } { - const { added, madeRequired, changedDestination, removed } = - linkChangeCount; + const { + added, + minItemsChanged, + maxItemsChanged, + nowRequired, + changedDestination, + removed, + } = linkChangeCount; if (added > 0) { messages.push(`adds ${generateCountString(added, "link")}`); } @@ -121,15 +170,25 @@ const CalloutMessage = ({ `changes the target of ${generateCountString(changedDestination, "link")}`, ); } - if (madeRequired > 0) { + if (nowRequired > 0) { + messages.push( + `makes ${generateCountString(nowRequired, "link")} required`, + ); + } + if (minItemsChanged > 0) { + messages.push( + `changes the minimum number of items for ${generateCountString(minItemsChanged, "link")}`, + ); + } + if (maxItemsChanged > 0) { messages.push( - `makes ${generateCountString(madeRequired, "link")} required`, + `changes the maximum number of items for ${generateCountString(maxItemsChanged, "link")}`, ); } } } - const detail = + const detail = `${ prefix + messages.reduce((acc, message, index) => { if (index === 0) { @@ -139,7 +198,8 @@ const CalloutMessage = ({ return `${acc} and ${message}`; } return `${acc}, ${message}`; - }, ""); + }, "") + }.`; return ( - blockCount > 0 - ? palette.red[70] - : shouldWarn - ? palette.yellow[80] - : palette.blue[70], + shouldWarn ? palette.yellow[80] : palette.blue[70], }} > - {blockCount > 0 - ? `${blockCount} blocking conflicts` - : type === "Update" - ? "Update available" - : "Removal available"} + {type === "Update" ? "Update type" : "Removing type"} {". "} {detail} @@ -179,11 +231,21 @@ const ModalHeader = ({ proposedChange: EntityTypeChangeDetails["proposedChange"]; }) => { return ( - + palette.gray[80] }} + > {proposedChange.type} - {proposedChange.entityTypeTitle} + + {" "} + {proposedChange.entityTypeTitle} + {" entity type "} - v{proposedChange.currentVersion} + + v{proposedChange.currentVersion} + {proposedChange.type === "Update" && ( <> @@ -211,63 +273,83 @@ export const EntityTypeChangeModal = ({ propertyChanges, open, }: EntityTypeChangeModalProps) => { - /** - * @todo H-3408 – use this summary to show a table of changes, which are blocking, etc. - */ const changeSummary = useMemo(() => { const summary: ChangeSummary = { - blockCount: 0, shouldWarn: false, totalChangeCount: linkChanges.length + propertyChanges.length, linkChangeCount: { added: 0, changedDestination: 0, - madeRequired: 0, + minItemsChanged: 0, + maxItemsChanged: 0, + nowRequired: 0, removed: 0, }, propertyChangeCount: { added: 0, - changedType: 0, - madeRequired: 0, + minItemsChanged: 0, + maxItemsChanged: 0, + noLongerList: 0, + nowList: 0, + nowRequired: 0, + typeChanged: 0, removed: 0, }, }; for (const propertyChange of propertyChanges) { - if (propertyChange.blocking) { - summary.blockCount++; - } - if (propertyChange.change === "Added") { + if ( + propertyChange.change === "Added (optional)" || + propertyChange.change === "Added (required)" + ) { summary.propertyChangeCount.added++; } + if (propertyChange.change === "Now required") { + summary.propertyChangeCount.nowRequired++; + } + if (propertyChange.change === "Min items changed") { + summary.propertyChangeCount.minItemsChanged++; + } + if (propertyChange.change === "Max items changed") { + summary.propertyChangeCount.maxItemsChanged++; + } + if (propertyChange.change === "Now a list") { + summary.propertyChangeCount.nowList++; + } + if (propertyChange.change === "No longer a list") { + summary.propertyChangeCount.noLongerList++; + } if (propertyChange.change === "Removed") { summary.propertyChangeCount.removed++; summary.shouldWarn = true; } - if (propertyChange.change === "Now required") { - summary.propertyChangeCount.madeRequired++; - } - if (propertyChange.change === "Type changed") { - summary.propertyChangeCount.changedType++; + if (propertyChange.change === "Value type changed") { + summary.propertyChangeCount.typeChanged++; summary.shouldWarn = true; } } for (const linkChange of linkChanges) { - if (linkChange.blocking) { - summary.blockCount++; - } - if (linkChange.change === "Added") { + if ( + linkChange.change === "Added (optional)" || + linkChange.change === "Added (required)" + ) { summary.linkChangeCount.added++; } + if (linkChange.change === "Min items changed") { + summary.linkChangeCount.minItemsChanged++; + } + if (linkChange.change === "Max items changed") { + summary.linkChangeCount.maxItemsChanged++; + } + if (linkChange.change === "Now required") { + summary.linkChangeCount.nowRequired++; + } if (linkChange.change === "Removed") { summary.linkChangeCount.removed++; summary.shouldWarn = true; } - if (linkChange.change === "Now required") { - summary.linkChangeCount.madeRequired++; - } - if (linkChange.change === "Target changed") { + if (linkChange.change === "Target type(s) changed") { summary.linkChangeCount.changedDestination++; summary.shouldWarn = true; } @@ -276,32 +358,108 @@ export const EntityTypeChangeModal = ({ return summary; }, [linkChanges, propertyChanges]); - const { blockCount, shouldWarn } = changeSummary; + const { shouldWarn } = changeSummary; + + const propertyChangesByTitle = Object.groupBy( + propertyChanges, + (change) => change.propertyTitle, + ); + + const linkChangseByTitle = Object.groupBy( + linkChanges, + (change) => change.linkTitle, + ); return ( Proceed with change? - // @todo H-3408 – uncomment this when changes are calculated by the parent component - // + } close={onReject} confirmButtonText={`${proposedChange.type} entity type`} header={} open={open} processing={changeIsProcessing} - type={blockCount > 0 ? "error" : shouldWarn ? "warning" : "info"} + type={shouldWarn ? "warning" : "info"} > - {/* @todo H-3408 – replace this with a proper list of changes */} - - Updating the type an entity is assigned to may cause property values to - be removed, if properties have been removed from the type or if their - expected values have changed to be incompatible with existing data. - + {changeSummary.totalChangeCount !== 0 && ( + ({ + background: palette.gray[10], + border: `1px solid ${palette.gray[30]}`, + borderRadius: 2, + borderCollapse: "separate", + th: { + background: palette.gray[20], + fontWeight: 600, + fontSize: 11, + textTransform: "uppercase", + }, + td: { + fontWeight: 500, + fontSize: 14, + }, + "& th, td": { + color: palette.gray[80], + lineHeight: 1, + px: 1.5, + py: 1.5, + }, + "& tr:last-child td:first-of-type": { + borderBottomLeftRadius: 8, + }, + "& tr:last-of-type td:last-of-type": { + borderBottomRightRadius: 8, + }, + "& tbody tr:last-of-type td": { + pb: 2.25, + }, + "& tbody tr:first-of-type td": { + pt: 2.25, + }, + })} + > + + + # + Name + Change + + + + {Object.entries(propertyChangesByTitle) + .sort(([aTitle], [bTitle]) => aTitle.localeCompare(bTitle)) + .map(([propertyTitle, changes = []], index) => ( + + {index + 1} + {propertyTitle} + + {changes.length > 1 ? "Multiple" : changes[0]!.change} + + + ))} + {Object.entries(linkChangseByTitle).map( + ([linkTitle, changes = []], index) => ( + + #{index + 1 + propertyChanges.length} + {linkTitle} + 1 ? Multiple : ""} + > + + {changes.length > 1 ? "Multiple" : changes[0]!.change} + + + + ), + )} + +
+ )}
); }; diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/types-section/use-get-type-change-details.ts b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/types-section/use-get-type-change-details.ts new file mode 100644 index 00000000000..4d700cf9c09 --- /dev/null +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/types-section/use-get-type-change-details.ts @@ -0,0 +1,254 @@ +import type { VersionedUrl } from "@blockprotocol/type-system/slim"; +import { typedEntries, typedKeys } from "@local/advanced-types/typed-entries"; +import { getPropertyTypeForClosedEntityType } from "@local/hash-graph-sdk/entity"; +import { getRoots } from "@local/hash-subgraph/stdlib"; +import { useCallback } from "react"; + +import { useGetClosedMultiEntityType } from "../../shared/use-get-closed-multi-entity-type"; +import { useEntityEditor } from "../entity-editor-context"; +import type { EntityTypeChangeDetails } from "./entity-type-change-modal"; + +export const useGetTypeChangeDetails = () => { + const { getClosedMultiEntityType } = useGetClosedMultiEntityType(); + + const { + closedMultiEntityType: currentClosedType, + closedMultiEntityTypesDefinitions: currentDefinitions, + entitySubgraph, + } = useEntityEditor(); + + return useCallback( + async ( + newEntityTypeIds: VersionedUrl[], + ): Promise< + Pick + > => { + const entity = getRoots(entitySubgraph)[0]; + + if (!entity) { + throw new Error("No entity found in entitySubgraph"); + } + + const { + closedMultiEntityType: proposedClosedMultiType, + closedMultiEntityTypesDefinitions: proposedDefinitions, + } = await getClosedMultiEntityType(newEntityTypeIds); + + const changeDetails: Pick< + EntityTypeChangeDetails, + "linkChanges" | "propertyChanges" + > = { + linkChanges: [], + propertyChanges: [], + }; + + for (const [newPropertyBaseUrl, schema] of typedEntries( + proposedClosedMultiType.properties, + )) { + const newDetails = getPropertyTypeForClosedEntityType({ + closedMultiEntityType: proposedClosedMultiType, + definitions: proposedDefinitions, + propertyTypeBaseUrl: newPropertyBaseUrl, + }); + + const required = + proposedClosedMultiType.required?.includes(newPropertyBaseUrl); + const newListSchema = "items" in schema ? schema : undefined; + + const propertyTitle = newDetails.propertyType.title; + + if (!currentClosedType.properties[newPropertyBaseUrl]) { + changeDetails.propertyChanges.push({ + change: required ? "Added (required)" : "Added (optional)", + propertyTitle, + }); + continue; + } + + const existingDetails = getPropertyTypeForClosedEntityType({ + closedMultiEntityType: currentClosedType, + definitions: currentDefinitions, + propertyTypeBaseUrl: newPropertyBaseUrl, + }); + + const wasRequired = + currentClosedType.required?.includes(newPropertyBaseUrl); + + if (required && !wasRequired) { + changeDetails.propertyChanges.push({ + change: "Now required", + propertyTitle, + }); + } + + const oldListSchema = + "items" in existingDetails.schema + ? existingDetails.schema + : undefined; + + if (oldListSchema && !newListSchema) { + changeDetails.propertyChanges.push({ + change: "No longer a list", + propertyTitle, + }); + } + + if (!oldListSchema && newListSchema) { + changeDetails.propertyChanges.push({ + change: "Now a list", + propertyTitle, + }); + } + + if (oldListSchema && newListSchema) { + if (oldListSchema.minItems !== newListSchema.minItems) { + changeDetails.propertyChanges.push({ + change: "Min items changed", + propertyTitle, + }); + } + + if (oldListSchema.maxItems !== newListSchema.maxItems) { + changeDetails.propertyChanges.push({ + change: "Max items changed", + propertyTitle, + }); + } + } + + const newExpectedValues = newDetails.propertyType.oneOf; + const oldExpectedValues = existingDetails.propertyType.oneOf; + + if (newExpectedValues.length !== oldExpectedValues.length) { + changeDetails.propertyChanges.push({ + change: "Value type changed", + propertyTitle, + }); + continue; + } + + for (const newValueOption of newExpectedValues) { + const matchingOldOption = oldExpectedValues.some((oldOption) => { + if ("$ref" in newValueOption) { + return ( + "$ref" in oldOption && newValueOption.$ref === oldOption.$ref + ); + } + + if ("items" in newValueOption) { + return ( + JSON.stringify(newValueOption) === JSON.stringify(oldOption) + ); + } + + return false; + }); + + if (!matchingOldOption) { + changeDetails.propertyChanges.push({ + change: "Value type changed", + propertyTitle, + }); + } + + // @todo handle expected values of arrays and objects + } + } + + for (const oldPropertyBaseUrl of typedKeys( + currentClosedType.properties, + )) { + if (!proposedClosedMultiType.properties[oldPropertyBaseUrl]) { + const oldDetails = getPropertyTypeForClosedEntityType({ + closedMultiEntityType: currentClosedType, + definitions: currentDefinitions, + propertyTypeBaseUrl: oldPropertyBaseUrl, + }); + + const propertyTitle = oldDetails.propertyType.title; + + changeDetails.propertyChanges.push({ + change: "Removed", + propertyTitle, + }); + } + } + + for (const [newLinkTypeId, newLinkSchema] of typedEntries( + proposedClosedMultiType.links ?? {}, + )) { + const currentLinks = currentClosedType.links ?? {}; + + const minimalLinkType = proposedDefinitions.entityTypes[newLinkTypeId]; + + if (!minimalLinkType) { + throw new Error(`Minimal link type not found for ${newLinkTypeId}`); + } + + const oldLinkSchema = currentLinks[newLinkTypeId]; + + const newMinItems = newLinkSchema.minItems ?? 0; + const newMaxItems = newLinkSchema.maxItems ?? 0; + const oldMinItems = oldLinkSchema?.minItems ?? 0; + const oldMaxItems = oldLinkSchema?.maxItems ?? 0; + + if (!oldLinkSchema) { + changeDetails.linkChanges.push({ + change: newMinItems > 0 ? "Added (required)" : "Added (optional)", + linkTitle: minimalLinkType.title, + }); + continue; + } + + if (oldMinItems === 0 && newMinItems > 0) { + changeDetails.linkChanges.push({ + change: "Now required", + linkTitle: minimalLinkType.title, + }); + } else if (oldMinItems !== newMinItems) { + changeDetails.linkChanges.push({ + change: "Min items changed", + linkTitle: minimalLinkType.title, + }); + } + + if (oldMaxItems !== newMaxItems) { + changeDetails.linkChanges.push({ + change: "Max items changed", + linkTitle: minimalLinkType.title, + }); + } + + const oldTargets = + "oneOf" in oldLinkSchema.items + ? new Set(oldLinkSchema.items.oneOf.map((item) => item.$ref)) + : new Set(); + + const newTargets = + "oneOf" in newLinkSchema.items + ? new Set(newLinkSchema.items.oneOf.map((item) => item.$ref)) + : new Set(); + + if ( + !( + oldTargets.size === newTargets.size && + oldTargets.isSupersetOf(newTargets) + ) + ) { + changeDetails.linkChanges.push({ + change: "Target type(s) changed", + linkTitle: minimalLinkType.title, + }); + } + } + + return changeDetails; + }, + [ + currentClosedType, + currentDefinitions, + entitySubgraph, + getClosedMultiEntityType, + ], + ); +}; diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/shared/properties-section-empty-state.tsx b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/shared/properties-section-empty-state.tsx index f24f3c098a7..7f622ca585b 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/shared/properties-section-empty-state.tsx +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/shared/properties-section-empty-state.tsx @@ -8,7 +8,12 @@ export const PropertiesSectionEmptyState = () => { return ( } + titleStartContent={ + + } > , +) => { + /** + * @todo - This is a problem, subgraphs should probably be immutable, there will be a new identifier + * for the updated entity. This version will not match the one returned by the data store. + * For places where we mutate elements, we should probably store them separately from the subgraph to + * allow for optimistic updates without being incorrect. + */ + const metadata = JSON.parse( + JSON.stringify(entity.metadata), + ) as EntityMetadata; + metadata.entityTypeIds = entityTypeIds; + + const newEntityRevisionId = new Date().toISOString() as EntityRevisionId; + metadata.temporalVersioning.decisionTime.start.limit = newEntityRevisionId; + metadata.temporalVersioning.transactionTime.start.limit = newEntityRevisionId; + + const newEntity = new Entity({ + ...entity.toJSON(), + metadata, + } as SerializedEntity); + + return { + ...currentSubgraph, + roots: [ + { + baseId: newEntity.metadata.recordId.entityId, + revisionId: newEntityRevisionId, + }, + ], + vertices: { + ...currentSubgraph.vertices, + [newEntity.metadata.recordId.entityId]: { + ...currentSubgraph.vertices[newEntity.metadata.recordId.entityId], + [newEntityRevisionId]: { + kind: "entity", + inner: newEntity, + }, + }, + }, + }; +}; diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/shared/update-entity-subgraph-state-by-entity.ts b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/shared/update-entity-subgraph-state-by-entity.ts deleted file mode 100644 index d9f53c9393a..00000000000 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/shared/update-entity-subgraph-state-by-entity.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { SerializedEntity } from "@local/hash-graph-sdk/entity"; -import { Entity } from "@local/hash-graph-sdk/entity"; -import type { - EntityRevisionId, - EntityRootType, - Subgraph, -} from "@local/hash-subgraph"; -import type { Dispatch, SetStateAction } from "react"; - -export const updateEntitySubgraphStateByEntity = ( - entity: Entity, - setStateAction: Dispatch< - SetStateAction | undefined> - >, -) => - setStateAction((subgraph) => { - /** - * @todo - This is a problem, subgraphs should probably be immutable, there will be a new identifier - * for the updated entity. This version will not match the one returned by the data store. - * For places where we mutate elements, we should probably store them separately from the subgraph to - * allow for optimistic updates without being incorrect. - */ - const metadata = JSON.parse(JSON.stringify(entity.metadata)); - const newEntityRevisionId = new Date().toISOString() as EntityRevisionId; - metadata.temporalVersioning.decisionTime.start.limit = newEntityRevisionId; - metadata.temporalVersioning.transactionTime.start.limit = - newEntityRevisionId; - const newEntity = new Entity({ - ...entity.toJSON(), - metadata, - } as SerializedEntity); - - return subgraph - ? { - ...subgraph, - roots: [ - { - baseId: newEntity.metadata.recordId.entityId, - revisionId: newEntityRevisionId, - }, - ], - vertices: { - ...subgraph.vertices, - [newEntity.metadata.recordId.entityId]: { - ...subgraph.vertices[newEntity.metadata.recordId.entityId], - [newEntityRevisionId]: { - kind: "entity", - inner: entity, - }, - }, - }, - } - : undefined; - }); diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/shared/use-draft-entity-subgraph.ts b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/shared/use-draft-entity-subgraph.ts deleted file mode 100644 index d11eb388df1..00000000000 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/shared/use-draft-entity-subgraph.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { VersionedUrl } from "@blockprotocol/type-system"; -import type { Entity as GraphApiEntity } from "@local/hash-graph-client"; -import { Entity } from "@local/hash-graph-sdk/entity"; -import type { EntityId } from "@local/hash-graph-types/entity"; -import type { Timestamp } from "@local/hash-graph-types/temporal-versioning"; -import type { - EntityRevisionId, - EntityRootType, - EntityVertexId, - Subgraph, -} from "@local/hash-subgraph"; -import { extractOwnedByIdFromEntityId } from "@local/hash-subgraph"; -import type { Dispatch, SetStateAction } from "react"; -import { useEffect, useState } from "react"; - -import { useBlockProtocolGetEntityType } from "../../../../../components/hooks/block-protocol-functions/ontology/use-block-protocol-get-entity-type"; - -export const useDraftEntitySubgraph = ( - entityTypeId: VersionedUrl, -): [ - Subgraph | undefined, - Dispatch | undefined>>, - boolean, -] => { - const [loading, setLoading] = useState(true); - const [draftEntitySubgraph, setDraftEntitySubgraph] = - useState>(); - - const { getEntityType } = useBlockProtocolGetEntityType(); - - useEffect(() => { - const init = async () => { - try { - const { data: subgraph } = await getEntityType({ - data: { - entityTypeId, - graphResolveDepths: { - constrainsValuesOn: { outgoing: 255 }, - constrainsLinksOn: { outgoing: 255 }, - constrainsLinkDestinationsOn: { outgoing: 255 }, - constrainsPropertiesOn: { outgoing: 255 }, - }, - }, - }); - - if (!subgraph) { - throw new Error("subgraph not found"); - } - - const now = new Date().toISOString() as Timestamp; - - const draftEntityVertexId: EntityVertexId = { - baseId: "draft~draft" as EntityId, - revisionId: now as EntityRevisionId, - }; - const creator = extractOwnedByIdFromEntityId( - draftEntityVertexId.baseId, - ); - - setDraftEntitySubgraph({ - ...subgraph, - roots: [draftEntityVertexId], - vertices: { - ...subgraph.vertices, - [draftEntityVertexId.baseId]: { - [draftEntityVertexId.revisionId]: { - kind: "entity", - inner: new Entity({ - properties: {}, - metadata: { - recordId: { - entityId: draftEntityVertexId.baseId, - editionId: now, - }, - entityTypeIds: [entityTypeId], - temporalVersioning: { - decisionTime: { - start: { - kind: "inclusive", - limit: now, - }, - end: { - kind: "unbounded", - }, - }, - transactionTime: { - start: { - kind: "inclusive", - limit: now, - }, - end: { - kind: "unbounded", - }, - }, - }, - archived: false, - provenance: { - createdAtDecisionTime: now, - createdAtTransactionTime: now, - createdById: creator, - edition: { - createdById: creator, - }, - }, - }, - } satisfies GraphApiEntity), - }, - }, - }, - }); - } finally { - setLoading(false); - } - }; - - void init(); - }, [entityTypeId, getEntityType]); - - return [draftEntitySubgraph, setDraftEntitySubgraph, loading]; -}; diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/shared/use-draft-link-state.ts b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/shared/use-draft-link-state.ts index 6e25ed30a9f..6ef6a594cb8 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/shared/use-draft-link-state.ts +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/shared/use-draft-link-state.ts @@ -1,12 +1,12 @@ import type { EntityId } from "@local/hash-graph-types/entity"; -import type { EntityRootType, Subgraph } from "@local/hash-subgraph"; import type { Dispatch, SetStateAction } from "react"; import { useState } from "react"; import type { LinkAndTargetEntity } from "../entity-editor/links-section/outgoing-links-section/types"; export type DraftLinksToCreate = (LinkAndTargetEntity & { - sourceSubgraph: Subgraph | null; + rightEntityLabel: string; + linkEntityLabel: string; })[]; export type DraftLinksToArchive = EntityId[]; diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/shared/use-get-closed-multi-entity-type.ts b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/shared/use-get-closed-multi-entity-type.ts new file mode 100644 index 00000000000..d69fc40fe07 --- /dev/null +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/shared/use-get-closed-multi-entity-type.ts @@ -0,0 +1,50 @@ +import { useLazyQuery } from "@apollo/client"; +import type { VersionedUrl } from "@blockprotocol/type-system/slim"; +import { useCallback } from "react"; + +import type { + GetClosedMultiEntityTypeQuery, + GetClosedMultiEntityTypeQueryVariables, +} from "../../../../../graphql/api-types.gen"; +import { getClosedMultiEntityTypeQuery } from "../../../../../graphql/queries/ontology/entity-type.queries"; + +export const useGetClosedMultiEntityType = () => { + const [getMultiEntityType, { loading }] = useLazyQuery< + GetClosedMultiEntityTypeQuery, + GetClosedMultiEntityTypeQueryVariables + >(getClosedMultiEntityTypeQuery, { + fetchPolicy: "cache-first", + }); + + const getClosedMultiEntityType = useCallback( + async (newEntityTypeIds: VersionedUrl[]) => { + const response = await getMultiEntityType({ + variables: { + entityTypeIds: newEntityTypeIds, + includeArchived: false, + includeDrafts: false, + }, + }); + + if (!response.data) { + throw new Error( + `Failed to fetch closedMultiEntityType for ids ${[...newEntityTypeIds].join(", ")}`, + ); + } + + const { closedMultiEntityType, definitions } = + response.data.getClosedMultiEntityType; + + return { + closedMultiEntityType, + closedMultiEntityTypesDefinitions: definitions, + }; + }, + [getMultiEntityType], + ); + + return { + getClosedMultiEntityType, + loading, + }; +}; diff --git a/apps/hash-frontend/src/pages/[shortname]/shared/flow-visualizer/outputs/entity-result-graph.tsx b/apps/hash-frontend/src/pages/[shortname]/shared/flow-visualizer/outputs/entity-result-graph.tsx index 5e1b553a00b..d233c91b6bc 100644 --- a/apps/hash-frontend/src/pages/[shortname]/shared/flow-visualizer/outputs/entity-result-graph.tsx +++ b/apps/hash-frontend/src/pages/[shortname]/shared/flow-visualizer/outputs/entity-result-graph.tsx @@ -1,4 +1,4 @@ -import type { VersionedUrl } from "@blockprotocol/type-system-rs/pkg/type-system"; +import type { VersionedUrl } from "@blockprotocol/type-system/slim"; import type { EntityForGraphChart } from "@hashintel/block-design-system"; import { LoadingSpinner } from "@hashintel/design-system"; import type { EntityId } from "@local/hash-graph-types/entity"; diff --git a/apps/hash-frontend/src/pages/shared/accept-draft-entity-button.tsx b/apps/hash-frontend/src/pages/shared/accept-draft-entity-button.tsx index bda6cafd705..2b1213afc50 100644 --- a/apps/hash-frontend/src/pages/shared/accept-draft-entity-button.tsx +++ b/apps/hash-frontend/src/pages/shared/accept-draft-entity-button.tsx @@ -259,6 +259,8 @@ export const AcceptDraftEntityButton: FunctionComponent< }} > = ({ ); const entityLabel = useMemo( - () => (entitySubgraph ? generateEntityLabel(entitySubgraph) : undefined), - [entitySubgraph], + () => + entitySubgraph && entity + ? generateEntityLabel(entitySubgraph, entity) + : undefined, + [entity, entitySubgraph], ); const entityOwnedById = useMemo( diff --git a/apps/hash-frontend/src/pages/shared/entities-table/use-entities-table/worker.ts b/apps/hash-frontend/src/pages/shared/entities-table/use-entities-table/worker.ts index 6d2dd67e2cc..aa834f0b32e 100644 --- a/apps/hash-frontend/src/pages/shared/entities-table/use-entities-table/worker.ts +++ b/apps/hash-frontend/src/pages/shared/entities-table/use-entities-table/worker.ts @@ -267,7 +267,7 @@ const generateTableData = async ( const sourceEntityLabel = !source ? entity.linkData.leftEntityId : source.linkData - ? generateLinkEntityLabel(subgraph, source) + ? generateLinkEntityLabel(subgraph, source, null, null) : generateEntityLabel(subgraph, source); /** @@ -294,7 +294,7 @@ const generateTableData = async ( const targetEntityLabel = !target ? entity.linkData.leftEntityId : target.linkData - ? generateLinkEntityLabel(subgraph, target) + ? generateLinkEntityLabel(subgraph, target, null, null) : generateEntityLabel(subgraph, target); /** diff --git a/apps/hash-frontend/src/pages/shared/entity-editor-slide-stack.tsx b/apps/hash-frontend/src/pages/shared/entity-editor-slide-stack.tsx index 3fe33d8e8d9..eb6e9b550da 100644 --- a/apps/hash-frontend/src/pages/shared/entity-editor-slide-stack.tsx +++ b/apps/hash-frontend/src/pages/shared/entity-editor-slide-stack.tsx @@ -1,4 +1,8 @@ import type { EntityId } from "@local/hash-graph-types/entity"; +import type { + ClosedMultiEntityTypesDefinitions, + ClosedMultiEntityTypesRootMap, +} from "@local/hash-graph-types/ontology"; import type { EntityRootType, Subgraph } from "@local/hash-subgraph"; import { Backdrop } from "@mui/material"; import type { FunctionComponent, RefObject } from "react"; @@ -10,6 +14,8 @@ import type { EntityEditorProps } from "../[shortname]/entities/[entity-uuid].pa import { generateEntityRootedSubgraph } from "./subgraphs"; interface EntityEditorSlideStackProps { + closedMultiEntityTypesMap?: ClosedMultiEntityTypesRootMap; + closedMultiEntityTypesDefinitions?: ClosedMultiEntityTypesDefinitions; customColumns?: EntityEditorProps["customColumns"]; disableTypeClick?: boolean; /** diff --git a/apps/hash-frontend/src/pages/shared/entity-graph-visualizer.tsx b/apps/hash-frontend/src/pages/shared/entity-graph-visualizer.tsx index 4d59633470b..ec56a7b9a22 100644 --- a/apps/hash-frontend/src/pages/shared/entity-graph-visualizer.tsx +++ b/apps/hash-frontend/src/pages/shared/entity-graph-visualizer.tsx @@ -1,5 +1,5 @@ import { mustHaveAtLeastOne } from "@blockprotocol/type-system"; -import type { VersionedUrl } from "@blockprotocol/type-system-rs/pkg/type-system"; +import type { VersionedUrl } from "@blockprotocol/type-system/slim"; import { ibm } from "@hashintel/design-system/palettes"; import type { EntityId, diff --git a/apps/hash-frontend/src/pages/shared/entity-selector.tsx b/apps/hash-frontend/src/pages/shared/entity-selector.tsx index aeee95a6c9d..6acd8257ad6 100644 --- a/apps/hash-frontend/src/pages/shared/entity-selector.tsx +++ b/apps/hash-frontend/src/pages/shared/entity-selector.tsx @@ -1,4 +1,5 @@ import { useQuery } from "@apollo/client"; +import type { EntityType } from "@blockprotocol/type-system"; import { mustHaveAtLeastOne } from "@blockprotocol/type-system"; import type { SelectorAutocompleteProps, @@ -6,8 +7,12 @@ import type { } from "@hashintel/design-system"; import { Chip, SelectorAutocomplete } from "@hashintel/design-system"; import type { Entity } from "@local/hash-graph-sdk/entity"; +import { + getClosedMultiEntityTypeFromMap, + getDisplayFieldsForClosedEntityType, +} from "@local/hash-graph-sdk/entity"; import type { EntityId } from "@local/hash-graph-types/entity"; -import type { EntityTypeWithMetadata } from "@local/hash-graph-types/ontology"; +import type { ClosedMultiEntityTypesRootMap } from "@local/hash-graph-types/ontology"; import { generateEntityLabel } from "@local/hash-isomorphic-utils/generate-entity-label"; import { currentTimeInstantTemporalAxes, @@ -15,13 +20,13 @@ import { mapGqlSubgraphFieldsFragmentToSubgraph, zeroedGraphResolveDepths, } from "@local/hash-isomorphic-utils/graph-queries"; -import type { EntityRootType, Subgraph } from "@local/hash-subgraph"; +import type { EntityRootType } from "@local/hash-subgraph"; import { entityIdFromComponents, extractDraftIdFromEntityId, splitEntityId, } from "@local/hash-subgraph"; -import { getEntityTypeById, getRoots } from "@local/hash-subgraph/stdlib"; +import { getRoots } from "@local/hash-subgraph/stdlib"; import { useMemo, useState } from "react"; import type { @@ -41,13 +46,13 @@ type EntitySelectorProps = Omit< | "dropdownProps" > & { dropdownProps?: Omit; - expectedEntityTypes?: EntityTypeWithMetadata[]; + expectedEntityTypes?: Pick[]; entityIdsToFilterOut?: EntityId[]; includeDrafts: boolean; multiple?: Multiple; onSelect: ( event: Multiple extends true ? Entity[] : Entity, - sourceSubgraph: Subgraph | null, + closedMultiEntityTypeMap: ClosedMultiEntityTypesRootMap, ) => void; value?: Multiple extends true ? Entity : Entity[]; }; @@ -71,19 +76,16 @@ export const EntitySelector = ({ !expectedEntityTypes || expectedEntityTypes.length === 0 ? { all: [] } : { - any: expectedEntityTypes.map(({ schema }) => - generateVersionedUrlMatchingFilter(schema.$id, { + any: expectedEntityTypes.map(({ $id }) => + generateVersionedUrlMatchingFilter($id, { ignoreParents: true, }), ), }, temporalAxes: currentTimeInstantTemporalAxes, - graphResolveDepths: { - ...zeroedGraphResolveDepths, - inheritsFrom: { outgoing: 255 }, - isOfType: { outgoing: 1 }, - }, + graphResolveDepths: zeroedGraphResolveDepths, includeDrafts, + includeEntityTypes: "resolved", }, includePermissions: false, }, @@ -95,6 +97,9 @@ export const EntitySelector = ({ ) : undefined; + const closedMultiEntityTypesRootMap = + entitiesData?.getEntitySubgraph.closedMultiEntityTypes; + const sortedAndFilteredEntities = useMemo(() => { if (!entitiesSubgraph) { return []; @@ -167,7 +172,13 @@ export const EntitySelector = ({ loading={loading} onChange={(_, option) => { - onSelect(option, entitiesSubgraph ?? null); + if (!closedMultiEntityTypesRootMap) { + throw new Error( + "Cannot select an entity without a closed multi entity types map", + ); + } + + onSelect(option, closedMultiEntityTypesRootMap); }} inputValue={query} isOptionEqualToValue={(option, value) => @@ -176,32 +187,41 @@ export const EntitySelector = ({ onInputChange={(_, value) => setQuery(value)} options={sortedAndFilteredEntities} optionToRenderData={(entity) => { - /** - * @todo H-3363 use the closed schema to get the first icon - */ - const entityTypes = entity.metadata.entityTypeIds - .toSorted() - .map((entityTypeId) => { - const entityType = getEntityTypeById( - entitiesSubgraph!, - entityTypeId, - ); - - if (!entityType) { - throw new Error(`Cannot find entity type ${entityTypeId}`); - } - - return entityType.schema; - }); + const typesMap = entitiesData?.getEntitySubgraph.closedMultiEntityTypes; + + if (!typesMap) { + throw new Error( + "Cannot render an entity without a closed multi entity types map", + ); + } + + const closedType = getClosedMultiEntityTypeFromMap( + typesMap, + entity.metadata.entityTypeIds, + ); + + const { icon: entityIcon } = + getDisplayFieldsForClosedEntityType(closedType); return { entityProperties: entity.properties, uniqueId: entity.metadata.recordId.entityId, - icon: entityTypes[0]?.icon ?? null, + icon: entityIcon ?? null, /** * @todo update SelectorAutocomplete to show an entity's namespace as well as / instead of its entityTypeId * */ - types: mustHaveAtLeastOne(entityTypes), + types: mustHaveAtLeastOne( + closedType.allOf.map((type) => { + const { icon: typeIcon } = + getDisplayFieldsForClosedEntityType(type); + + return { + $id: type.$id, + icon: typeIcon, + title: type.title, + }; + }), + ), title: generateEntityLabel(entitiesSubgraph!, entity), draft: !!extractDraftIdFromEntityId( entity.metadata.recordId.entityId, diff --git a/apps/hash-frontend/src/pages/shared/hidden-types.ts b/apps/hash-frontend/src/pages/shared/hidden-types.ts index 490da9631f9..02f7135dbd7 100644 --- a/apps/hash-frontend/src/pages/shared/hidden-types.ts +++ b/apps/hash-frontend/src/pages/shared/hidden-types.ts @@ -38,6 +38,4 @@ export const hiddenEntityTypeIds: VersionedUrl[] = [ * because they either are forbidden from being created by users, * or because they require special handling and will be unusable if manually created. */ -export const nonAssignableTypes = Object.values(systemEntityTypes).map( - (type) => type.entityTypeId, -); +export const nonAssignableTypes = hiddenEntityTypeIds; diff --git a/apps/hash-frontend/src/pages/shared/link-label-with-source-and-destination.tsx b/apps/hash-frontend/src/pages/shared/link-label-with-source-and-destination.tsx index f93eaefd946..65067c1fe3e 100644 --- a/apps/hash-frontend/src/pages/shared/link-label-with-source-and-destination.tsx +++ b/apps/hash-frontend/src/pages/shared/link-label-with-source-and-destination.tsx @@ -1,12 +1,23 @@ import type { EntityPropertyValue } from "@blockprotocol/graph"; +import type { EntityType } from "@blockprotocol/type-system/slim"; +import { extractVersion } from "@blockprotocol/type-system/slim"; import { EntityOrTypeIcon, EyeSlashRegularIcon, } from "@hashintel/design-system"; import { typedEntries } from "@local/advanced-types/typed-entries"; import type { Entity, LinkEntity } from "@local/hash-graph-sdk/entity"; +import { + getClosedMultiEntityTypeFromMap, + getDisplayFieldsForClosedEntityType, + getPropertyTypeForClosedMultiEntityType, +} from "@local/hash-graph-sdk/entity"; import type { EntityId } from "@local/hash-graph-types/entity"; -import type { EntityTypeWithMetadata } from "@local/hash-graph-types/ontology"; +import type { + ClosedMultiEntityTypesDefinitions, + ClosedMultiEntityTypesRootMap, + EntityTypeWithMetadata, +} from "@local/hash-graph-types/ontology"; import { generateEntityLabel } from "@local/hash-isomorphic-utils/generate-entity-label"; import type { EntityRootType, Subgraph } from "@local/hash-subgraph"; import { extractEntityUuidFromEntityId } from "@local/hash-subgraph"; @@ -58,46 +69,70 @@ const stringifyEntityPropertyValue = (value: EntityPropertyValue): string => { }; const LeftOrRightEntity: FunctionComponent<{ + closedMultiEntityTypesMap: ClosedMultiEntityTypesRootMap | null; + closedMultiEntityTypesDefinitions: ClosedMultiEntityTypesDefinitions | null; + endAdornment?: ReactNode; entity?: Entity; + label?: ReactNode; onEntityClick?: (entityId: EntityId) => void; - subgraph: Subgraph; openInNew?: boolean; - endAdornment?: ReactNode; - label?: ReactNode; + subgraph: Subgraph; sx?: BoxProps["sx"]; }> = ({ - subgraph, - entity, + closedMultiEntityTypesMap, + closedMultiEntityTypesDefinitions, endAdornment, - onEntityClick, - sx, + entity, label, + onEntityClick, openInNew, + subgraph, + sx, }) => { - const entityLabel = useMemo( - () => (entity ? generateEntityLabel(subgraph, entity) : "Hidden entity"), - [subgraph, entity], - ); + const entityLabel = useMemo(() => { + if (!entity) { + return "Hidden entity"; + } + + if (closedMultiEntityTypesMap) { + const closedEntityType = getClosedMultiEntityTypeFromMap( + closedMultiEntityTypesMap, + entity.metadata.entityTypeIds, + ); - const entityTypes = useMemo(() => { + return generateEntityLabel(closedEntityType, entity); + } + + return generateEntityLabel(subgraph, entity); + }, [closedMultiEntityTypesMap, subgraph, entity]); + + const entityIcon = useMemo(() => { if (!entity) { return undefined; } - return entity.metadata.entityTypeIds.map((entityTypeId) => { + if (closedMultiEntityTypesMap) { + const closedEntityType = getClosedMultiEntityTypeFromMap( + closedMultiEntityTypesMap, + entity.metadata.entityTypeIds, + ); + + const { icon } = getDisplayFieldsForClosedEntityType(closedEntityType); + return icon; + } + + for (const entityTypeId of entity.metadata.entityTypeIds) { const entityType = getEntityTypeById(subgraph, entityTypeId); + if (!entityType) { throw new Error(`Entity type not found: ${entityTypeId}`); } - return entityType; - }); - }, [entity, subgraph]); - /** - * @todo H-3363 account for multitype and inheritance here (use closed schema) - */ - const firstTypeIcon = entityTypes?.find((type) => type.schema.icon)?.schema - .icon; + if (entityType.schema.icon) { + return entityType.schema.icon; + } + } + }, [closedMultiEntityTypesMap, entity, subgraph]); const getOwnerForEntity = useGetOwnerForEntity(); @@ -145,7 +180,7 @@ const LeftOrRightEntity: FunctionComponent<{ palette.gray[50]} /> @@ -190,11 +225,25 @@ const LeftOrRightEntity: FunctionComponent<{ return typedEntries(entity.properties) .map(([baseUrl, propertyValue]) => { - const propertyType = getPropertyTypeForEntity( - subgraph, - entity.metadata.entityTypeIds, - baseUrl, - ).propertyType; + const closedEntityType = closedMultiEntityTypesMap + ? getClosedMultiEntityTypeFromMap( + closedMultiEntityTypesMap, + entity.metadata.entityTypeIds, + ) + : null; + + const propertyType = + closedEntityType && closedMultiEntityTypesDefinitions + ? getPropertyTypeForClosedMultiEntityType( + closedEntityType, + baseUrl, + closedMultiEntityTypesDefinitions, + ) + : getPropertyTypeForEntity( + subgraph, + entity.metadata.entityTypeIds, + baseUrl, + ).propertyType; const stringifiedPropertyValue = stringifyEntityPropertyValue(propertyValue); @@ -205,7 +254,12 @@ const LeftOrRightEntity: FunctionComponent<{ }; }) .flat(); - }, [entity, subgraph]); + }, [ + closedMultiEntityTypesMap, + closedMultiEntityTypesDefinitions, + entity, + subgraph, + ]); const outgoingLinkTypesAndTargetEntities = useMemo(() => { if (!entity) { @@ -448,7 +502,7 @@ const LinkTypeInner = ({ elementRef, }: { amongMultipleTypes: boolean; - linkEntityType: EntityTypeWithMetadata; + linkEntityType: Pick; elementRef: HTMLDivElement; clickable: boolean; }) => ( @@ -486,11 +540,11 @@ const LinkTypeInner = ({ fontSize={13} isLink fill={({ palette }) => palette.blue[70]} - icon={linkEntityType.schema.icon} + icon={linkEntityType.icon} />
- {linkEntityType.schema.title} + {linkEntityType.title} - v{linkEntityType.metadata.recordId.version} + v{extractVersion(linkEntityType.$id)} ); export const LinkLabelWithSourceAndDestination: FunctionComponent<{ - linkEntity: LinkEntity; - subgraph: Subgraph; + closedMultiEntityTypesMap: ClosedMultiEntityTypesRootMap | null; + closedMultiEntityTypesDefinitions: ClosedMultiEntityTypesDefinitions | null; + displayLabels?: boolean; leftEntityEndAdornment?: ReactNode; - rightEntityEndAdornment?: ReactNode; - sx?: BoxProps["sx"]; leftEntitySx?: BoxProps["sx"]; - rightEntitySx?: BoxProps["sx"]; - displayLabels?: boolean; + linkEntity: LinkEntity; onEntityClick?: (entityId: EntityId) => void; openInNew?: boolean; + rightEntityEndAdornment?: ReactNode; + rightEntitySx?: BoxProps["sx"]; + subgraph: Subgraph; + sx?: BoxProps["sx"]; }> = ({ - linkEntity, - subgraph, + closedMultiEntityTypesMap, + closedMultiEntityTypesDefinitions, + displayLabels = false, leftEntityEndAdornment, - rightEntityEndAdornment, - sx, leftEntitySx, - rightEntitySx, - displayLabels = false, + linkEntity, onEntityClick, openInNew = false, + rightEntityEndAdornment, + rightEntitySx, + subgraph, + sx, }) => { const { disableTypeClick } = useEntityEditor(); const { leftEntity, rightEntity, linkEntityTypes } = useMemo(() => { return { - linkEntityTypes: linkEntity.metadata.entityTypeIds.map((entityTypeId) => { - const entityType = getEntityTypeById(subgraph, entityTypeId); - if (!entityType) { - throw new Error(`Entity type not found: ${entityTypeId}`); - } - return entityType; - }), + linkEntityTypes: closedMultiEntityTypesMap + ? getClosedMultiEntityTypeFromMap( + closedMultiEntityTypesMap, + linkEntity.metadata.entityTypeIds, + ).allOf.map((entityType) => { + const { icon } = getDisplayFieldsForClosedEntityType(entityType); + + return { + $id: entityType.$id, + title: entityType.title, + icon, + }; + }) + : linkEntity.metadata.entityTypeIds.map((entityTypeId) => { + const entityType = getEntityTypeById(subgraph, entityTypeId); + if (!entityType) { + throw new Error(`Entity type not found: ${entityTypeId}`); + } + return entityType.schema; + }), leftEntity: getEntityRevision(subgraph, linkEntity.linkData.leftEntityId), rightEntity: getEntityRevision( subgraph, linkEntity.linkData.rightEntityId, ), }; - }, [linkEntity, subgraph]); + }, [closedMultiEntityTypesMap, linkEntity, subgraph]); /** * If there are multiple link entity types for this link entity, @@ -590,6 +661,8 @@ export const LinkLabelWithSourceAndDestination: FunctionComponent<{ ]} > {linkEntityTypes.map((linkEntityType, index) => ( - + {disableTypeClick ? ( - + {header} {calloutMessage} {children ? {children} : null} - + {callback && (