diff --git a/apps/hash-api/src/graphql/resolvers/index.ts b/apps/hash-api/src/graphql/resolvers/index.ts index d962f2f5d59..9c1ba8f2fc8 100644 --- a/apps/hash-api/src/graphql/resolvers/index.ts +++ b/apps/hash-api/src/graphql/resolvers/index.ts @@ -94,6 +94,7 @@ import { queryEntityTypesResolver, unarchiveEntityTypeResolver, updateEntityTypeResolver, + updateEntityTypesResolver, } from "./ontology/entity-type"; import { archivePropertyTypeResolver, @@ -180,6 +181,7 @@ export const resolvers: Omit & { ), createEntityType: loggedInAndSignedUpMiddleware(createEntityTypeResolver), updateEntityType: loggedInAndSignedUpMiddleware(updateEntityTypeResolver), + updateEntityTypes: loggedInAndSignedUpMiddleware(updateEntityTypesResolver), archiveEntityType: loggedInAndSignedUpMiddleware(archiveEntityTypeResolver), unarchiveEntityType: loggedInAndSignedUpMiddleware( unarchiveEntityTypeResolver, 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 892ac38934d..f52cc001dd4 100644 --- a/apps/hash-api/src/graphql/resolvers/ontology/entity-type.ts +++ b/apps/hash-api/src/graphql/resolvers/ontology/entity-type.ts @@ -28,6 +28,7 @@ import type { MutationCreateEntityTypeArgs, MutationUnarchiveEntityTypeArgs, MutationUpdateEntityTypeArgs, + MutationUpdateEntityTypesArgs, QueryCheckUserPermissionsOnEntityTypeArgs, QueryGetClosedMultiEntityTypeArgs, QueryGetEntityTypeArgs, @@ -218,6 +219,46 @@ export const updateEntityTypeResolver: ResolverFn< }, ); +export const updateEntityTypesResolver: ResolverFn< + Promise, + Record, + LoggedInGraphQLContext, + MutationUpdateEntityTypesArgs +> = async (_, params, graphQLContext) => + Promise.all( + params.updates.map((update) => + updateEntityType( + graphQLContextToImpureGraphContext(graphQLContext), + graphQLContext.authentication, + { + entityTypeId: update.entityTypeId, + schema: update.updatedEntityType, + relationships: [ + { + relation: "setting", + subject: { + kind: "setting", + subjectId: "updateFromWeb", + }, + }, + { + relation: "viewer", + subject: { + kind: "public", + }, + }, + { + relation: "instantiator", + subject: { + kind: "public", + }, + }, + ], + }, + ), + ), + ); + export const checkUserPermissionsOnEntityTypeResolver: ResolverFn< Promise, Record, diff --git a/apps/hash-frontend/src/components/forms/checkbox.tsx b/apps/hash-frontend/src/components/forms/checkbox.tsx deleted file mode 100644 index 89c451cce09..00000000000 --- a/apps/hash-frontend/src/components/forms/checkbox.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import type { FunctionComponent, HTMLProps } from "react"; - -type CheckboxProps = { - checked: boolean; - onChangeChecked: (value: boolean) => void; - label?: string; -} & Omit, "onChange">; - -export const Checkbox: FunctionComponent = ({ - checked, - label, - onChangeChecked, - ...props -}) => { - if (label) { - return ( -
- -
- ); - } - return ( -
- onChangeChecked(!checked)} - type="checkbox" - {...props} - /> -
- ); -}; 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 75363ed169e..bae2c6f9498 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 @@ -92,6 +92,12 @@ export const updateEntityTypeMutation = gql` } `; +export const updateEntityTypesMutation = gql` + mutation updateEntityTypes($updates: [EntityTypeUpdate!]!) { + updateEntityTypes(updates: $updates) + } +`; + export const archiveEntityTypeMutation = gql` mutation archiveEntityType($entityTypeId: VersionedUrl!) { archiveEntityType(entityTypeId: $entityTypeId) 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 aafb73bc142..585a0255843 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 @@ -294,6 +294,8 @@ export const ArrayEditor: ValueCellEditorComponent = ({ const canAddMore = isNumber(maxItems) ? items.length < maxItems : true; const isAddingDraft = editingRow === DRAFT_ROW_KEY; + const hasConstraints = minItems !== undefined || maxItems !== undefined; + return ( )} - {(!canAddMore || isAddingDraft) && ( + {!canAddMore && hasConstraints && ( )} diff --git a/apps/hash-frontend/src/pages/@/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/array-editor/add-another-button.tsx b/apps/hash-frontend/src/pages/@/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/array-editor/add-another-button.tsx index 239902643e3..a16b64e1bd4 100644 --- a/apps/hash-frontend/src/pages/@/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/array-editor/add-another-button.tsx +++ b/apps/hash-frontend/src/pages/@/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/array-editor/add-another-button.tsx @@ -17,7 +17,12 @@ export const AddAnotherButton = ({ variant="tertiary_quiet" fullWidth startIcon={} - sx={{ justifyContent: "flex-start", borderRadius: 0 }} + sx={{ + justifyContent: "flex-start", + borderRadius: 0, + fontSize: 13, + color: ({ palette }) => palette.gray[60], + }} > {title} 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 af756d0417f..381314d1058 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 @@ -7,9 +7,14 @@ import { DRAFT_ROW_KEY } from "../array-editor"; import { getEditorSpecs } from "../editor-specs"; import { EditorTypePicker } from "../editor-type-picker"; import { isBlankStringOrNullish } from "../utils"; +import { ItemLimitInfo } from "./item-limit-info"; import { SortableRow } from "./sortable-row"; interface DraftRowProps { + arrayConstraints?: { + minItems?: number; + maxItems?: number; + }; expectedTypes: ClosedDataTypeDefinition[]; existingItemCount: number; onDraftSaved: (value: unknown, dataTypeId: VersionedUrl) => void; @@ -17,6 +22,7 @@ interface DraftRowProps { } export const DraftRow = ({ + arrayConstraints, expectedTypes, existingItemCount, onDraftSaved, @@ -58,23 +64,31 @@ export const DraftRow = ({ } return ( - { - if (isBlankStringOrNullish(value)) { - return onDraftDiscarded(); - } + <> + { + if (isBlankStringOrNullish(value)) { + return onDraftDiscarded(); + } - onDraftSaved(value, dataType.$id); - }} - onDiscardChanges={onDraftDiscarded} - expectedTypes={expectedTypes} - /> + onDraftSaved(value, dataType.$id); + }} + onDiscardChanges={onDraftDiscarded} + expectedTypes={expectedTypes} + /> + {arrayConstraints && ( + + )} + ); }; 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 c4507787fd0..bd09e5cef66 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 @@ -43,6 +43,7 @@ export const EditorTypePicker = ({ return ( diff --git a/apps/hash-frontend/src/pages/@/[shortname]/types/entity-type/[...slug-maybe-version].page.tsx b/apps/hash-frontend/src/pages/@/[shortname]/types/entity-type/[...slug-maybe-version].page.tsx index 0f43c851539..a0d08f4514f 100644 --- a/apps/hash-frontend/src/pages/@/[shortname]/types/entity-type/[...slug-maybe-version].page.tsx +++ b/apps/hash-frontend/src/pages/@/[shortname]/types/entity-type/[...slug-maybe-version].page.tsx @@ -83,6 +83,7 @@ const Page: NextPageWithLayout = () => { accountId={routeNamespace.accountId} draftEntityType={draftEntityType} entityTypeBaseUrl={entityTypeBaseUrl} + key={`${entityTypeBaseUrl}-${requestedVersion}`} requestedVersion={requestedVersion} /> ); diff --git a/apps/hash-frontend/src/pages/shared/entity-type-page.tsx b/apps/hash-frontend/src/pages/shared/entity-type-page.tsx index 4d93c859973..2d393a7a70f 100644 --- a/apps/hash-frontend/src/pages/shared/entity-type-page.tsx +++ b/apps/hash-frontend/src/pages/shared/entity-type-page.tsx @@ -1,5 +1,9 @@ import type { EntityTypeWithMetadata } from "@blockprotocol/graph"; -import { atLeastOne, extractVersion } from "@blockprotocol/type-system"; +import { + atLeastOne, + extractVersion, + mustHaveAtLeastOne, +} from "@blockprotocol/type-system"; import type { VersionedUrl } from "@blockprotocol/type-system/slim"; import { EntityOrTypeIcon, OntologyChip } from "@hashintel/design-system"; import type { EntityTypeEditorFormData } from "@hashintel/type-editor"; @@ -12,7 +16,7 @@ import { import type { AccountId } from "@local/hash-graph-types/account"; import type { BaseUrl } from "@local/hash-graph-types/ontology"; import type { OwnedById } from "@local/hash-graph-types/web"; -import { generateLinkMapWithConsistentSelfReferences } from "@local/hash-isomorphic-utils/ontology-types"; +import { rewriteSchemasToNextVersion } from "@local/hash-isomorphic-utils/ontology-types"; import { linkEntityTypeUrl } from "@local/hash-subgraph"; import type { Theme } from "@mui/material"; import { Box, Container, Typography } from "@mui/material"; @@ -38,6 +42,11 @@ import { EntityTypeContext } from "./entity-type-page/shared/entity-type-context import { EntityTypeHeader } from "./entity-type-page/shared/entity-type-header"; import { useCurrentTab } from "./entity-type-page/shared/tabs"; import { TypeSlideOverStack } from "./entity-type-page/type-slide-over-stack"; +import { UpgradeDependentsModal } from "./entity-type-page/upgrade-dependents-modal"; +import { + type EntityTypeDependent, + useGetEntityTypeDependents, +} from "./entity-type-page/use-entity-type-dependents"; import { useEntityTypeValue } from "./entity-type-page/use-entity-type-value"; import { TopContextBar } from "./top-context-bar"; @@ -71,7 +80,7 @@ export const EntityTypePage = ({ remoteEntityType, latestVersion, remotePropertyTypes, - updateEntityType, + updateEntityTypes, publishDraft, { loading: loadingRemoteEntityType }, ] = useEntityTypeValue( @@ -112,6 +121,48 @@ export const EntityTypePage = ({ entityType?.schema.$id, ); + const [entityTypeDependents, setEntityTypeDependents] = useState< + Record + >({}); + + const [dependentsExcludedFromUpgrade, setDependentsExcludedFromUpgrade] = + useState([]); + + const { + upgradableDependencies: _upgradableDependencies, + entityTypesToUpgrade, + } = useMemo( + () => ({ + upgradableDependencies: Object.values(entityTypeDependents).filter( + (dependent) => + dependent.noFurtherTraversalBecause !== "external-web" && + dependent.noFurtherTraversalBecause !== "external-type-host", + ), + entityTypesToUpgrade: Object.values(entityTypeDependents).filter( + (dependent) => !dependent.noFurtherTraversalBecause, + ), + }), + [entityTypeDependents], + ); + + const [showDependencyUpgradeModal, setShowDependencyUpgradeModal] = + useState(false); + + const { getEntityTypeDependents, loading } = useGetEntityTypeDependents(); + + useEffect(() => { + if (!entityType) { + return; + } + + void getEntityTypeDependents({ + entityTypeId: entityType.schema.$id, + excludeBaseUrls: dependentsExcludedFromUpgrade, + }).then((dependents) => { + setEntityTypeDependents(dependents); + }); + }, [getEntityTypeDependents, entityType, dependentsExcludedFromUpgrade]); + const handleSubmit = wrapHandleSubmit(async (data) => { if (!isDirty && !isDraft) { /** @@ -133,6 +184,20 @@ export const EntityTypePage = ({ }); reset(data); } else { + if (!remoteEntityType) { + throw new Error( + "Cannot update entity type without existing entityType schema", + ); + } + + // @todo H-4026: enable this when abillity to update multiple entity types at once is in place + // if (upgradableDependencies.length && !showDependencyUpgradeModal) { + // setShowDependencyUpgradeModal(true); + // return; + // } + + setShowDependencyUpgradeModal(false); + const currentEntityTypeId = entityType?.schema.$id; if (!currentEntityTypeId) { throw new Error( @@ -141,24 +206,27 @@ export const EntityTypePage = ({ } /** - * If an entity type refers to itself as a link destination, e.g. a Company may have a Parent which is a Company, - * we want the version specified as the link target in the schema to be the same as the version of the entity type. - * This rewriting of the schema ensures that by looking for self references and giving them the expected next version. - * If we don't do this, creating a new version of Company means the new version will have a link to the previous version. + * Rewrite schemas of the type and any types dependent on it that the user has choosen to upgrade, + * so that they all refer to the latest versions (after the update has been applied). + * Types may refer to each other, or reference themselves – via inheritance (allOf), as link types, or as link destinations */ - const schemaWithConsistentSelfReferences = { - ...entityTypeSchema, - links: generateLinkMapWithConsistentSelfReferences( - entityTypeSchema, - currentEntityTypeId, - ), - }; + const [{ $id: _, ...rootType }, ..._dependents] = mustHaveAtLeastOne( + rewriteSchemasToNextVersion([ + { + ...remoteEntityType.schema, + ...entityTypeSchema, + }, + ...entityTypesToUpgrade.map((dependent) => dependent.entityType), + ]), + ); - const res = await updateEntityType(schemaWithConsistentSelfReferences); + // @todo H-4026: enable this when abillity to update multiple entity types at once is in place + // const res = await updateEntityTypes(rootType, dependents); + const res = await updateEntityTypes(rootType, []); - if (!res.errors?.length && res.data) { + if (!res.errors?.length && res.data?.updateEntityTypes[0]) { void router.push( - generateLinkParameters(res.data.updateEntityType.schema.$id).href, + generateLinkParameters(res.data.updateEntityTypes[0].schema.$id).href, ); } else { throw new Error("Could not publish changes"); @@ -210,16 +278,57 @@ export const EntityTypePage = ({ : extractVersion(entityType.schema.$id); const convertToLinkType = wrapHandleSubmit(async (data) => { - const { schema } = getEntityTypeFromFormData(data); + // @todo H-4026: enable this when abillity to update multiple entity types at once is in place + // if (upgradableDependencies.length && !showDependencyUpgradeModal) { + // setShowDependencyUpgradeModal(true); + // return; + // } + + if (!remoteEntityType) { + throw new Error( + "Cannot update entity type without existing entityType schema", + ); + } - const res = await updateEntityType({ - ...schema, - allOf: [{ $ref: linkEntityTypeUrl }, ...(schema.allOf ?? [])], - }); + setShowDependencyUpgradeModal(false); - if (!res.errors?.length && res.data) { + const { schema } = getEntityTypeFromFormData(data); + + /** + * Rewrite schemas of the type and any types dependent on it that the user has choosen to upgrade, + * so that they all refer to the latest versions (after the update has been applied). + * Types may refer to each other, or reference themselves – via inheritance (allOf), as link types, or as link destinations + */ + const [rootType, ..._dependents] = mustHaveAtLeastOne( + rewriteSchemasToNextVersion([ + { + ...remoteEntityType.schema, + ...schema, + }, + ...entityTypesToUpgrade.map((dependent) => dependent.entityType), + ]), + ); + + // @todo H-4026: enable this when abillity to update multiple entity types at once is in place + // const res = await updateEntityTypes( + // { + // ...rootType, + // allOf: [{ $ref: linkEntityTypeUrl }, ...(rootType.allOf ?? [])], + // }, + // dependents, + // ); + + const res = await updateEntityTypes( + { + ...rootType, + allOf: [{ $ref: linkEntityTypeUrl }, ...(rootType.allOf ?? [])], + }, + [], + ); + + if (!res.errors?.length && res.data?.updateEntityTypes[0]) { void router.push( - generateLinkParameters(res.data.updateEntityType.schema.$id).href, + generateLinkParameters(res.data.updateEntityTypes[0].schema.$id).href, ); } else { throw new Error("Could not publish changes"); @@ -235,6 +344,22 @@ export const EntityTypePage = ({ return ( <> + { + setShowDependencyUpgradeModal(false); + }} + onConfirm={() => { + void handleSubmit(); + }} + setDependenciesToExclude={(excludedDependencies) => { + setDependentsExcludedFromUpgrade(excludedDependencies); + }} + upgradingEntityType={entityType.schema} + /> diff --git a/apps/hash-frontend/src/pages/shared/entity-type-page/upgrade-dependents-modal.tsx b/apps/hash-frontend/src/pages/shared/entity-type-page/upgrade-dependents-modal.tsx new file mode 100644 index 00000000000..0290ff39f30 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entity-type-page/upgrade-dependents-modal.tsx @@ -0,0 +1,300 @@ +import type { EntityType, VersionedUrl } from "@blockprotocol/type-system"; +import type { BaseUrl } from "@local/hash-graph-types/ontology"; +import { extractBaseUrl } from "@local/hash-subgraph/type-system-patch"; +import { + Box, + Checkbox, + CircularProgress, + List, + ListItem, + Stack, + Typography, +} from "@mui/material"; +import { useMemo } from "react"; + +import { Button } from "../../../shared/ui/button"; +import { Modal } from "../../../shared/ui/modal"; +import type { EntityTypeDependent } from "./use-entity-type-dependents"; + +type UpgradeDependentsModalProps = { + dependents: Record; + excludedDependencies: BaseUrl[]; + loading: boolean; + open: boolean; + onCancel: () => void; + onConfirm: () => void; + setDependenciesToExclude: (excludedDependencies: BaseUrl[]) => void; + upgradingEntityType: Pick; +}; + +type DependentForDisplay = Omit & { + dependentOn: string[]; + namespace?: string; + toggleable: boolean; +}; + +const extractNamespace = (id: VersionedUrl) => { + return id.match(/\/(@[^/]+)/)?.[1]; +}; + +const DependentListItem = ({ + dependent, + toggleExcluded, + showNamespace, +}: { + dependent: DependentForDisplay; + toggleExcluded: (excluded: boolean) => void; + showNamespace: boolean; +}) => { + const { + dependentOn, + namespace, + entityType, + noFurtherTraversalBecause, + toggleable, + } = dependent; + + return ( + + + {showNamespace && namespace && ( + palette.gray[60], + fontWeight: 400, + fontSize: 13, + mr: "1px", + }} + > + {`${namespace}`} + + / + + + )} + palette.gray[90], + }} + > + {entityType.title} + + palette.gray[50], + }} + > + (depends on {dependentOn.join(", ")}) + + + + {toggleable ? ( + toggleExcluded(!noFurtherTraversalBecause)} + sx={{ + svg: { + rect: { + stroke: "#ddd", + }, + width: 18, + height: 18, + }, + }} + /> + ) : ( + palette.gray[50], fontSize: 13 }} + > + External + + )} + + + ); +}; + +export const UpgradeDependentsModal = ({ + dependents, + excludedDependencies, + loading, + open, + onCancel, + onConfirm, + setDependenciesToExclude, + upgradingEntityType, +}: UpgradeDependentsModalProps) => { + const { upgradingDependencies, notUpgradingDependencies, showNamespace } = + useMemo(() => { + const upgrading: DependentForDisplay[] = []; + const notUpgrading: DependentForDisplay[] = []; + + const namespacesSeen = new Set(); + + for (const dependent of Object.values(dependents)) { + const dependentOn = Array.from(dependent.dependentOn).map((baseUrl) => { + if (baseUrl === extractBaseUrl(upgradingEntityType.$id)) { + return upgradingEntityType.title; + } + + const title = dependents[baseUrl]?.entityType.title; + + if (!title) { + throw new Error(`No title found for dependent ${baseUrl}`); + } + + return title; + }); + + const namespace = extractNamespace(dependent.entityType.$id); + + if (namespace) { + namespacesSeen.add(namespace); + } + + if (dependent.noFurtherTraversalBecause) { + notUpgrading.push({ + ...dependent, + dependentOn, + namespace, + toggleable: dependent.noFurtherTraversalBecause === "user-excluded", + }); + } else { + upgrading.push({ + ...dependent, + dependentOn, + namespace, + toggleable: true, + }); + } + } + + return { + upgradingDependencies: upgrading, + notUpgradingDependencies: notUpgrading, + showNamespace: namespacesSeen.size > 1, + }; + }, [dependents, upgradingEntityType]); + + return ( + + + + + This entity type is referenced by other entity types. You can choose + which of these to automatically upgrade. All selected entity types + will be upgraded to use the latest version of each other. + + + Any you choose not to upgrade, or which are in webs you don't belong + to, will continue to use the previous version. + + + {loading ? ( + + + palette.gray[70] }} + > + Checking for affected types... + + + ) : ( + <> + + {upgradingDependencies.length > 0 && ( + + Upgrading + + {upgradingDependencies.map((dependent) => ( + { + setDependenciesToExclude([ + ...excludedDependencies, + extractBaseUrl(dependent.entityType.$id), + ]); + }} + /> + ))} + + + )} + {notUpgradingDependencies.length > 0 && ( + + Not upgrading + + {notUpgradingDependencies.map((dependent) => ( + { + setDependenciesToExclude( + excludedDependencies.filter( + (baseUrl) => + baseUrl !== + extractBaseUrl(dependent.entityType.$id), + ), + ); + }} + /> + ))} + + + )} + + `1px solid ${palette.gray[30]}`, + pt: 1.5, + mt: 1.5, + }} + > + + + + + )} + + + ); +}; diff --git a/apps/hash-frontend/src/pages/shared/entity-type-page/use-entity-type-dependents.ts b/apps/hash-frontend/src/pages/shared/entity-type-page/use-entity-type-dependents.ts new file mode 100644 index 00000000000..57d11b1e8aa --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entity-type-page/use-entity-type-dependents.ts @@ -0,0 +1,262 @@ +import { useLazyQuery } from "@apollo/client"; +import type { EntityType, VersionedUrl } from "@blockprotocol/type-system"; +import { typedEntries } from "@local/advanced-types/typed-entries"; +import type { + BaseUrl, + EntityTypeWithMetadata, +} from "@local/hash-graph-types/ontology"; +import type { OwnedById } from "@local/hash-graph-types/web"; +import { + mapGqlSubgraphFieldsFragmentToSubgraph, + zeroedGraphResolveDepths, +} from "@local/hash-isomorphic-utils/graph-queries"; +import { + type EntityTypeRootType, + isExternalOntologyElementMetadata, +} from "@local/hash-subgraph"; +import { getRoots } from "@local/hash-subgraph/stdlib"; +import { + componentsFromVersionedUrl, + extractBaseUrl, +} from "@local/hash-subgraph/type-system-patch"; +import { useCallback, useMemo } from "react"; + +import type { + QueryEntityTypesQuery, + QueryEntityTypesQueryVariables, +} from "../../../graphql/api-types.gen"; +import { queryEntityTypesQuery } from "../../../graphql/queries/ontology/entity-type.queries"; +import { useAuthenticatedUser } from "../auth-info-context"; + +export type EntityTypeDependent = { + entityType: EntityType; + /** + * If the dependents of this type were not checked – why? + * + * - "user-excluded" – the hook was instructed to exclude this type: the user doesn't want to upgrade it + * - "external-web" – the type is owned by an external web: the user cannot upgrade it + * - "external-type-host" – the type is hosted on an external type host: the user cannot upgrade it + */ + noFurtherTraversalBecause?: + | "user-excluded" + | "external-web" + | "external-type-host"; + /** + * The types that this type depends on that were encountered during traversal. + * These will all be either among the dependents returned from the hook, or the initially-provided type. + */ + dependentOn: Set; +}; + +/** + * We'll look for types dependening on the provided entity type at ANY version, + * in case some dependents are on older or newer versions. + * + * As the hook is used to allow users to make a graph of types have consistent references across them, + * we do this so that we allow all types to end up on the next version of all types, + * regardless of the discrepancies in versions that exist across the current graph. + */ +const generateDependentsFilter = (entityTypeBaseUrl: BaseUrl) => ({ + any: [ + { + equal: [ + { path: ["links", "*", "baseUrl"] }, + { parameter: entityTypeBaseUrl }, + ], + }, + { + equal: [ + { + path: ["inheritsFrom(inheritanceDepth=0)", "*", "baseUrl"], + }, + { + parameter: entityTypeBaseUrl, + }, + ], + }, + { + equal: [ + { + path: ["linkDestinations", "*", "baseUrl"], + }, + { + parameter: entityTypeBaseUrl, + }, + ], + }, + ], +}); + +/** + * This hook is designed to find all the dependents of a given entity type to allow users to upgrade types in a way that is consistent across the graph. + * + * It has design decisions tailored to that purpose which should be taken into account if re-using it elsewhere: + * 1. If the provided entityTypeId depends on itself, this is not reported. The caller is assumed to deal with this case. + * 2. Types are classified as dependents if their LATEST version depends on ANY version of a type, i.e. + * - if ONLY an EARLIER version of a type depends on any type in the resulting graph, it is NOT reported (the latestOnly: true query will not capture it) + * - if the latest version of a type depends on a DIFFERENT version of one of those encountered, it IS reported. + * This is to allow all the LATEST version of types to be upgraded to the LATEST version of whatever types they currently refer to, + * regardless of what version they currently refer to, and to IGNORE older versions of types which no longer refer to types encountered. + */ +export const useGetEntityTypeDependents = (): { + getEntityTypeDependents: (params: { + entityTypeId: VersionedUrl; + excludeBaseUrls?: BaseUrl[]; + }) => Promise>; + loading: boolean; +} => { + const [queryEntityTypes, { loading }] = useLazyQuery< + QueryEntityTypesQuery, + QueryEntityTypesQueryVariables + >(queryEntityTypesQuery, { + fetchPolicy: "cache-first", + }); + + const { authenticatedUser } = useAuthenticatedUser(); + + const userWebs = useMemo(() => { + return [ + authenticatedUser.accountId as OwnedById, + ...authenticatedUser.memberOf.map( + ({ org }) => org.accountGroupId as OwnedById, + ), + ]; + }, [authenticatedUser]); + + const getEntityTypeDependents = useCallback( + async ({ + entityTypeId: rootEntityTypeId, + excludeBaseUrls, + }: { + entityTypeId: VersionedUrl; + excludeBaseUrls?: BaseUrl[]; + }) => { + const dependentsByBaseUrl: Record = {}; + + const layerStack = [[rootEntityTypeId]]; + + while (layerStack.length > 0) { + const nextEntityTypeIds = layerStack.pop()!; + + const dependentsAndBaseUrl = await Promise.all( + nextEntityTypeIds.map(async (nextTypeId) => { + const { baseUrl } = componentsFromVersionedUrl(nextTypeId); + + const dependentTypesAtLatestVersion = await queryEntityTypes({ + variables: { + filter: generateDependentsFilter(baseUrl), + latestOnly: true, + ...zeroedGraphResolveDepths, + }, + }).then((resp) => { + if (!resp.data) { + throw new Error("No data returned from queryEntityTypes"); + } + + const types = getRoots( + mapGqlSubgraphFieldsFragmentToSubgraph( + resp.data.queryEntityTypes, + ), + ); + + return types; + }); + + return { + baseUrl, + dependentTypesAtLatestVersion, + }; + }), + ); + + const currentDependentsByDependencyBaseUrl = + dependentsAndBaseUrl.reduce< + Record + >( + (acc, { baseUrl, dependentTypesAtLatestVersion }) => { + acc[baseUrl] = dependentTypesAtLatestVersion; + return acc; + }, + {} as Record, + ); + + const nextLayer: VersionedUrl[] = []; + + for (const [dependencyBaseUrl, dependentTypes] of typedEntries( + currentDependentsByDependencyBaseUrl, + )) { + for (const dependent of dependentTypes) { + /** + * This is the type passed to the hook – we already found its dependents, + * and we don't need to report it as a dependent of itself or of any other types. + * We assume that the caller will be rewriting its schema along with the others returned. + */ + if ( + dependent.metadata.recordId.baseUrl === + extractBaseUrl(rootEntityTypeId) + ) { + continue; + } + + const dependentBaseUrl = dependent.metadata.recordId.baseUrl; + + if (dependentsByBaseUrl[dependentBaseUrl]) { + dependentsByBaseUrl[dependentBaseUrl].dependentOn.add( + dependencyBaseUrl, + ); + continue; + } + + if (isExternalOntologyElementMetadata(dependent.metadata)) { + dependentsByBaseUrl[dependentBaseUrl] = { + entityType: dependent.schema, + noFurtherTraversalBecause: "external-type-host", + dependentOn: new Set([dependencyBaseUrl]), + }; + continue; + } + + if (!userWebs.includes(dependent.metadata.ownedById)) { + dependentsByBaseUrl[dependentBaseUrl] = { + entityType: dependent.schema, + noFurtherTraversalBecause: "external-web", + dependentOn: new Set([dependencyBaseUrl]), + }; + continue; + } + + const excludedByUser = + !!excludeBaseUrls?.includes(dependentBaseUrl); + + if (!excludedByUser) { + nextLayer.push(dependent.schema.$id); + } + + dependentsByBaseUrl[dependentBaseUrl] = { + entityType: dependent.schema, + noFurtherTraversalBecause: excludedByUser + ? "user-excluded" + : undefined, + dependentOn: new Set([dependencyBaseUrl]), + }; + } + + if (nextLayer.length > 0) { + layerStack.push(nextLayer); + } + } + } + + return dependentsByBaseUrl; + }, + [queryEntityTypes, userWebs], + ); + + return useMemo( + () => ({ + getEntityTypeDependents, + loading, + }), + [getEntityTypeDependents, loading], + ); +}; diff --git a/apps/hash-frontend/src/pages/shared/entity-type-page/use-entity-type-value.tsx b/apps/hash-frontend/src/pages/shared/entity-type-page/use-entity-type-value.tsx index a32c7473590..ecf70baf27f 100644 --- a/apps/hash-frontend/src/pages/shared/entity-type-page/use-entity-type-value.tsx +++ b/apps/hash-frontend/src/pages/shared/entity-type-page/use-entity-type-value.tsx @@ -26,12 +26,12 @@ import { import type { CreateEntityTypeMutation, CreateEntityTypeMutationVariables, - UpdateEntityTypeMutation, - UpdateEntityTypeMutationVariables, + UpdateEntityTypesMutation, + UpdateEntityTypesMutationVariables, } from "../../../graphql/api-types.gen"; import { createEntityTypeMutation, - updateEntityTypeMutation, + updateEntityTypesMutation, } from "../../../graphql/queries/ontology/entity-type.queries"; import { useEntityTypesLoading, @@ -59,10 +59,10 @@ export const useEntityTypeValue = ( const isDraft = !entityTypeBaseUrl; - const [updateEntityType] = useMutation< - UpdateEntityTypeMutation, - UpdateEntityTypeMutationVariables - >(updateEntityTypeMutation); + const [updateEntityTypes] = useMutation< + UpdateEntityTypesMutation, + UpdateEntityTypesMutationVariables + >(updateEntityTypesMutation); const { contextEntityType, latestVersion } = useMemo<{ contextEntityType: EntityTypeWithMetadata | null; @@ -190,7 +190,10 @@ export const useEntityTypeValue = ( }, [entityTypeBaseUrl, refetch]); const updateCallback = useCallback( - async (partialEntityType: Partial) => { + async ( + partialEntityType: Partial, + dependentsToUpgrade: EntityType[], + ) => { if (!stateEntityTypeRef.current) { throw new Error("Cannot update yet"); } @@ -199,13 +202,23 @@ export const useEntityTypeValue = ( schema: { $id, ...restOfEntityType }, } = stateEntityTypeRef.current; - const res = await updateEntityType({ + const primaryUpdate = { + entityTypeId: $id, + updatedEntityType: { + ...restOfEntityType, + ...partialEntityType, + }, + }; + + const res = await updateEntityTypes({ variables: { - entityTypeId: $id, - updatedEntityType: { - ...restOfEntityType, - ...partialEntityType, - }, + updates: [ + primaryUpdate, + ...dependentsToUpgrade.map(({ $id: dependencyId, ...rest }) => ({ + entityTypeId: dependencyId, + updatedEntityType: rest, + })), + ], }, }); @@ -213,7 +226,7 @@ export const useEntityTypeValue = ( return res; }, - [refetch, updateEntityType], + [refetch, updateEntityTypes], ); const publishDraft = useCallback( diff --git a/apps/hash-frontend/src/pages/types/external/entity-type/[...base64-baseurl-maybe-version].page.tsx b/apps/hash-frontend/src/pages/types/external/entity-type/[...base64-baseurl-maybe-version].page.tsx index 20953a73c85..ad5d0134f55 100644 --- a/apps/hash-frontend/src/pages/types/external/entity-type/[...base64-baseurl-maybe-version].page.tsx +++ b/apps/hash-frontend/src/pages/types/external/entity-type/[...base64-baseurl-maybe-version].page.tsx @@ -21,6 +21,7 @@ const Page: NextPageWithLayout = () => { return ( ); diff --git a/apps/hash-frontend/src/shared/entity-types-context/hooks.ts b/apps/hash-frontend/src/shared/entity-types-context/hooks.ts index 2b3cf2b71d6..3b806934a70 100644 --- a/apps/hash-frontend/src/shared/entity-types-context/hooks.ts +++ b/apps/hash-frontend/src/shared/entity-types-context/hooks.ts @@ -76,14 +76,22 @@ export const useLatestEntityTypesOptional = (params?: { export const useIsSpecialEntityType = ( entityType: Pick & { $id?: EntityType["$id"] }, ) => { - const { entityTypes } = useEntityTypesContextRequired(); + const { entityTypes, loading } = useEntityTypesContextRequired(); return useMemo(() => { + if (loading) { + return { + isFile: false, + isImage: false, + isLink: false, + }; + } + const typesByVersion: Record = Object.fromEntries( (entityTypes ?? []).map((type) => [type.schema.$id, type]), ); return isSpecialEntityType(entityType, typesByVersion); - }, [entityType, entityTypes]); + }, [entityType, entityTypes, loading]); }; diff --git a/libs/@hashintel/type-editor/src/entity-type-editor/property-list-card/property-type-form/expected-value-selector.tsx b/libs/@hashintel/type-editor/src/entity-type-editor/property-list-card/property-type-form/expected-value-selector.tsx index e497c4e094b..6cf401359cc 100644 --- a/libs/@hashintel/type-editor/src/entity-type-editor/property-list-card/property-type-form/expected-value-selector.tsx +++ b/libs/@hashintel/type-editor/src/entity-type-editor/property-list-card/property-type-form/expected-value-selector.tsx @@ -89,6 +89,7 @@ const ExpectedValueSelectorDropdown = () => { { diff --git a/libs/@local/hash-isomorphic-utils/src/graphql/type-defs/ontology/entity-type.typedef.ts b/libs/@local/hash-isomorphic-utils/src/graphql/type-defs/ontology/entity-type.typedef.ts index 283af2e92fc..cfe5a223ffd 100644 --- a/libs/@local/hash-isomorphic-utils/src/graphql/type-defs/ontology/entity-type.typedef.ts +++ b/libs/@local/hash-isomorphic-utils/src/graphql/type-defs/ontology/entity-type.typedef.ts @@ -55,6 +55,11 @@ export const entityTypeTypedef = gql` ): UserPermissionsOnEntityType! } + input EntityTypeUpdate { + entityTypeId: VersionedUrl! + updatedEntityType: ConstructEntityTypeParams! + } + extend type Mutation { """ Create a entity type. @@ -81,6 +86,13 @@ export const entityTypeTypedef = gql` updatedEntityType: ConstructEntityTypeParams! ): EntityTypeWithMetadata! + """ + Update multiple entity types at once. + """ + updateEntityTypes( + updates: [EntityTypeUpdate!]! + ): [EntityTypeWithMetadata!]! + """ Archive a entity type. """ diff --git a/libs/@local/hash-isomorphic-utils/src/ontology-types.ts b/libs/@local/hash-isomorphic-utils/src/ontology-types.ts index babc350a50b..b9d2a292351 100644 --- a/libs/@local/hash-isomorphic-utils/src/ontology-types.ts +++ b/libs/@local/hash-isomorphic-utils/src/ontology-types.ts @@ -4,6 +4,7 @@ import { typedEntries } from "@local/advanced-types/typed-entries"; import type { BaseUrl } from "@local/hash-graph-types/ontology"; import { componentsFromVersionedUrl, + extractBaseUrl, versionedUrlFromComponents, } from "@local/hash-subgraph/type-system-patch"; @@ -124,6 +125,65 @@ export const generateLinkMapWithConsistentSelfReferences = ( {}, ); +export const rewriteSchemasToNextVersion = ( + entityTypesToChange: EntityType[], +) => { + const baseUrlToNewVersion: Record = {}; + + for (const entityType of entityTypesToChange) { + const { baseUrl, version } = componentsFromVersionedUrl(entityType.$id); + + baseUrlToNewVersion[baseUrl] = versionedUrlFromComponents( + baseUrl, + version + 1, + ); + } + + const updatedSchemas: EntityType[] = []; + + for (const entityType of entityTypesToChange) { + const clonedType = JSON.parse( + JSON.stringify(entityType), + ) as typeof entityType; + + for (const [linkTypeId, linkSchema] of typedEntries( + clonedType.links ?? {}, + )) { + if ("oneOf" in linkSchema.items) { + for (const item of linkSchema.items.oneOf) { + const baseUrl = extractBaseUrl(item.$ref); + + const newDestinationVersionedUrl = baseUrlToNewVersion[baseUrl]; + if (newDestinationVersionedUrl) { + item.$ref = newDestinationVersionedUrl; + } + } + } + + const baseUrl = extractBaseUrl(linkTypeId); + const newLinkTypeId = baseUrlToNewVersion[baseUrl]; + + if (newLinkTypeId) { + delete clonedType.links?.[linkTypeId]; + clonedType.links![newLinkTypeId] = linkSchema; + } + } + + for (const allOf of clonedType.allOf ?? []) { + const baseUrl = extractBaseUrl(allOf.$ref); + const newAllOfVersionedUrl = baseUrlToNewVersion[baseUrl]; + + if (newAllOfVersionedUrl) { + allOf.$ref = newAllOfVersionedUrl; + } + } + + updatedSchemas.push(clonedType); + } + + return updatedSchemas; +}; + const hashFormattedVersionedUrlRegExp = /https?:\/\/.+\/@(.+)\/types\/(entity-type|data-type|property-type)\/.+\/v\/\d+$/;