diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidationExtensions.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidationExtensions.cs index e08089efc..4f20ad771 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidationExtensions.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidationExtensions.cs @@ -11,14 +11,15 @@ using System.Threading.Tasks; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Infrastructure; +using Squidex.Infrastructure.Geocoding; namespace Squidex.Domain.Apps.Core.ValidateContent { public static class ContentValidationExtensions { - public static async Task ValidateAsync(this NamedContentData data, ValidationContext context, Schema schema, PartitionResolver partitionResolver, IList errors) + public static async Task ValidateAsync(this NamedContentData data, ValidationContext context, Schema schema, PartitionResolver partitionResolver, IList errors, IGeocoder geocoder) { - var validator = new ContentValidator(schema, partitionResolver, context); + var validator = new ContentValidator(schema, partitionResolver, context, geocoder); await validator.ValidateAsync(data); @@ -28,9 +29,9 @@ namespace Squidex.Domain.Apps.Core.ValidateContent } } - public static async Task ValidatePartialAsync(this NamedContentData data, ValidationContext context, Schema schema, PartitionResolver partitionResolver, IList errors) + public static async Task ValidatePartialAsync(this NamedContentData data, ValidationContext context, Schema schema, PartitionResolver partitionResolver, IList errors, IGeocoder geocoder) { - var validator = new ContentValidator(schema, partitionResolver, context); + var validator = new ContentValidator(schema, partitionResolver, context, geocoder); await validator.ValidatePartialAsync(data); diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidator.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidator.cs index d9470234f..76d7ddb47 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidator.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidator.cs @@ -13,6 +13,7 @@ using Newtonsoft.Json.Linq; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Infrastructure; +using Squidex.Infrastructure.Geocoding; #pragma warning disable 168 @@ -22,6 +23,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent { private readonly Schema schema; private readonly PartitionResolver partitionResolver; + private readonly IGeocoder geocoder; private readonly ValidationContext context; private readonly ConcurrentBag errors = new ConcurrentBag(); @@ -30,10 +32,11 @@ namespace Squidex.Domain.Apps.Core.ValidateContent get { return errors; } } - public ContentValidator(Schema schema, PartitionResolver partitionResolver, ValidationContext context) + public ContentValidator(Schema schema, PartitionResolver partitionResolver, ValidationContext context, IGeocoder geocoder) { Guard.NotNull(schema, nameof(schema)); Guard.NotNull(partitionResolver, nameof(partitionResolver)); + Guard.NotNull(geocoder, nameof(geocoder)); this.schema = schema; this.context = context; @@ -74,7 +77,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent { if (partition.TryGetItem(partitionValues.Key, out var item)) { - tasks.Add(field.ValidateAsync(partitionValues.Value, context.Optional(item.IsOptional), m => errors.AddError(m, field, item))); + tasks.Add(field.ValidateAsync(partitionValues.Value, context.Optional(item.IsOptional), m => errors.AddError(m, field, item), geocoder)); } else { @@ -133,7 +136,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent { var value = fieldData.GetOrCreate(item.Key, k => JValue.CreateNull()); - tasks.Add(field.ValidateAsync(value, context.Optional(item.IsOptional), m => errors.AddError(m, field, item))); + tasks.Add(field.ValidateAsync(value, context.Optional(item.IsOptional), m => errors.AddError(m, field, item), geocoder)); } return Task.WhenAll(tasks); diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/FieldExtensions.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/FieldExtensions.cs index b56bf1908..c2fc2e0eb 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/FieldExtensions.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/FieldExtensions.cs @@ -12,6 +12,7 @@ using System.Threading.Tasks; using Newtonsoft.Json.Linq; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Infrastructure; +using Squidex.Infrastructure.Geocoding; using Squidex.Infrastructure.Json; namespace Squidex.Domain.Apps.Core.ValidateContent @@ -38,20 +39,26 @@ namespace Squidex.Domain.Apps.Core.ValidateContent errors.Add(new ValidationError(message.Replace("", displayName), fieldName)); } - public static async Task ValidateAsync(this Field field, JToken value, ValidationContext context, Action addError) + public static async Task ValidateAsync(this Field field, JToken value, ValidationContext context, Action addError, IGeocoder geocoder) { try { - var typedValue = value.IsNull() ? null : JsonValueConverter.ConvertValue(field, value); + var typedValue = value.IsNull() ? null : JsonValueConverter.ConvertValue(field, value, geocoder); foreach (var validator in ValidatorsFactory.CreateValidators(field)) { await validator.ValidateAsync(typedValue, context, addError); } } - catch + catch (InvalidCastException ex) { - addError(" is not a valid value."); + var error = ex.Message; + addError($" is not a valid value. {error}".Trim()); + } + catch (Exception ex) + { + var error = (ex as AggregateException)?.InnerException?.Message; + addError($" is not a valid value. {error ?? string.Empty}".Trim()); } } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueConverter.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueConverter.cs index 00f4f3e9d..e5dcc8575 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueConverter.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueConverter.cs @@ -8,25 +8,32 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; using Newtonsoft.Json.Linq; +using NJsonSchema.Infrastructure; using NodaTime.Text; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Infrastructure; +using Squidex.Infrastructure.Geocoding; namespace Squidex.Domain.Apps.Core.ValidateContent { public sealed class JsonValueConverter : IFieldVisitor { + private IGeocoder geocoder; + public JToken Value { get; } - private JsonValueConverter(JToken value) + private JsonValueConverter(JToken value, IGeocoder geocoder) { this.Value = value; + this.geocoder = geocoder; } - public static object ConvertValue(Field field, JToken json) + public static object ConvertValue(Field field, JToken json, IGeocoder geocoder) { - return field.Accept(new JsonValueConverter(json)); + return field.Accept(new JsonValueConverter(json, geocoder)); } public object Visit(AssetsField field) @@ -59,30 +66,59 @@ namespace Squidex.Domain.Apps.Core.ValidateContent public object Visit(GeolocationField field) { var geolocation = (JObject)Value; + List addressString = new List(); + var validProperties = new string[] + { + "latitude", "longitude", "address1", "address2", "city", "state", "zip" + }; foreach (var property in geolocation.Properties()) { - if (!string.Equals(property.Name, "latitude", StringComparison.OrdinalIgnoreCase) && - !string.Equals(property.Name, "longitude", StringComparison.OrdinalIgnoreCase)) + if (!validProperties.Contains(property.Name.ToLower())) { - throw new InvalidCastException("Geolocation can only have latitude and longitude property."); + throw new InvalidCastException("Geolocation must have proper properties."); } + + addressString.Add(geolocation[property.Name.ToLower()]?.ToString()); } - var lat = (double)geolocation["latitude"]; - var lon = (double)geolocation["longitude"]; + var lat = geolocation["latitude"]; + var lon = geolocation["longitude"]; + var state = geolocation["state"]?.ToString(); + var zip = geolocation["zip"]?.ToString(); + + if (lat == null || lon == null || + ((JValue)lat).Value == null || ((JValue)lon).Value == null) + { + var response = geocoder.GeocodeAddress(string.Join(string.Empty, addressString)); + lat = response.TryGetPropertyValue("Latitude", (JToken)null); + lon = response.TryGetPropertyValue("Longitude", (JToken)null); + + geolocation["latitude"] = lat; + geolocation["longitude"] = lon; + } - if (!lat.IsBetween(-90, 90)) + if (!((double)lat).IsBetween(-90, 90)) { throw new InvalidCastException("Latitude must be between -90 and 90."); } - if (!lon.IsBetween(-180, 180)) + if (!((double)lon).IsBetween(-180, 180)) { throw new InvalidCastException("Longitude must be between -180 and 180."); } - return Value; + if (!string.IsNullOrWhiteSpace(state) && !Regex.IsMatch(state, "[A-Z]{2}")) + { + throw new InvalidCastException("State must be two capital letters."); + } + + if (!string.IsNullOrWhiteSpace(zip) && !Regex.IsMatch(zip, "[0-9]{5}(\\-[0-9]{4})?")) + { + throw new InvalidCastException("ZIP Code must match postal code with optional suffix pattern."); + } + + return geolocation; } public object Visit(JsonField field) diff --git a/src/Squidex.Domain.Apps.Write/Contents/ContentCommandMiddleware.cs b/src/Squidex.Domain.Apps.Write/Contents/ContentCommandMiddleware.cs index 5c111c5bd..a063f0dc8 100644 --- a/src/Squidex.Domain.Apps.Write/Contents/ContentCommandMiddleware.cs +++ b/src/Squidex.Domain.Apps.Write/Contents/ContentCommandMiddleware.cs @@ -17,6 +17,7 @@ using Squidex.Domain.Apps.Write.Contents.Guards; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Dispatching; +using Squidex.Infrastructure.Geocoding; namespace Squidex.Domain.Apps.Write.Contents { @@ -27,25 +28,29 @@ namespace Squidex.Domain.Apps.Write.Contents private readonly IAssetRepository assetRepository; private readonly IContentRepository contentRepository; private readonly IScriptEngine scriptEngine; + private readonly IGeocoder geocoder; public ContentCommandMiddleware( IAggregateHandler handler, IAppProvider appProvider, IAssetRepository assetRepository, IScriptEngine scriptEngine, - IContentRepository contentRepository) + IContentRepository contentRepository, + IGeocoder geocoder) { Guard.NotNull(handler, nameof(handler)); Guard.NotNull(appProvider, nameof(appProvider)); Guard.NotNull(scriptEngine, nameof(scriptEngine)); Guard.NotNull(assetRepository, nameof(assetRepository)); Guard.NotNull(contentRepository, nameof(contentRepository)); + Guard.NotNull(geocoder, nameof(geocoder)); this.handler = handler; this.appProvider = appProvider; this.scriptEngine = scriptEngine; this.assetRepository = assetRepository; this.contentRepository = contentRepository; + this.geocoder = geocoder; } protected async Task On(CreateContent command, CommandContext context) @@ -151,7 +156,8 @@ namespace Squidex.Domain.Apps.Write.Contents appProvider, assetRepository, scriptEngine, - message); + message, + geocoder); return operationContext; } diff --git a/src/Squidex.Domain.Apps.Write/Contents/ContentOperationContext.cs b/src/Squidex.Domain.Apps.Write/Contents/ContentOperationContext.cs index 530c7505e..9689dbfca 100644 --- a/src/Squidex.Domain.Apps.Write/Contents/ContentOperationContext.cs +++ b/src/Squidex.Domain.Apps.Write/Contents/ContentOperationContext.cs @@ -20,6 +20,7 @@ using Squidex.Domain.Apps.Read.Contents.Repositories; using Squidex.Domain.Apps.Read.Schemas; using Squidex.Domain.Apps.Write.Contents.Commands; using Squidex.Infrastructure; +using Squidex.Infrastructure.Geocoding; using Squidex.Infrastructure.Tasks; #pragma warning disable IDE0017 // Simplify object initialization @@ -36,6 +37,7 @@ namespace Squidex.Domain.Apps.Write.Contents private ISchemaEntity schemaEntity; private IAppEntity appEntity; private Func message; + private IGeocoder geocoder; public static async Task CreateAsync( IContentRepository contentRepository, @@ -44,20 +46,23 @@ namespace Squidex.Domain.Apps.Write.Contents IAppProvider appProvider, IAssetRepository assetRepository, IScriptEngine scriptEngine, - Func message) + Func message, + IGeocoder geocoder) { var (appEntity, schemaEntity) = await appProvider.GetAppWithSchemaAsync(command.AppId.Name, command.SchemaId.Id); - var context = new ContentOperationContext(); - - context.appEntity = appEntity; - context.assetRepository = assetRepository; - context.contentRepository = contentRepository; - context.content = content; - context.command = command; - context.message = message; - context.schemaEntity = schemaEntity; - context.scriptEngine = scriptEngine; + var context = new ContentOperationContext + { + appEntity = appEntity, + assetRepository = assetRepository, + contentRepository = contentRepository, + content = content, + command = command, + message = message, + schemaEntity = schemaEntity, + scriptEngine = scriptEngine, + geocoder = geocoder + }; return context; } @@ -93,11 +98,11 @@ namespace Squidex.Domain.Apps.Write.Contents if (partial) { - await dataCommand.Data.ValidatePartialAsync(ctx, schemaEntity.SchemaDef, appEntity.PartitionResolver(), errors); + await dataCommand.Data.ValidatePartialAsync(ctx, schemaEntity.SchemaDef, appEntity.PartitionResolver(), errors, geocoder); } else { - await dataCommand.Data.ValidateAsync(ctx, schemaEntity.SchemaDef, appEntity.PartitionResolver(), errors); + await dataCommand.Data.ValidateAsync(ctx, schemaEntity.SchemaDef, appEntity.PartitionResolver(), errors, geocoder); } if (errors.Count > 0) diff --git a/src/Squidex.Infrastructure/Geocoding/GoogleMapsGeocoder.cs b/src/Squidex.Infrastructure/Geocoding/GoogleMapsGeocoder.cs new file mode 100644 index 000000000..2dfa7c671 --- /dev/null +++ b/src/Squidex.Infrastructure/Geocoding/GoogleMapsGeocoder.cs @@ -0,0 +1,57 @@ +// ========================================================================== +// GoogleMapsGeocoder.cs +// CivicPlus implementation of Squidex Headless CMS +// ========================================================================== + +using System; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Squidex.Infrastructure.Geocoding +{ + public class GoogleMapsGeocoder : IGeocoder + { + private readonly string geoCodeUrl = "https://maps.googleapis.com/maps/api/geocode/json"; + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(2); + + public GoogleMapsGeocoder(string key) + { + Key = key; + } + + public string Key { get; } + + public object GeocodeAddress(string address) + { + var requestUrl = $"{geoCodeUrl}?key={Key}&address={address}"; + var request = new HttpRequestMessage(HttpMethod.Get, requestUrl); + + return GetLatLong(request).Result; + } + + private async Task GetLatLong(HttpRequestMessage request) + { + try + { + HttpResponseMessage response; + using (var client = new HttpClient { Timeout = Timeout }) + { + response = await client.SendAsync(request); + } + + var result = JsonConvert.DeserializeObject(response.Content.ReadAsStringAsync().Result); + var innerResults = ((JObject)result["results"].FirstOrDefault()); + var geometry = ((JObject)innerResults["geometry"]); + var location = ((JObject)geometry["location"]); + return new { Latitude = location["lat"], Longitude = location["lng"] }; + } + catch + { + throw new InvalidCastException("Latitude and Longitude could not be calculated. Please enter a valid address."); + } + } + } +} diff --git a/src/Squidex.Infrastructure/Geocoding/IGeocoder.cs b/src/Squidex.Infrastructure/Geocoding/IGeocoder.cs new file mode 100644 index 000000000..c159ce466 --- /dev/null +++ b/src/Squidex.Infrastructure/Geocoding/IGeocoder.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// IGeocoder.cs +// CivicPlus implementation of Squidex Headless CMS +// ========================================================================== + +namespace Squidex.Infrastructure.Geocoding +{ + public interface IGeocoder + { + string Key { get; } + + object GeocodeAddress(string address); + } +} diff --git a/src/Squidex.Infrastructure/Geocoding/OSMGeocoder.cs b/src/Squidex.Infrastructure/Geocoding/OSMGeocoder.cs new file mode 100644 index 000000000..099d265b4 --- /dev/null +++ b/src/Squidex.Infrastructure/Geocoding/OSMGeocoder.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// OSMGeocoder.cs +// CivicPlus implementation of Squidex Headless CMS +// ========================================================================== + +using System; + +namespace Squidex.Infrastructure.Geocoding +{ + public class OSMGeocoder : IGeocoder + { + public string Key { get; } + + public object GeocodeAddress(string address) + { + throw new InvalidCastException("Latitude and Longitude must be provided."); + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs index 71aa34709..796a98265 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs @@ -17,6 +17,7 @@ using Squidex.Domain.Apps.Read; using Squidex.Domain.Apps.Read.Apps.Services; using Squidex.Domain.Apps.Write.Apps.Commands; using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Geocoding; using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Security; using Squidex.Pipeline; @@ -33,14 +34,17 @@ namespace Squidex.Areas.Api.Controllers.Apps { private readonly IAppProvider appProvider; private readonly IAppPlansProvider appPlansProvider; + private readonly IGeocoder geocoder; public AppsController(ICommandBus commandBus, IAppProvider appProvider, - IAppPlansProvider appPlansProvider) + IAppPlansProvider appPlansProvider, + IGeocoder geocoder) : base(commandBus) { this.appProvider = appProvider; this.appPlansProvider = appPlansProvider; + this.geocoder = geocoder; } /// @@ -65,7 +69,7 @@ namespace Squidex.Areas.Api.Controllers.Apps var response = apps.Select(a => { - var dto = SimpleMapper.Map(a, new AppDto()); + var dto = SimpleMapper.Map(a, new AppDto() { GeocoderKey = geocoder.Key ?? string.Empty }); dto.Permission = a.Contributors[subject]; @@ -104,7 +108,7 @@ namespace Squidex.Areas.Api.Controllers.Apps var context = await CommandBus.PublishAsync(command); var result = context.Result>(); - var response = new AppCreatedDto { Id = result.IdOrValue.ToString(), Version = result.Version }; + var response = new AppCreatedDto { Id = result.IdOrValue.ToString(), Version = result.Version, GeocoderKey = geocoder.Key ?? string.Empty }; response.Permission = AppContributorPermission.Owner; diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppCreatedDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppCreatedDto.cs index 30b3de2c9..e19b4b5f8 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppCreatedDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppCreatedDto.cs @@ -41,5 +41,10 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// Gets the next plan name. /// public string PlanUpgrade { get; set; } + + /// + /// The geocoding api key for the application + /// + public string GeocoderKey { get; set; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs index dbc6e513e..dca6f5782 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs @@ -59,5 +59,10 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// Gets the next plan name. /// public string PlanUpgrade { get; set; } + + /// + /// The geocoding api key for the application + /// + public string GeocoderKey { get; set; } } } diff --git a/src/Squidex/Config/Domain/InfrastructureServices.cs b/src/Squidex/Config/Domain/InfrastructureServices.cs index 5552c561a..1ef8c683e 100644 --- a/src/Squidex/Config/Domain/InfrastructureServices.cs +++ b/src/Squidex/Config/Domain/InfrastructureServices.cs @@ -18,6 +18,7 @@ using Squidex.Infrastructure.Assets; using Squidex.Infrastructure.Assets.ImageSharp; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Geocoding; using Squidex.Infrastructure.Log; using Squidex.Infrastructure.States; using Squidex.Infrastructure.UsageTracking; @@ -47,6 +48,25 @@ namespace Squidex.Config.Domain .As(); } + var geocoder = config.GetRequiredValue("geolocation:type"); + + if (string.Equals(geocoder, "GoogleMaps", StringComparison.OrdinalIgnoreCase)) + { + var geocoderKey = config.GetRequiredValue("geolocation:key"); + + services.AddSingletonAs(c => new GoogleMapsGeocoder(geocoderKey)) + .As(); + } + else if (string.Equals(geocoder, "OSM", StringComparison.OrdinalIgnoreCase)) + { + services.AddSingletonAs() + .As(); + } + else + { + throw new ConfigurationException($"Unsupported value '{geocoder}' for 'geocoder:type', supported: GoogleMaps, OSM."); + } + services.AddSingletonAs(c => new ApplicationInfoLogAppender(typeof(Program).Assembly, Guid.NewGuid())) .As(); diff --git a/src/Squidex/app/framework/angular/geolocation-editor.component.html b/src/Squidex/app/framework/angular/geolocation-editor.component.html deleted file mode 100644 index fd3392b28..000000000 --- a/src/Squidex/app/framework/angular/geolocation-editor.component.html +++ /dev/null @@ -1,19 +0,0 @@ -
-
- -
-
-
- -
-
- -
-
- -
- - -
-
-
\ No newline at end of file diff --git a/src/Squidex/app/framework/angular/geolocation-editor.component.scss b/src/Squidex/app/framework/angular/geolocation-editor.component.scss deleted file mode 100644 index ffa89e6ba..000000000 --- a/src/Squidex/app/framework/angular/geolocation-editor.component.scss +++ /dev/null @@ -1,14 +0,0 @@ -@import '_mixins'; -@import '_vars'; - -.editor { - height: 30rem; -} - -.form-inline { - margin-top: .5rem; -} - -.latitude-group { - margin-right: .25rem; -} \ No newline at end of file diff --git a/src/Squidex/app/framework/angular/geolocation-editor.component.ts b/src/Squidex/app/framework/angular/geolocation-editor.component.ts deleted file mode 100644 index 6226b8ac3..000000000 --- a/src/Squidex/app/framework/angular/geolocation-editor.component.ts +++ /dev/null @@ -1,217 +0,0 @@ -/* - * Squidex Headless CMS - * - * @license - * Copyright (c) Sebastian Stehle. All rights reserved - */ - -import { AfterViewInit, Component, ElementRef, forwardRef, ViewChild } from '@angular/core'; -import { ControlValueAccessor, FormBuilder, NG_VALUE_ACCESSOR } from '@angular/forms'; - -import { Types } from './../utils/types'; - -import { ResourceLoaderService } from './../services/resource-loader.service'; -import { ValidatorsEx } from './validators'; - -declare var L: any; - -export const SQX_GEOLOCATION_EDITOR_CONTROL_VALUE_ACCESSOR: any = { - provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => GeolocationEditorComponent), multi: true -}; - -interface Geolocation { - latitude: number; - longitude: number; -} - -@Component({ - selector: 'sqx-geolocation-editor', - styleUrls: ['./geolocation-editor.component.scss'], - templateUrl: './geolocation-editor.component.html', - providers: [SQX_GEOLOCATION_EDITOR_CONTROL_VALUE_ACCESSOR] -}) -export class GeolocationEditorComponent implements ControlValueAccessor, AfterViewInit { - private callChange = (v: any) => { /* NOOP */ }; - private callTouched = () => { /* NOOP */ }; - private marker: any; - private map: any; - private value: Geolocation | null = null; - - public get hasValue() { - return !!this.value; - } - - public geolocationForm = - this.formBuilder.group({ - latitude: ['', - [ - ValidatorsEx.between(-90, 90) - ]], - longitude: ['', - [ - ValidatorsEx.between(-180, 180) - ]] - }); - - @ViewChild('editor') - public editor: ElementRef; - - public isDisabled = false; - - constructor( - private readonly resourceLoader: ResourceLoaderService, - private readonly formBuilder: FormBuilder - ) { - } - - public writeValue(value: Geolocation) { - if (Types.isObject(value) && Types.isNumber(value.latitude) && Types.isNumber(value.longitude)) { - this.value = value; - } else { - this.value = null; - } - - if (this.marker) { - this.updateMarker(true, false); - } - } - - public setDisabledState(isDisabled: boolean): void { - this.isDisabled = isDisabled; - - if (isDisabled) { - if (this.map) { - this.map.zoomControl.disable(); - - this.map._handlers.forEach((handler: any) => { - handler.disable(); - }); - } - - if (this.marker) { - this.marker.dragging.disable(); - } - - this.geolocationForm.disable(); - } else { - if (this.map) { - this.map.zoomControl.enable(); - - this.map._handlers.forEach((handler: any) => { - handler.enable(); - }); - } - - if (this.marker) { - this.marker.dragging.enable(); - } - - this.geolocationForm.enable(); - } - } - - public registerOnChange(fn: any) { - this.callChange = fn; - } - - public registerOnTouched(fn: any) { - this.callTouched = fn; - } - - public updateValueByInput() { - if (this.geolocationForm.controls['latitude'].value !== null && - this.geolocationForm.controls['longitude'].value !== null && - this.geolocationForm.valid) { - this.value = this.geolocationForm.value; - } else { - this.value = null; - } - - this.updateMarker(true, true); - } - - public ngAfterViewInit() { - this.resourceLoader.loadStyle('https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.0.3/leaflet.css'); - this.resourceLoader.loadScript('https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.0.3/leaflet.js').then(() => { - this.map = L.map(this.editor.nativeElement).fitWorld(); - - L.tileLayer('http://{s}.tile.osm.org/{z}/{x}/{y}.png', { - attribution: '© OpenStreetMap contributors' - }).addTo(this.map); - - this.map.on('click', (event: any) => { - if (!this.marker && !this.isDisabled) { - const latlng = event.latlng.wrap(); - - this.value = { latitude: latlng.lat, longitude: latlng.lng }; - - this.updateMarker(false, true); - } - }); - - this.updateMarker(true, false); - - if (this.isDisabled) { - this.map.zoomControl.disable(); - - this.map._handlers.forEach((handler: any) => { - handler.disable(); - }); - } - }); - } - - public reset() { - this.value = null; - - this.updateMarker(true, true); - } - - private updateMarker(zoom: boolean, fireEvent: boolean) { - if (this.value) { - if (!this.marker) { - this.marker = L.marker([0, 90], { draggable: true }).addTo(this.map); - - this.marker.on('drag', (event: any) => { - const latlng = event.latlng.wrap(); - - this.value = { latitude: latlng.lat, longitude: latlng.lng }; - }); - - this.marker.on('dragend', () => { - this.updateMarker(false, true); - }); - - if (this.isDisabled) { - this.marker.dragging.disable(); - } - } - - const latLng = L.latLng(this.value.latitude, this.value.longitude); - - if (zoom) { - this.map.setView(latLng, 8); - } else { - this.map.panTo(latLng); - } - - this.marker.setLatLng(latLng); - - this.geolocationForm.setValue(this.value, { emitEvent: false, onlySelf: false }); - } else { - if (this.marker) { - this.marker.removeFrom(this.map); - this.marker = null; - } - - this.map.fitWorld(); - - this.geolocationForm.reset(undefined, { emitEvent: false, onlySelf: false }); - } - - if (fireEvent) { - this.callChange(this.value); - this.callTouched(); - } - } -} \ No newline at end of file diff --git a/src/Squidex/app/framework/declarations.ts b/src/Squidex/app/framework/declarations.ts index 51fcb48ac..1b4ab3b68 100644 --- a/src/Squidex/app/framework/declarations.ts +++ b/src/Squidex/app/framework/declarations.ts @@ -17,7 +17,6 @@ export * from './angular/dialog-renderer.component'; export * from './angular/dropdown.component'; export * from './angular/file-drop.directive'; export * from './angular/focus-on-init.directive'; -export * from './angular/geolocation-editor.component'; export * from './angular/http-extensions-impl'; export * from './angular/image-source.directive'; export * from './angular/indeterminate-value.directive'; diff --git a/src/Squidex/app/framework/module.ts b/src/Squidex/app/framework/module.ts index 1edc0e5a5..f162d4f6b 100644 --- a/src/Squidex/app/framework/module.ts +++ b/src/Squidex/app/framework/module.ts @@ -32,7 +32,6 @@ import { FileSizePipe, FocusOnInitDirective, FromNowPipe, - GeolocationEditorComponent, ImageSourceDirective, IndeterminateValueDirective, JscriptEditorComponent, @@ -99,7 +98,6 @@ import { FileSizePipe, FocusOnInitDirective, FromNowPipe, - GeolocationEditorComponent, ImageSourceDirective, IndeterminateValueDirective, JscriptEditorComponent, @@ -149,7 +147,6 @@ import { FileSizePipe, FocusOnInitDirective, FromNowPipe, - GeolocationEditorComponent, ImageSourceDirective, IndeterminateValueDirective, JscriptEditorComponent, diff --git a/src/Squidex/app/shared/components/geolocation-editor.component.html b/src/Squidex/app/shared/components/geolocation-editor.component.html new file mode 100644 index 000000000..c5be81bb3 --- /dev/null +++ b/src/Squidex/app/shared/components/geolocation-editor.component.html @@ -0,0 +1,47 @@ +
+
+
+ +
+
+
+ + +
+ + + +
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
\ No newline at end of file diff --git a/src/Squidex/app/shared/components/geolocation-editor.component.scss b/src/Squidex/app/shared/components/geolocation-editor.component.scss new file mode 100644 index 000000000..b82dca5e8 --- /dev/null +++ b/src/Squidex/app/shared/components/geolocation-editor.component.scss @@ -0,0 +1,68 @@ +@import '_mixins'; +@import '_vars'; + +.editor { + height: 30rem; +} + +.form-group { + margin-top: .5rem; + margin-bottom: .5rem; + + + &.city-group { + display: inline-block; + width: 70%; + + &.hasClear{ + width: 55%; + } + } + + &.state-group { + display: inline-block; + width: 10%; + } + + &.zip-group { + display: inline-block; + width: 18.5%; + } + + &.clear-group { + display: inline-block; + &.hidden{ + display: none; + } + } +} + +.latitude-group { + margin-right: .25rem; +} + +#pac-input { + background-color: #fff; + font-size: 15px; + font-weight: 300; + margin-left: 12px; + padding: 0 11px 0 13px; + text-overflow: ellipsis; + width: 400px; + height: 40px; + + &:focus { + border-color: #4d90fe; + } +} + +#type-selector { + color: #fff; + background-color: #4d90fe; + padding: 5px 11px 0px 11px; +} + +#type-selector label { + font-size: 13px; + font-weight: 300; +} \ No newline at end of file diff --git a/src/Squidex/app/shared/components/geolocation-editor.component.ts b/src/Squidex/app/shared/components/geolocation-editor.component.ts new file mode 100644 index 000000000..b825912a5 --- /dev/null +++ b/src/Squidex/app/shared/components/geolocation-editor.component.ts @@ -0,0 +1,496 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Sebastian Stehle. All rights reserved + */ + +import { AfterViewInit, Component, ElementRef, forwardRef, ViewChild } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, NG_VALUE_ACCESSOR } from '@angular/forms'; + +import { Types } from './../../framework/utils/types'; + +import { ResourceLoaderService } from './../../framework/services/resource-loader.service'; +import { ValidatorsEx } from './../../framework/angular/validators'; + +import { AppContext } from './app-context'; + +declare var L: any; +declare var google: any; + +export const SQX_GEOLOCATION_EDITOR_CONTROL_VALUE_ACCESSOR: any = { + provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => GeolocationEditorComponent), multi: true +}; + +interface Geolocation { + latitude: number; + longitude: number; + address1: string; + address2: string; + city: string; + state: string; + zip: string; +} + +@Component({ + selector: 'sqx-geolocation-editor', + styleUrls: ['./geolocation-editor.component.scss'], + templateUrl: './geolocation-editor.component.html', + providers: [SQX_GEOLOCATION_EDITOR_CONTROL_VALUE_ACCESSOR] +}) +export class GeolocationEditorComponent implements ControlValueAccessor, AfterViewInit { + private callChange = (v: any) => { /* NOOP */ }; + private callTouched = () => { /* NOOP */ }; + private marker: any; + private map: any; + private searchBox: any; + private value: Geolocation | null = null; + public useGoogleMaps = false; + + public get hasValue() { + return !!this.value; + } + + public geolocationForm = + this.formBuilder.group({ + address1: ['', []], + address2: ['', []], + city: ['', []], + state: [ + '', [ + ValidatorsEx.pattern('[A-Z]{2}', 'This field must be a valid state abbreviation.') + ] + ], + zip: [ + '', [ + ValidatorsEx.pattern('[0-9]{5}(\-[0-9]{4})?', 'This field must be a valid ZIP Code.') + ] + ], + latitude: [ + '', + [ + ValidatorsEx.between(-90, 90) + ] + ], + longitude: [ + '', + [ + ValidatorsEx.between(-180, 180) + ] + ] + }); + + @ViewChild('editor') + public editor: ElementRef; + + @ViewChild('searchBox') + public searchBoxInput: ElementRef; + + public isDisabled = false; + + constructor( + private readonly resourceLoader: ResourceLoaderService, + private readonly formBuilder: FormBuilder, + private readonly ctx: AppContext + ) { + this.useGoogleMaps = this.ctx.app.geocoderKey !== ''; + } + + public writeValue(value: Geolocation) { + if (Types.isObject(value) && Types.isNumber(value.latitude) && Types.isNumber(value.longitude)) { + this.value = value; + } else { + this.value = null; + } + + if (this.marker || (!this.marker && this.map && this.value)) { + this.updateMarker(true, false); + } + } + + public setDisabledState(isDisabled: boolean): void { + if (this.ctx.app.geocoderKey === '') { + this.setDisabledStateOSM(isDisabled); + } else { + this.setDisabledStateGoogle(isDisabled); + } + } + + private setDisabledStateOSM(isDisabled: boolean): void { + this.isDisabled = isDisabled; + + if (isDisabled) { + if (this.map) { + this.map.zoomControl.disable(); + + this.map._handlers.forEach((handler: any) => { + handler.disable(); + }); + } + + if (this.marker) { + this.marker.dragging.disable(); + } + + this.geolocationForm.disable(); + } else { + if (this.map) { + this.map.zoomControl.enable(); + + this.map._handlers.forEach((handler: any) => { + handler.enable(); + }); + } + + if (this.marker) { + this.marker.dragging.enable(); + } + + this.geolocationForm.enable(); + } + } + + private setDisabledStateGoogle(isDisabled: boolean): void { + this.isDisabled = isDisabled; + + if (isDisabled) { + if (this.map) { + this.map.setOptions({ + draggable: false, + zoomControl: false + }); + } + + if (this.marker) { + this.marker.setDraggable(false); + } + + this.geolocationForm.disable(); + this.searchBoxInput.nativeElement.disabled = true; + } else { + if (this.map) { + this.map.setOptions({ + draggable: true, + zoomControl: true + }); + } + + if (this.marker) { + this.marker.setDraggable(true); + } + + this.geolocationForm.enable(); + this.searchBoxInput.nativeElement.disabled = false; + } + } + + public registerOnChange(fn: any) { + this.callChange = fn; + } + + public registerOnTouched(fn: any) { + this.callTouched = fn; + } + + public updateValueByInput() { + let updateMap = this.geolocationForm.controls['latitude'].value !== null && + this.geolocationForm.controls['longitude'].value !== null; + + this.value = this.geolocationForm.value; + + if (updateMap) { + this.updateMarker(true, true); + } else { + this.callChange(this.value); + this.callTouched(); + } + } + + public ngAfterViewInit() { + if (!this.useGoogleMaps) { + this.ngAfterViewInitOSM(); + } else { + this.ngAfterViewInitGoogle(); + } + } + + private ngAfterViewInitOSM() { + this.searchBoxInput.nativeElement.remove(); + + this.resourceLoader.loadStyle('https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.0.3/leaflet.css'); + this.resourceLoader.loadScript('https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.0.3/leaflet.js').then( + () => { + this.map = L.map(this.editor.nativeElement).fitWorld(); + + L.tileLayer('http://{s}.tile.osm.org/{z}/{x}/{y}.png', + { + attribution: '© OpenStreetMap contributors' + }).addTo(this.map); + + this.map.on('click', + (event: any) => { + if (!this.marker && !this.isDisabled) { + const latlng = event.latlng.wrap(); + + this.value = { + latitude: latlng.lat, + longitude: latlng.lng, + address1: '', + address2: '', + city: '', + state: '', + zip: '' + }; + + this.updateMarker(false, true); + } + }); + + this.updateMarker(true, false); + + if (this.isDisabled) { + this.map.zoomControl.disable(); + + this.map._handlers.forEach((handler: any) => { + handler.disable(); + }); + } + }); + } + + private ngAfterViewInitGoogle() { + this.resourceLoader.loadScript(`https://maps.googleapis.com/maps/api/js?key=${this.ctx.app.geocoderKey}&libraries=places`).then( + () => { + this.map = new google.maps.Map(this.editor.nativeElement, + { + zoom: 1, + mapTypeControl: false, + streetViewControl: false, + center: { lat: 0, lng: 0 } + }); + + this.searchBox = new google.maps.places.SearchBox(this.searchBoxInput.nativeElement); + this.map.controls[google.maps.ControlPosition.LEFT_TOP].push(this.searchBoxInput.nativeElement); + + this.map.addListener('click', + (event: any) => { + if (!this.isDisabled) { + this.value = { + latitude: event.latLng.lat(), + longitude: event.latLng.lng(), + address1: this.value == null ? '' : this.value.address1, + address2: this.value == null ? '' : this.value.address2, + city: this.value == null ? '' : this.value.city, + state: this.value == null ? '' : this.value.state, + zip: this.value == null ? '' : this.value.zip + }; + + this.updateMarker(false, true); + } + }); + + this.map.addListener('bounds_changed', (event: any) => { + this.searchBox.setBounds(this.map.getBounds()); + }); + + this.searchBox.addListener('places_changed', (event: any) => { + let places = this.searchBox.getPlaces(); + + if (places.length === 1) { + let place = places[0]; + + if (!place.geometry) { + console.log('Returned place contains no geometry'); + return; + } + + if (!this.isDisabled) { + this.value = this.parseAddress(place); + + this.updateMarker(false, true); + } + } + }); + + this.updateMarker(true, false); + + if (this.isDisabled) { + this.map.setOptions({ + draggable: false, + zoomControl: false + }); + } + }); + } + + public reset() { + this.value = null; + this.searchBoxInput.nativeElement.value = null; + + this.updateMarker(true, true); + } + + private parseAddress(value: any): Geolocation { + let latitude = value.geometry.location.lat(); + let longitude = value.geometry.location.lng(); + let address1 = this.getAddressValue(value.address_components.find((a: any) => a.types.indexOf('street_number') > -1)) + + ' ' + this.getAddressValue(value.address_components.find((a: any) => a.types.indexOf('route') > -1)); + let address2 = this.getAddressValue(value.address_components.find((a: any) => a.types.indexOf('subpremise') > -1)); + let city = this.getAddressValue(value.address_components.find((a: any) => a.types.indexOf('locality') > -1)); + let state = this.getAddressValue(value.address_components.find((a: any) => a.types.indexOf('administrative_area_level_1') > -1)).toUpperCase(); + + let zipCode = this.getAddressValue(value.address_components.find((a: any) => a.types.indexOf('postal_code') > -1)); + let zipCodeSuffix = this.getAddressValue(value.address_components.find((a: any) => a.types.indexOf('postal_code_suffix') > -1)); + let zip = zipCodeSuffix === '' ? zipCode : zipCode + '-' + zipCodeSuffix; + + return { latitude: latitude, longitude: longitude, address1: address1, address2: address2, city: city, state: state, zip: zip }; + } + + private getAddressValue(value: any) { + return value == null ? '' : value.short_name; + } + + private fillMissingParameters() { + return { + latitude: this.value.latitude, + longitude: this.value.longitude, + address1: this.value.address1 ? this.value.address1 : '', + address2: this.value.address2 ? this.value.address2 : '', + city: this.value.city ? this.value.city : '', + state: this.value.state ? this.value.state : '', + zip: this.value.zip ? this.value.zip : '' + }; + } + + private updateMarker(zoom: boolean, fireEvent: boolean) { + if (this.ctx.app.geocoderKey === '') { + this.updateMarkerOSM(zoom, fireEvent); + } else { + this.updateMarkerGoogle(zoom, fireEvent); + } + } + + private updateMarkerOSM(zoom: boolean, fireEvent: boolean) { + if (this.value) { + if (!this.marker) { + this.marker = L.marker([0, 90], { draggable: true }).addTo(this.map); + + this.marker.on('drag', (event: any) => { + const latlng = event.latlng.wrap(); + + this.value = { + latitude: latlng.lat, + longitude: latlng.lng, + address1: '', + address2: '', + city: '', + state: '', + zip: '' }; + }); + + this.marker.on('dragend', () => { + this.updateMarker(false, true); + }); + + if (this.isDisabled) { + this.marker.dragging.disable(); + } + } + + const latLng = L.latLng(this.value.latitude, this.value.longitude); + + if (zoom) { + this.map.setView(latLng, 8); + } else { + this.map.panTo(latLng); + } + + this.marker.setLatLng(latLng); + + this.geolocationForm.setValue(this.fillMissingParameters(), { emitEvent: false, onlySelf: false }); + } else { + if (this.marker) { + this.marker.removeFrom(this.map); + this.marker = null; + } + + this.map.fitWorld(); + + this.geolocationForm.reset(undefined, { emitEvent: false, onlySelf: false }); + } + + if (fireEvent) { + this.callChange(this.value); + this.callTouched(); + } + } + + private updateMarkerGoogle(zoom: boolean, fireEvent: boolean) { + if (this.value) { + if (!this.marker) { + this.marker = new google.maps.Marker({ + position: { lat: 0, lng: 0 }, + map: this.map, + draggable: true + }); + + this.marker.addListener('drag', (event: any) => { + if (!this.isDisabled) { + this.value = { + latitude: event.latLng.lat(), + longitude: event.latLng.lng(), + address1: this.value == null ? '' : this.value.address1, + address2: this.value == null ? '' : this.value.address2, + city: this.value == null ? '' : this.value.city, + state: this.value == null ? '' : this.value.state, + zip: this.value == null ? '' : this.value.zip + }; + } + }); + this.marker.addListener('dragend', (event: any) => { + if (!this.isDisabled) { + this.value = { + latitude: event.latLng.lat(), + longitude: event.latLng.lng(), + address1: this.value == null ? '' : this.value.address1, + address2: this.value == null ? '' : this.value.address2, + city: this.value == null ? '' : this.value.city, + state: this.value == null ? '' : this.value.state, + zip: this.value == null ? '' : this.value.zip + }; + + this.updateMarker(false, true); + } + }); + } + + const latLng = { lat: this.value.latitude, lng: this.value.longitude }; + + if (zoom) { + this.map.setCenter(latLng); + } else { + this.map.panTo(latLng); + } + + this.marker.setPosition(latLng); + this.map.setZoom(16); + + this.geolocationForm.setValue(this.fillMissingParameters(), { emitEvent: false, onlySelf: false }); + } else { + if (this.marker) { + this.marker.setMap(null); + this.marker = null; + } + + this.map.setCenter({ lat: 0, lng: 0 }); + this.map.setZoom(1); + + this.geolocationForm.reset(undefined, { emitEvent: false, onlySelf: false }); + } + + if (fireEvent) { + this.callChange(this.value); + this.callTouched(); + } + } +} \ No newline at end of file diff --git a/src/Squidex/app/shared/declarations.ts b/src/Squidex/app/shared/declarations.ts index 0d7371c00..f8b2efe39 100644 --- a/src/Squidex/app/shared/declarations.ts +++ b/src/Squidex/app/shared/declarations.ts @@ -9,6 +9,7 @@ export * from './components/app-context'; export * from './components/app-form.component'; export * from './components/asset.component'; export * from './components/help.component'; +export * from './components/geolocation-editor.component'; export * from './components/history.component'; export * from './components/language-selector.component'; export * from './components/pipes'; diff --git a/src/Squidex/app/shared/module.ts b/src/Squidex/app/shared/module.ts index 3cc68672f..7d60668a8 100644 --- a/src/Squidex/app/shared/module.ts +++ b/src/Squidex/app/shared/module.ts @@ -28,6 +28,7 @@ import { ContentsService, EventConsumersService, FileIconPipe, + GeolocationEditorComponent, GraphQlService, HelpComponent, HelpService, @@ -73,6 +74,7 @@ import { AssetPreviewUrlPipe, AssetUrlPipe, FileIconPipe, + GeolocationEditorComponent, HelpComponent, HistoryComponent, LanguageSelectorComponent, @@ -92,6 +94,7 @@ import { AssetPreviewUrlPipe, AssetUrlPipe, FileIconPipe, + GeolocationEditorComponent, HelpComponent, HistoryComponent, LanguageSelectorComponent, diff --git a/src/Squidex/app/shared/services/apps-store.service.spec.ts b/src/Squidex/app/shared/services/apps-store.service.spec.ts index 96f86dded..e64ed5cb1 100644 --- a/src/Squidex/app/shared/services/apps-store.service.spec.ts +++ b/src/Squidex/app/shared/services/apps-store.service.spec.ts @@ -20,10 +20,10 @@ describe('AppsStoreService', () => { const now = DateTime.now(); const oldApps = [ - new AppDto('id', 'old-name', 'Owner', now, now, 'Free', 'Plan'), - new AppDto('id', 'old-name', 'Owner', now, now, 'Free', 'Plan') + new AppDto('id', 'old-name', 'Owner', now, now, 'Free', 'Plan', ''), + new AppDto('id', 'old-name', 'Owner', now, now, 'Free', 'Plan', '') ]; - const newApp = new AppDto('id', 'new-name', 'Owner', now, now, 'Free', 'Plan'); + const newApp = new AppDto('id', 'new-name', 'Owner', now, now, 'Free', 'Plan', ''); let appsService: IMock; diff --git a/src/Squidex/app/shared/services/apps.service.spec.ts b/src/Squidex/app/shared/services/apps.service.spec.ts index d255b7f23..09b204ab7 100644 --- a/src/Squidex/app/shared/services/apps.service.spec.ts +++ b/src/Squidex/app/shared/services/apps.service.spec.ts @@ -59,7 +59,8 @@ describe('AppsService', () => { created: '2016-01-01', lastModified: '2016-02-02', planName: 'Free', - planUpgrade: 'Basic' + planUpgrade: 'Basic', + geocoderKey: '' }, { id: '456', @@ -68,13 +69,14 @@ describe('AppsService', () => { created: '2017-01-01', lastModified: '2017-02-02', planName: 'Basic', - planUpgrade: 'Enterprise' + planUpgrade: 'Enterprise', + geocoderKey: '' } ]); expect(apps).toEqual([ - new AppDto('123', 'name1', 'Owner', DateTime.parseISO('2016-01-01'), DateTime.parseISO('2016-02-02'), 'Free', 'Basic'), - new AppDto('456', 'name2', 'Owner', DateTime.parseISO('2017-01-01'), DateTime.parseISO('2017-02-02'), 'Basic', 'Enterprise') + new AppDto('123', 'name1', 'Owner', DateTime.parseISO('2016-01-01'), DateTime.parseISO('2016-02-02'), 'Free', 'Basic', ''), + new AppDto('456', 'name2', 'Owner', DateTime.parseISO('2017-01-01'), DateTime.parseISO('2017-02-02'), 'Basic', 'Enterprise', '') ]); })); @@ -98,9 +100,10 @@ describe('AppsService', () => { id: '123', permission: 'Reader', planName: 'Basic', - planUpgrade: 'Enterprise' + planUpgrade: 'Enterprise', + geocoderKey: '' }); - expect(app).toEqual(new AppDto('123', dto.name, 'Reader', now, now, 'Basic', 'Enterprise')); + expect(app).toEqual(new AppDto('123', dto.name, 'Reader', now, now, 'Basic', 'Enterprise', '')); })); }); \ No newline at end of file diff --git a/src/Squidex/app/shared/services/apps.service.ts b/src/Squidex/app/shared/services/apps.service.ts index d1f99b348..12ed810ee 100644 --- a/src/Squidex/app/shared/services/apps.service.ts +++ b/src/Squidex/app/shared/services/apps.service.ts @@ -26,7 +26,8 @@ export class AppDto { public readonly created: DateTime, public readonly lastModified: DateTime, public readonly planName: string, - public readonly planUpgrade: string + public readonly planUpgrade: string, + public readonly geocoderKey: string ) { } } @@ -64,7 +65,8 @@ export class AppsService { DateTime.parseISO(item.created), DateTime.parseISO(item.lastModified), item.planName, - item.planUpgrade); + item.planUpgrade, + item.geocoderKey); }); }) .pretifyError('Failed to load apps. Please reload.'); @@ -79,7 +81,7 @@ export class AppsService { now = now || DateTime.now(); - return new AppDto(body.id, dto.name, body.permission, now, now, body.planName, body.planUpgrade); + return new AppDto(body.id, dto.name, body.permission, now, now, body.planName, body.planUpgrade, body.geocoderKey); }) .do(() => { this.analytics.trackEvent('App', 'Created', dto.name); diff --git a/src/Squidex/appsettings.json b/src/Squidex/appsettings.json index 2709ed69d..7650fd432 100644 --- a/src/Squidex/appsettings.json +++ b/src/Squidex/appsettings.json @@ -22,6 +22,16 @@ } }, + "geolocation": { + /* + * Define the type of the geolocation service. + * + * Supported: GoogleMaps, OSM + */ + "type": "OSM", + "key": "" + }, + "logging": { /* * Setting the flag to true, enables well formatteds json logs. diff --git a/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ContentValidationTests.cs b/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ContentValidationTests.cs index a85b1f562..c092631ef 100644 --- a/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ContentValidationTests.cs +++ b/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ContentValidationTests.cs @@ -8,12 +8,14 @@ using System.Collections.Generic; using System.Threading.Tasks; +using FakeItEasy; using FluentAssertions; using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.ValidateContent; using Squidex.Infrastructure; +using Squidex.Infrastructure.Geocoding; using Xunit; namespace Squidex.Domain.Apps.Core.Operations.ValidateContent @@ -22,6 +24,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent { private readonly LanguagesConfig languagesConfig = LanguagesConfig.Build(Language.DE, Language.EN); private readonly List errors = new List(); + private readonly IGeocoder geocoder = A.Fake(); private readonly ValidationContext context = ValidationTestExtensions.ValidContext; private Schema schema = new Schema("my-schema"); @@ -33,7 +36,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent .AddField("unknown", new ContentFieldData()); - await data.ValidateAsync(context, schema, languagesConfig.ToResolver(), errors); + await data.ValidateAsync(context, schema, languagesConfig.ToResolver(), errors, geocoder); errors.ShouldBeEquivalentTo( new List @@ -54,7 +57,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent new ContentFieldData() .AddValue(1000)); - await data.ValidateAsync(context, schema, languagesConfig.ToResolver(), errors); + await data.ValidateAsync(context, schema, languagesConfig.ToResolver(), errors, geocoder); errors.ShouldBeEquivalentTo( new List @@ -75,7 +78,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent .AddValue("es", 1) .AddValue("it", 1)); - await data.ValidateAsync(context, schema, languagesConfig.ToResolver(), errors); + await data.ValidateAsync(context, schema, languagesConfig.ToResolver(), errors, geocoder); errors.ShouldBeEquivalentTo( new List @@ -94,7 +97,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent var data = new NamedContentData(); - await data.ValidateAsync(context, schema, languagesConfig.ToResolver(), errors); + await data.ValidateAsync(context, schema, languagesConfig.ToResolver(), errors, geocoder); errors.ShouldBeEquivalentTo( new List @@ -113,7 +116,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent var data = new NamedContentData(); - await data.ValidateAsync(context, schema, languagesConfig.ToResolver(), errors); + await data.ValidateAsync(context, schema, languagesConfig.ToResolver(), errors, geocoder); errors.ShouldBeEquivalentTo( new List @@ -134,7 +137,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent .AddValue("de", 1) .AddValue("xx", 1)); - await data.ValidateAsync(context, schema, languagesConfig.ToResolver(), errors); + await data.ValidateAsync(context, schema, languagesConfig.ToResolver(), errors, geocoder); errors.ShouldBeEquivalentTo( new List @@ -160,7 +163,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent new ContentFieldData() .AddValue("es", "value")); - await data.ValidateAsync(context, schema, optionalConfig.ToResolver(), errors); + await data.ValidateAsync(context, schema, optionalConfig.ToResolver(), errors, geocoder); Assert.Empty(errors); } @@ -177,7 +180,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent .AddValue("es", 1) .AddValue("it", 1)); - await data.ValidateAsync(context, schema, languagesConfig.ToResolver(), errors); + await data.ValidateAsync(context, schema, languagesConfig.ToResolver(), errors, geocoder); errors.ShouldBeEquivalentTo( new List @@ -195,7 +198,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent .AddField("unknown", new ContentFieldData()); - await data.ValidatePartialAsync(context, schema, languagesConfig.ToResolver(), errors); + await data.ValidatePartialAsync(context, schema, languagesConfig.ToResolver(), errors, geocoder); errors.ShouldBeEquivalentTo( new List @@ -216,7 +219,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent new ContentFieldData() .AddValue(1000)); - await data.ValidatePartialAsync(context, schema, languagesConfig.ToResolver(), errors); + await data.ValidatePartialAsync(context, schema, languagesConfig.ToResolver(), errors, geocoder); errors.ShouldBeEquivalentTo( new List @@ -237,7 +240,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent .AddValue("es", 1) .AddValue("it", 1)); - await data.ValidatePartialAsync(context, schema, languagesConfig.ToResolver(), errors); + await data.ValidatePartialAsync(context, schema, languagesConfig.ToResolver(), errors, geocoder); errors.ShouldBeEquivalentTo( new List @@ -256,7 +259,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent var data = new NamedContentData(); - await data.ValidatePartialAsync(context, schema, languagesConfig.ToResolver(), errors); + await data.ValidatePartialAsync(context, schema, languagesConfig.ToResolver(), errors, geocoder); Assert.Empty(errors); } @@ -270,7 +273,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent var data = new NamedContentData(); - await data.ValidatePartialAsync(context, schema, languagesConfig.ToResolver(), errors); + await data.ValidatePartialAsync(context, schema, languagesConfig.ToResolver(), errors, geocoder); Assert.Empty(errors); } @@ -287,7 +290,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent .AddValue("de", 1) .AddValue("xx", 1)); - await data.ValidatePartialAsync(context, schema, languagesConfig.ToResolver(), errors); + await data.ValidatePartialAsync(context, schema, languagesConfig.ToResolver(), errors, geocoder); errors.ShouldBeEquivalentTo( new List @@ -308,7 +311,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent .AddValue("es", 1) .AddValue("it", 1)); - await data.ValidatePartialAsync(context, schema, languagesConfig.ToResolver(), errors); + await data.ValidatePartialAsync(context, schema, languagesConfig.ToResolver(), errors, geocoder); errors.ShouldBeEquivalentTo( new List diff --git a/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/DateTimeFieldTests.cs b/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/DateTimeFieldTests.cs index 5580c1883..aaabde7b2 100644 --- a/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/DateTimeFieldTests.cs +++ b/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/DateTimeFieldTests.cs @@ -91,7 +91,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent await sut.ValidateAsync(CreateValue(123), errors); errors.ShouldBeEquivalentTo( - new[] { " is not a valid value." }); + new[] { " is not a valid value. Invalid json type, expected string." }); } private static Instant FutureDays(int days) diff --git a/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/GeolocationFieldTests.cs b/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/GeolocationFieldTests.cs index c8c275fe2..3269c1d64 100644 --- a/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/GeolocationFieldTests.cs +++ b/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/GeolocationFieldTests.cs @@ -63,7 +63,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent await sut.ValidateAsync(CreateValue(geolocation), errors); errors.ShouldBeEquivalentTo( - new[] { " is not a valid value." }); + new[] { " is not a valid value. Latitude must be between -90 and 90." }); } [Fact] @@ -78,7 +78,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent await sut.ValidateAsync(CreateValue(geolocation), errors); errors.ShouldBeEquivalentTo( - new[] { " is not a valid value." }); + new[] { " is not a valid value. Longitude must be between -180 and 180." }); } [Fact] @@ -94,7 +94,39 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent await sut.ValidateAsync(CreateValue(geolocation), errors); errors.ShouldBeEquivalentTo( - new[] { " is not a valid value." }); + new[] { " is not a valid value. Geolocation must have proper properties." }); + } + + [Fact] + public async Task Should_add_errors_if_geolocation_has_invalid_zip() + { + var sut = new GeolocationField(1, "my-geolocation", Partitioning.Invariant, new GeolocationFieldProperties { IsRequired = true }); + + var geolocation = new JObject( + new JProperty("latitude", 0), + new JProperty("longitude", 0), + new JProperty("zip", "1234")); + + await sut.ValidateAsync(CreateValue(geolocation), errors); + + errors.ShouldBeEquivalentTo( + new[] { " is not a valid value. ZIP Code must match postal code with optional suffix pattern." }); + } + + [Fact] + public async Task Should_add_errors_if_geolocation_has_invalid_state() + { + var sut = new GeolocationField(1, "my-geolocation", Partitioning.Invariant, new GeolocationFieldProperties { IsRequired = true }); + + var geolocation = new JObject( + new JProperty("latitude", 0), + new JProperty("longitude", 0), + new JProperty("state", "1")); + + await sut.ValidateAsync(CreateValue(geolocation), errors); + + errors.ShouldBeEquivalentTo( + new[] { " is not a valid value. State must be two capital letters." }); } [Fact] diff --git a/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ValidationTestExtensions.cs b/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ValidationTestExtensions.cs index c08bef25f..c42a0790a 100644 --- a/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ValidationTestExtensions.cs +++ b/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ValidationTestExtensions.cs @@ -14,6 +14,7 @@ using Newtonsoft.Json.Linq; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.ValidateContent; using Squidex.Domain.Apps.Core.ValidateContent.Validators; +using Squidex.Infrastructure.Geocoding; namespace Squidex.Domain.Apps.Core.Operations.ValidateContent { @@ -34,14 +35,14 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent return validator.ValidateAsync(value, (context ?? ValidContext).Optional(true), errors.Add); } - public static Task ValidateAsync(this Field field, JToken value, IList errors, ValidationContext context = null) + public static Task ValidateAsync(this Field field, JToken value, IList errors, ValidationContext context = null, IGeocoder geocoder = null) { - return field.ValidateAsync(value, context ?? ValidContext, errors.Add); + return field.ValidateAsync(value, context ?? ValidContext, errors.Add, geocoder); } - public static Task ValidateOptionalAsync(this Field field, JToken value, IList errors, ValidationContext context = null) + public static Task ValidateOptionalAsync(this Field field, JToken value, IList errors, ValidationContext context = null, IGeocoder geocoder = null) { - return field.ValidateAsync(value, (context ?? ValidContext).Optional(true), errors.Add); + return field.ValidateAsync(value, (context ?? ValidContext).Optional(true), errors.Add, geocoder); } public static ValidationContext Assets(params IAssetInfo[] assets) diff --git a/tests/Squidex.Domain.Apps.Write.Tests/Contents/ContentCommandMiddlewareTests.cs b/tests/Squidex.Domain.Apps.Write.Tests/Contents/ContentCommandMiddlewareTests.cs index 21d668f7e..ecd6678fa 100644 --- a/tests/Squidex.Domain.Apps.Write.Tests/Contents/ContentCommandMiddlewareTests.cs +++ b/tests/Squidex.Domain.Apps.Write.Tests/Contents/ContentCommandMiddlewareTests.cs @@ -10,6 +10,7 @@ using System; using System.Security.Claims; using System.Threading.Tasks; using FakeItEasy; +using Newtonsoft.Json.Linq; using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Core.Contents; @@ -24,6 +25,7 @@ using Squidex.Domain.Apps.Write.Contents.Commands; using Squidex.Domain.Apps.Write.TestHelpers; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Geocoding; using Xunit; namespace Squidex.Domain.Apps.Write.Contents @@ -36,6 +38,7 @@ namespace Squidex.Domain.Apps.Write.Contents private readonly IScriptEngine scriptEngine = A.Fake(); private readonly IAppProvider appProvider = A.Fake(); private readonly IAppEntity app = A.Fake(); + private readonly IGeocoder geocoder = A.Fake(); private readonly ClaimsPrincipal user = new ClaimsPrincipal(); private readonly LanguagesConfig languagesConfig = LanguagesConfig.Build(Language.DE); private readonly Guid contentId = Guid.NewGuid(); @@ -57,6 +60,14 @@ namespace Squidex.Domain.Apps.Write.Contents .AddField("my-field1", new ContentFieldData() .AddValue(1)); + private readonly NamedContentData invalidOSMLatLongData = new NamedContentData() + .AddField("my-geolocation-field1", new ContentFieldData() + .AddValue(JObject.FromObject(new { latitude = 0, longitude = (double?)null }))); + + private readonly NamedContentData invalidGoogleMapsLatLongData = new NamedContentData() + .AddField("my-geolocation-field1", new ContentFieldData() + .AddValue(JObject.FromObject(new { latitude = 0, longitude = (double?)null, address1 = "baddata" }))); + public ContentCommandMiddlewareTests() { var schemaDef = @@ -68,7 +79,7 @@ namespace Squidex.Domain.Apps.Write.Contents content = new ContentDomainObject(contentId, -1); - sut = new ContentCommandMiddleware(Handler, appProvider, A.Dummy(), scriptEngine, A.Dummy()); + sut = new ContentCommandMiddleware(Handler, appProvider, A.Dummy(), scriptEngine, A.Dummy(), geocoder); A.CallTo(() => app.LanguagesConfig).Returns(languagesConfig); @@ -239,6 +250,49 @@ namespace Squidex.Domain.Apps.Write.Contents A.CallTo(() => scriptEngine.Execute(A.Ignored, "")).MustHaveHappened(); } + [Fact] + public async Task Create_geolocation_should_throw_exception_if_OSM_data_is_invalid() + { + A.CallTo(() => scriptEngine.ExecuteAndTransform(A.Ignored, A.Ignored)) + .Returns(invalidOSMLatLongData); + SetupGeolocationTest(); + + var context = CreateContextForCommand(new CreateContent { ContentId = contentId, Data = invalidOSMLatLongData, User = user }); + + await TestCreate(content, async _ => + { + await Assert.ThrowsAsync(() => sut.HandleAsync(context)); + }, false); + } + + [Fact] + public async Task Create_geolocation_should_throw_exception_if_Google_Maps_data_does_not_return_lat_long() + { + A.CallTo(() => scriptEngine.ExecuteAndTransform(A.Ignored, A.Ignored)) + .Returns(invalidGoogleMapsLatLongData); + SetupGeolocationTest("key"); + + var context = CreateContextForCommand(new CreateContent { ContentId = contentId, Data = invalidOSMLatLongData, User = user }); + + await TestCreate(content, async _ => + { + await Assert.ThrowsAsync(() => sut.HandleAsync(context)); + }, false); + } + + private void SetupGeolocationTest(string googleApiKey = "") + { + var geolocationSchemaDef = + new Schema("my-geolocation-schema") + .AddField(new GeolocationField(1, "my-geolocation-field1", Partitioning.Invariant, + new GeolocationFieldProperties { IsRequired = true })); + + A.CallTo(() => schema.SchemaDef).Returns(geolocationSchemaDef); + A.CallTo(() => geocoder.Key).Returns(googleApiKey); + A.CallTo(() => geocoder.GeocodeAddress("baddata")).Throws(); + A.CallTo(() => geocoder.GeocodeAddress(string.Empty)).Throws(); + } + private void CreateContent() { content.Create(new CreateContent { Data = data });