From 0361c436dc9a99030135adebf2d78734d577d706 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Mon, 2 Dec 2024 13:04:46 -0800 Subject: [PATCH] feat: add `multiple` support to `c-select`. (#499) --- CHANGELOG.md | 5 + .../components/c-input.md | 1 + .../components/c-select-many-to-many.md | 43 +- .../components/c-select.md | 36 +- playground/Coalesce.Domain/Case.cs | 9 + playground/Coalesce.Domain/Person.cs | 2 +- .../Api/Generated/PersonController.g.cs | 13 +- .../Coalesce.Web.Vue2/src/api-clients.g.ts | 3 +- .../Coalesce.Web.Vue2/src/metadata.g.ts | 23 + playground/Coalesce.Web.Vue2/src/models.g.ts | 4 + .../Coalesce.Web.Vue2/src/viewmodels.g.ts | 6 +- .../Coalesce.Web.Vue3/.vscode/settings.json | 4 + .../Api/Generated/PersonController.g.cs | 13 +- playground/Coalesce.Web.Vue3/src/App.vue | 12 +- .../Coalesce.Web.Vue3/src/api-clients.g.ts | 3 +- .../src/components/Examples.vue | 67 ++ .../Coalesce.Web.Vue3/src/components/test.vue | 42 +- .../c-select-many-to-many/binding.vue | 53 ++ .../c-select-many-to-many/styling.vue | 33 + .../examples/c-select/multiple-binding.vue | 121 +++ .../src/examples/c-select/single-binding.vue | 88 ++ .../src/examples/c-select/styling.vue | 69 ++ playground/Coalesce.Web.Vue3/src/main.ts | 13 +- .../Coalesce.Web.Vue3/src/metadata.g.ts | 23 + playground/Coalesce.Web.Vue3/src/models.g.ts | 4 + .../src/styles/_variables.scss | 2 - .../Coalesce.Web.Vue3/src/viewmodels.g.ts | 6 +- src/coalesce-vue-vuetify3/package-lock.json | 132 +-- src/coalesce-vue-vuetify3/package.json | 2 +- .../src/components/c-metadata-component.ts | 216 +++-- .../src/components/input/c-input.vue | 86 +- .../input/c-select-many-to-many.vue | 311 +++--- .../src/components/input/c-select.spec.tsx | 85 +- .../src/components/input/c-select.vue | 899 ++++++++++++------ 34 files changed, 1621 insertions(+), 808 deletions(-) create mode 100644 playground/Coalesce.Web.Vue3/src/components/Examples.vue create mode 100644 playground/Coalesce.Web.Vue3/src/examples/c-select-many-to-many/binding.vue create mode 100644 playground/Coalesce.Web.Vue3/src/examples/c-select-many-to-many/styling.vue create mode 100644 playground/Coalesce.Web.Vue3/src/examples/c-select/multiple-binding.vue create mode 100644 playground/Coalesce.Web.Vue3/src/examples/c-select/single-binding.vue create mode 100644 playground/Coalesce.Web.Vue3/src/examples/c-select/styling.vue delete mode 100644 playground/Coalesce.Web.Vue3/src/styles/_variables.scss diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f64e0ff7..8739babab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 5.3.0 + +- Added `multiple` prop to `c-select`, allowing for the selection of multiple models. +- `c-select-many-to-many` is now based on `c-select` rather than `v-autocomplete`. As a result, it has gained support for all of the props and slots of `c-select`. + # 5.2.1 ## Fixes diff --git a/docs/stacks/vue/coalesce-vue-vuetify/components/c-input.md b/docs/stacks/vue/coalesce-vue-vuetify/components/c-input.md index 627766487..00cb2f9bb 100644 --- a/docs/stacks/vue/coalesce-vue-vuetify/components/c-input.md +++ b/docs/stacks/vue/coalesce-vue-vuetify/components/c-input.md @@ -20,6 +20,7 @@ A summary of the components delegated to, by type: - date: [c-datetime-picker](/stacks/vue/coalesce-vue-vuetify/components/c-datetime-picker.md) - model: [c-select](/stacks/vue/coalesce-vue-vuetify/components/c-select.md) - [[ManyToMany]](/modeling/model-components/attributes/many-to-many.md) collection: [c-select-many-to-many](/stacks/vue/coalesce-vue-vuetify/components/c-select-many-to-many.md) +- Model collection (not many-to-many): [c-select](/stacks/vue/coalesce-vue-vuetify/components/c-select.md) - Non-object collection: [c-select-values](/stacks/vue/coalesce-vue-vuetify/components/c-select-values.md) Any other unsupported type will simply be displayed with [c-display](/stacks/vue/coalesce-vue-vuetify/components/c-display.md), unless a [default slot](https://vuejs.org/guide/components/slots.html) is provided - in that case, the default slot will be rendered instead. diff --git a/docs/stacks/vue/coalesce-vue-vuetify/components/c-select-many-to-many.md b/docs/stacks/vue/coalesce-vue-vuetify/components/c-select-many-to-many.md index fc50426c1..59f12072d 100644 --- a/docs/stacks/vue/coalesce-vue-vuetify/components/c-select-many-to-many.md +++ b/docs/stacks/vue/coalesce-vue-vuetify/components/c-select-many-to-many.md @@ -19,50 +19,17 @@ It is unlikely that you'll ever need to use this component directly - it is high ``` vue-html -``` - -``` vue-html - ``` ## Props - - -A metadata specifier for the value being bound. One of: - -- A string with the name of the value belonging to `model`. -- A direct reference to a metadata object. -- A string in dot-notation that starts with a type name. - -::: tip Note -c-select-many-to-many expects metadata for the "real" collection navigation property on a model. If you provide it the string you passed to [[ManyToMany]](/modeling/model-components/attributes/many-to-many.md), an error wil be thrown. -::: +See [c-select / Props](./c-select.md#props). - - -An object owning the value that was specified by the `for` prop. If provided, the input will be bound to the corresponding property on the `model` object. - - - -If binding the component with ``v-model``, accepts the ``value`` part of ``v-model``. - - - -An optional set of [Data Source Standard Parameters](/modeling/model-components/data-sources.md#standard-parameters) to pass to API calls made to the server. - - - -If provided and non-false, enables [response caching](/stacks/vue/layers/api-clients.md#response-caching) on the component's internal API caller. - +Since `c-select-many-to-many` internally uses `c-select` as its implementation, all props of `c-select` are also supported by `c-select-many-to-many`. ## Events diff --git a/docs/stacks/vue/coalesce-vue-vuetify/components/c-select.md b/docs/stacks/vue/coalesce-vue-vuetify/components/c-select.md index 8737e08ab..ec274deb6 100644 --- a/docs/stacks/vue/coalesce-vue-vuetify/components/c-select.md +++ b/docs/stacks/vue/coalesce-vue-vuetify/components/c-select.md @@ -29,6 +29,17 @@ Binding an arbitrary primary key value or an arbitrary object: ``` +Multi-select: + +``` vue-html + + + + + + +``` + Examples of other props: ``` vue-html @@ -48,6 +59,8 @@ Examples of other props: ## Props +Note: In addition to the below props, `c-select` also supports most props that are supported by Vuetify's [v-text-field](https://vuetifyjs.com/en/components/text-fields/). + A metadata specifier for the value being bound. One of: @@ -55,10 +68,9 @@ A metadata specifier for the value being bound. One of: - The name of a foreign key or reference navigation property belonging to `model`. - The name of a model type. - A direct reference to a metadata object. -- A string in dot-notation that starts with a type name that resolves to a foreign key or reference navigation property. ::: tip -When binding by a key value, if the corresponding object cannot be found (e.g. there is no navigation property, or the navigation property is null), c-select will automatically attempt to load the object from the server so it can be displayed in the UI. +When the binding can only locate a PK value and the corresponding object cannot be found (e.g. there is no navigation property, or the navigation property is null), c-select will automatically attempt to load the object from the server so it can be displayed in the UI. ::: @@ -72,13 +84,19 @@ modelValue?: any // Vue 3" lang="ts" /> When binding the component with ``v-model``, accepts the ``value`` part of ``v-model``. If `for` was specified as a foreign key, this will expect a key; likewise, if `for` was specified as a type or as a navigation property, this will expect an object. - + + +Enables multi-select functionality. Bindings for `modelValue`, `keyValue`, and `objectValue` will accept and emit arrays instead of single values. + + -When bound with `v-model:key-value="keyValue"`, allows binding the primary key of the selected object explicitly. +When bound with `v-model:key-value="keyValue"`, allows binding the primary key of the selected object explicitly. Binds an array when in multi-select mode. - + -When bound with `v-model:object-value="objectValue"`, allows binding the selected object explicitly. +When bound with `v-model:object-value="objectValue"`, allows binding the selected object explicitly. Binds an array when in multi-select mode. @@ -149,8 +167,8 @@ createMethods = { ## Slots -`#item="{ item, search }"` - Slot used to customize the text of both items inside the list, as well as the text of selected items. By default, items are rendered with [c-display](/stacks/vue/coalesce-vue-vuetify/components/c-display.md). Slot is passed a parameter `item` containing a [model instance](/stacks/vue/layers/models.md), and `search` containing the current search query. +`#item="{ item: TModel, search: string }"` - Slot used to customize the text of both items inside the list, as well as the text of selected items. By default, items are rendered with [c-display](/stacks/vue/coalesce-vue-vuetify/components/c-display.md). Slot is passed a parameter `item` containing a [model instance](/stacks/vue/layers/models.md), and `search` containing the current search query. -`#list-item="{ item, search }"` - Slot used to customize the text of items inside the list. If not provided, falls back to the `item` slot. +`#list-item="{ item: TModel, search: string, selected: boolean }"` - Slot used to customize the text of items inside the list. If not provided, falls back to the `item` slot. Contents are wrapped in a `v-list-item-title`. -`#selected-item="{ item, search }"` - Slot used to customize the text of selected items. If not provided, falls back to the `item` slot. \ No newline at end of file +`#selected-item="{ item: TModel, search: string, index: number, remove: () => void }"` - Slot used to customize the text of selected items. If not provided, falls back to the `item` slot. The `remove` function will deselect the item and is only valid when using multi-select. \ No newline at end of file diff --git a/playground/Coalesce.Domain/Case.cs b/playground/Coalesce.Domain/Case.cs index 79538474c..63d49549a 100644 --- a/playground/Coalesce.Domain/Case.cs +++ b/playground/Coalesce.Domain/Case.cs @@ -232,6 +232,15 @@ public override IQueryable GetQuery(IDataSourceParameters parameters) => D .IncludeChildren(); } + public class MissingManyToManyFarSide(CrudContext context) : StandardDataSource(context) + { + public override IQueryable GetQuery(IDataSourceParameters parameters) => Db.Cases + .Include(c => c.CaseProducts) + .Include(c => c.AssignedTo) + .Include(c => c.ReportedBy); + } + + /// /// Returns a list of summary information about Cases /// diff --git a/playground/Coalesce.Domain/Person.cs b/playground/Coalesce.Domain/Person.cs index 660c24ed3..e9864493e 100644 --- a/playground/Coalesce.Domain/Person.cs +++ b/playground/Coalesce.Domain/Person.cs @@ -298,7 +298,7 @@ public static string[] MethodWithStringArrayParameter(AppDbContext db, string[] } [Coalesce, Execute] - public static Person MethodWithEntityParameter(AppDbContext db, Person person) + public static Person MethodWithEntityParameter(AppDbContext db, Person person, Person[] people) { return person; } diff --git a/playground/Coalesce.Web.Vue2/Api/Generated/PersonController.g.cs b/playground/Coalesce.Web.Vue2/Api/Generated/PersonController.g.cs index a39ceed94..62fc17e62 100644 --- a/playground/Coalesce.Web.Vue2/Api/Generated/PersonController.g.cs +++ b/playground/Coalesce.Web.Vue2/Api/Generated/PersonController.g.cs @@ -839,11 +839,13 @@ [FromBody] MethodWithStringArrayParameterParameters _params [Authorize] [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] public virtual ItemResult MethodWithEntityParameter( - [FromForm(Name = "person")] PersonParameter person) + [FromForm(Name = "person")] PersonParameter person, + [FromForm(Name = "people")] PersonParameter[] people) { var _params = new { - Person = !Request.Form.HasAnyValue(nameof(person)) ? null : person + Person = !Request.Form.HasAnyValue(nameof(person)) ? null : person, + People = !Request.Form.HasAnyValue(nameof(people)) ? null : people.ToList() }; if (Context.Options.ValidateAttributesForMethods) @@ -857,7 +859,8 @@ public virtual ItemResult MethodWithEntityParameter( var _mappingContext = new MappingContext(Context); var _methodResult = Coalesce.Domain.Person.MethodWithEntityParameter( Db, - _params.Person?.MapToNew(_mappingContext) + _params.Person?.MapToNew(_mappingContext), + _params.People?.Select(_m => _m.MapToNew(_mappingContext)).ToArray() ); var _result = new ItemResult(); _result.Object = Mapper.MapToDto(_methodResult, _mappingContext, includeTree); @@ -867,6 +870,7 @@ public virtual ItemResult MethodWithEntityParameter( public class MethodWithEntityParameterParameters { public PersonParameter Person { get; set; } + public PersonParameter[] People { get; set; } } /// @@ -890,7 +894,8 @@ [FromBody] MethodWithEntityParameterParameters _params var _mappingContext = new MappingContext(Context); var _methodResult = Coalesce.Domain.Person.MethodWithEntityParameter( Db, - _params.Person?.MapToNew(_mappingContext) + _params.Person?.MapToNew(_mappingContext), + _params.People?.Select(_m => _m.MapToNew(_mappingContext)).ToArray() ); var _result = new ItemResult(); _result.Object = Mapper.MapToDto(_methodResult, _mappingContext, includeTree); diff --git a/playground/Coalesce.Web.Vue2/src/api-clients.g.ts b/playground/Coalesce.Web.Vue2/src/api-clients.g.ts index b48d2a865..ad19ae1c9 100644 --- a/playground/Coalesce.Web.Vue2/src/api-clients.g.ts +++ b/playground/Coalesce.Web.Vue2/src/api-clients.g.ts @@ -269,10 +269,11 @@ export class PersonApiClient extends ModelApiClient<$models.Person> { return this.$invoke($method, $params, $config) } - public methodWithEntityParameter(person: $models.Person | null, $config?: AxiosRequestConfig): AxiosPromise> { + public methodWithEntityParameter(person: $models.Person | null, people: $models.Person[] | null, $config?: AxiosRequestConfig): AxiosPromise> { const $method = this.$metadata.methods.methodWithEntityParameter const $params = { person, + people, } return this.$invoke($method, $params, $config) } diff --git a/playground/Coalesce.Web.Vue2/src/metadata.g.ts b/playground/Coalesce.Web.Vue2/src/metadata.g.ts index 336a0b869..928dae24b 100644 --- a/playground/Coalesce.Web.Vue2/src/metadata.g.ts +++ b/playground/Coalesce.Web.Vue2/src/metadata.g.ts @@ -812,6 +812,13 @@ export const Case = domain.types.Case = { }, }, }, + missingManyToManyFarSide: { + type: "dataSource", + name: "MissingManyToManyFarSide" as const, + displayName: "Missing Many To Many Far Side", + props: { + }, + }, }, } export const CaseDto = domain.types.CaseDto = { @@ -1795,6 +1802,22 @@ export const Person = domain.types.Person = { required: val => val != null || "Person is required.", } }, + people: { + name: "people", + displayName: "People", + type: "collection", + itemType: { + name: "$collectionItem", + displayName: "", + role: "value", + type: "model", + get typeDef() { return (domain.types.Person as ModelType & { name: "Person" }) }, + }, + role: "value", + rules: { + required: val => val != null || "People is required.", + } + }, }, return: { name: "$return", diff --git a/playground/Coalesce.Web.Vue2/src/models.g.ts b/playground/Coalesce.Web.Vue2/src/models.g.ts index 0117febb4..e344abb1a 100644 --- a/playground/Coalesce.Web.Vue2/src/models.g.ts +++ b/playground/Coalesce.Web.Vue2/src/models.g.ts @@ -174,6 +174,10 @@ export namespace Case { return reactiveDataSource(this); } } + + export class MissingManyToManyFarSide implements DataSource { + readonly $metadata = metadata.Case.dataSources.missingManyToManyFarSide + } } } diff --git a/playground/Coalesce.Web.Vue2/src/viewmodels.g.ts b/playground/Coalesce.Web.Vue2/src/viewmodels.g.ts index 2686d4cd0..9f8d465ad 100644 --- a/playground/Coalesce.Web.Vue2/src/viewmodels.g.ts +++ b/playground/Coalesce.Web.Vue2/src/viewmodels.g.ts @@ -635,9 +635,9 @@ export class PersonListViewModel extends ListViewModel<$models.Person, $apiClien public get methodWithEntityParameter() { const methodWithEntityParameter = this.$apiClient.$makeCaller( this.$metadata.methods.methodWithEntityParameter, - (c, person: $models.Person | null) => c.methodWithEntityParameter(person), - () => ({person: null as $models.Person | null, }), - (c, args) => c.methodWithEntityParameter(args.person)) + (c, person: $models.Person | null, people: $models.Person[] | null) => c.methodWithEntityParameter(person, people), + () => ({person: null as $models.Person | null, people: null as $models.Person[] | null, }), + (c, args) => c.methodWithEntityParameter(args.person, args.people)) Object.defineProperty(this, 'methodWithEntityParameter', {value: methodWithEntityParameter}); return methodWithEntityParameter diff --git a/playground/Coalesce.Web.Vue3/.vscode/settings.json b/playground/Coalesce.Web.Vue3/.vscode/settings.json index ec3b37134..a091bf3db 100644 --- a/playground/Coalesce.Web.Vue3/.vscode/settings.json +++ b/playground/Coalesce.Web.Vue3/.vscode/settings.json @@ -1,4 +1,8 @@ { + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", "typescript.tsdk": "node_modules\\typescript\\lib", "dotnet.defaultSolution": "Coalesce.Web.Vue3.sln" } \ No newline at end of file diff --git a/playground/Coalesce.Web.Vue3/Api/Generated/PersonController.g.cs b/playground/Coalesce.Web.Vue3/Api/Generated/PersonController.g.cs index eaedbdf18..8ff962edc 100644 --- a/playground/Coalesce.Web.Vue3/Api/Generated/PersonController.g.cs +++ b/playground/Coalesce.Web.Vue3/Api/Generated/PersonController.g.cs @@ -839,11 +839,13 @@ [FromBody] MethodWithStringArrayParameterParameters _params [Authorize] [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] public virtual ItemResult MethodWithEntityParameter( - [FromForm(Name = "person")] PersonParameter person) + [FromForm(Name = "person")] PersonParameter person, + [FromForm(Name = "people")] PersonParameter[] people) { var _params = new { - Person = !Request.Form.HasAnyValue(nameof(person)) ? null : person + Person = !Request.Form.HasAnyValue(nameof(person)) ? null : person, + People = !Request.Form.HasAnyValue(nameof(people)) ? null : people.ToList() }; if (Context.Options.ValidateAttributesForMethods) @@ -857,7 +859,8 @@ public virtual ItemResult MethodWithEntityParameter( var _mappingContext = new MappingContext(Context); var _methodResult = Coalesce.Domain.Person.MethodWithEntityParameter( Db, - _params.Person?.MapToNew(_mappingContext) + _params.Person?.MapToNew(_mappingContext), + _params.People?.Select(_m => _m.MapToNew(_mappingContext)).ToArray() ); var _result = new ItemResult(); _result.Object = Mapper.MapToDto(_methodResult, _mappingContext, includeTree); @@ -867,6 +870,7 @@ public virtual ItemResult MethodWithEntityParameter( public class MethodWithEntityParameterParameters { public PersonParameter Person { get; set; } + public PersonParameter[] People { get; set; } } /// @@ -890,7 +894,8 @@ [FromBody] MethodWithEntityParameterParameters _params var _mappingContext = new MappingContext(Context); var _methodResult = Coalesce.Domain.Person.MethodWithEntityParameter( Db, - _params.Person?.MapToNew(_mappingContext) + _params.Person?.MapToNew(_mappingContext), + _params.People?.Select(_m => _m.MapToNew(_mappingContext)).ToArray() ); var _result = new ItemResult(); _result.Object = Mapper.MapToDto(_methodResult, _mappingContext, includeTree); diff --git a/playground/Coalesce.Web.Vue3/src/App.vue b/playground/Coalesce.Web.Vue3/src/App.vue index 7393653e6..15a91bd21 100644 --- a/playground/Coalesce.Web.Vue3/src/App.vue +++ b/playground/Coalesce.Web.Vue3/src/App.vue @@ -12,13 +12,11 @@ label="Dark Mode" v-model="darkMode" hide-details - class="ml-2" + class="mx-3" density="compact" /> - Home - Test - Test2 + Examples/Tests Audit Swagger OpenAPI @@ -46,10 +44,8 @@ - - - - + + diff --git a/playground/Coalesce.Web.Vue3/src/api-clients.g.ts b/playground/Coalesce.Web.Vue3/src/api-clients.g.ts index b48d2a865..ad19ae1c9 100644 --- a/playground/Coalesce.Web.Vue3/src/api-clients.g.ts +++ b/playground/Coalesce.Web.Vue3/src/api-clients.g.ts @@ -269,10 +269,11 @@ export class PersonApiClient extends ModelApiClient<$models.Person> { return this.$invoke($method, $params, $config) } - public methodWithEntityParameter(person: $models.Person | null, $config?: AxiosRequestConfig): AxiosPromise> { + public methodWithEntityParameter(person: $models.Person | null, people: $models.Person[] | null, $config?: AxiosRequestConfig): AxiosPromise> { const $method = this.$metadata.methods.methodWithEntityParameter const $params = { person, + people, } return this.$invoke($method, $params, $config) } diff --git a/playground/Coalesce.Web.Vue3/src/components/Examples.vue b/playground/Coalesce.Web.Vue3/src/components/Examples.vue new file mode 100644 index 000000000..5ff343140 --- /dev/null +++ b/playground/Coalesce.Web.Vue3/src/components/Examples.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/playground/Coalesce.Web.Vue3/src/components/test.vue b/playground/Coalesce.Web.Vue3/src/components/test.vue index e94742fce..824900ed8 100644 --- a/playground/Coalesce.Web.Vue3/src/components/test.vue +++ b/playground/Coalesce.Web.Vue3/src/components/test.vue @@ -1,8 +1,16 @@ + + diff --git a/playground/Coalesce.Web.Vue3/src/examples/c-select-many-to-many/styling.vue b/playground/Coalesce.Web.Vue3/src/examples/c-select-many-to-many/styling.vue new file mode 100644 index 000000000..70d24cf6f --- /dev/null +++ b/playground/Coalesce.Web.Vue3/src/examples/c-select-many-to-many/styling.vue @@ -0,0 +1,33 @@ + + + diff --git a/playground/Coalesce.Web.Vue3/src/examples/c-select/multiple-binding.vue b/playground/Coalesce.Web.Vue3/src/examples/c-select/multiple-binding.vue new file mode 100644 index 000000000..a94503763 --- /dev/null +++ b/playground/Coalesce.Web.Vue3/src/examples/c-select/multiple-binding.vue @@ -0,0 +1,121 @@ + + + diff --git a/playground/Coalesce.Web.Vue3/src/examples/c-select/single-binding.vue b/playground/Coalesce.Web.Vue3/src/examples/c-select/single-binding.vue new file mode 100644 index 000000000..a8c2091b0 --- /dev/null +++ b/playground/Coalesce.Web.Vue3/src/examples/c-select/single-binding.vue @@ -0,0 +1,88 @@ + + + diff --git a/playground/Coalesce.Web.Vue3/src/examples/c-select/styling.vue b/playground/Coalesce.Web.Vue3/src/examples/c-select/styling.vue new file mode 100644 index 000000000..02bfe7ad4 --- /dev/null +++ b/playground/Coalesce.Web.Vue3/src/examples/c-select/styling.vue @@ -0,0 +1,69 @@ + + + diff --git a/playground/Coalesce.Web.Vue3/src/main.ts b/playground/Coalesce.Web.Vue3/src/main.ts index 24773d571..0d877ebc5 100644 --- a/playground/Coalesce.Web.Vue3/src/main.ts +++ b/playground/Coalesce.Web.Vue3/src/main.ts @@ -26,6 +26,7 @@ import "@/viewmodels.g"; import $metadata from "@/metadata.g"; import testWorker from "./worker.ts?worker"; +import Examples from "./components/Examples.vue"; new testWorker(); new Worker(new URL("./worker.ts", import.meta.url)); @@ -33,10 +34,11 @@ new Worker(new URL("./worker.ts", import.meta.url)); AxiosClient.defaults.baseURL = "/api"; AxiosClient.defaults.withCredentials = true; +const examples = import.meta.glob("@/examples/**/*.vue"); + const router = createRouter({ history: createWebHistory(), routes: [ - { path: "/", component: () => import("@/components/HelloWorld.vue") }, { path: "/test", component: () => import("./components/test.vue") }, { path: "/test-setup", @@ -47,6 +49,15 @@ const router = createRouter({ component: CAdminAuditLogPage, props: { type: "AuditLog" }, }, + { + path: "/examples", + alias: "/", + component: Examples, + children: Object.entries(examples).map((x) => ({ + path: x[0].replace("/src/examples/", "").replace(".vue", ""), + component: x[1], + })), + }, { path: "/admin/:type", name: "coalesce-admin-list", diff --git a/playground/Coalesce.Web.Vue3/src/metadata.g.ts b/playground/Coalesce.Web.Vue3/src/metadata.g.ts index 336a0b869..928dae24b 100644 --- a/playground/Coalesce.Web.Vue3/src/metadata.g.ts +++ b/playground/Coalesce.Web.Vue3/src/metadata.g.ts @@ -812,6 +812,13 @@ export const Case = domain.types.Case = { }, }, }, + missingManyToManyFarSide: { + type: "dataSource", + name: "MissingManyToManyFarSide" as const, + displayName: "Missing Many To Many Far Side", + props: { + }, + }, }, } export const CaseDto = domain.types.CaseDto = { @@ -1795,6 +1802,22 @@ export const Person = domain.types.Person = { required: val => val != null || "Person is required.", } }, + people: { + name: "people", + displayName: "People", + type: "collection", + itemType: { + name: "$collectionItem", + displayName: "", + role: "value", + type: "model", + get typeDef() { return (domain.types.Person as ModelType & { name: "Person" }) }, + }, + role: "value", + rules: { + required: val => val != null || "People is required.", + } + }, }, return: { name: "$return", diff --git a/playground/Coalesce.Web.Vue3/src/models.g.ts b/playground/Coalesce.Web.Vue3/src/models.g.ts index 0117febb4..e344abb1a 100644 --- a/playground/Coalesce.Web.Vue3/src/models.g.ts +++ b/playground/Coalesce.Web.Vue3/src/models.g.ts @@ -174,6 +174,10 @@ export namespace Case { return reactiveDataSource(this); } } + + export class MissingManyToManyFarSide implements DataSource { + readonly $metadata = metadata.Case.dataSources.missingManyToManyFarSide + } } } diff --git a/playground/Coalesce.Web.Vue3/src/styles/_variables.scss b/playground/Coalesce.Web.Vue3/src/styles/_variables.scss deleted file mode 100644 index 77091eda6..000000000 --- a/playground/Coalesce.Web.Vue3/src/styles/_variables.scss +++ /dev/null @@ -1,2 +0,0 @@ -// Place SASS variable overrides here -// $font-size-root: 18px; diff --git a/playground/Coalesce.Web.Vue3/src/viewmodels.g.ts b/playground/Coalesce.Web.Vue3/src/viewmodels.g.ts index 2686d4cd0..9f8d465ad 100644 --- a/playground/Coalesce.Web.Vue3/src/viewmodels.g.ts +++ b/playground/Coalesce.Web.Vue3/src/viewmodels.g.ts @@ -635,9 +635,9 @@ export class PersonListViewModel extends ListViewModel<$models.Person, $apiClien public get methodWithEntityParameter() { const methodWithEntityParameter = this.$apiClient.$makeCaller( this.$metadata.methods.methodWithEntityParameter, - (c, person: $models.Person | null) => c.methodWithEntityParameter(person), - () => ({person: null as $models.Person | null, }), - (c, args) => c.methodWithEntityParameter(args.person)) + (c, person: $models.Person | null, people: $models.Person[] | null) => c.methodWithEntityParameter(person, people), + () => ({person: null as $models.Person | null, people: null as $models.Person[] | null, }), + (c, args) => c.methodWithEntityParameter(args.person, args.people)) Object.defineProperty(this, 'methodWithEntityParameter', {value: methodWithEntityParameter}); return methodWithEntityParameter diff --git a/src/coalesce-vue-vuetify3/package-lock.json b/src/coalesce-vue-vuetify3/package-lock.json index d1030ed25..1b64759e8 100644 --- a/src/coalesce-vue-vuetify3/package-lock.json +++ b/src/coalesce-vue-vuetify3/package-lock.json @@ -32,7 +32,7 @@ "vitest": "^2.0.3", "vue": "^3.4.6", "vue-router": "^4.4.1", - "vue-tsc": "^2.1.4", + "vue-tsc": "^2.1.10", "vuetify": "^3.7.1" }, "optionalDependencies": { @@ -64,7 +64,7 @@ "cross-blob": "^1.2.1", "jsdom": "^23.0.1", "lodash": "^4.17.15", - "typescript": "4.9.3", + "typescript": "5.6.3", "vite": "^5.4.6", "vitest": "^1.1.1", "vue": "2.7.8", @@ -377,18 +377,18 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz", - "integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", - "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", "dev": true, "engines": { "node": ">=6.9.0" @@ -432,10 +432,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.8.tgz", - "integrity": "sha512-WzfbgXOkGzZiXXCqk43kKwZjzwx4oulxZi3nq2TYL9mOjQv6kYwul9mz6ID36njuL7Xkp6nJEfok848Zj10j/w==", + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.2.tgz", + "integrity": "sha512-DWMCZH9WA4Maitz2q21SRKHo9QXZxkDsbNZoVD62gusNtNBBqDg9i7uOhASfTfIGNzW+O+r7+jAlM8dwphcJKQ==", "dev": true, + "dependencies": { + "@babel/types": "^7.26.0" + }, "bin": { "parser": "bin/babel-parser.js" }, @@ -527,14 +530,13 @@ } }, "node_modules/@babel/types": { - "version": "7.24.9", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.9.tgz", - "integrity": "sha512-xm8XrMKz0IlUdocVbYJe0Z9xEgidU7msskG8BbhnTPK/HZ2z/7FP7ykqPgrUH+C+r414mNfNWam1f2vqOjqjYQ==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.0.tgz", + "integrity": "sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==", "dev": true, "dependencies": { - "@babel/helper-string-parser": "^7.24.8", - "@babel/helper-validator-identifier": "^7.24.7", - "to-fast-properties": "^2.0.0" + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1813,27 +1815,27 @@ } }, "node_modules/@volar/language-core": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.2.tgz", - "integrity": "sha512-sONt5RLvLL1SlBdhyUSthZzuKePbJ7DwFFB9zT0eyWpDl+v7GXGh/RkPxxWaR22bIhYtTzp4Ka1MWatl/53Riw==", + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.10.tgz", + "integrity": "sha512-hG3Z13+nJmGaT+fnQzAkS0hjJRa2FCeqZt6Bd+oGNhUkQ+mTFsDETg5rqUTxyzIh5pSOGY7FHCWUS8G82AzLCA==", "dev": true, "dependencies": { - "@volar/source-map": "2.4.2" + "@volar/source-map": "2.4.10" } }, "node_modules/@volar/source-map": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.2.tgz", - "integrity": "sha512-qiGfGgeZ5DEarPX3S+HcFktFCjfDrFPCXKeXNbrlB7v8cvtPRm8YVwoXOdGG1NhaL5rMlv5BZPVQyu4EdWWIvA==", + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.10.tgz", + "integrity": "sha512-OCV+b5ihV0RF3A7vEvNyHPi4G4kFa6ukPmyVocmqm5QzOd8r5yAtiNvaPEjl8dNvgC/lj4JPryeeHLdXd62rWA==", "dev": true }, "node_modules/@volar/typescript": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.2.tgz", - "integrity": "sha512-m2uZduhaHO1SZuagi30OsjI/X1gwkaEAC+9wT/nCNAtJ5FqXEkKvUncHmffG7ESDZPlFFUBK4vJ0D9Hfr+f2EA==", + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.10.tgz", + "integrity": "sha512-F8ZtBMhSXyYKuBfGpYwqA5rsONnOwAVvjyE7KPYJ7wgZqo2roASqNWUnianOomJX5u1cxeRooHV59N0PhvEOgw==", "dev": true, "dependencies": { - "@volar/language-core": "2.4.2", + "@volar/language-core": "2.4.10", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } @@ -2002,16 +2004,16 @@ } }, "node_modules/@vue/language-core": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.1.4.tgz", - "integrity": "sha512-i8pfAgNjTNjabBX1xRsuV6aRw2E8bdQXwd5H8m3cUkTVJju3QN5nfdoXET0uK+yXsuloNJPzo6PXFujRRPNmMA==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.1.10.tgz", + "integrity": "sha512-DAI289d0K3AB5TUG3xDp9OuQ71CnrujQwJrQnfuZDwo6eGNf0UoRlPuaVNO+Zrn65PC3j0oB2i7mNmVPggeGeQ==", "dev": true, "dependencies": { - "@volar/language-core": "~2.4.1", - "@vue/compiler-dom": "^3.4.0", + "@volar/language-core": "~2.4.8", + "@vue/compiler-dom": "^3.5.0", "@vue/compiler-vue2": "^2.7.16", - "@vue/shared": "^3.4.0", - "computeds": "^0.0.1", + "@vue/shared": "^3.5.0", + "alien-signals": "^0.2.0", "minimatch": "^9.0.3", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1" @@ -2025,6 +2027,35 @@ } } }, + "node_modules/@vue/language-core/node_modules/@vue/compiler-core": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.13.tgz", + "integrity": "sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.25.3", + "@vue/shared": "3.5.13", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.0" + } + }, + "node_modules/@vue/language-core/node_modules/@vue/compiler-dom": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz", + "integrity": "sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==", + "dev": true, + "dependencies": { + "@vue/compiler-core": "3.5.13", + "@vue/shared": "3.5.13" + } + }, + "node_modules/@vue/language-core/node_modules/@vue/shared": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz", + "integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==", + "dev": true + }, "node_modules/@vue/reactivity": { "version": "3.4.32", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.32.tgz", @@ -2143,6 +2174,12 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/alien-signals": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-0.2.2.tgz", + "integrity": "sha512-cZIRkbERILsBOXTQmMrxc9hgpxglstn69zm+F1ARf4aPAzdAFYd6sBq87ErO0Fj3DV94tglcyHG5kQz9nDC/8A==", + "dev": true + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2497,12 +2534,6 @@ "node": ">=14" } }, - "node_modules/computeds": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/computeds/-/computeds-0.0.1.tgz", - "integrity": "sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==", - "dev": true - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -5067,15 +5098,6 @@ "node": ">=14.0.0" } }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5527,13 +5549,13 @@ } }, "node_modules/vue-tsc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.1.4.tgz", - "integrity": "sha512-XTzMXQcsixAvNbpou/9qngEsZawaiJRZH3Ja+lfgRfv2A1TJv9vnZ/Kyv7XxPqv/TaZVFSnjGpM87VbWIg6yQg==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.1.10.tgz", + "integrity": "sha512-RBNSfaaRHcN5uqVqJSZh++Gy/YUzryuv9u1aFWhsammDJXNtUiJMNoJ747lZcQ68wUQFx6E73y4FY3D8E7FGMA==", "dev": true, "dependencies": { - "@volar/typescript": "~2.4.1", - "@vue/language-core": "2.1.4", + "@volar/typescript": "~2.4.8", + "@vue/language-core": "2.1.10", "semver": "^7.5.4" }, "bin": { diff --git a/src/coalesce-vue-vuetify3/package.json b/src/coalesce-vue-vuetify3/package.json index ac59a798a..56f2ba7e8 100644 --- a/src/coalesce-vue-vuetify3/package.json +++ b/src/coalesce-vue-vuetify3/package.json @@ -67,7 +67,7 @@ "vitest": "^2.0.3", "vue": "^3.4.6", "vue-router": "^4.4.1", - "vue-tsc": "^2.1.4", + "vue-tsc": "^2.1.10", "vuetify": "^3.7.1" }, "postcss": { diff --git a/src/coalesce-vue-vuetify3/src/components/c-metadata-component.ts b/src/coalesce-vue-vuetify3/src/components/c-metadata-component.ts index e82debc4f..d3e78cd8a 100644 --- a/src/coalesce-vue-vuetify3/src/components/c-metadata-component.ts +++ b/src/coalesce-vue-vuetify3/src/components/c-metadata-component.ts @@ -23,6 +23,7 @@ import type { ApiStateTypeWithArgs, ListViewModel, ServiceViewModel, + ModelCollectionValue, } from "coalesce-vue"; import { ApiState, ViewModel } from "coalesce-vue"; import { computed, inject, useAttrs } from "vue"; @@ -47,7 +48,7 @@ type MethodsOf = TModel extends { // prettier-ignore export type ForSpec< TModel extends ModelAllowedType | unknown = unknown, - ValueKind extends Value = Value + ValueKind extends Value | ClassType = Value | ClassType > = // Handle binding of `:model` to a Model or ViewModel: TModel extends Model ? @@ -87,6 +88,7 @@ TModel extends Model ? ValueKind extends (NumberValue & {role: 'value'}) ? number : ValueKind extends FileValue ? File : ValueKind extends BooleanValue ? boolean : + ValueKind extends ModelCollectionValue ? (Model[]) : ValueKind extends CollectionValue ? (string[] | number[]) : ValueKind extends Property ? never : ValueKind extends Value ? any : @@ -170,128 +172,132 @@ export function getValueMeta( return null; } - if (typeof forVal != "string") { - return forVal; - } - - if (forVal.length === 0) { - throw `prop 'for' must not be an empty string`; - } - - if (modelMeta && "props" in modelMeta) { - // Handle the 90% case: check if 'for' is a prop on 'model' - const matchedProp = modelMeta.props[forVal]; - if (matchedProp) { - return matchedProp; - } - } - - const forParts = forVal.split("."); - let tail: ClassType | Method | Property | Value | undefined = undefined; let tailKind: "type" | "method" | "property" | "value" | undefined = undefined; - if (modelMeta) { - if ("params" in modelMeta) { - tail = modelMeta; - tailKind = "method"; - } else if ( - modelMeta.type == "object" || - modelMeta.type == "model" || - modelMeta.type == "dataSource" - ) { - tail = modelMeta; + if (typeof forVal != "string") { + if ("props" in forVal) { + tail = forVal; tailKind = "type"; + } else { + return forVal; } - } - - $metadata ??= useMetadata(); - - for (let i = 0; i < forParts.length; i++) { - const forPart = forParts[i]; - const forPartNext = forParts[i + 1]; - - // Check if 'for' is a type name. Type name is only valid in the first position. - if (i == 0 && $metadata) { - if (forPart in $metadata.types) { - tail = ($metadata.types as any)[forPart]; - tailKind = "type"; - continue; - } - if (forPart in $metadata.enums) { - const type: EnumType = ($metadata.enums as any)[forPart]; - tail = { - type: type.type, - displayName: type.displayName, - name: "", - role: "value", - typeDef: type, - }; - tailKind = "value"; - continue; - } + } else { + if (forVal.length === 0) { + throw `prop 'for' must not be an empty string`; } - if (tailKind == "type") { - // See if the part is a prop name. - const type = tail as ClassType; - if (type.props[forPart]) { - tail = type.props[forPart]; - tailKind = "property"; - continue; + if (modelMeta && "props" in modelMeta) { + // Handle the 90% case: check if 'for' is a prop on 'model' + const matchedProp = modelMeta.props[forVal]; + if (matchedProp) { + return matchedProp; } + } - // See if the part is a method name. - if (type.type == "model" && type.methods[forPart]) { - tail = type.methods[forPart]; + const forParts = forVal.split("."); + if (modelMeta) { + if ("params" in modelMeta) { + tail = modelMeta; tailKind = "method"; - continue; + } else if ( + modelMeta.type == "object" || + modelMeta.type == "model" || + modelMeta.type == "dataSource" + ) { + tail = modelMeta; + tailKind = "type"; } + } - // forPart wasn't itself a method or prop. - // Check if forPart is the literal string "props" or "method" - // and the actual name is the following token. - if (forPart == "props" && type.props[forPartNext]) { - i++; - tail = type.props[forPartNext]; - tailKind = "property"; - continue; + $metadata ??= useMetadata(); + + for (let i = 0; i < forParts.length; i++) { + const forPart = forParts[i]; + const forPartNext = forParts[i + 1]; + + // Check if 'for' is a type name. Type name is only valid in the first position. + if (i == 0 && $metadata) { + if (forPart in $metadata.types) { + tail = ($metadata.types as any)[forPart]; + tailKind = "type"; + continue; + } + if (forPart in $metadata.enums) { + const type: EnumType = ($metadata.enums as any)[forPart]; + tail = { + type: type.type, + displayName: type.displayName, + name: "", + role: "value", + typeDef: type, + }; + tailKind = "value"; + continue; + } } - if ( - forPart == "methods" && - type.type == "model" && - type.methods[forPartNext] - ) { - i++; - tail = type.methods[forPartNext]; - tailKind = "method"; - continue; - } - } else if (tailKind == "method") { - const method = tail as Method; - if (method.params[forPart]) { - tail = method.params[forPart]; - tailKind = "value"; - continue; + if (tailKind == "type") { + // See if the part is a prop name. + const type = tail as ClassType; + if (type.props[forPart]) { + tail = type.props[forPart]; + tailKind = "property"; + continue; + } + + // See if the part is a method name. + if (type.type == "model" && type.methods[forPart]) { + tail = type.methods[forPart]; + tailKind = "method"; + continue; + } + + // forPart wasn't itself a method or prop. + // Check if forPart is the literal string "props" or "method" + // and the actual name is the following token. + if (forPart == "props" && type.props[forPartNext]) { + i++; + tail = type.props[forPartNext]; + tailKind = "property"; + continue; + } + + if ( + forPart == "methods" && + type.type == "model" && + type.methods[forPartNext] + ) { + i++; + tail = type.methods[forPartNext]; + tailKind = "method"; + continue; + } + } else if (tailKind == "method") { + const method = tail as Method; + if (method.params[forPart]) { + tail = method.params[forPart]; + tailKind = "value"; + continue; + } + + // Check if forPart is the literal string "params" + // and the actual name is the following token. + if (forPart == "params" && method.params[forPartNext]) { + i++; + tail = method.params[forPartNext]; + tailKind = "value"; + continue; + } } - // Check if forPart is the literal string "params" - // and the actual name is the following token. - if (forPart == "params" && method.params[forPartNext]) { - i++; - tail = method.params[forPartNext]; - tailKind = "value"; - continue; - } + throw Error( + `Could not resolve token '${forPart}'${ + forVal != forPart ? " in " + forVal : "" + } from ${tailKind} '${tail?.name}'` + ); } - - throw Error( - `Could not resolve token '${forPart}'${ - forVal != forPart ? " in " + forVal : "" - } from ${tailKind} '${tail?.name}'` - ); } if (!tail) { diff --git a/src/coalesce-vue-vuetify3/src/components/input/c-input.vue b/src/coalesce-vue-vuetify3/src/components/input/c-input.vue index 6f493c49c..62a6391e1 100644 --- a/src/coalesce-vue-vuetify3/src/components/input/c-input.vue +++ b/src/coalesce-vue-vuetify3/src/components/input/c-input.vue @@ -32,6 +32,45 @@ function addHandler(data: any, eventName: string, handler: Function) { oldValue.push(handler); } } + +type _ValueType< + TModel extends Model | DataSource | AnyArgCaller | undefined, + TFor extends ForSpec +> = TFor extends string & keyof ModelTypeLookup + ? ModelTypeLookup[TFor] + : TFor extends ModelReferenceNavigationProperty | ModelValue + ? TFor["typeDef"]["name"] extends keyof ModelTypeLookup + ? ModelTypeLookup[TFor["typeDef"]["name"]] + : any + : TFor extends ForeignKeyProperty + ? TFor["principalType"]["name"] extends keyof ModelTypeLookup + ? ModelTypeLookup[TFor["principalType"]["name"]] + : any + : TModel extends ApiStateTypeWithArgs + ? TFor extends keyof TArgsObj + ? TArgsObj[TFor] + : any + : TModel extends Model + ? TFor extends PropNames + ? TModel["$metadata"]["props"][TFor] extends + | ModelReferenceNavigationProperty + | ModelValue + ? TModel["$metadata"]["props"][TFor]["typeDef"]["name"] extends keyof ModelTypeLookup + ? ModelTypeLookup[TModel["$metadata"]["props"][TFor]["typeDef"]["name"]] + : any + : TModel["$metadata"]["props"][TFor] extends ForeignKeyProperty + ? TModel["$metadata"]["props"][TFor]["principalType"]["name"] extends keyof ModelTypeLookup + ? ModelTypeLookup[TModel["$metadata"]["props"][TFor]["principalType"]["name"]] + : any + : TModel["$metadata"]["props"][TFor] extends CollectionProperty + ? Array< + TypeDiscriminatorToType< + TModel["$metadata"]["props"][TFor]["itemType"]["type"] + > + > + : TypeDiscriminatorToType + : any + : Model; diff --git a/src/coalesce-vue-vuetify3/src/components/input/c-select.spec.tsx b/src/coalesce-vue-vuetify3/src/components/input/c-select.spec.tsx index b0509f213..da3368bd7 100644 --- a/src/coalesce-vue-vuetify3/src/components/input/c-select.spec.tsx +++ b/src/coalesce-vue-vuetify3/src/components/input/c-select.spec.tsx @@ -20,7 +20,13 @@ import { FunctionalComponent, ref } from "vue"; import { VForm } from "vuetify/components"; import { CSelect } from ".."; -import { Company, ComplexModel, EnumPkId, Test } from "@test-targets/models.g"; +import { + Case, + Company, + ComplexModel, + EnumPkId, + Test, +} from "@test-targets/models.g"; import { CaseViewModel, ComplexModelViewModel, @@ -83,8 +89,10 @@ describe("CSelect", () => { const genericModel: Model = model; function receivesTestModel(model: Test | null) {} + function receivesTestModels(model: Test[] | null) {} function receivesComplexModel(model: ComplexModel | null) {} function receivesNumber(model: number | null) {} + function receivesNumbers(model: number[] | null) {} // Binding to FK or ref nav on a ViewModel: () => ; @@ -100,6 +108,11 @@ describe("CSelect", () => { // @ts-expect-error wrong event handler type. () => ; + () => ; + () => ; // Against models that might be null @@ -110,14 +123,69 @@ describe("CSelect", () => { () => ; //@ts-expect-error wrong type of property () => ; + //@ts-expect-error Cannot bind to many-to-many + () => ; //@ts-expect-error wrong type of property () => ; // Untyped bindings: () => ; () => ; + + //@ts-expect-error invalid `model` type + () => ; + //@ts-expect-error invalid `for` type + () => ; + + + // ******** + // Multiple + // ******** + () => (); + + // Explicit `multiple` for object collection + () => (); + // Implicit `multiple` for object collection + () => (); + + //@ts-expect-error Explicit `multiple` for object non-collection is disallowed + () => (); + //@ts-expect-error Explicit `multiple` for object non-collection is disallowed + () => (); + //@ts-expect-error Arrays passed to non-multiple binding + () => (); + + //@ts-expect-error arrays passed to non-multiple + () => (); + + //@ts-expect-error arrays required for multiple + () => (); + //@ts-expect-error arrays required for multiple + () => (); + //@ts-expect-error arrays required for multiple + () => (); + //@ts-expect-error arrays required for multiple + () => (); + //@ts-expect-error arrays required for multiple + () => (); + //@ts-expect-error arrays required for multiple + () => (); + + + + // ******** // Binding with for + v-model + // ******** + () => (); () => (); () => (); () => (); @@ -141,7 +209,9 @@ describe("CSelect", () => { () => (); + // ******** // Prop types that are only weakly known, missing ability to resolve types + // ******** const weakModelProp: ModelReferenceNavigationProperty = complexVm.$metadata.props.singleTest; () => (); () => (); @@ -156,6 +226,9 @@ describe("CSelect", () => { () => (); + // ******** + // Passing concrete metadata objects + // ******** () => (); // @ts-expect-error wrong modelValue and event types () => (); @@ -189,14 +262,10 @@ describe("CSelect", () => { // This has to be valid when we don't have a known type for the caller () => (); - //@ts-expect-error invalid model type - () => ; - //@ts-expect-error invalid for type - () => ; - - + // ******** // Rules + // ******** () => v === 7 || 'Must be 7']} />; //@ts-expect-error invalid rule func (`v` is number, equality to string is invalid). () => v === "foo" || 'Must be 7']} />; @@ -625,7 +694,7 @@ describe("CSelect", () => { // Pick it await menuInput.trigger("keydown.enter"); - expect(menuWrapper.find(".v-list-item--active").text()).toBe("bar 202"); + expect(menuWrapper.find(".pending-selection").text()).toBe("bar 202"); expect(model.singleTestId).toBe(202); // second result in the list expect(wrapper.vm.menuOpen).toBeFalsy(); diff --git a/src/coalesce-vue-vuetify3/src/components/input/c-select.vue b/src/coalesce-vue-vuetify3/src/components/input/c-select.vue index 220969dbe..a9d97cc4b 100644 --- a/src/coalesce-vue-vuetify3/src/components/input/c-select.vue +++ b/src/coalesce-vue-vuetify3/src/components/input/c-select.vue @@ -3,183 +3,223 @@ class="c-select" :class="{ 'c-select--is-menu-active': menuOpen, + 'c-select--multiple': effectiveMultiple, }" - :error-messages="error" :focused="focused" v-bind="inputBindAttrs" :rules="effectiveRules" - :modelValue="internalModelValue" + :modelValue="effectiveMultiple ? internalModelValue : internalModelValue[0]" :disabled="isDisabled" :readonly="isReadonly" - #default="{ isValid }" > - -
- - - - - - - - - -
-
- - - + + + @@ -192,7 +232,7 @@ .v-field__field { align-items: center; .v-field__input { - flex-wrap: nowrap; + // flex-wrap: nowrap; input { min-width: 0; flex: 1 1; @@ -219,17 +259,104 @@ transform: rotate(180deg); } } + +.c-select__menu-content { + .v-list-item.pending-selection { + &::after { + opacity: calc(0.15 * var(--v-theme-overlay-multiplier)); + } + &:not(.v-list-item--active) > .v-list-item__overlay { + opacity: calc(0.05 * var(--v-theme-overlay-multiplier)); + } + } +} +.c-select__create-item { + .v-list-item__prepend { + width: 40px; + } +} + +