Browse Source

Adding the option to use Google Maps for the geolocation map.

pull/204/head
Alex Van Dyke 9 years ago
parent
commit
68ea315fc1
  1. 9
      src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidationExtensions.cs
  2. 9
      src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidator.cs
  3. 15
      src/Squidex.Domain.Apps.Core.Operations/ValidateContent/FieldExtensions.cs
  4. 58
      src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueConverter.cs
  5. 10
      src/Squidex.Domain.Apps.Write/Contents/ContentCommandMiddleware.cs
  6. 31
      src/Squidex.Domain.Apps.Write/Contents/ContentOperationContext.cs
  7. 57
      src/Squidex.Infrastructure/Geocoding/GoogleMapsGeocoder.cs
  8. 14
      src/Squidex.Infrastructure/Geocoding/IGeocoder.cs
  9. 19
      src/Squidex.Infrastructure/Geocoding/OSMGeocoder.cs
  10. 10
      src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs
  11. 5
      src/Squidex/Areas/Api/Controllers/Apps/Models/AppCreatedDto.cs
  12. 5
      src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs
  13. 20
      src/Squidex/Config/Domain/InfrastructureServices.cs
  14. 19
      src/Squidex/app/framework/angular/geolocation-editor.component.html
  15. 14
      src/Squidex/app/framework/angular/geolocation-editor.component.scss
  16. 217
      src/Squidex/app/framework/angular/geolocation-editor.component.ts
  17. 1
      src/Squidex/app/framework/declarations.ts
  18. 3
      src/Squidex/app/framework/module.ts
  19. 47
      src/Squidex/app/shared/components/geolocation-editor.component.html
  20. 68
      src/Squidex/app/shared/components/geolocation-editor.component.scss
  21. 496
      src/Squidex/app/shared/components/geolocation-editor.component.ts
  22. 1
      src/Squidex/app/shared/declarations.ts
  23. 3
      src/Squidex/app/shared/module.ts
  24. 6
      src/Squidex/app/shared/services/apps-store.service.spec.ts
  25. 15
      src/Squidex/app/shared/services/apps.service.spec.ts
  26. 8
      src/Squidex/app/shared/services/apps.service.ts
  27. 10
      src/Squidex/appsettings.json
  28. 33
      tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ContentValidationTests.cs
  29. 2
      tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/DateTimeFieldTests.cs
  30. 38
      tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/GeolocationFieldTests.cs
  31. 9
      tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ValidationTestExtensions.cs
  32. 56
      tests/Squidex.Domain.Apps.Write.Tests/Contents/ContentCommandMiddlewareTests.cs

9
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<ValidationError> errors)
public static async Task ValidateAsync(this NamedContentData data, ValidationContext context, Schema schema, PartitionResolver partitionResolver, IList<ValidationError> 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<ValidationError> errors)
public static async Task ValidatePartialAsync(this NamedContentData data, ValidationContext context, Schema schema, PartitionResolver partitionResolver, IList<ValidationError> errors, IGeocoder geocoder)
{
var validator = new ContentValidator(schema, partitionResolver, context);
var validator = new ContentValidator(schema, partitionResolver, context, geocoder);
await validator.ValidatePartialAsync(data);

9
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<ValidationError> errors = new ConcurrentBag<ValidationError>();
@ -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);

15
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("<FIELD>", displayName), fieldName));
}
public static async Task ValidateAsync(this Field field, JToken value, ValidationContext context, Action<string> addError)
public static async Task ValidateAsync(this Field field, JToken value, ValidationContext context, Action<string> 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("<FIELD> is not a valid value.");
var error = ex.Message;
addError($"<FIELD> is not a valid value. {error}".Trim());
}
catch (Exception ex)
{
var error = (ex as AggregateException)?.InnerException?.Message;
addError($"<FIELD> is not a valid value. {error ?? string.Empty}".Trim());
}
}
}

58
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<object>
{
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<string> addressString = new List<string>();
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)

10
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;
}

31
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<string> message;
private IGeocoder geocoder;
public static async Task<ContentOperationContext> CreateAsync(
IContentRepository contentRepository,
@ -44,20 +46,23 @@ namespace Squidex.Domain.Apps.Write.Contents
IAppProvider appProvider,
IAssetRepository assetRepository,
IScriptEngine scriptEngine,
Func<string> message)
Func<string> 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)

57
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<object> GetLatLong(HttpRequestMessage request)
{
try
{
HttpResponseMessage response;
using (var client = new HttpClient { Timeout = Timeout })
{
response = await client.SendAsync(request);
}
var result = JsonConvert.DeserializeObject<JObject>(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.");
}
}
}
}

14
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);
}
}

19
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.");
}
}
}

10
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;
}
/// <summary>
@ -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<EntityCreatedResult<Guid>>();
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;

5
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.
/// </summary>
public string PlanUpgrade { get; set; }
/// <summary>
/// The geocoding api key for the application
/// </summary>
public string GeocoderKey { get; set; }
}
}

5
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.
/// </summary>
public string PlanUpgrade { get; set; }
/// <summary>
/// The geocoding api key for the application
/// </summary>
public string GeocoderKey { get; set; }
}
}

20
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<IExternalSystem>();
}
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<IGeocoder>();
}
else if (string.Equals(geocoder, "OSM", StringComparison.OrdinalIgnoreCase))
{
services.AddSingletonAs<OSMGeocoder>()
.As<IGeocoder>();
}
else
{
throw new ConfigurationException($"Unsupported value '{geocoder}' for 'geocoder:type', supported: GoogleMaps, OSM.");
}
services.AddSingletonAs(c => new ApplicationInfoLogAppender(typeof(Program).Assembly, Guid.NewGuid()))
.As<ILogAppender>();

19
src/Squidex/app/framework/angular/geolocation-editor.component.html

@ -1,19 +0,0 @@
<div>
<div class="editor" #editor></div>
<div>
<form class="form-inline" [formGroup]="geolocationForm" (submit)="updateValueByInput()">
<div class="form-group latitude-group">
<input type="number" class="form-control" formControlName="latitude" step="any" #dateInput />
</div>
<div class="form-group longitude-group">
<input type="number" class="form-control" formControlName="longitude" step="any" />
</div>
<div class="form-group" [class.hidden]="!hasValue">
<button type="reset" class="btn btn-link clear" [disabled]="isDisabled" (click)="reset()">Clear</button>
</div>
<button type="submit" class="hidden"></button>
</form>
</div>
</div>

14
src/Squidex/app/framework/angular/geolocation-editor.component.scss

@ -1,14 +0,0 @@
@import '_mixins';
@import '_vars';
.editor {
height: 30rem;
}
.form-inline {
margin-top: .5rem;
}
.latitude-group {
margin-right: .25rem;
}

217
src/Squidex/app/framework/angular/geolocation-editor.component.ts

@ -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: '&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> 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();
}
}
}

1
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';

3
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,

47
src/Squidex/app/shared/components/geolocation-editor.component.html

@ -0,0 +1,47 @@
<div>
<form>
<div class="editor" #editor></div>
<input id="pac-input" class="form-control controls" type="text" placeholder="Search Google Maps" #searchBox />
</form>
<div>
<form [formGroup]="geolocationForm" (change)="updateValueByInput()" [class.form-inline]="!useGoogleMaps">
<sqx-control-errors for="state"></sqx-control-errors>
<sqx-control-errors for="zip"></sqx-control-errors>
<div class="form-group latitude-group" [hidden]="useGoogleMaps">
<sqx-control-errors for="latitude" style="z-index: 10000;"></sqx-control-errors>
<label for="latitude">Lat. </label>
<input type="number" class="form-control" formControlName="latitude" step="any"/>
</div>
<div class="form-group longitude-group" [hidden]="useGoogleMaps">
<sqx-control-errors for="longitude" style="z-index: 10000;"></sqx-control-errors>
<label for="longitude">Long. </label>
<input type="number" class="form-control" formControlName="longitude" step="any"/>
</div>
<div class="form-group address1-group" [hidden]="!useGoogleMaps">
<label for="latitude">Address 1 </label>
<input type="text" class="form-control" formControlName="address1"/>
</div>
<div class="form-group address2-group" [hidden]="!useGoogleMaps">
<label for="longitude">Address 2 </label>
<input type="text" class="form-control" formControlName="address2"/>
</div>
<div class="form-group city-group" [hidden]="!useGoogleMaps" [class.hasClear]="hasValue">
<label for="latitude">City </label>
<input type="text" class="form-control" formControlName="city"/>
</div>
<div class="form-group state-group" [hidden]="!useGoogleMaps">
<label for="longitude">State </label>
<input type="text" class="form-control" formControlName="state" sqxUpperCaseInput/>
</div>
<div class="form-group zip-group" [hidden]="!useGoogleMaps">
<label for="longitude">ZIP Code </label>
<input type="text" class="form-control" formControlName="zip"/>
</div>
<div class="form-group clear-group" [class.hidden]="!hasValue">
<button type="reset" class="btn btn-link clear" [disabled]="isDisabled" (click)="reset()">Clear</button>
</div>
<button type="submit" class="hidden"></button>
</form>
</div>
</div>

68
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;
}

496
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: '&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> 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();
}
}
}

1
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';

3
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,

6
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<AppsService>;

15
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', ''));
}));
});

8
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);

10
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.

33
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<ValidationError> errors = new List<ValidationError>();
private readonly IGeocoder geocoder = A.Fake<IGeocoder>();
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<ValidationError>
@ -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<ValidationError>
@ -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<ValidationError>
@ -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<ValidationError>
@ -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<ValidationError>
@ -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<ValidationError>
@ -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<ValidationError>
@ -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<ValidationError>
@ -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<ValidationError>
@ -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<ValidationError>
@ -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<ValidationError>
@ -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<ValidationError>

2
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[] { "<FIELD> is not a valid value." });
new[] { "<FIELD> is not a valid value. Invalid json type, expected string." });
}
private static Instant FutureDays(int days)

38
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[] { "<FIELD> is not a valid value." });
new[] { "<FIELD> 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[] { "<FIELD> is not a valid value." });
new[] { "<FIELD> 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[] { "<FIELD> is not a valid value." });
new[] { "<FIELD> 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[] { "<FIELD> 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[] { "<FIELD> is not a valid value. State must be two capital letters." });
}
[Fact]

9
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<string> errors, ValidationContext context = null)
public static Task ValidateAsync(this Field field, JToken value, IList<string> 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<string> errors, ValidationContext context = null)
public static Task ValidateOptionalAsync(this Field field, JToken value, IList<string> 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)

56
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<IScriptEngine>();
private readonly IAppProvider appProvider = A.Fake<IAppProvider>();
private readonly IAppEntity app = A.Fake<IAppEntity>();
private readonly IGeocoder geocoder = A.Fake<IGeocoder>();
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<IAssetRepository>(), scriptEngine, A.Dummy<IContentRepository>());
sut = new ContentCommandMiddleware(Handler, appProvider, A.Dummy<IAssetRepository>(), scriptEngine, A.Dummy<IContentRepository>(), geocoder);
A.CallTo(() => app.LanguagesConfig).Returns(languagesConfig);
@ -239,6 +250,49 @@ namespace Squidex.Domain.Apps.Write.Contents
A.CallTo(() => scriptEngine.Execute(A<ScriptContext>.Ignored, "<delete-script>")).MustHaveHappened();
}
[Fact]
public async Task Create_geolocation_should_throw_exception_if_OSM_data_is_invalid()
{
A.CallTo(() => scriptEngine.ExecuteAndTransform(A<ScriptContext>.Ignored, A<string>.Ignored))
.Returns(invalidOSMLatLongData);
SetupGeolocationTest();
var context = CreateContextForCommand(new CreateContent { ContentId = contentId, Data = invalidOSMLatLongData, User = user });
await TestCreate(content, async _ =>
{
await Assert.ThrowsAsync<ValidationException>(() => 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<ScriptContext>.Ignored, A<string>.Ignored))
.Returns(invalidGoogleMapsLatLongData);
SetupGeolocationTest("key");
var context = CreateContextForCommand(new CreateContent { ContentId = contentId, Data = invalidOSMLatLongData, User = user });
await TestCreate(content, async _ =>
{
await Assert.ThrowsAsync<ValidationException>(() => 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<InvalidCastException>();
A.CallTo(() => geocoder.GeocodeAddress(string.Empty)).Throws<InvalidCastException>();
}
private void CreateContent()
{
content.Create(new CreateContent { Data = data });

Loading…
Cancel
Save