diff --git a/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/migrations/021-create-more-custom-data-types.dev.migration.ts b/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/migrations/021-create-more-custom-data-types.dev.migration.ts index 634218eb5c1..6ef862fe83e 100644 --- a/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/migrations/021-create-more-custom-data-types.dev.migration.ts +++ b/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/migrations/021-create-more-custom-data-types.dev.migration.ts @@ -65,7 +65,12 @@ const migrate: MigrationFunction = async ({ "A measure of the length of time in the International System of Units (SI), defined as exactly 1/1000 of a second.", type: "number", }, - conversions: {}, + conversions: { + [secondDataType.metadata.recordId.baseUrl]: { + from: { expression: ["/", "self", { const: 1000, type: "number" }] }, + to: { expression: ["*", "self", { const: 1000, type: "number" }] }, + }, + }, webShortname: "h", migrationState, }); @@ -78,7 +83,12 @@ const migrate: MigrationFunction = async ({ "A measure of the length of time in the International System of Units (SI), defined as exactly 1/1000000 (1 millionth) of a second.", type: "number", }, - conversions: {}, + conversions: { + [secondDataType.metadata.recordId.baseUrl]: { + from: { expression: ["/", "self", { const: 1000000, type: "number" }] }, + to: { expression: ["*", "self", { const: 1000000, type: "number" }] }, + }, + }, webShortname: "h", migrationState, }); diff --git a/apps/hash-api/src/graph/ontology/primitive/data-type.ts b/apps/hash-api/src/graph/ontology/primitive/data-type.ts index 57c21abff84..4329646ad87 100644 --- a/apps/hash-api/src/graph/ontology/primitive/data-type.ts +++ b/apps/hash-api/src/graph/ontology/primitive/data-type.ts @@ -18,12 +18,13 @@ import type { } from "@local/hash-graph-client"; import type { ConstructDataTypeParams, + DataTypeConversionTargets, + DataTypeDirectConversionsMap, DataTypeMetadata, DataTypeWithMetadata, OntologyTypeRecordId, } from "@local/hash-graph-types/ontology"; import type { OwnedById } from "@local/hash-graph-types/web"; -import type { DataTypeConversionTargets } from "@local/hash-isomorphic-utils/data-types"; import { currentTimeInstantTemporalAxes } from "@local/hash-isomorphic-utils/graph-queries"; import { generateTypeId } from "@local/hash-isomorphic-utils/ontology-types"; import { @@ -65,7 +66,7 @@ export const createDataType: ImpureGraphFunction< webShortname?: string; relationships: DataTypeRelationAndSubject[]; provenance?: ProvidedOntologyEditionProvenance; - conversions: Record; + conversions?: DataTypeDirectConversionsMap | null; }, Promise > = async (ctx, authentication, params) => { @@ -102,7 +103,7 @@ export const createDataType: ImpureGraphFunction< ...ctx.provenance, ...params.provenance, }, - conversions, + conversions: conversions ?? {}, }, ); diff --git a/apps/hash-api/src/graphql/resolvers/ontology/data-type.ts b/apps/hash-api/src/graphql/resolvers/ontology/data-type.ts index b3da5761709..a25bd6c9c7a 100644 --- a/apps/hash-api/src/graphql/resolvers/ontology/data-type.ts +++ b/apps/hash-api/src/graphql/resolvers/ontology/data-type.ts @@ -1,6 +1,6 @@ import type { DataTypeWithMetadata } from "@blockprotocol/graph"; -import type { OntologyTemporalMetadata } from "@local/hash-graph-client/dist/api.d"; -import type { DataTypeConversionsMap } from "@local/hash-isomorphic-utils/data-types"; +import type { OntologyTemporalMetadata } from "@local/hash-graph-client"; +import type { DataTypeFullConversionTargetsMap } from "@local/hash-graph-types/ontology"; import { currentTimeInstantTemporalAxes, defaultDataTypeAuthorizationRelationships, @@ -115,7 +115,7 @@ export const getDataType: ResolverFn< ); export const getDataTypeConversionTargetsResolver: ResolverFn< - Promise, + Promise, Record, GraphQLContext, QueryGetDataTypeConversionTargetsArgs @@ -133,7 +133,7 @@ export const createDataTypeResolver: ResolverFn< LoggedInGraphQLContext, MutationCreateDataTypeArgs > = async (_, params, { dataSources, authentication, provenance }) => { - const { ownedById, dataType } = params; + const { ownedById, conversions, dataType } = params; const createdDataType = await createDataType( { @@ -145,7 +145,7 @@ export const createDataTypeResolver: ResolverFn< ownedById, schema: dataType, relationships: defaultDataTypeAuthorizationRelationships, - conversions: {}, + conversions: conversions ?? {}, }, ); diff --git a/apps/hash-frontend/src/graphql/queries/ontology/data-type.queries.ts b/apps/hash-frontend/src/graphql/queries/ontology/data-type.queries.ts index e9fbf37442e..6489204b21b 100644 --- a/apps/hash-frontend/src/graphql/queries/ontology/data-type.queries.ts +++ b/apps/hash-frontend/src/graphql/queries/ontology/data-type.queries.ts @@ -52,13 +52,21 @@ export const checkUserPermissionsOnDataTypeQuery = gql` `; export const createDataTypeMutation = gql` - mutation createDataType($ownedById: OwnedById!, $dataType: ConstructDataTypeParams!) { - createDataType(ownedById: $ownedById, dataType: $dataType) + mutation createDataType( + $ownedById: OwnedById! + $dataType: ConstructDataTypeParams! + $conversions: DataTypeDirectConversionsMap + ) { + createDataType(ownedById: $ownedById, dataType: $dataType, conversions: $conversions) } `; export const updateDataTypeMutation = gql` - mutation updateDataType($dataTypeId: VersionedUrl!, $dataType: ConstructDataTypeParams!) { - updateDataType(dataTypeId: $dataTypeId, dataType: $dataType) + mutation updateDataType( + $dataTypeId: VersionedUrl! + $dataType: ConstructDataTypeParams! + $conversions: DataTypeDirectConversionsMap + ) { + updateDataType(dataTypeId: $dataTypeId, dataType: $dataType, conversions: $conversions) } `; diff --git a/apps/hash-frontend/src/pages/shared/create-data-type-form.tsx b/apps/hash-frontend/src/pages/shared/create-data-type-form.tsx index f604c3017ac..03fb3e0edcd 100644 --- a/apps/hash-frontend/src/pages/shared/create-data-type-form.tsx +++ b/apps/hash-frontend/src/pages/shared/create-data-type-form.tsx @@ -17,6 +17,7 @@ import { getDataTypeQuery } from "../../graphql/queries/ontology/data-type.queri import { Button } from "../../shared/ui/button"; import { useAuthenticatedUser } from "./auth-info-context"; import { useDataTypesContext } from "./data-types-context"; +import { useSlideStack } from "./slide-stack"; import { useGenerateTypeUrlsForUser } from "./use-generate-type-urls-for-user"; import { WorkspaceContext } from "./workspace-context"; @@ -76,6 +77,8 @@ export const CreateDataTypeForm = ({ const { dataTypes } = useDataTypesContext(); + const { closeSlideStack } = useSlideStack(); + const parentType = extendsDataTypeId ? dataTypes?.[extendsDataTypeId] : null; if (!activeWorkspace) { @@ -126,6 +129,7 @@ export const CreateDataTypeForm = ({ afterSubmit?.(); await router.push(nextUrl); + closeSlideStack(); }); const formItemWidth = `min(calc(100% - ${HELPER_TEXT_WIDTH + 52}px), 600px)`; diff --git a/apps/hash-frontend/src/pages/shared/create-entity-type-form.tsx b/apps/hash-frontend/src/pages/shared/create-entity-type-form.tsx index 478845a35bc..f488d0e02a2 100644 --- a/apps/hash-frontend/src/pages/shared/create-entity-type-form.tsx +++ b/apps/hash-frontend/src/pages/shared/create-entity-type-form.tsx @@ -25,6 +25,7 @@ import { import { useEntityTypesOptional } from "../../shared/entity-types-context/hooks"; import { Button } from "../../shared/ui/button"; import { useAuthenticatedUser } from "./auth-info-context"; +import { useSlideStack } from "./slide-stack"; import { useGenerateTypeUrlsForUser } from "./use-generate-type-urls-for-user"; import { WorkspaceContext } from "./workspace-context"; @@ -76,6 +77,8 @@ export const CreateEntityTypeForm = ({ defaultValues: initialData, }); + const { closeSlideStack } = useSlideStack(); + const title = watch("title"); const titlePlural = watch("titlePlural"); const inverseTitle = watch("inverseTitle"); @@ -217,6 +220,7 @@ export const CreateEntityTypeForm = ({ afterSubmit?.(); await router.push(nextUrl); + closeSlideStack(); }); const formItemWidth = `min(calc(100% - ${HELPER_TEXT_WIDTH + 52}px), 600px)`; diff --git a/apps/hash-frontend/src/pages/shared/data-type.tsx b/apps/hash-frontend/src/pages/shared/data-type.tsx index c65fc74ca30..57fb6fd2d46 100644 --- a/apps/hash-frontend/src/pages/shared/data-type.tsx +++ b/apps/hash-frontend/src/pages/shared/data-type.tsx @@ -35,6 +35,7 @@ import { generateLinkParameters } from "../../shared/generate-link-parameters"; import { Link } from "../../shared/ui/link"; import { useUserPermissionsOnDataType } from "../../shared/use-user-permissions-on-data-type"; import { DataTypeConstraints } from "./data-type/data-type-constraints"; +import { DataTypeConversions } from "./data-type/data-type-conversions"; import { type DataTypeFormData, getDataTypeFromFormData, @@ -43,6 +44,7 @@ import { import { DataTypeHeader } from "./data-type/data-type-header"; import { DataTypeLabels } from "./data-type/data-type-labels"; import { DataTypesParents } from "./data-type/data-type-parents"; +import { InheritedConstraintsProvider } from "./data-type/shared/use-inherited-constraints"; import { useDataTypesContext } from "./data-types-context"; import { EditBarTypeEditor } from "./entity-type-page/edit-bar-type-editor"; import { NotFound } from "./not-found"; @@ -118,9 +120,19 @@ export const DataType = ({ defaultValue: [], }); + const abstract = useWatch({ + control, + name: "abstract", + }); + useEffect(() => { if (draftNewDataType) { - reset(getFormDataFromDataType(draftNewDataType.schema)); + reset( + getFormDataFromDataType({ + schema: draftNewDataType.schema, + metadata: {}, + }), + ); } }, [draftNewDataType, reset]); @@ -194,7 +206,7 @@ export const DataType = ({ useEffect(() => { if (remoteDataType) { - formMethods.reset(getFormDataFromDataType(remoteDataType.schema)); + formMethods.reset(getFormDataFromDataType(remoteDataType)); } }, [remoteDataType, formMethods]); @@ -219,7 +231,8 @@ export const DataType = ({ return; } - const inputData = getDataTypeFromFormData(data); + const { dataType: inputDataType, conversions } = + getDataTypeFromFormData(data); if (isDraft) { if (!ownedById) { @@ -228,7 +241,8 @@ export const DataType = ({ const response = await createDataType({ variables: { - dataType: inputData, + dataType: inputDataType, + conversions, ownedById, }, }); @@ -248,7 +262,8 @@ export const DataType = ({ const response = await updateDataType({ variables: { dataTypeId: remoteDataType.schema.$id, - dataType: inputData, + dataType: inputDataType, + conversions, }, }); @@ -310,7 +325,7 @@ export const DataType = ({ return ( <> - + {!inSlide && } - - + + + + {!abstract && ( + + )} + diff --git a/apps/hash-frontend/src/pages/shared/data-type/data-type-constraints/abstract-constraint.tsx b/apps/hash-frontend/src/pages/shared/data-type/data-type-constraints/abstract-constraint.tsx index 95b31263715..605f39003b6 100644 --- a/apps/hash-frontend/src/pages/shared/data-type/data-type-constraints/abstract-constraint.tsx +++ b/apps/hash-frontend/src/pages/shared/data-type/data-type-constraints/abstract-constraint.tsx @@ -19,7 +19,7 @@ export const AbstractConstraint = ({ } return ( - + {abstract ? "Not assignable" : "Assignable"} diff --git a/apps/hash-frontend/src/pages/shared/data-type/data-type-constraints/number-constraints.tsx b/apps/hash-frontend/src/pages/shared/data-type/data-type-constraints/number-constraints.tsx index 6a0bad0b5e5..4d87ed73b51 100644 --- a/apps/hash-frontend/src/pages/shared/data-type/data-type-constraints/number-constraints.tsx +++ b/apps/hash-frontend/src/pages/shared/data-type/data-type-constraints/number-constraints.tsx @@ -208,7 +208,7 @@ export const NumberConstraintEditor = ({ inheritedConstraints: InheritedConstraints; }) => { return ( - + {!inheritedConstraints.enum && ( void }) => ( @@ -128,6 +132,17 @@ const EnumItem = ({ id: item, }); + const { control } = useFormContext(); + + const ownLabel = useWatch({ control, name: "label" }); + + const inheritedLabel = useInheritedConstraints().label; + + const label = { + left: ownLabel?.left ?? inheritedLabel?.left?.value, + right: ownLabel?.right ?? inheritedLabel?.right?.value, + }; + const isLastUnremovableItem = isOnlyItem && inheritedFromTitle; return ( @@ -170,7 +185,14 @@ const EnumItem = ({ ml: inheritedFromTitle ? 0.5 : 0, }} > - {item} + {createFormattedValueParts({ + inner: item.toString(), + schema: { label }, + }).map(({ color, text }) => ( + + {text} + + ))} diff --git a/apps/hash-frontend/src/pages/shared/data-type/data-type-constraints/shared/number-input.tsx b/apps/hash-frontend/src/pages/shared/data-type/data-type-constraints/shared/number-input.tsx index 7c14ce5151f..864aa4a8377 100644 --- a/apps/hash-frontend/src/pages/shared/data-type/data-type-constraints/shared/number-input.tsx +++ b/apps/hash-frontend/src/pages/shared/data-type/data-type-constraints/shared/number-input.tsx @@ -1,4 +1,4 @@ -import { Box } from "@mui/material"; +import { Box, type SxProps, type Theme } from "@mui/material"; import { inputStyles } from "../../shared/input-styles"; @@ -8,6 +8,7 @@ export const NumberInput = ({ min, max, multipleOf, + sx, value, onChange, width = 120, @@ -18,6 +19,7 @@ export const NumberInput = ({ max?: number; multipleOf?: number; onChange: (value: number | null) => void; + sx?: SxProps; value: number | null; width?: number; }) => { @@ -40,7 +42,7 @@ export const NumberInput = ({ onChange(parsedValue); } }} - sx={[inputStyles, { width }]} + sx={[inputStyles, { width }, ...(Array.isArray(sx) ? sx : [sx])]} /> ); }; diff --git a/apps/hash-frontend/src/pages/shared/data-type/data-type-constraints/string-constraints.tsx b/apps/hash-frontend/src/pages/shared/data-type/data-type-constraints/string-constraints.tsx index fcf69904964..6a6a29e450c 100644 --- a/apps/hash-frontend/src/pages/shared/data-type/data-type-constraints/string-constraints.tsx +++ b/apps/hash-frontend/src/pages/shared/data-type/data-type-constraints/string-constraints.tsx @@ -100,7 +100,7 @@ export const StringConstraintEditor = ({ const hasEnum = "enum" in inheritedConstraints || !!ownEnum; return ( - + {!inheritedConstraints.enum && !isStringLengthIrrelevant(format) && ( = { from: DataType; @@ -31,4 +33,5 @@ export type InheritedConstraints = { left?: InheritedConstraint; right?: InheritedConstraint; }; + conversions?: Record>; }; diff --git a/apps/hash-frontend/src/pages/shared/data-type/data-type-conversions.tsx b/apps/hash-frontend/src/pages/shared/data-type/data-type-conversions.tsx new file mode 100644 index 00000000000..0a6e30ad569 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/data-type/data-type-conversions.tsx @@ -0,0 +1,306 @@ +import { useQuery } from "@apollo/client"; +import { + type Conversions, + type DataType, + type VersionedUrl, +} from "@blockprotocol/type-system"; +import { typedEntries, typedValues } from "@local/advanced-types/typed-entries"; +import type { + BaseUrl, + DataTypeWithMetadata, +} from "@local/hash-graph-types/ontology"; +import { createConversionFunction } from "@local/hash-isomorphic-utils/data-types"; +import { formatNumber } from "@local/hash-isomorphic-utils/format-number"; +import { extractBaseUrl } from "@local/hash-subgraph/type-system-patch"; +import { Box, Typography } from "@mui/material"; +import { useMemo } from "react"; +import { useFormContext, useWatch } from "react-hook-form"; + +import type { + GetDataTypeConversionTargetsQuery, + GetDataTypeConversionTargetsQueryVariables, +} from "../../../graphql/api-types.gen"; +import { getDataTypeConversionTargetsQuery } from "../../../graphql/queries/ontology/data-type.queries"; +import { generateLinkParameters } from "../../../shared/generate-link-parameters"; +import { Link } from "../../../shared/ui/link"; +import { useDataTypesContext } from "../data-types-context"; +import { useSlideStack } from "../slide-stack"; +import { ConversionEditor } from "./data-type-conversions/conversion-editor"; +import { ConversionTargetEditor } from "./data-type-conversions/conversion-target-editor"; +import type { DataTypeFormData } from "./data-type-form"; +import { useInheritedConstraints } from "./shared/use-inherited-constraints"; + +type CombinedConversions = Record< + BaseUrl, + { + conversions: Conversions; + inheritedFromTitle: string | null; + target: DataTypeWithMetadata; + } +>; + +export const DataTypeConversions = ({ + dataType, + isReadOnly, +}: { + dataType: DataType; + isReadOnly: boolean; +}) => { + const { control } = useFormContext(); + + const { dataTypes } = useDataTypesContext(); + + const inheritedConstraints = useInheritedConstraints(); + + const inheritedConversions = inheritedConstraints.conversions; + + const ownConversions = useWatch({ control, name: "conversions" }); + + const combinedConversions = useMemo(() => { + const combined: CombinedConversions = {}; + + const latestDataTypeByBaseUrl: Record = {}; + for (const dataTypeOption of Object.values(dataTypes ?? {})) { + const currentLatest = + latestDataTypeByBaseUrl[dataTypeOption.metadata.recordId.baseUrl]; + + if ( + !currentLatest || + dataTypeOption.metadata.recordId.version > + currentLatest.metadata.recordId.version + ) { + latestDataTypeByBaseUrl[dataTypeOption.metadata.recordId.baseUrl] = + dataTypeOption; + } + } + + for (const [targetBaseUrl, conversions] of typedEntries( + ownConversions ?? {}, + )) { + const target = latestDataTypeByBaseUrl[targetBaseUrl]; + + if (!target) { + throw new Error(`Target data type not found: ${targetBaseUrl}`); + } + + combined[targetBaseUrl] = { + conversions, + inheritedFromTitle: null, + target, + }; + } + + for (const [targetBaseUrl, { value: conversions, from }] of typedEntries( + inheritedConversions ?? {}, + )) { + if (!combined[targetBaseUrl]) { + const target = Object.values(dataTypes ?? {}).find( + (option) => option.metadata.recordId.baseUrl === targetBaseUrl, + ); + + if (!target) { + throw new Error(`Target data type not found: ${targetBaseUrl}`); + } + + combined[targetBaseUrl] = { + conversions, + inheritedFromTitle: from.title, + target, + }; + } + } + + return combined; + }, [inheritedConversions, ownConversions, dataTypes]); + + const { pushToSlideStack } = useSlideStack(); + + const { data, loading } = useQuery< + GetDataTypeConversionTargetsQuery, + GetDataTypeConversionTargetsQueryVariables + >(getDataTypeConversionTargetsQuery, { + variables: { + /** + * This fetches the conversions available from the data types that _this_ data type defines conversions to ("local conversions"). + * We can then add our 'local conversion' to each target data type's conversions to get the full list of conversions. + * + * We don't fetch _this_ data type's conversionTargets from the API if it has any defined because: + * 1. They may not be persisted in the db yet + * 2. The user may be editing them locally + */ + dataTypeIds: Object.keys(combinedConversions).length + ? Object.values(combinedConversions).map( + ({ target }) => target.schema.$id, + ) + : /** + * If the data type has no conversions defined, we fetch its own conversionTargets. + * A data type which is the TARGET of conversions in a group won't have any defined on itself. + * @todo when we have data types which may be both the target of conversions, + * and have their own conversions defined, these will have to be combined somehow. + */ + [dataType.$id], + }, + }); + + const conversionTargetsMap = data?.getDataTypeConversionTargets; + + const conversionTargets = typedEntries(conversionTargetsMap ?? {}) + .flatMap<{ + targetDataTypeId: VersionedUrl; + title: string; + valueForOneOfThese: number; + } | null>(([directTargetDataTypeId, onwardConversionsMap]) => { + if (!Object.keys(combinedConversions).length) { + return typedEntries(onwardConversionsMap).map( + ([onwardTargetDataTypeId, { conversions, title }]) => ({ + targetDataTypeId: onwardTargetDataTypeId, + title, + valueForOneOfThese: createConversionFunction(conversions)(1), + }), + ); + } + + const localConversions = + combinedConversions[extractBaseUrl(directTargetDataTypeId)] + ?.conversions; + + if (!localConversions) { + throw new Error( + `Local conversions not found for ${directTargetDataTypeId}`, + ); + } + + const conversionFnToDirectTarget = createConversionFunction([ + localConversions.to, + ]); + + const directTarget = dataTypes?.[directTargetDataTypeId]; + + if (!directTarget) { + throw new Error( + `Direct target data type not found: ${directTargetDataTypeId}`, + ); + } + + const directTargetData = { + targetDataTypeId: directTargetDataTypeId, + title: directTarget.schema.title, + valueForOneOfThese: conversionFnToDirectTarget(1), + }; + + return [ + directTargetData, + ...typedEntries(onwardConversionsMap).map( + ([ + onwardTargetDataTypeId, + { conversions: onwardConversions, title }, + ]) => { + if (onwardTargetDataTypeId === dataType.$id) { + return null; + } + + const conversionFunction = createConversionFunction([ + localConversions.to, + ...onwardConversions, + ]); + + return { + targetDataTypeId: onwardTargetDataTypeId, + title, + valueForOneOfThese: conversionFunction(1), + }; + }, + ), + ]; + }) + .filter((conversionTarget) => conversionTarget !== null) + .sort((a, b) => a.valueForOneOfThese - b.valueForOneOfThese); + + const type = useWatch({ control, name: "constraints.type" }); + + if ( + loading || + (!Object.keys(conversionTargetsMap ?? {}).length && isReadOnly) || + type !== "number" + ) { + return null; + } + + return ( + + + Conversions + + + {conversionTargets.length > 0 && ( + + palette.gray[80] }} + > + Values of this data type can be converted to the following: + + {conversionTargets.map((conversionTarget) => { + return ( + + + + 1 {dataType.title} ={" "} + + + {formatNumber(conversionTarget.valueForOneOfThese)} + { + event.preventDefault(); + pushToSlideStack({ + kind: "dataType", + itemId: conversionTarget.targetDataTypeId, + }); + }} + sx={{ textDecoration: "none", ml: 0.5 }} + > + {conversionTarget.title} + + + + + ); + })} + + )} + + {Object.keys(combinedConversions).length > 0 && ( + + palette.gray[80], mb: 1.5 }} + > + Calculated according to the following formulae: + + {typedValues(combinedConversions).map( + ({ conversions, inheritedFromTitle, target }) => { + return ( + + ); + }, + )} + + )} + + + + ); +}; diff --git a/apps/hash-frontend/src/pages/shared/data-type/data-type-conversions/conversion-editor.tsx b/apps/hash-frontend/src/pages/shared/data-type/data-type-conversions/conversion-editor.tsx new file mode 100644 index 00000000000..8521cb1ed03 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/data-type/data-type-conversions/conversion-editor.tsx @@ -0,0 +1,384 @@ +import type { + ConversionDefinition, + Conversions, + DataType, + Operator, +} from "@blockprotocol/type-system"; +import { CloseIcon, IconButton, Select } from "@hashintel/design-system"; +import { typedKeys } from "@local/advanced-types/typed-entries"; +import type { DataTypeWithMetadata } from "@local/hash-graph-types/ontology"; +import { createConversionFunction } from "@local/hash-isomorphic-utils/data-types"; +import { formatNumber } from "@local/hash-isomorphic-utils/format-number"; +import { + Box, + outlinedInputClasses, + Stack, + Tooltip, + Typography, +} from "@mui/material"; +import { useMemo } from "react"; +import { useFormContext, useWatch } from "react-hook-form"; + +import { TriangleExclamationRegularIcon } from "../../../../shared/icons/triangle-exclamation-regular-icon"; +import { MenuItem } from "../../../../shared/ui/menu-item"; +import { NumberInput } from "../data-type-constraints/shared/number-input"; +import type { DataTypeFormData } from "../data-type-form"; +import { ItemLabel } from "../shared/item-label"; + +const characterToOpMap = { + "+": "+", + "–": "-", + "×": "*", + "÷": "/", +}; + +const operatorCharacters = typedKeys(characterToOpMap); + +type OperatorCharacter = (typeof operatorCharacters)[number]; + +const operatorToOpCharacterMap: Record = { + "+": "+", + "-": "–", + "*": "×", + "/": "÷", +}; + +const ReadOnlyCalculation = ({ + definition, + inheritedFrom, + sourceTitle, + targetTitle, +}: { + definition: ConversionDefinition; + inheritedFrom: string | null; + sourceTitle: string; + targetTitle: string; +}) => { + const [operator, left, right] = definition.expression; + + return ( + + + The calculation to convert {sourceTitle} to {targetTitle}. + {inheritedFrom ? ( + <> + {" "} +
+ Inherited from ${inheritedFrom}. + + ) : ( + "" + )} +
+ } + > + {sourceTitle} to {targetTitle} + + + {[left, operator, right].map((token, index) => { + let value: string; + + if (token === "self") { + value = sourceTitle; + } else if (typeof token === "string") { + value = operatorToOpCharacterMap[token]; + } else if (Array.isArray(token)) { + throw new Error("Nested conversion expressions are not supported"); + } else { + value = formatNumber(token.const); + } + + return ( + + {value} + + ); + })} + + = {targetTitle} + + + + ); +}; + +const OperatorDropdown = ({ + operator, + onChange, +}: { + operator: Operator; + onChange: (operator: Operator) => void; +}) => { + return ( + + ); +}; + +const ConversionFormulaEditor = ({ + definition, + direction, + error, + inheritedFrom, + isReadOnly, + self, + target, +}: { + definition: ConversionDefinition; + direction: "from" | "to"; + inheritedFrom: string | null; + error?: string; + isReadOnly: boolean; + self: DataType; + target: DataTypeWithMetadata; +}) => { + const { control, setValue } = useFormContext(); + + const conversions = useWatch({ control, name: "conversions" }); + + const [operator, left, right] = definition.expression; + + if (Array.isArray(left) || Array.isArray(right)) { + throw new Error("Nested conversion expressions are not supported"); + } + + if (right === "self") { + throw new Error("Expected a constant value for the right operand"); + } + + if (isReadOnly) { + return ( + + ); + } + + return ( + + + The calculation to convert{" "} + {direction === "from" ? self.title : target.schema.title} to{" "} + {direction === "from" ? target.schema.title : self.title}. + {inheritedFrom ? ( + <> + {" "} +
+ Inherited from ${inheritedFrom}, but may be overridden + + ) : ( + "" + )} +
+ } + > + {direction === "from" ? self.title : target.schema.title} to{" "} + {direction === "from" ? target.schema.title : self.title} + + + + {direction === "from" ? self.title : target.schema.title} + + { + setValue("conversions", { + ...conversions, + [target.metadata.recordId.baseUrl]: { + ...(conversions?.[target.metadata.recordId.baseUrl] ?? {}), + [direction]: { + expression: [newOp, left, right], + } satisfies ConversionDefinition, + }, + }); + }} + /> + { + setValue("conversions", { + ...conversions, + [target.metadata.recordId.baseUrl]: { + ...(conversions?.[target.metadata.recordId.baseUrl] ?? {}), + [direction]: { + expression: [ + operator, + left, + { const: newRightConst ?? 1, type: "number" }, + ], + } satisfies ConversionDefinition, + }, + }); + }} + value={right.const} + sx={{ + px: 1, + py: "4px", + borderRadius: 1.5, + minWidth: 50, + mt: 0, + width: right.const.toString().length * 8 + 50, + }} + /> + + = {direction === "from" ? target.schema.title : self.title} + + {error !== undefined && ( + + palette.red[70], + ml: 0.5, + }} + /> + + )} + + + ); +}; + +export const ConversionEditor = ({ + conversions, + dataType, + inheritedFromTitle, + isReadOnly, + target, +}: { + conversions: Conversions; + dataType: DataType; + inheritedFromTitle: string | null; + isReadOnly: boolean; + target: DataTypeWithMetadata; +}) => { + const { from, to } = conversions; + + const { control, setValue } = useFormContext(); + + const ownConversions = useWatch({ control, name: "conversions" }); + + const unexpectedResult = useMemo(() => { + const fromFn = createConversionFunction([from]); + const toFn = createConversionFunction([to]); + + const result = fromFn(toFn(1)); + + return result === 1 ? null : result; + }, [from, to]); + + return ( + ({ + background: palette.gray[15], + borderRadius: 2, + border: `1px solid ${palette.gray[20]}`, + px: 1.5, + py: 1, + width: "fit-content", + })} + > + + + {!inheritedFromTitle && !isReadOnly && ( + + { + const newConversions = ownConversions ?? {}; + + delete newConversions[target.metadata.recordId.baseUrl]; + + setValue("conversions", newConversions); + }} + sx={({ palette }) => ({ + mt: 0.1, + "& svg": { fontSize: 11 }, + "&:hover": { + background: "none", + "& svg": { fill: palette.red[70] }, + }, + p: 0.5, + })} + type="button" + > + ({ + fill: palette.gray[40], + transition: transitions.create("fill"), + })} + /> + + + )} + + + + ); +}; diff --git a/apps/hash-frontend/src/pages/shared/data-type/data-type-conversions/conversion-target-editor.tsx b/apps/hash-frontend/src/pages/shared/data-type/data-type-conversions/conversion-target-editor.tsx new file mode 100644 index 00000000000..edcb1cc7aeb --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/data-type/data-type-conversions/conversion-target-editor.tsx @@ -0,0 +1,278 @@ +import { useQuery } from "@apollo/client"; +import type { + Conversions, + DataType, + VersionedUrl, +} from "@blockprotocol/type-system"; +import { + buildDataTypeTreesForSelector, + DataTypeSelector, +} from "@hashintel/design-system"; +import { typedKeys } from "@local/advanced-types/typed-entries"; +import { mapGqlSubgraphFieldsFragmentToSubgraph } from "@local/hash-isomorphic-utils/graph-queries"; +import { blockProtocolDataTypes } from "@local/hash-isomorphic-utils/ontology-type-ids"; +import type { DataTypeRootType } from "@local/hash-subgraph"; +import { getRoots } from "@local/hash-subgraph/stdlib"; +import { extractBaseUrl } from "@local/hash-subgraph/type-system-patch"; +import { Box, Stack, Typography } from "@mui/material"; +import { useEffect, useMemo, useState } from "react"; +import { useFormContext, useWatch } from "react-hook-form"; + +import type { + QueryDataTypesQuery, + QueryDataTypesQueryVariables, +} from "../../../../graphql/api-types.gen"; +import { queryDataTypesQuery } from "../../../../graphql/queries/ontology/data-type.queries"; +import { Button } from "../../../../shared/ui/button"; +import { useDataTypesContext } from "../../data-types-context"; +import type { DataTypeFormData } from "../data-type-form"; +import { useInheritedConstraints } from "../shared/use-inherited-constraints"; + +export const ConversionTargetEditor = ({ + dataType, +}: { + dataType: DataType; +}) => { + const { dataTypes, latestDataTypes } = useDataTypesContext(); + + const [selectingConversionTarget, setSelectingConversionTarget] = + useState(false); + + const { control, setValue } = useFormContext(); + + const directParentDataTypeIds = useWatch({ + control, + name: "allOf", + }); + + const ownConversions = useWatch({ control, name: "conversions" }); + + const inheritedConversions = useInheritedConstraints().conversions; + + useEffect(() => { + if (Object.keys(inheritedConversions ?? {}).length > 0) { + /** + * If we have inherited a conversion, wipe any local ones defined. + * We currently don't support multiple conversions (frontend limitation only to avoid complexity, can be lifted if needed). + */ + setValue("conversions", {}); + } + }, [inheritedConversions, setValue]); + + const addConversionTarget = (dataTypeId: VersionedUrl) => { + setValue("conversions", { + [extractBaseUrl(dataTypeId)]: { + to: { expression: ["/", "self", { const: 1, type: "number" }] }, + from: { expression: ["*", "self", { const: 1, type: "number" }] }, + } satisfies Conversions, + }); + }; + + const [hasCheckedSiblings, setHasCheckedSiblings] = useState(false); + + /** + * If someone happens to have defined a conversion on a direct child of Number, + * we don't want to declare it the canonical conversion target for all children of Number. + */ + const nonNumberParentIds = directParentDataTypeIds.filter( + (id) => + blockProtocolDataTypes.number.dataTypeBaseUrl !== extractBaseUrl(id), + ); + + const { data: siblingDataTypesData } = useQuery< + QueryDataTypesQuery, + QueryDataTypesQueryVariables + >(queryDataTypesQuery, { + variables: { + constrainsValuesOn: { outgoing: 0 }, + filter: { + any: nonNumberParentIds.map((id) => ({ + equal: [ + { path: ["inheritsFrom(inheritanceDepth=0)", "*", "versionedUrl"] }, + { parameter: id }, + ], + })), + }, + includeArchived: true, + inheritsFrom: { outgoing: 255 }, + latestOnly: false, + }, + skip: !nonNumberParentIds.length, + fetchPolicy: "cache-and-network", + }); + + const targetFromSiblings = useMemo(() => { + if (!siblingDataTypesData) { + return null; + } + + /** + * We check the siblings (direct children of direct parents) to see if any of them have conversions defined. + * We assume that there is only one 'canonical' conversion target in a given collection of siblings. + * If we find one, it is the only conversion target we will allow the user to select. + */ + const siblings = getRoots( + mapGqlSubgraphFieldsFragmentToSubgraph( + siblingDataTypesData.queryDataTypes, + ), + ); + + for (const sibling of siblings) { + const existingConversionMap = sibling.metadata.conversions; + + const targetBaseUrl = typedKeys(existingConversionMap ?? {})[0]; + + if (!targetBaseUrl) { + continue; + } + + const target = latestDataTypes?.[targetBaseUrl]; + + if (!target) { + throw new Error(`Target data type not found: ${targetBaseUrl}`); + } + + setHasCheckedSiblings(true); + + return target; + } + + setHasCheckedSiblings(true); + + return null; + }, [siblingDataTypesData, latestDataTypes]); + + useEffect(() => { + if (!targetFromSiblings) { + return; + } + + if (Object.keys(ownConversions ?? {}).length === 0) { + return; + } + + if (!ownConversions?.[targetFromSiblings.metadata.recordId.baseUrl]) { + /** + * Wipe the conversion targets if the user doesn't have the canonical for its siblings set. + * The user can choose to add this via the button. We don't want any other targets set. + */ + setValue("conversions", {}); + } + }, [setValue, ownConversions, targetFromSiblings]); + + const dataTypeOptions = useMemo(() => { + if (!dataTypes) { + return []; + } + + const dataTypesArray = Object.values(dataTypes); + + const baseUrl = extractBaseUrl(dataType.$id); + + return buildDataTypeTreesForSelector({ + targetDataTypes: dataTypesArray + .filter( + (type) => + "type" in type.schema && + /** + * Only allow defining conversions to numbers for now (we only show mathematical operators) + */ + type.schema.type === "number" && + /** + * Don't include the data type itself as a conversion target. + */ + type.metadata.recordId.baseUrl !== baseUrl && + /** + * Don't include 'Number' itself as a conversion target. + * These are already numbers. + */ + type.metadata.recordId.baseUrl !== + blockProtocolDataTypes.number.dataTypeBaseUrl && + /** + * Don't include anything which itself defines a conversion target. + * There should be another data type in its group which can be converted to. + * @todo this does not hold for data types which have defined conversions to targets in other groups. + */ + !Object.keys(type.metadata.conversions ?? {}).length, + ) + .map((type) => type.schema), + dataTypePoolById: dataTypesArray.reduce>( + (acc, type) => { + if ( + type.metadata.recordId.baseUrl === baseUrl /** + * Don't include anything which itself defines a conversion target. + * There should be another data type in its group which can be converted to. + * @todo this does not hold for data types which have defined conversions to targets in other groups. + */ || + !Object.keys(type.metadata.conversions ?? {}).length + ) { + return acc; + } + + acc[type.schema.$id] = type.schema; + return acc; + }, + {}, + ), + }); + }, [dataTypes, dataType.$id]); + + if ( + (nonNumberParentIds.length && !hasCheckedSiblings) || + Object.keys(ownConversions ?? {}).length > 0 || + Object.keys(inheritedConversions ?? {}).length > 0 || + targetFromSiblings?.schema.$id === dataType.$id + ) { + return null; + } + + return ( + + + + You can define how to convert values of this type to other data types. + + {targetFromSiblings && ( + + In this data type group, conversions are defined via{" "} + {targetFromSiblings.schema.title}. + + )} + + {selectingConversionTarget ? ( + + { + addConversionTarget(newParentTypeId); + setSelectingConversionTarget(false); + }} + placeholder="Select a data type to convert to..." + /> + + ) : ( + + )} + + ); +}; diff --git a/apps/hash-frontend/src/pages/shared/data-type/data-type-form.tsx b/apps/hash-frontend/src/pages/shared/data-type/data-type-form.tsx index 6379f88f65e..3c101ed4695 100644 --- a/apps/hash-frontend/src/pages/shared/data-type/data-type-form.tsx +++ b/apps/hash-frontend/src/pages/shared/data-type/data-type-form.tsx @@ -6,6 +6,10 @@ import type { VersionedUrl, } from "@blockprotocol/type-system"; import type { DistributiveOmit } from "@local/advanced-types/distribute"; +import type { + DataTypeDirectConversionsMap, + DataTypeMetadata, +} from "@local/hash-graph-types/ontology"; /** * For the purposes of react-hook-form we need to have explicit null values to be able to unset values. @@ -49,17 +53,19 @@ export type DataTypeFormData = Pick< > & { allOf: VersionedUrl[]; constraints: NullableSingleValueConstraints; + conversions?: DataTypeDirectConversionsMap; }; export const getDataTypeFromFormData = ({ allOf, constraints, + conversions, label, ...rest -}: DataTypeFormData): DistributiveOmit< - DataType, - "$id" | "$schema" | "kind" -> => { +}: DataTypeFormData): { + dataType: DistributiveOmit; + conversions?: DataTypeDirectConversionsMap; +} => { let unNulledConstraints: SingleValueConstraints; switch (constraints.type) { @@ -102,16 +108,20 @@ export const getDataTypeFromFormData = ({ } return { - ...rest, - allOf: allOf.map((versionedUrl) => ({ $ref: versionedUrl })), - label: Object.keys(label ?? {}).length > 0 ? label : undefined, - ...unNulledConstraints, + dataType: { + ...rest, + allOf: allOf.map((versionedUrl) => ({ $ref: versionedUrl })), + label: Object.keys(label ?? {}).length > 0 ? label : undefined, + ...unNulledConstraints, + }, + conversions, }; }; -export const getFormDataFromDataType = ( - dataType: DataType, -): DataTypeFormData => { +export const getFormDataFromDataType = (dataTypeWithMetadata: { + schema: DataType; + metadata: Pick; +}): DataTypeFormData => { const { $id: _$id, $schema: _$schema, @@ -122,12 +132,13 @@ export const getFormDataFromDataType = ( label, title, ...constraints - } = dataType; + } = dataTypeWithMetadata.schema; if ("anyOf" in constraints) { if (title === "Value") { return { allOf: [], + conversions: {}, description, label, title, @@ -174,9 +185,15 @@ export const getFormDataFromDataType = ( break; } + const { conversions } = + "ownedById" in dataTypeWithMetadata.metadata + ? dataTypeWithMetadata.metadata + : { conversions: null }; + return { allOf: allOf?.map(({ $ref }) => $ref) ?? [], abstract: !!abstract, + conversions: conversions ?? {}, description, label: label?.left?.length || label?.right?.length ? label : undefined, title, diff --git a/apps/hash-frontend/src/pages/shared/data-type/data-type-labels.tsx b/apps/hash-frontend/src/pages/shared/data-type/data-type-labels.tsx index 05d2b3124c4..649a0783dde 100644 --- a/apps/hash-frontend/src/pages/shared/data-type/data-type-labels.tsx +++ b/apps/hash-frontend/src/pages/shared/data-type/data-type-labels.tsx @@ -51,7 +51,6 @@ export const DataTypeLabels = ({ palette.gray[80], }} > @@ -66,7 +65,6 @@ export const DataTypeLabels = ({ component="p" variant="smallTextParagraphs" sx={{ - fontSize: 13, color: ({ palette }) => palette.gray[80], }} > diff --git a/apps/hash-frontend/src/pages/shared/data-type/data-type-parents.tsx b/apps/hash-frontend/src/pages/shared/data-type/data-type-parents.tsx index fc83d7ffa24..fbe01dea474 100644 --- a/apps/hash-frontend/src/pages/shared/data-type/data-type-parents.tsx +++ b/apps/hash-frontend/src/pages/shared/data-type/data-type-parents.tsx @@ -10,8 +10,7 @@ import { TypeCard, } from "@hashintel/design-system"; import { blockProtocolDataTypes } from "@local/hash-isomorphic-utils/ontology-type-ids"; -import { Typography } from "@mui/material"; -import { Box, Stack } from "@mui/system"; +import { Box, Stack, Typography } from "@mui/material"; import { useEffect, useMemo } from "react"; import { useFormContext, useWatch } from "react-hook-form"; @@ -246,38 +245,17 @@ export const DataTypesParents = ({ Extends - { + addParent(newParentTypeId); }} - > - ({ - background: palette.common.white, - border: `1px solid ${palette.gray[30]}`, - borderRadius: 2, - position: "absolute", - top: 0, - left: 0, - width: 600, - })} - > - { - addParent(newParentTypeId); - }} - selectedDataTypeIds={directParentDataTypeIds} - /> - - + placeholder="Select a data type to extend..." + selectedDataTypeIds={directParentDataTypeIds} + /> ); } @@ -287,24 +265,26 @@ export const DataTypesParents = ({ Extends - - {parents.map((parent) => { - return ( - { - onDataTypeClick(parent.dataType.$id); - }} - onlyParent={parents.length === 1} - parent={parent} - onRemove={() => { - removeParent(parent.dataType.$id); - }} - /> - ); - })} - + + + {parents.map((parent) => { + return ( + { + onDataTypeClick(parent.dataType.$id); + }} + onlyParent={parents.length === 1} + parent={parent} + onRemove={() => { + removeParent(parent.dataType.$id); + }} + /> + ); + })} + + ); }; diff --git a/apps/hash-frontend/src/pages/shared/data-type/shared/use-inherited-constraints.tsx b/apps/hash-frontend/src/pages/shared/data-type/shared/use-inherited-constraints.tsx index fb2d55a8fcb..b6cc6ef98d4 100644 --- a/apps/hash-frontend/src/pages/shared/data-type/shared/use-inherited-constraints.tsx +++ b/apps/hash-frontend/src/pages/shared/data-type/shared/use-inherited-constraints.tsx @@ -1,13 +1,14 @@ import type { DataType, VersionedUrl } from "@blockprotocol/type-system"; +import { typedEntries } from "@local/advanced-types/typed-entries"; import { blockProtocolDataTypes } from "@local/hash-isomorphic-utils/ontology-type-ids"; -import { useMemo } from "react"; +import { createContext, useContext, useMemo } from "react"; import { useFormContext, useWatch } from "react-hook-form"; import { useDataTypesContext } from "../../data-types-context"; import type { InheritedConstraints } from "../data-type-constraints/types"; import type { DataTypeFormData } from "../data-type-form"; -export const useInheritedConstraints = () => { +const useInheritedConstraintsValue = (): InheritedConstraints => { const { dataTypes } = useDataTypesContext(); const { control } = useFormContext(); @@ -52,7 +53,7 @@ export const useInheritedConstraints = () => { continue; } - const { schema: parentSchema } = parent; + const { schema: parentSchema, metadata: parentMetadata } = parent; if ("anyOf" in parentSchema) { /** @@ -101,6 +102,24 @@ export const useInheritedConstraints = () => { }; } + if (parentMetadata.conversions) { + narrowedConstraints.conversions ??= {}; + + for (const [targetBaseUrl, conversions] of typedEntries( + parentMetadata.conversions, + )) { + /** + * We only want to set this if it hasn't already been set. + * If a child (which we'll encounter sooner) has set a conversion for a given target, + * that's the one that should be used. + */ + narrowedConstraints.conversions[targetBaseUrl] ??= { + value: conversions, + from: parent.schema, + }; + } + } + if ("enum" in parentSchema) { if ( !narrowedConstraints.enum || @@ -220,3 +239,31 @@ export const useInheritedConstraints = () => { return narrowedConstraints; }, [dataTypes, allOf]); }; + +const InheritedConstraintsContext = createContext( + null, +); + +export const InheritedConstraintsProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const inheritedConstraints = useInheritedConstraintsValue(); + + return ( + + {children} + + ); +}; + +export const useInheritedConstraints = (): InheritedConstraints => { + const inheritedConstraints = useContext(InheritedConstraintsContext); + + if (!inheritedConstraints) { + throw new Error("InheritedConstraintsProvider not found"); + } + + return inheritedConstraints; +}; diff --git a/apps/hash-frontend/src/pages/shared/data-types-context.tsx b/apps/hash-frontend/src/pages/shared/data-types-context.tsx index 18d4fd73ca4..14cf98f078e 100644 --- a/apps/hash-frontend/src/pages/shared/data-types-context.tsx +++ b/apps/hash-frontend/src/pages/shared/data-types-context.tsx @@ -1,7 +1,10 @@ import { useQuery } from "@apollo/client"; import type { VersionedUrl } from "@blockprotocol/type-system"; import { typedValues } from "@local/advanced-types/typed-entries"; -import type { DataTypeWithMetadata } from "@local/hash-graph-types/ontology"; +import type { + BaseUrl, + DataTypeWithMetadata, +} from "@local/hash-graph-types/ontology"; import type { PropsWithChildren } from "react"; import { createContext, useContext, useMemo } from "react"; @@ -13,7 +16,7 @@ import { queryDataTypesQuery } from "../../graphql/queries/ontology/data-type.qu export type DataTypesContextValue = { dataTypes: Record | null; - latestDataTypes: Record | null; + latestDataTypes: Record | null; loading: boolean; refetch: () => void; }; @@ -44,7 +47,7 @@ export const DataTypesContextProvider = ({ children }: PropsWithChildren) => { } const all: Record = {}; - const latest: Record = {}; + const latest: Record = {}; for (const versionToVertexMap of Object.values( data.queryDataTypes.vertices, @@ -65,7 +68,7 @@ export const DataTypesContextProvider = ({ children }: PropsWithChildren) => { } if (highestVersion) { - latest[highestVersion.schema.$id] = highestVersion; + latest[highestVersion.metadata.recordId.baseUrl] = highestVersion; } } diff --git a/apps/hash-frontend/src/pages/shared/entity.tsx b/apps/hash-frontend/src/pages/shared/entity.tsx index 5671d71cf03..53883704dce 100644 --- a/apps/hash-frontend/src/pages/shared/entity.tsx +++ b/apps/hash-frontend/src/pages/shared/entity.tsx @@ -319,13 +319,6 @@ export const Entity = ({ UpdateEntityMutationVariables >(updateEntityMutation); - const isReadOnly = - !!proposedEntitySubgraph || - (!draftLocalEntity && - !getEntitySubgraphData?.getEntitySubgraph.userPermissionsOnEntities?.[ - entityId - ]?.edit); - const applyDraftLinkEntityChanges = useApplyDraftLinkEntityChanges(); const handleTypeChanges = useHandleTypeChanges({ @@ -340,6 +333,14 @@ export const Entity = ({ [draftEntitySubgraph], ); + const isReadOnly = + !!draftEntity?.metadata.archived || + !!proposedEntitySubgraph || + (!draftLocalEntity && + !getEntitySubgraphData?.getEntitySubgraph.userPermissionsOnEntities?.[ + entityId + ]?.edit); + const entityFromDb = useMemo( () => (dataFromDb ? getRoots(dataFromDb.entitySubgraph)[0] : null), [dataFromDb], @@ -556,7 +557,10 @@ export const Entity = ({ await validateEntity(changedEntity); - setIsDirty(true); + setIsDirty( + JSON.stringify(changedEntity.properties) !== + JSON.stringify(entityFromDb?.properties), + ); }} validationReport={validationReport} mode={ @@ -680,7 +684,10 @@ export const Entity = ({ await validateEntity(changedEntity); - setIsDirty(true); + setIsDirty( + JSON.stringify(changedEntity.properties) !== + JSON.stringify(entityFromDb?.properties), + ); }} validationReport={validationReport} /> diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor-container.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor-container.tsx index 02a1c4c721d..b55b67e7980 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor-container.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor-container.tsx @@ -1,5 +1,4 @@ -import { Box } from "@mui/material"; -import { Container } from "@mui/system"; +import { Box, Container } from "@mui/material"; import type { PropsWithChildren } from "react"; import { inSlideContainerStyles } from "../shared/slide-styles"; diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/history-section/get-history-events.ts b/apps/hash-frontend/src/pages/shared/entity/entity-editor/history-section/get-history-events.ts index e1171d3e1de..67b7080258b 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/history-section/get-history-events.ts +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/history-section/get-history-events.ts @@ -59,6 +59,18 @@ export const getHistoryEvents = (diffs: EntityDiff[], subgraph: Subgraph) => { ); } + const previousEntityEdition = getEntityRevision( + subgraph, + diffData.input.firstEntityId, + diffData.input.firstDecisionTime as Timestamp, + ); + + if (!previousEntityEdition) { + throw new Error( + `Could not find entity with id ${diffData.input.firstEntityId} in subgraph`, + ); + } + /** * The original edition is not included in the diffs, so the 0-based index needs +2 to get the nth edition with base 1 */ @@ -71,6 +83,21 @@ export const getHistoryEvents = (diffs: EntityDiff[], subgraph: Subgraph) => { const editionProvenance = changedEntityEdition.metadata.provenance.edition; + if ( + changedEntityEdition.metadata.archived !== + previousEntityEdition.metadata.archived + ) { + events.push({ + type: "archive-status-change", + newArchiveStatus: changedEntityEdition.metadata.archived, + provenance: { + edition: editionProvenance, + }, + number: `${editionNumber}.${subChangeNumber++}`, + timestamp, + }); + } + if (diffData.diff.entityTypeIds) { const upgradedFromEntityTypeIds: VersionedUrl[] = []; diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/history-section/history-table/shared/event-detail.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/history-section/history-table/shared/event-detail.tsx index 781d62c17e8..e19532c86e0 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/history-section/history-table/shared/event-detail.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/history-section/history-table/shared/event-detail.tsx @@ -136,6 +136,12 @@ export const EventDetail = ({ : "Live edition created from draft"} ); + case "archive-status-change": + return ( + + {event.newArchiveStatus ? "Entity archived" : "Entity unarchived"} + + ); default: { throw new Error("Unhandled history event type"); } diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/history-section/shared/types.ts b/apps/hash-frontend/src/pages/shared/entity/entity-editor/history-section/shared/types.ts index b6008da3441..05015a98a57 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/history-section/shared/types.ts +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/history-section/shared/types.ts @@ -54,8 +54,17 @@ type DraftStatusChangeEvent = HistoryEventBase & { }; }; +type ArchiveStatusChangeEvent = HistoryEventBase & { + type: "archive-status-change"; + newArchiveStatus: boolean; + provenance: { + edition: EntityEditionProvenance; + }; +}; + export type HistoryEvent = | CreationEvent | PropertyUpdateEvent | TypeUpdateEvent - | DraftStatusChangeEvent; + | DraftStatusChangeEvent + | ArchiveStatusChangeEvent; diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/properties-section/property-table/cells/value-cell/editor-type-picker.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/properties-section/property-table/cells/value-cell/editor-type-picker.tsx index 61293c559aa..68249f6ce93 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/properties-section/property-table/cells/value-cell/editor-type-picker.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/properties-section/property-table/cells/value-cell/editor-type-picker.tsx @@ -43,7 +43,6 @@ export const EditorTypePicker = ({ return ( diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/properties-section/property-table/cells/value-cell/single-value-editor.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/properties-section/property-table/cells/value-cell/single-value-editor.tsx index 1c94b78a3c0..9738a342a13 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/properties-section/property-table/cells/value-cell/single-value-editor.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/properties-section/property-table/cells/value-cell/single-value-editor.tsx @@ -338,6 +338,11 @@ export const SingleValueEditor: ValueCellEditorComponent = (props) => { draftCell.data.propertyRow.value = newValue; }); + if (value === newValue) { + onFinishedEditing(); + return; + } + onFinishedEditing(newCell); }} renderOption={({ key, ...itemProps }, item) => ( diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/properties-section/property-table/use-create-on-cell-edited.ts b/apps/hash-frontend/src/pages/shared/entity/entity-editor/properties-section/property-table/use-create-on-cell-edited.ts index 973a27ab489..d081241e6cd 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/properties-section/property-table/use-create-on-cell-edited.ts +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/properties-section/property-table/use-create-on-cell-edited.ts @@ -20,13 +20,13 @@ export const useCreateOnCellEdited = () => { (rows: PropertyRow[]) => { const onCellEdited = ( [colIndex, rowIndex]: Item, - newValue: EditableGridCell, + newValueCellOldType: EditableGridCell, ) => { - if (newValue.kind !== GridCellKind.Custom) { + if (newValueCellOldType.kind !== GridCellKind.Custom) { return; } - const newValueCell = newValue as ValueCell; + const newValueCell = newValueCellOldType as ValueCell; const key = propertyGridIndexes[colIndex]; const row = rows[rowIndex]; @@ -44,7 +44,13 @@ export const useCreateOnCellEdited = () => { const updatedMetadata = cloneDeep(entity.metadata); - const { propertyKeyChain } = row; + const { propertyKeyChain, value: previousValue } = row; + + const newValue = newValueCell.data.propertyRow.value; + + if (previousValue === newValue) { + return; + } /** * we're reaching to the nested property by the property keys array diff --git a/apps/hash-frontend/src/pages/shared/shared/edit-bar-contents.tsx b/apps/hash-frontend/src/pages/shared/shared/edit-bar-contents.tsx index 1248b50c1b1..b462a361409 100644 --- a/apps/hash-frontend/src/pages/shared/shared/edit-bar-contents.tsx +++ b/apps/hash-frontend/src/pages/shared/shared/edit-bar-contents.tsx @@ -220,7 +220,8 @@ export const useFreezeScrollWhileTransitioning = () => { }; export const EditBarContainer = styled(Box, { - shouldForwardProp: (prop) => prop !== "hasErrors", + shouldForwardProp: (prop) => + prop !== "hasErrors" && prop !== "gentleErrorStyling", })<{ hasErrors?: boolean; gentleErrorStyling?: boolean }>( ({ hasErrors, theme, gentleErrorStyling }) => ({ height: EDIT_BAR_HEIGHT, diff --git a/apps/hash-frontend/src/pages/shared/shared/type-editor-styling.tsx b/apps/hash-frontend/src/pages/shared/shared/type-editor-styling.tsx index 88f2c49b927..188c19c30ac 100644 --- a/apps/hash-frontend/src/pages/shared/shared/type-editor-styling.tsx +++ b/apps/hash-frontend/src/pages/shared/shared/type-editor-styling.tsx @@ -1,5 +1,5 @@ -import { Box } from "@mui/material"; -import { Container, type SxProps, type Theme } from "@mui/system"; +import { Box, Container } from "@mui/material"; +import { type SxProps, type Theme } from "@mui/system"; import { inSlideContainerStyles } from "./slide-styles"; diff --git a/apps/hash-frontend/src/pages/types/[[...type-kind]].page/types-table.tsx b/apps/hash-frontend/src/pages/types/[[...type-kind]].page/types-table.tsx index eed1f180575..7540be61c75 100644 --- a/apps/hash-frontend/src/pages/types/[[...type-kind]].page/types-table.tsx +++ b/apps/hash-frontend/src/pages/types/[[...type-kind]].page/types-table.tsx @@ -319,7 +319,7 @@ export const TypesTable: FunctionComponent<{ const row = rows[rowIndex]; if (!row) { - throw new Error("link not found"); + throw new Error("row not found"); } const column = typesTableColumns[colIndex]; diff --git a/libs/@hashintel/design-system/src/data-type-selector.tsx b/libs/@hashintel/design-system/src/data-type-selector.tsx index 71c2d1e4d95..2aefb057c69 100644 --- a/libs/@hashintel/design-system/src/data-type-selector.tsx +++ b/libs/@hashintel/design-system/src/data-type-selector.tsx @@ -15,12 +15,13 @@ import { componentsFromVersionedUrl } from "@local/hash-subgraph/type-system-pat import { Box, outlinedInputClasses, + Popper, Stack, TextField, Tooltip, Typography, } from "@mui/material"; -import type { MouseEventHandler } from "react"; +import type { MouseEventHandler, ReactNode, RefObject } from "react"; import { useEffect, useMemo, useRef, useState } from "react"; import { getIconForDataType } from "./data-type-selector/icons"; @@ -55,7 +56,6 @@ const isDataType = ( return "$id" in dataType; }; -const defaultMaxHeight = 500; const inputHeight = 48; const hintHeight = 36; @@ -348,17 +348,21 @@ const DataTypeFlatView = (props: { return ( onSelect(dataType.$id) + : (event) => { + event.stopPropagation(); + onSelect(dataType.$id); + } } sx={({ palette, transitions }) => ({ cursor: "pointer", px: 2.5, py: 1.5, - background: selected ? palette.blue[20] : undefined, + background: selected ? palette.blue[20] : palette.common.white, borderRadius: 1, border: `1px solid ${selected ? palette.blue[30] : palette.gray[30]}`, "&:hover": { @@ -456,6 +460,7 @@ const DataTypeTreeView = (props: { <> ; + /** + * If the parent is providing its own search input, this object is required, + */ + externalSearchInput?: { + /** + * Whether the search input is focused (determines whether the menu is open) + */ + focused: boolean; + /** + * The ref to the search input (determines the width of the menu and the element it is anchored to) + */ + inputRef: RefObject; + /** + * The search text (needed to filter the data types) + */ + searchText: string; + }; hideHint?: boolean; - maxHeight?: number; + placeholder?: string; onSelect: (dataTypeId: VersionedUrl) => void; - searchText?: string; selectedDataTypeIds?: VersionedUrl[]; }; +const maxMenuHeight = 300; + export const DataTypeSelector = (props: DataTypeSelectorProps) => { const { + additionalMenuContent, allowSelectingAbstractTypes, + autoFocus = true, dataTypes, + externallyProvidedPopoverRef, + externalSearchInput, hideHint, - handleScroll, - maxHeight: maxHeightFromProps, onSelect, - searchText: externallyControlledSearchText, + placeholder, selectedDataTypeIds, } = props; - const containerRef = useRef(null); - const [availableHeight, setAvailableHeight] = useState(); - - useEffect(() => { - const updateAvailableHeight = () => { - if (!containerRef.current || !handleScroll) { - return; - } - - const rect = containerRef.current.getBoundingClientRect(); - const windowHeight = window.innerHeight; - const bottomSpace = windowHeight - rect.top; - - // Add a small buffer (20px) to prevent touching the bottom of the window - const maxAvailableHeight = bottomSpace - 20; - - setAvailableHeight( - Math.min(maxHeightFromProps ?? defaultMaxHeight, maxAvailableHeight), - ); - }; - - updateAvailableHeight(); - window.addEventListener("resize", updateAvailableHeight); + const [textFieldFocused, setTextFieldFocused] = useState(false); - return () => { - window.removeEventListener("resize", updateAvailableHeight); - }; - }, [handleScroll, maxHeightFromProps]); + const containerRef = useRef(null); + const popoverRef = useRef(null); - const maxHeight = - !availableHeight || !handleScroll ? undefined : availableHeight; + const textFieldRef = useRef(null); const [localSearchText, setLocalSearchText] = useState(""); - const searchText = externallyControlledSearchText ?? localSearchText; + const searchText = externalSearchInput?.searchText ?? localSearchText; const { flattenedDataTypes, latestVersionByBaseUrl } = useMemo(() => { const flattened: DataTypeForSelector[] = []; @@ -682,103 +690,142 @@ export const DataTypeSelector = (props: DataTypeSelectorProps) => { }, [dataTypesToDisplay, searchText]); return ( - - {externallyControlledSearchText === undefined && ( + + {externalSearchInput === undefined && ( setLocalSearchText(event.target.value)} - placeholder="Start typing to filter options..." + onFocus={() => setTextFieldFocused(true)} + onBlur={(event) => { + const isMenuClick = popoverRef.current?.contains( + event.relatedTarget as Node, + ); + + if (!isMenuClick) { + setTextFieldFocused(false); + } + }} + placeholder={placeholder ?? "Start typing to filter options..."} + ref={textFieldRef} sx={{ - borderBottom: ({ palette }) => `1px solid ${palette.gray[30]}`, height: inputHeight, - "*": { - border: "none", - boxShadow: "none", - borderRadius: 0, - }, + maxWidth: 500, + border: ({ palette }) => `1px solid ${palette.gray[30]}`, + borderRadius: 1, [`.${outlinedInputClasses.root} input`]: { fontSize: 14, }, + [`.${outlinedInputClasses.notchedOutline}`]: { + border: "none", + }, }} /> )} - {!hideHint && ( - - palette.gray[80], - mr: 1, - textTransform: "uppercase", - }} - > - Choose data type - - palette.gray[50], - }} - > - How are you representing this value? - - - )} - - - {!sortedDataTypes.length && ( - palette.gray[50], fontSize: 14 }} - > - No options found... - - )} - {sortedDataTypes.map((dataType) => { - if (searchText) { - return ( - - ); - } + ({ + background: palette.common.white, + border: `1px solid ${palette.gray[20]}`, + borderRadius: 1, + maxHeight: maxMenuHeight, + width: + externalSearchInput?.inputRef.current?.clientWidth ?? + textFieldRef.current?.clientWidth, + })} + > + {!hideHint && ( + + palette.gray[80], + mr: 1, + textTransform: "uppercase", + }} + > + Choose data type + + palette.gray[50], + }} + > + How are you representing this value? + + + )} - return ( - - ); - })} - + + {!sortedDataTypes.length && ( + palette.gray[50], + fontSize: 14, + }} + > + No options found... + + )} + {sortedDataTypes.map((dataType) => { + if (searchText) { + return ( + { + onSelect(dataTypeId); + setTextFieldFocused(false); + }} + selectedDataTypeIds={selectedDataTypeIds} + /> + ); + } + + return ( + { + onSelect(dataTypeId); + setTextFieldFocused(false); + }} + selectedDataTypeIds={selectedDataTypeIds} + /> + ); + })} + + {additionalMenuContent?.element} + +
); }; 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 0fbbbb672de..b42adaaa0ad 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 @@ -47,6 +47,7 @@ const ExpectedValueSelectorDropdown = () => { inputRef, searchText, selectedDataTypeIds, + textFieldRef, } = useExpectedValueSelectorContext(); const { dataTypes } = useDataTypesOptions(); @@ -68,10 +69,17 @@ const ExpectedValueSelectorDropdown = () => { }); }, [dataTypes]); + const dataTypeSelectorMenuRef = useRef(null); + const [paperRef] = useOutsideClickRef((event) => { - if (!customExpectedValueBuilderOpen && event.target === inputRef?.current) { + if (!customExpectedValueBuilderOpen && event.target === inputRef.current) { + return; + } + + if (dataTypeSelectorMenuRef.current?.contains(event.target as Node)) { return; } + closeAutocomplete(); }, autocompleteFocused); @@ -86,57 +94,62 @@ const ExpectedValueSelectorDropdown = () => { {customExpectedValueBuilderOpen ? ( ) : ( - <> - { - addDataType(dataTypeId); - }} - searchText={searchText} - selectedDataTypeIds={selectedDataTypeIds} - /> - - - + } + sx={{ + width: "100%", + height: 55, + display: "flex", + alignItems: "center", + borderBottom: "none", + borderLeft: "none", + borderRight: "none", + }} + onMouseDown={(event) => { + // prevent dropdown from closing + event.preventDefault(); + }} + onClick={() => { + handleEdit(); + }} + > + ({ + color: theme.palette.gray[60], + fontWeight: 500, + })} + > + Specify a custom expected value + + + + + ), + }} + allowSelectingAbstractTypes + dataTypes={dataTypeOptions} + externallyProvidedPopoverRef={dataTypeSelectorMenuRef} + externalSearchInput={{ + focused: autocompleteFocused, + inputRef: textFieldRef, + searchText, + }} + hideHint + onSelect={(dataTypeId) => { + addDataType(dataTypeId); + }} + selectedDataTypeIds={selectedDataTypeIds} + /> )} ); @@ -217,6 +230,7 @@ export const ExpectedValueSelector = ({ }); const inputRef = useRef(null); + const textFieldRef = useRef(null); const [inputValue, setInputValue] = useState(""); @@ -266,6 +280,7 @@ export const ExpectedValueSelector = ({ }, customExpectedValueBuilderOpen: creatingCustomExpectedValue, inputRef, + textFieldRef, handleEdit: (index?: number, id?: string) => { expectedValueSelectorFormMethods.setValue( "flattenedCustomExpectedValueList", @@ -414,6 +429,7 @@ export const ExpectedValueSelector = ({ )} - sx={{ width: "70%" }} + sx={{ width: "100%" }} options={[]} componentsProps={{ popper: { diff --git a/libs/@hashintel/type-editor/src/entity-type-editor/property-list-card/property-type-form/expected-value-selector/custom-expected-value-builder/array-expected-value-builder.tsx b/libs/@hashintel/type-editor/src/entity-type-editor/property-list-card/property-type-form/expected-value-selector/custom-expected-value-builder/array-expected-value-builder.tsx index 232e534d153..88fddd9ccc5 100644 --- a/libs/@hashintel/type-editor/src/entity-type-editor/property-list-card/property-type-form/expected-value-selector/custom-expected-value-builder/array-expected-value-builder.tsx +++ b/libs/@hashintel/type-editor/src/entity-type-editor/property-list-card/property-type-form/expected-value-selector/custom-expected-value-builder/array-expected-value-builder.tsx @@ -276,7 +276,7 @@ export const ArrayExpectedValueBuilder: FunctionComponent< { @@ -301,6 +301,7 @@ export const ArrayExpectedValueBuilder: FunctionComponent< [...itemIds, childId], ); }} + placeholder="Choose expected array item type..." selectedDataTypeIds={selectedDataTypeIds} /> diff --git a/libs/@hashintel/type-editor/src/entity-type-editor/property-list-card/property-type-form/expected-value-selector/shared/expected-value-selector-context.ts b/libs/@hashintel/type-editor/src/entity-type-editor/property-list-card/property-type-form/expected-value-selector/shared/expected-value-selector-context.ts index d41a58f78d3..1acadfdbae6 100644 --- a/libs/@hashintel/type-editor/src/entity-type-editor/property-list-card/property-type-form/expected-value-selector/shared/expected-value-selector-context.ts +++ b/libs/@hashintel/type-editor/src/entity-type-editor/property-list-card/property-type-form/expected-value-selector/shared/expected-value-selector-context.ts @@ -10,9 +10,10 @@ export type ExpectedValueSelectorContextValue = { handleEdit: (index?: number, id?: string) => void; handleCancelCustomBuilder: () => void; handleSave: () => void; - inputRef?: RefObject; + inputRef: RefObject; searchText: string; selectedDataTypeIds: VersionedUrl[]; + textFieldRef: RefObject; }; export const ExpectedValueSelectorContext = diff --git a/libs/@local/graph/types/typescript/src/ontology.ts b/libs/@local/graph/types/typescript/src/ontology.ts index 70c455eeeed..bc11765313b 100644 --- a/libs/@local/graph/types/typescript/src/ontology.ts +++ b/libs/@local/graph/types/typescript/src/ontology.ts @@ -5,6 +5,8 @@ import type { PropertyTypeWithMetadata as PropertyTypeWithMetadataBp, } from "@blockprotocol/graph"; import type { + ConversionDefinition, + Conversions, PropertyTypeReference, ValueOrArray, } from "@blockprotocol/type-system"; @@ -27,6 +29,7 @@ import type { DistributiveOmit } from "@local/advanced-types/distribute"; import type { Subtype } from "@local/advanced-types/subtype"; import type { ClosedMultiEntityTypeMap, + DataTypeConversionTargets as GraphApiDataTypeConversionTargets, EntityTypeResolveDefinitions as EntityTypeResolveDefinitionsGraphApi, GetClosedMultiEntityTypeResponseDefinitions, OntologyEditionProvenance as OntologyEditionProvenanceGraphApi, @@ -101,7 +104,35 @@ export type ConstructDataTypeParams = DistributiveOmit< "$id" | "kind" | "$schema" >; -export type DataTypeMetadata = OntologyElementMetadata; +export type DataTypeConversionTargets = Omit< + GraphApiDataTypeConversionTargets, + "conversions" +> & { + conversions: ConversionDefinition[]; +}; + +/** + * A map from a dataTypeId, to a map of target dataTypeIds, to conversion definitions. + * This is ALL the possible conversion targets of a data type, derived from the ones it directly has a conversion defined to in its record, + * as well as any onward conversions that are possible (i.e. because a direct target can be converted to something else). + * + * Each conversion definition contains (1) the target data type `title`, and (2) the `conversions`: steps required to convert to the target dataTypeId. + */ +export type DataTypeFullConversionTargetsMap = Record< + VersionedUrl, + Record +>; + +/** + * The conversions that are directly defined for a data type, and stored in its record. + * + * This does not represent all the data types a data type is convertible to, as it may be transitively convertible to others via one of its targets. + */ +export type DataTypeDirectConversionsMap = Record; + +export type DataTypeMetadata = OntologyElementMetadata & { + conversions?: DataTypeDirectConversionsMap; +}; export type PropertyTypeMetadata = OntologyElementMetadata; export type EntityTypeMetadata = OntologyElementMetadata; @@ -109,7 +140,7 @@ export type DataTypeWithMetadata = Subtype< DataTypeWithMetadataBp, { schema: DataType; - metadata: OntologyElementMetadata; + metadata: DataTypeMetadata; } >; diff --git a/libs/@local/hash-isomorphic-utils/src/data-types.ts b/libs/@local/hash-isomorphic-utils/src/data-types.ts index 0161461cffe..d205b709995 100644 --- a/libs/@local/hash-isomorphic-utils/src/data-types.ts +++ b/libs/@local/hash-isomorphic-utils/src/data-types.ts @@ -13,7 +13,6 @@ import type { VersionedUrl, } from "@blockprotocol/type-system"; import { mustHaveAtLeastOne } from "@blockprotocol/type-system"; -import type { DataTypeConversionTargets as GraphApiDataTypeConversionTargets } from "@local/hash-graph-client"; import type { ClosedDataTypeDefinition } from "@local/hash-graph-types/ontology"; import { add, divide, multiply, subtract } from "./numbers.js"; @@ -225,23 +224,6 @@ export const createConversionFunction = ( }; }; -export type DataTypeConversionTargets = Omit< - GraphApiDataTypeConversionTargets, - "conversions" -> & { - conversions: ConversionDefinition[]; -}; - -/** - * A map from a dataTypeId, to a map of target dataTypeIds, to conversion definitions. - * - * Each conversion definition contains (1) the target data type `title`, and (2) the `conversions`: steps required to convert to the target dataTypeId. - */ -export type DataTypeConversionsMap = Record< - VersionedUrl, - Record ->; - const transformConstraint = ( constraint: SingleValueConstraints & { description: string; diff --git a/libs/@local/hash-isomorphic-utils/src/format-number.ts b/libs/@local/hash-isomorphic-utils/src/format-number.ts index b3c0b9c6457..790f45e129a 100644 --- a/libs/@local/hash-isomorphic-utils/src/format-number.ts +++ b/libs/@local/hash-isomorphic-utils/src/format-number.ts @@ -1,3 +1,3 @@ export const formatNumber = (num: number) => { - return num.toString().replace(/\B(?( diff --git a/libs/@local/hash-isomorphic-utils/src/graphql/scalar-mapping.ts b/libs/@local/hash-isomorphic-utils/src/graphql/scalar-mapping.ts index 310e27b4afc..927aa284513 100644 --- a/libs/@local/hash-isomorphic-utils/src/graphql/scalar-mapping.ts +++ b/libs/@local/hash-isomorphic-utils/src/graphql/scalar-mapping.ts @@ -17,8 +17,10 @@ export const scalars = { DataTypeWithMetadata: "@local/hash-graph-types/ontology#DataTypeWithMetadata", ConstructDataTypeParams: "@local/hash-graph-types/ontology#ConstructDataTypeParams", - DataTypeConversionsMap: - "@local/hash-isomorphic-utils/data-types#DataTypeConversionsMap", + DataTypeFullConversionTargetsMap: + "@local/hash-graph-types/ontology#DataTypeFullConversionTargetsMap", + DataTypeDirectConversionsMap: + "@local/hash-graph-types/ontology#DataTypeDirectConversionsMap", ClosedMultiEntityType: "@local/hash-graph-types/ontology#ClosedMultiEntityType", diff --git a/libs/@local/hash-isomorphic-utils/src/graphql/type-defs/ontology/data-type.typedef.ts b/libs/@local/hash-isomorphic-utils/src/graphql/type-defs/ontology/data-type.typedef.ts index bdb54cde6df..f527019bd90 100644 --- a/libs/@local/hash-isomorphic-utils/src/graphql/type-defs/ontology/data-type.typedef.ts +++ b/libs/@local/hash-isomorphic-utils/src/graphql/type-defs/ontology/data-type.typedef.ts @@ -3,8 +3,9 @@ import { gql } from "apollo-server-express"; export const dataTypeTypedef = gql` scalar ConstructDataTypeParams scalar DataTypeWithMetadata + scalar DataTypeDirectConversionsMap + scalar DataTypeFullConversionTargetsMap - scalar DataTypeConversionsMap scalar UserPermissionsOnDataType extend type Query { @@ -30,7 +31,7 @@ export const dataTypeTypedef = gql` getDataTypeConversionTargets( dataTypeIds: [VersionedUrl!]! - ): DataTypeConversionsMap! + ): DataTypeFullConversionTargetsMap! """ Check the requesting user's permissions on a data type @@ -42,8 +43,8 @@ export const dataTypeTypedef = gql` extend type Mutation { - createDataType(ownedById: OwnedById!, dataType: ConstructDataTypeParams!): DataTypeWithMetadata! - updateDataType(dataTypeId: VersionedUrl!, dataType: ConstructDataTypeParams!): DataTypeWithMetadata! + createDataType(ownedById: OwnedById!, dataType: ConstructDataTypeParams!, conversions: DataTypeDirectConversionsMap): DataTypeWithMetadata! + updateDataType(dataTypeId: VersionedUrl!, dataType: ConstructDataTypeParams!, conversions: DataTypeDirectConversionsMap): DataTypeWithMetadata! archiveDataType(dataTypeId: VersionedUrl!): OntologyTemporalMetadata! unarchiveDataType(dataTypeId: VersionedUrl!): OntologyTemporalMetadata! } diff --git a/libs/@local/hash-isomorphic-utils/src/numbers.ts b/libs/@local/hash-isomorphic-utils/src/numbers.ts index a0df323096a..055243f2884 100644 --- a/libs/@local/hash-isomorphic-utils/src/numbers.ts +++ b/libs/@local/hash-isomorphic-utils/src/numbers.ts @@ -26,7 +26,7 @@ export const divide = (numerator: number, denominator: number): number => { } if (denominator === 0) { - throw new Error("Cannot divide by zero"); + return 0; } const commonDivisor = greatestCommonDivisor(numerator, denominator); diff --git a/libs/@local/hash-isomorphic-utils/src/subgraph-mapping.ts b/libs/@local/hash-isomorphic-utils/src/subgraph-mapping.ts index 2d50cd8a86b..0cd4f8fa364 100644 --- a/libs/@local/hash-isomorphic-utils/src/subgraph-mapping.ts +++ b/libs/@local/hash-isomorphic-utils/src/subgraph-mapping.ts @@ -24,6 +24,7 @@ import type { BaseUrl, ClosedEntityType, ClosedMultiEntityType, + DataTypeConversionTargets, DataTypeWithMetadata, EntityTypeResolveDefinitions, EntityTypeWithMetadata, @@ -44,7 +45,6 @@ import { isEntityVertex, } from "@local/hash-subgraph"; -import type { DataTypeConversionTargets } from "./data-types.js"; import { systemEntityTypes, systemPropertyTypes } from "./ontology-type-ids.js"; const restrictedPropertyBaseUrls: string[] = [ @@ -220,28 +220,28 @@ export const mapGraphApiEntityTypesToEntityTypes = ( ) => entityTypes as EntityTypeWithMetadata[]; export const mapGraphApiClosedEntityTypesToClosedEntityTypes = ( - entityTypes: GraphApiClosedEntityType[], -) => entityTypes as ClosedEntityType[]; + closedEntityTypes: GraphApiClosedEntityType[], +) => closedEntityTypes as ClosedEntityType[]; export const mapGraphApiEntityTypeResolveDefinitionsToEntityTypeResolveDefinitions = - (entityType: GraphApiEntityTypeResolveDefinitions) => - entityType as EntityTypeResolveDefinitions; + (entityTypeResolveDefinitions: GraphApiEntityTypeResolveDefinitions) => + entityTypeResolveDefinitions as EntityTypeResolveDefinitions; export const mapGraphApiClosedMultiEntityTypeToClosedMultiEntityType = ( - entityType: GraphApiClosedMultiEntityType, -) => entityType as ClosedMultiEntityType; + closedMultiEntityType: GraphApiClosedMultiEntityType, +) => closedMultiEntityType as ClosedMultiEntityType; export const mapGraphApiClosedMultiEntityTypesToClosedMultiEntityTypes = ( - entityTypes: GraphApiClosedMultiEntityType[], -) => entityTypes as ClosedMultiEntityType[]; + closedMultiEntityTypes: GraphApiClosedMultiEntityType[], +) => closedMultiEntityTypes as ClosedMultiEntityType[]; export const mapGraphApiPropertyTypesToPropertyTypes = ( - entityTypes: GraphApiPropertyTypeWithMetadata[], -) => entityTypes as PropertyTypeWithMetadata[]; + propertyTypes: GraphApiPropertyTypeWithMetadata[], +) => propertyTypes as PropertyTypeWithMetadata[]; export const mapGraphApiDataTypesToDataTypes = ( - entityTypes: GraphApiDataTypeWithMetadata[], -) => entityTypes as DataTypeWithMetadata[]; + dataTypes: GraphApiDataTypeWithMetadata[], +) => dataTypes as DataTypeWithMetadata[]; export const mapGraphApiDataTypeConversions = ( conversions: Record<