diff --git a/spec/dev/model/i18n.yml b/spec/dev/model/i18n.yml index 3b473b09..64256296 100644 --- a/spec/dev/model/i18n.yml +++ b/spec/dev/model/i18n.yml @@ -1,6 +1,13 @@ i18n: en: types: + Morality: + label: Morality + labelPlural: Morality + hint: The morality of the hero + values: + GOOD: Good + EVIL: Evil Hero: label: Hero labelPlural: Heroes @@ -22,6 +29,14 @@ i18n: id: ID de: types: + Morality: + label: Moral + labelPlural: Moral + hint: Die Moral des Helden + values: + GOOD: Gut + EVIL: Böse + Hero: label: Held labelPlural: Helden diff --git a/spec/meta-schema/meta-schema.spec.ts b/spec/meta-schema/meta-schema.spec.ts index a0b90b90..d381e118 100644 --- a/spec/meta-schema/meta-schema.spec.ts +++ b/spec/meta-schema/meta-schema.spec.ts @@ -1,6 +1,7 @@ import { expect } from 'chai'; import { DocumentNode, graphql, print } from 'graphql'; import gql from 'graphql-tag'; +import { CRUDDL_VERSION } from '../../src/cruddl-version'; import { ExecutionOptions, ExecutionOptionsCallbackArgs, @@ -9,7 +10,6 @@ import { getMetaSchema } from '../../src/meta-schema/meta-schema'; import { AggregationOperator, Model, TypeKind } from '../../src/model'; import { Project } from '../../src/project/project'; import { stopMetaServer } from '../dev/server'; -import { CRUDDL_VERSION } from '../../src/cruddl-version'; describe('Meta schema API', () => { const introQuery = gql` @@ -143,6 +143,36 @@ describe('Meta schema API', () => { } `; + const rawI18nQuery = gql` + { + rootEntityType(name: "Shipment") { + label + labelPlural + hint + fields { + name + label + hint + } + } + enumType(name: "TransportKind") { + label + labelPlural + hint + values { + value + label + hint + } + } + valueObjectType(name: "Address") { + label + labelPlural + hint + } + } + `; + const permissionsQuery = gql` { rootEntityType(name: "Shipment") { @@ -349,6 +379,40 @@ describe('Meta schema API', () => { }, }, }, + Shipment: { + label: 'Lieferung', + labelPlural: 'Lieferungen', + hint: 'Eine Lieferung', + fields: { + transportKind: { + label: 'Transportart', + hint: 'Transportart der Lieferung', + }, + handlingUnits: { + label: 'Packstücke', + hint: 'Die Packstücke der Lieferung' + } + }, + }, + TransportKind: { + label: 'Transportart', + labelPlural: 'Transportarten', + hint: 'Die Art des Transports', + values: { + AIR: { + label: 'Luft', + hint: 'Lieferung mittels Fluchtfracht', + }, + ROAD: { + label: 'Straße', + hint: 'Lieferung mittels LKW', + }, + SEA: { + label: 'Übersee', + hint: 'Lieferung mittels Schiff', + }, + }, + }, }, }, { @@ -362,6 +426,36 @@ describe('Meta schema API', () => { }, }, }, + Shipment: { + label: 'Shipment', + labelPlural: 'Shipments', + hint: 'A shipment', + fields: { + transportKind: { + label: 'Transport kind', + hint: 'The kind of transport for the shipment', + }, + }, + }, + TransportKind: { + label: 'Transport kind', + labelPlural: 'Transport kinds', + hint: 'The kind of transport', + values: { + AIR: { + label: 'Air', + hint: 'Delivery via airfreight', + }, + ROAD: { + label: 'Road', + hint: 'Delivery via truck', + }, + SEA: { + label: 'Sea', + hint: 'Delivery via ship', + }, + }, + }, }, }, ], @@ -939,6 +1033,104 @@ describe('Meta schema API', () => { }); }); + it('can query raw localization of types', async () => { + const result = (await execute(rawI18nQuery)) as any; + + // test for object types including fields + const shipmentType = result.rootEntityType; + expect(shipmentType.label).to.deep.equal({ + en: 'Shipment', + de: 'Lieferung', + }); + expect(shipmentType.labelPlural).to.deep.equal({ + en: 'Shipments', + de: 'Lieferungen', + }); + expect(shipmentType.hint).to.deep.equal({ + en: 'A shipment', + de: 'Eine Lieferung', + }); + const transportKindField = shipmentType.fields.find( + (field: any) => field.name === 'transportKind', + ); + expect(transportKindField.label).to.deep.equal({ + en: 'Transport kind', + de: 'Transportart', + }); + expect(transportKindField.hint).to.deep.equal({ + en: 'The kind of transport for the shipment', + de: 'Transportart der Lieferung', + }); + const handlingUnitsField = shipmentType.fields.find( + (field: any) => field.name === 'handlingUnits' + ); + expect(handlingUnitsField.label).to.deep.equal({ + de: 'Packstücke' + }); + expect(handlingUnitsField.hint).to.deep.equal({ + de: 'Die Packstücke der Lieferung' + }); + + // test for enumType including values + const transportKindType = result.enumType; + expect(transportKindType.label).to.deep.equal({ + en: 'Transport kind', + de: 'Transportart', + }); + expect(transportKindType.labelPlural).to.deep.equal({ + en: 'Transport kinds', + de: 'Transportarten', + }); + expect(transportKindType.hint).to.deep.equal({ + en: 'The kind of transport', + de: 'Die Art des Transports', + }); + const transportKindValueAir = transportKindType.values.find( + (value: any) => value.value === 'AIR', + ); + const transportKindValueRoad = transportKindType.values.find( + (value: any) => value.value === 'ROAD', + ); + const transportKindValueSea = transportKindType.values.find( + (value: any) => value.value === 'SEA', + ); + expect(transportKindValueAir.label).to.deep.equal({ + en: 'Air', + de: 'Luft', + }); + expect(transportKindValueAir.hint).to.deep.equal({ + en: 'Delivery via airfreight', + de: 'Lieferung mittels Fluchtfracht', + }); + expect(transportKindValueRoad.label).to.deep.equal({ + de: 'Straße', + en: 'Road', + }); + expect(transportKindValueRoad.hint).to.deep.equal({ + de: 'Lieferung mittels LKW', + en: 'Delivery via truck', + }); + expect(transportKindValueSea.label).to.deep.equal({ + de: 'Übersee', + en: 'Sea', + }); + expect(transportKindValueSea.hint).to.deep.equal({ + de: 'Lieferung mittels Schiff', + en: 'Delivery via ship', + }); + + const adressType = result.valueObjectType; + expect(adressType.label).to.deep.equal({ + de: 'Adresse' + }); + expect(adressType.labelPlural).to.deep.equal({ + de: 'Adressen' + }); + expect(adressType.hint).to.deep.equal({ + de: 'Eine Adresse' + }); + }); + it('can query permissions', async () => { const result = await execute(permissionsQuery, { authRoles: ['user'] }); expect(result!.rootEntityType).to.deep.equal({ diff --git a/spec/model/create-model.spec.ts b/spec/model/create-model.spec.ts index 0b0f4744..f41d8f83 100644 --- a/spec/model/create-model.spec.ts +++ b/spec/model/create-model.spec.ts @@ -185,4 +185,172 @@ describe('createModel', () => { expect(field.isFlexSearchIndexed).to.be.true; expect(field.isIncludedInSearch).to.be.false; }); + + it('it allows to access raw label/labelPlural/hint localization on type "ObjectType" and its fields', () => { + const document: DocumentNode = gql` + type Shipment @rootEntity { + shipmentNumber: String + } + `; + const model = createSimpleModel(document, { + de: { + namespacePath: [], + types: { + Shipment: { + label: 'Lieferung', + labelPlural: 'Lieferungen', + hint: 'Eine Lieferung', + fields: { + shipmentNumber: { + label: 'Lieferungsnummer', + hint: 'Die Nummer der Lieferung', + }, + }, + }, + }, + }, + en: { + namespacePath: [], + types: { + Shipment: { + label: 'Shipment', + labelPlural: 'Shipments', + hint: 'A shipment', + fields: { + shipmentNumber: { + label: 'Shipment number', + hint: 'The number of the shipment', + }, + }, + }, + }, + }, + }); + const shipmentType = model.getObjectTypeOrThrow('Shipment'); + expect(shipmentType.label).to.deep.equal({ + de: 'Lieferung', + en: 'Shipment', + }); + expect(shipmentType.labelPlural).to.deep.equal({ + de: 'Lieferungen', + en: 'Shipments', + }); + expect(shipmentType.hint).to.deep.equal({ + de: 'Eine Lieferung', + en: 'A shipment', + }); + const shipmentNumberField = shipmentType.getFieldOrThrow('shipmentNumber'); + expect(shipmentNumberField.label).to.deep.equal({ + de: 'Lieferungsnummer', + en: 'Shipment number', + }); + expect(shipmentNumberField.hint).to.deep.equal({ + de: 'Die Nummer der Lieferung', + en: 'The number of the shipment', + }); + }); + + it('it allows to access raw label/labelPlural/hint localization on type "EnumType" and its values', () => { + const document: DocumentNode = gql` + type Shipment @rootEntity { + transportKind: TransportKind + } + + enum TransportKind { + AIR + SEA + ROAD + } + `; + const model = createSimpleModel(document, { + de: { + namespacePath: [], + types: { + TransportKind: { + label: 'Transportart', + labelPlural: 'Transportarten', + hint: 'Die Art des Transports', + values: { + AIR: { + label: 'Luft', + hint: 'Lieferung mittels Fluchtfracht', + }, + ROAD: { + label: 'Straße', + hint: 'Lieferung mittels LKW', + }, + SEA: { + label: 'Übersee', + hint: 'Lieferung mittels Schiff', + }, + }, + }, + }, + }, + en: { + namespacePath: [], + types: { + TransportKind: { + label: 'Transport kind', + labelPlural: 'Transport kinds', + hint: 'The kind of transport', + values: { + AIR: { + label: 'Air', + hint: 'Delivery via airfreight', + }, + ROAD: { + label: 'Road', + hint: 'Delivery via truck', + }, + SEA: { + label: 'Sea', + hint: 'Delivery via ship', + }, + }, + }, + }, + }, + }); + const transportKindType = model.getEnumTypeOrThrow('TransportKind'); + expect(transportKindType.label).to.deep.equal({ + de: 'Transportart', + en: 'Transport kind', + }); + expect(transportKindType.labelPlural).to.deep.equal({ + de: 'Transportarten', + en: 'Transport kinds', + }); + expect(transportKindType.hint).to.deep.equal({ + de: 'Die Art des Transports', + en: 'The kind of transport', + }); + const valueAIR = transportKindType.values.find((value) => value.value === 'AIR'); + const valueROAD = transportKindType.values.find((value) => value.value === 'ROAD'); + const valueSEA = transportKindType.values.find((value) => value.value === 'SEA'); + expect(valueAIR?.label).to.deep.equal({ + de: 'Luft', + en: 'Air', + }); + expect(valueAIR?.hint).to.deep.equal({ + de: 'Lieferung mittels Fluchtfracht', + en: 'Delivery via airfreight', + }); + expect(valueROAD?.label).to.deep.equal({ + de: 'Straße', + en: 'Road', + }); + expect(valueROAD?.hint).to.deep.equal({ + de: 'Lieferung mittels LKW', + en: 'Delivery via truck', + }); + expect(valueSEA?.label).to.deep.equal({ + de: 'Übersee', + en: 'Sea', + }); + expect(valueSEA?.hint).to.deep.equal({ + de: 'Lieferung mittels Schiff', + en: 'Delivery via ship', + }); + }); }); diff --git a/spec/model/model-spec.helper.ts b/spec/model/model-spec.helper.ts index 9269489b..3fefd57b 100644 --- a/spec/model/model-spec.helper.ts +++ b/spec/model/model-spec.helper.ts @@ -1,8 +1,20 @@ import { DocumentNode } from 'graphql'; -import { ParsedProject, ParsedProjectSourceBaseKind } from '../../src/config/parsed-project'; -import { createModel, Model, PermissionProfileConfigMap } from '../../src/model'; +import { + ParsedProject, + ParsedProjectSource, + ParsedProjectSourceBaseKind, +} from '../../src/config/parsed-project'; +import { + createModel, + Model, + NamespaceLocalizationConfig, + PermissionProfileConfigMap, +} from '../../src/model'; -export function createSimpleModel(document: DocumentNode): Model { +export function createSimpleModel( + document: DocumentNode, + i18n?: Record, +): Model { const permissionProfiles: PermissionProfileConfigMap = { default: { permissions: [ @@ -26,6 +38,16 @@ export function createSimpleModel(document: DocumentNode): Model { object: { permissionProfiles }, pathLocationMap: {}, }, + ...(i18n + ? [ + { + kind: ParsedProjectSourceBaseKind.OBJECT, + namespacePath: [], + object: { i18n }, + pathLocationMap: {}, + } as ParsedProjectSource, + ] + : []), ], }; return createModel(parsedProject); diff --git a/src/meta-schema/meta-schema.ts b/src/meta-schema/meta-schema.ts index 1f16a6d2..5a47ebcf 100644 --- a/src/meta-schema/meta-schema.ts +++ b/src/meta-schema/meta-schema.ts @@ -2,7 +2,7 @@ import { makeExecutableSchema } from '@graphql-tools/schema'; import { IResolvers } from '@graphql-tools/utils'; import { GraphQLResolveInfo, GraphQLSchema } from 'graphql'; import gql from 'graphql-tag'; -import { AccessOperation, AuthContext } from '../authorization/auth-basics'; +import { AccessOperation } from '../authorization/auth-basics'; import { PermissionResult } from '../authorization/permission-descriptors'; import { getPermissionDescriptorOfField, @@ -15,12 +15,17 @@ import { Project } from '../project/project'; import { compact, flatMap } from '../utils/utils'; import { I18N_GENERIC, I18N_LOCALE } from './constants'; import { CRUDDL_VERSION } from '../cruddl-version'; +import { mapValues } from 'lodash'; +import { GraphQLI18nString } from '../schema/scalars/string-map'; const resolutionOrderDescription = JSON.stringify( 'The order in which languages and other localization providers are queried for a localization. You can specify languages as defined in the schema as well as the following special identifiers:\n\n- `_LOCALE`: The language defined by the GraphQL request (might be a list of languages, e.g. ["de_DE", "de", "en"])\n- `_GENERIC`: is auto-generated localization from field and type names (e. G. `orderDate` => `Order date`)\n\nThe default `resolutionOrder` is `["_LOCALE", "_GENERIC"]` (if not specified).', ); +// noinspection GraphQLUnresolvedReference const typeDefs = gql` + scalar I18nString + enum TypeKind { ROOT_ENTITY, CHILD_ENTITY, ENTITY_EXTENSION, VALUE_OBJECT, ENUM, SCALAR } @@ -80,6 +85,9 @@ const typeDefs = gql` ${resolutionOrderDescription} resolutionOrder: [String] ): FieldLocalization + label: I18nString! + hint: I18nString! + isFlexSearchIndexed: Boolean! isFlexSearchIndexCaseSensitive: Boolean! isIncludedInSearch: Boolean! @@ -158,6 +166,8 @@ const typeDefs = gql` localization( ${resolutionOrderDescription} resolutionOrder: [String] ): TypeLocalization + label: I18nString! + hint: I18nString! } interface ObjectType { @@ -168,6 +178,9 @@ const typeDefs = gql` localization( ${resolutionOrderDescription} resolutionOrder: [String] ): TypeLocalization + label: I18nString! + labelPlural: I18nString! + hint: I18nString! } type RootEntityType implements ObjectType & Type { @@ -202,6 +215,10 @@ const typeDefs = gql` localization( ${resolutionOrderDescription} resolutionOrder: [String] ): TypeLocalization + + label: I18nString! + labelPlural: I18nString! + hint: I18nString! "Indicates if this root entity type is one of the core objects of business transactions" isBusinessObject: Boolean @@ -217,6 +234,9 @@ const typeDefs = gql` localization( ${resolutionOrderDescription} resolutionOrder: [String] ): TypeLocalization + label: I18nString! + labelPlural: I18nString! + hint: I18nString! } type EntityExtensionType implements ObjectType & Type { @@ -227,6 +247,9 @@ const typeDefs = gql` localization( ${resolutionOrderDescription} resolutionOrder: [String] ): TypeLocalization + label: I18nString! + labelPlural: I18nString! + hint: I18nString! } type ValueObjectType implements ObjectType & Type { @@ -237,6 +260,9 @@ const typeDefs = gql` localization( ${resolutionOrderDescription} resolutionOrder: [String] ): TypeLocalization + label: I18nString! + labelPlural: I18nString! + hint: I18nString! } type ScalarType implements Type { @@ -246,6 +272,9 @@ const typeDefs = gql` localization( ${resolutionOrderDescription} resolutionOrder: [String] ): TypeLocalization + label: I18nString! + labelPlural: I18nString! + hint: I18nString! } type EnumType implements Type { @@ -256,6 +285,9 @@ const typeDefs = gql` localization( ${resolutionOrderDescription} resolutionOrder: [String] ): TypeLocalization + label: I18nString! + labelPlural: I18nString! + hint: I18nString! } type EnumValue { @@ -264,6 +296,8 @@ const typeDefs = gql` localization( ${resolutionOrderDescription} resolutionOrder: [String] ): EnumValueLocalization + label: I18nString! + hint: I18nString! } type Namespace { @@ -637,6 +671,7 @@ export function getMetaSchema(project: Project): GraphQLSchema { EnumValue: { localization: localizeEnumValue, }, + I18nString: GraphQLI18nString, }; function getResolutionOrder( diff --git a/src/model/implementation/enum-type.ts b/src/model/implementation/enum-type.ts index b4f52f29..43e0087b 100644 --- a/src/model/implementation/enum-type.ts +++ b/src/model/implementation/enum-type.ts @@ -5,6 +5,7 @@ import { ModelComponent } from '../validation/validation-context'; import { EnumValueLocalization } from './i18n'; import { Model } from './model'; import { TypeBase } from './type-base'; +import memorize from 'memorize-decorator'; export class EnumType extends TypeBase { constructor(input: EnumTypeConfig, model: Model) { @@ -36,12 +37,14 @@ export class EnumValue implements ModelComponent { readonly description: string | undefined; readonly deprecationReason: string | undefined; readonly astNode: EnumValueDefinitionNode | undefined; + readonly model: Model; constructor(input: EnumValueConfig, public readonly declaringType: EnumType) { this.value = input.value; this.description = input.description; this.deprecationReason = input.deprecationReason; this.astNode = input.astNode; + this.model = declaringType.model; } public getLocalization(resolutionOrder: ReadonlyArray): EnumValueLocalization { @@ -64,4 +67,26 @@ export class EnumValue implements ModelComponent { ); } } + + @memorize() + get label(): Record { + const res: Record = {}; + for (const [lang, localization] of Object.entries(this.model.i18n.getEnumValueI18n(this))) { + if (localization.label) { + res[lang] = localization.label; + } + } + return res; + } + + @memorize() + get hint(): Record { + const res: Record = {}; + for (const [lang, localization] of Object.entries(this.model.i18n.getEnumValueI18n(this))) { + if (localization.hint) { + res[lang] = localization.hint; + } + } + return res; + } } diff --git a/src/model/implementation/field.ts b/src/model/implementation/field.ts index 853469b7..bafd71ea 100644 --- a/src/model/implementation/field.ts +++ b/src/model/implementation/field.ts @@ -158,6 +158,28 @@ export class Field implements ModelComponent { return this.defaultValue !== undefined; } + @memorize() + get label(): Record { + const res: Record = {}; + for (const [lang, localization] of Object.entries(this.model.i18n.getFieldI18n(this))) { + if (localization.label) { + res[lang] = localization.label; + } + } + return res; + } + + @memorize() + get hint(): Record { + const res: Record = {}; + for (const [lang, localization] of Object.entries(this.model.i18n.getFieldI18n(this))) { + if (localization.hint) { + res[lang] = localization.hint; + } + } + return res; + } + @memorize() public get permissionProfile(): PermissionProfile | undefined { if (!this.input.permissions || this.input.permissions.permissionProfileName == undefined) { diff --git a/src/model/implementation/i18n.ts b/src/model/implementation/i18n.ts index f5f6e282..a9f6246d 100644 --- a/src/model/implementation/i18n.ts +++ b/src/model/implementation/i18n.ts @@ -1,4 +1,4 @@ -import { I18N_GENERIC } from '../../meta-schema/constants'; +import { I18N_GENERIC, I18N_LOCALE } from '../../meta-schema/constants'; import { arrayStartsWith, capitalize, @@ -62,6 +62,12 @@ export class ModelI18n implements ModelComponent { }; } + public getTypeI18n(type: TypeBase): Record { + return mapValues(this.getAllLocalizationProviders(), (provider) => + provider.localizeType(type), + ); + } + public getFieldLocalization( field: Field, resolutionOrder: ReadonlyArray, @@ -75,6 +81,12 @@ export class ModelI18n implements ModelComponent { }; } + public getFieldI18n(field: Field): Record { + return mapValues(this.getAllLocalizationProviders(), (provider) => + provider.localizeField(field), + ); + } + public getEnumValueLocalization( enumValue: EnumValue, resolutionOrder: ReadonlyArray, @@ -92,6 +104,12 @@ export class ModelI18n implements ModelComponent { }; } + public getEnumValueI18n(enumValue: EnumValue): Record { + return mapValues(this.getAllLocalizationProviders(), (provider) => + provider.localizeEnumValue(enumValue), + ); + } + private getResolutionProviders( resolutionOrder: ReadonlyArray, ): ReadonlyArray { @@ -106,6 +124,18 @@ export class ModelI18n implements ModelComponent { }), ); } + + /** + * Returns all available localization providers. + * + * The derived languages "I18N_GENERIC" and "I18N_LOCALE" are not included. + */ + private getAllLocalizationProviders(): Record { + const filteredProviders = Array.from( + this.languageLocalizationProvidersByLanguage.entries(), + ).filter(([lang, _]) => lang !== I18N_GENERIC && lang !== I18N_LOCALE); + return Object.fromEntries(filteredProviders); + } } export class NamespaceLocalization { @@ -214,11 +244,8 @@ export class NamespaceLocalization { } } -export interface TypeLocalization { - readonly label?: string; +export interface TypeLocalization extends LocalizationBaseConfig { readonly labelPlural?: string; - readonly hint?: string; - readonly loc?: MessageLocation; } export interface FieldLocalization extends LocalizationBaseConfig {} diff --git a/src/model/implementation/object-type-base.ts b/src/model/implementation/object-type-base.ts index e9070145..00b320a2 100644 --- a/src/model/implementation/object-type-base.ts +++ b/src/model/implementation/object-type-base.ts @@ -1,6 +1,6 @@ import { groupBy } from 'lodash'; import { objectValues } from '../../utils/utils'; -import { FieldConfig, ObjectTypeConfig } from '../config'; +import { ObjectTypeConfig } from '../config'; import { ValidationContext, ValidationMessage } from '../validation'; import { Field, SystemFieldConfig } from './field'; import { Model } from './model'; diff --git a/src/model/implementation/type-base.ts b/src/model/implementation/type-base.ts index a5ade4a3..7149fbb1 100644 --- a/src/model/implementation/type-base.ts +++ b/src/model/implementation/type-base.ts @@ -80,6 +80,39 @@ export abstract class TypeBase implements ModelComponent { return this.model.getNamespaceByPathOrThrow(this.namespacePath); } + @memorize() + get label(): Record { + const res: Record = {}; + for (const [lang, localization] of Object.entries(this.model.i18n.getTypeI18n(this))) { + if (localization.label) { + res[lang] = localization.label; + } + } + return res; + } + + @memorize() + get labelPlural(): Record { + const res: Record = {}; + for (const [lang, localization] of Object.entries(this.model.i18n.getTypeI18n(this))) { + if (localization.labelPlural) { + res[lang] = localization.labelPlural; + } + } + return res; + } + + @memorize() + get hint(): Record { + const res: Record = {}; + for (const [lang, localization] of Object.entries(this.model.i18n.getTypeI18n(this))) { + if (localization.hint) { + res[lang] = localization.hint; + } + } + return res; + } + abstract readonly isObjectType: boolean; abstract readonly isRootEntityType: boolean; abstract readonly isChildEntityType: boolean;