From 60b64c8ed6089aee273403ba2008e04abc25df60 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Thu, 18 Jul 2024 12:52:57 -0700 Subject: [PATCH] feat: support collections of primitives as data source parameters --- .../modeling/model-components/data-sources.md | 2 +- playground/Coalesce.Domain/Person.cs | 5 +++- .../Coalesce.Web.Vue3/src/components/test.vue | 7 ++++- .../Coalesce.Web.Vue3/src/metadata.g.ts | 13 ++++++++++ playground/Coalesce.Web.Vue3/src/models.g.ts | 5 ++++ .../Generators/Scripts/TsMetadata.cs | 23 ---------------- .../TargetClasses/TestDbContext/Person.cs | 5 +++- .../TypeDefinition/ClassViewModel.cs | 2 +- src/coalesce-vue/src/api-client.ts | 1 + src/coalesce-vue/src/metadata.ts | 8 +++++- src/coalesce-vue/test/api-client.spec.ts | 26 +++++++++++++++++++ src/test-targets/metadata.g.ts | 13 ++++++++++ src/test-targets/models.g.ts | 5 ++++ 13 files changed, 86 insertions(+), 29 deletions(-) diff --git a/docs/modeling/model-components/data-sources.md b/docs/modeling/model-components/data-sources.md index 010885ff8..f1b9916c6 100644 --- a/docs/modeling/model-components/data-sources.md +++ b/docs/modeling/model-components/data-sources.md @@ -96,7 +96,7 @@ All methods on `IDataSource` take a parameter that contains all the client-sp ## Custom Parameters -On any data source that you create, you may add additional properties annotated with `[Coalesce]` that will then be exposed as parameters to the client. These property parameters are currently restricted to primitives (numeric types, strings) and dates (DateTime, DateTimeOffset). Property parameter primitives may be expanded to allow for more types in the future. +On any data source that you create, you may add additional properties annotated with `[Coalesce]` that will then be exposed as parameters to the client. These property parameters can be primitives (numeric types, strings, enums), dates (DateTime, DateTimeOffset, DateOnly, TimeOnly), and collections of the preceding types. ``` c# [Coalesce] diff --git a/playground/Coalesce.Domain/Person.cs b/playground/Coalesce.Domain/Person.cs index 07ee11dab..3e299f067 100644 --- a/playground/Coalesce.Domain/Person.cs +++ b/playground/Coalesce.Domain/Person.cs @@ -366,11 +366,14 @@ public class NamesStartingWithAWithCases : StandardDataSource context) : base(context) { } + [Coalesce] + public List AllowedStatuses { get; set; } + public override IQueryable GetQuery(IDataSourceParameters parameters) { Db.Cases .Include(c => c.CaseProducts).ThenInclude(cp => cp.Product) - .Where(c => c.Status == Case.Statuses.Open || c.Status == Case.Statuses.InProgress) + .Where(c => AllowedStatuses.Contains(c.Status)) .Load(); return Db.People diff --git a/playground/Coalesce.Web.Vue3/src/components/test.vue b/playground/Coalesce.Web.Vue3/src/components/test.vue index d7ff24bf8..74a4f9d8e 100644 --- a/playground/Coalesce.Web.Vue3/src/components/test.vue +++ b/playground/Coalesce.Web.Vue3/src/components/test.vue @@ -84,7 +84,7 @@ import { PersonListViewModel, } from "../viewmodels.g"; import { CaseApiClient, PersonApiClient } from "../api-clients.g"; -import { Person } from "../models.g"; +import { Person, Statuses } from "../models.g"; @Component({ components: {}, @@ -101,7 +101,12 @@ export default class Test extends Base { caseVm = new CaseViewModel(); async created() { + this.personList.$dataSource = + new Person.DataSources.NamesStartingWithAWithCases({ + allowedStatuses: [Statuses.Open, Statuses.InProgress], + }); this.personList.$params.noCount = true; + this.personList.$load(); await this.caseVm.$load(15); await this.caseVm.downloadImage(); diff --git a/playground/Coalesce.Web.Vue3/src/metadata.g.ts b/playground/Coalesce.Web.Vue3/src/metadata.g.ts index 90aaad3fa..c6c739686 100644 --- a/playground/Coalesce.Web.Vue3/src/metadata.g.ts +++ b/playground/Coalesce.Web.Vue3/src/metadata.g.ts @@ -1821,6 +1821,19 @@ export const Person = domain.types.Person = { name: "NamesStartingWithAWithCases", displayName: "Names Starting With A With Cases", props: { + allowedStatuses: { + name: "allowedStatuses", + displayName: "Allowed Statuses", + type: "collection", + itemType: { + name: "$collectionItem", + displayName: "", + role: "value", + type: "enum", + get typeDef() { return domain.enums.Statuses }, + }, + role: "value", + }, }, }, withoutCases: { diff --git a/playground/Coalesce.Web.Vue3/src/models.g.ts b/playground/Coalesce.Web.Vue3/src/models.g.ts index 5417cf4f3..19bf4d657 100644 --- a/playground/Coalesce.Web.Vue3/src/models.g.ts +++ b/playground/Coalesce.Web.Vue3/src/models.g.ts @@ -398,6 +398,11 @@ export namespace Person { export class NamesStartingWithAWithCases implements DataSource { readonly $metadata = metadata.Person.dataSources.namesStartingWithAWithCases + allowedStatuses: Statuses[] | null = null + + constructor(params?: Omit, '$metadata'>) { + if (params) Object.assign(this, params); + } } export class WithoutCases implements DataSource { diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsMetadata.cs b/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsMetadata.cs index 269c8cfdb..be061658d 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsMetadata.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsMetadata.cs @@ -605,27 +605,6 @@ private void WriteDataSourcesMetadata(TypeScriptCodeBuilder b, ClassViewModel mo { WriteDataSourceMetadata(b, model, source); } - - // Not sure we need to explicitly declare the default source. - // We can just use the absense of a data source to represent the default. - /* - var defaultSource = dataSources.SingleOrDefault(s => s.IsDefaultDataSource); - if (defaultSource != null) - { - var name = defaultSource.ClientTypeName.ToCamelCase(); - b.Line($"get default() {{ return this.{name} }},"); - } - else - { - using (b.Block($"default:", ',')) - { - b.StringProp("type", "dataSource"); - b.StringProp("name", "default"); - b.StringProp("displayName", "Default"); - b.Line("params: {}"); - } - } - */ } } @@ -634,8 +613,6 @@ private void WriteDataSourcesMetadata(TypeScriptCodeBuilder b, ClassViewModel mo /// private void WriteDataSourceMetadata(TypeScriptCodeBuilder b, ClassViewModel model, ClassViewModel source) { - // TODO: Should we be camel-casing the names of data sources in the metadata? - // TODO: OR, should we be not camel casing the members we place on the domain[key: string] objects? using (b.Block($"{source.ClientTypeName.ToCamelCase()}:", ',')) { b.StringProp("type", "dataSource"); diff --git a/src/IntelliTect.Coalesce.Tests/TargetClasses/TestDbContext/Person.cs b/src/IntelliTect.Coalesce.Tests/TargetClasses/TestDbContext/Person.cs index 3cff82e47..baa396694 100644 --- a/src/IntelliTect.Coalesce.Tests/TargetClasses/TestDbContext/Person.cs +++ b/src/IntelliTect.Coalesce.Tests/TargetClasses/TestDbContext/Person.cs @@ -225,11 +225,14 @@ public class NamesStartingWithAWithCases : StandardDataSource context) : base(context) { } + [Coalesce] + public List AllowedStatuses { get; set; } + public override IQueryable GetQuery(IDataSourceParameters parameters) { Db.Cases .Include(c => c.CaseProducts).ThenInclude(cp => cp.Product) - .Where(c => c.Status == Case.Statuses.Open || c.Status == Case.Statuses.InProgress) + .Where(c => AllowedStatuses.Contains(c.Status)) .Load(); return Db.People diff --git a/src/IntelliTect.Coalesce/TypeDefinition/ClassViewModel.cs b/src/IntelliTect.Coalesce/TypeDefinition/ClassViewModel.cs index 19084295d..f5cb4d0fa 100644 --- a/src/IntelliTect.Coalesce/TypeDefinition/ClassViewModel.cs +++ b/src/IntelliTect.Coalesce/TypeDefinition/ClassViewModel.cs @@ -183,7 +183,7 @@ internal IReadOnlyCollection Properties .Where(p => !p.IsInternalUse && p.HasPublicSetter && p.HasAttribute() // These are the only supported types, for now - && (p.Type.IsPrimitive || p.Type.IsDateOrTime) + && (p.PureType.IsPrimitive || p.PureType.IsDateOrTime) ); /// diff --git a/src/coalesce-vue/src/api-client.ts b/src/coalesce-vue/src/api-client.ts index e1d1d6952..e2a3771d6 100644 --- a/src/coalesce-vue/src/api-client.ts +++ b/src/coalesce-vue/src/api-client.ts @@ -406,6 +406,7 @@ export type ApiResultPromise = Promise< /** Axios instance to be used by all Coalesce API requests. Can be configured as needed. */ export const AxiosClient = axios.create(); AxiosClient.defaults.baseURL = "/api"; +AxiosClient.defaults.paramsSerializer = objectToQueryString; // Set X-Requested-With: XmlHttpRequest to prevent aspnetcore from serving HTML and redirects to API requests. // https://github.com/dotnet/aspnetcore/blob/c440ebcf49badd49f0e2cdde1b0a74992af04158/src/Security/Authentication/Cookies/src/CookieAuthenticationEvents.cs#L107-L111 diff --git a/src/coalesce-vue/src/metadata.ts b/src/coalesce-vue/src/metadata.ts index b6c406489..8680a138d 100644 --- a/src/coalesce-vue/src/metadata.ts +++ b/src/coalesce-vue/src/metadata.ts @@ -153,7 +153,13 @@ export interface DataSourceType extends Metadata { * Stored as `props` so it can be treated like a ModelType/ObjectType in many cases. */ readonly props: { - [paramName in string]: PrimitiveProperty | DateProperty | EnumProperty; + [paramName in string]: + | PrimitiveProperty + | DateProperty + | EnumProperty + | (BasicCollectionProperty & { + itemType: PrimitiveValue | DateValue | EnumValue; + }); }; // NOTE: this union is the currently supported set of data source parameters. // When we support more types in the future (e.g. objects), adjust accordingly. diff --git a/src/coalesce-vue/test/api-client.spec.ts b/src/coalesce-vue/test/api-client.spec.ts index 726fb24c4..60fd49330 100644 --- a/src/coalesce-vue/test/api-client.spec.ts +++ b/src/coalesce-vue/test/api-client.spec.ts @@ -24,6 +24,8 @@ import { Student as StudentMeta } from "./targets.metadata"; import { Student, Advisor } from "./targets.models"; import { ComplexModelApiClient } from "../../test-targets/api-clients.g"; +import { PersonListViewModel } from "@test-targets/viewmodels.g"; +import { Person, Statuses } from "@test-targets/models.g"; function makeAdapterMock(result?: any) { return makeEndpointMock(result); @@ -361,6 +363,30 @@ describe("$invoke", () => { expect(mock.mock.calls[0][0].data).toBe("name=bob&studentAdvisorId="); }); + + test("data source collection parameter", async () => { + const mock = mockEndpoint( + "/Person/list", + vitest.fn((req: AxiosRequestConfig) => { + return { + wasSuccessful: true, + list: [], + }; + }) + ); + + const personList = new PersonListViewModel(); + personList.$dataSource = new Person.DataSources.NamesStartingWithAWithCases( + { + allowedStatuses: [Statuses.Open, Statuses.InProgress], + } + ); + await personList.$load(); + + expect(AxiosClient.getUri(mock.mock.lastCall![0])).toBe( + "/api/Person/list?page=1&pageSize=10&dataSource=NamesStartingWithAWithCases&dataSource.allowedStatuses=0&dataSource.allowedStatuses=1" + ); + }); }); describe("$makeCaller", () => { diff --git a/src/test-targets/metadata.g.ts b/src/test-targets/metadata.g.ts index be3666b7b..9ec8f6fff 100644 --- a/src/test-targets/metadata.g.ts +++ b/src/test-targets/metadata.g.ts @@ -2365,6 +2365,19 @@ export const Person = domain.types.Person = { name: "NamesStartingWithAWithCases", displayName: "Names Starting With A With Cases", props: { + allowedStatuses: { + name: "allowedStatuses", + displayName: "Allowed Statuses", + type: "collection", + itemType: { + name: "$collectionItem", + displayName: "", + role: "value", + type: "enum", + get typeDef() { return domain.enums.Statuses }, + }, + role: "value", + }, }, }, withoutCases: { diff --git a/src/test-targets/models.g.ts b/src/test-targets/models.g.ts index 527b07747..dadf619db 100644 --- a/src/test-targets/models.g.ts +++ b/src/test-targets/models.g.ts @@ -427,6 +427,11 @@ export namespace Person { export class NamesStartingWithAWithCases implements DataSource { readonly $metadata = metadata.Person.dataSources.namesStartingWithAWithCases + allowedStatuses: Statuses[] | null = null + + constructor(params?: Omit, '$metadata'>) { + if (params) Object.assign(this, params); + } } export class WithoutCases implements DataSource {