Skip to content

Commit 33d1ee9

Browse files
committed
feat: #542 support binding to regular collection navigation properties with c-select.
1 parent bd0c52e commit 33d1ee9

File tree

7 files changed

+260
-46
lines changed

7 files changed

+260
-46
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
- `c-datetime-picker`: Added prop `showTodayButton`
2222
- `c-input`: Added a `filter` prop to for enum inputs to restrict the values available for selection.
2323
- `c-select`: When bound to a `ViewModel` or `ViewModelCollection`, selected items are converted to `ViewModel` instances before being emitted so that event handlers will receive the final object instance, rather than the intermediate plain model instance.
24+
- `c-select`: Now supports bound to a non-many-to-many collection navigation property. Selecting an item will populate the foreign key of the dependent item, and deselecting an item will clear the foreign key. This mechanism is only available when using c-select directly - it is not delegated by c-input.
2425
- `c-select-many-to-many`: The `itemTitle` prop now receives the existing selected middle entity instance, if there is one.
2526

2627
## Fixes

docs/stacks/vue/coalesce-vue-vuetify/components/c-select.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Used for selecting values for foreign key and navigation properties, or for sele
1010

1111
## Examples
1212

13-
Binding to a navigation property or foreign key of a model:
13+
Binding to a reference navigation property or foreign key of a model:
1414

1515
``` vue-html
1616
<c-select :model="person" for="company" />
@@ -40,6 +40,16 @@ Multi-select:
4040
<c-select for="Person" multiple v-model:object-value="selectedPeople" />
4141
```
4242

43+
Binding to a collection navigation property of a model:
44+
45+
``` vue-html
46+
<c-select :model="person" for="casesAssigned" />
47+
```
48+
49+
This will assign `person` and its PK to the inverse navigation property of the relationship (`Case.AssignedTo`) when items are selected, and will null those properties when items are deselected. Note that this scenario is not delegated to by `c-input` automatically and requires direct usage of `c-select` since it is a fairly unusual scenario and usually requires additional customization (e.g. the `params` and `create` props) to make it function well.
50+
51+
----
52+
4353
Examples of other props:
4454

4555
``` vue-html
@@ -61,12 +71,12 @@ Examples of other props:
6171

6272
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/).
6373

64-
<Prop def="for: string | ForeignKeyProperty | ModelReferenceNavigationProperty | ModelType" lang="ts" />
74+
<Prop def="for: string | Value | Property | ModelType" lang="ts" />
6575

6676
A metadata specifier for the value being bound. One of:
6777

68-
- The name of a foreign key or reference navigation property belonging to `model`.
6978
- The name of a model type.
79+
- The name of a foreign key or navigation property belonging to `model`.
7080
- A direct reference to a metadata object.
7181

7282
::: tip

docs/stacks/vue/coalesce-vue-vuetify/overview.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ If for whatever reason you find yourself adding Coalesce to an existing project,
1818
## Display Components
1919

2020
<table>
21-
<thead><tr><th width="170px">Component</th><th>Description</th></tr></thead>
21+
<thead><tr><th width="170px">Component</th><th>Description</th></tr></thead><tbody>
2222
<tr><td>
2323

2424
[c-display](./components/c-display.md)
@@ -51,13 +51,13 @@ If for whatever reason you find yourself adding Coalesce to an existing project,
5151

5252
@[import-md "after":"MARKER:summary", "before":"MARKER:summary-end"](./components/c-table.md)
5353
</td></tr>
54-
</table>
54+
</tbody></table>
5555

5656

5757
## Input Components
5858

5959
<table>
60-
<thead><tr><th width="170px">Component</th><th>Description</th></tr></thead>
60+
<thead><tr><th width="170px">Component</th><th>Description</th></tr></thead><tbody>
6161
<tr><td>
6262

6363
[c-input](./components/c-input.md)
@@ -139,13 +139,13 @@ If for whatever reason you find yourself adding Coalesce to an existing project,
139139
@[import-md "after":"MARKER:summary", "before":"MARKER:summary-end"](./components/c-list-page.md)
140140
</td></tr>
141141

142-
</table>
142+
</tbody></table>
143143

144144

145145
## Admin Components
146146

147147
<table>
148-
<thead><tr><th width="170px">Component</th><th>Description</th></tr></thead>
148+
<thead><tr><th width="170px">Component</th><th>Description</th></tr></thead><tbody>
149149
<tr><td>
150150

151151
[c-admin-method](./components/c-admin-method.md)
@@ -217,4 +217,4 @@ If for whatever reason you find yourself adding Coalesce to an existing project,
217217

218218
@[import-md "after":"MARKER:summary", "before":"MARKER:summary-end"](./components/c-admin-audit-log-page.md)
219219
</td></tr>
220-
</table>
220+
</tbody></table>

playground/Coalesce.Web.Vue3/src/examples/c-select/multiple-binding.vue

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,12 @@
1818
v-model:objectValue="selectedModels"
1919
open-on-clear
2020
:create="{
21-
getLabel(search: string, items: Person[]) { return items.length == 0 ? search : false },
22-
async getItem(search: string, label: string) { return new Person({ name: label }) }
21+
getLabel(search: string, items: Person[]) {
22+
return items.length == 0 ? search : false;
23+
},
24+
async getItem(search: string, label: string) {
25+
return new Person({ name: label });
26+
},
2327
}"
2428
></c-select>
2529
</v-col>
@@ -66,7 +70,22 @@
6670
</v-col>
6771
</v-row>
6872

69-
<h1>plain v-autocomplete multiple</h1>
73+
<h1>for non-many-to-many collection navigation</h1>
74+
<v-row v-if="person">
75+
<v-col>
76+
<c-select :model="person" for="casesAssigned" />
77+
<h3>Via c-input</h3>
78+
<c-input :model="person" for="casesAssigned" />
79+
</v-col>
80+
<v-col>
81+
<v-btn @click="person.$bulkSave()"> Save </v-btn>
82+
<div>
83+
<c-display :model="person" for="casesAssigned" />
84+
</div>
85+
</v-col>
86+
</v-row>
87+
88+
<h1>plain v-autocomplete multiple (for styling/visual reference)</h1>
7089
<v-row>
7190
<v-col>
7291
<v-autocomplete
@@ -87,9 +106,9 @@
87106

88107
<script setup lang="ts">
89108
import { Case, Person } from "@/models.g";
90-
import { PersonListViewModel } from "@/viewmodels.g";
109+
import { PersonListViewModel, PersonViewModel } from "@/viewmodels.g";
91110
import { modelDisplay, useBindToQueryString } from "coalesce-vue";
92-
import { ref } from "vue";
111+
import { computed, ref } from "vue";
93112
94113
const selectedIds = ref<number[]>([]);
95114
useBindToQueryString(selectedIds, {
@@ -101,6 +120,9 @@ useBindToQueryString(selectedIds, {
101120
const personList = new PersonListViewModel();
102121
personList.methodWithEntityParameter.args.person = new Person({ companyId: 1 });
103122
123+
personList.$load();
124+
const person = computed(() => personList.$items[0]);
125+
104126
const caseVm = new Case();
105127
const selectedModels = ref<Person[]>([]);
106128

src/coalesce-vue-vuetify3/src/components/input/c-select.spec.tsx

Lines changed: 129 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,13 @@ import {
2525
Company,
2626
ComplexModel,
2727
EnumPkId,
28+
Person,
2829
Test,
2930
} from "@test-targets/models.g";
3031
import {
3132
CaseViewModel,
3233
ComplexModelViewModel,
34+
PersonViewModel,
3335
TestViewModel,
3436
} from "@test-targets/viewmodels.g";
3537

@@ -118,13 +120,20 @@ describe("CSelect", () => {
118120
// Against models that might be null
119121
() => <CSelect model={complexVm.referenceNavigation} for="referenceNavigation" />;
120122

123+
// Binding to collection navigation
124+
() => <CSelect model={new Person} for="casesAssigned" />;
125+
126+
// Unfortunately, after adding support for binding to non-many-to-many collection navigation props,
127+
// there doesn't seem to be any practical way to exclude manyToMany props anymore with TS.
128+
// This is still a runtime error, but we can't enforce it with types.
129+
// // @ts-expect-error Cannot bind to many-to-many
130+
// () => <CSelect model={new Case} for="caseProducts" />;
131+
121132
// Binding to plain Models:
122133
() => <CSelect model={model} for="singleTest" />;
123134
() => <CSelect model={model} for="singleTestId" />;
124135
//@ts-expect-error wrong type of property
125136
() => <CSelect model={model} for="name" />;
126-
//@ts-expect-error Cannot bind to many-to-many
127-
() => <CSelect model={new Case} for="caseProducts" />;
128137
//@ts-expect-error wrong type of property
129138
() => <CSelect model={complexVm} for={complexVm.$metadata.props.name} />;
130139

@@ -675,11 +684,119 @@ describe("CSelect", () => {
675684

676685
// Assert
677686
expect(model.tests).toHaveLength(1);
678-
expect(model.tests[0].testId).toBe(101);
687+
const selectedItem = model.tests[0];
688+
expect(selectedItem.testId).toBe(101);
679689

680690
// Emitted event value should exactly equal by instance the resulting value on `model`.
681-
expect(onUpdateObject.mock.calls[0][0][0]).toBe(model.tests[0]);
682-
expect(onSelectionChanged.mock.calls[0][0][0]).toBe(model.tests[0]);
691+
expect(onUpdateObject.mock.calls[0][0][0]).toBe(selectedItem);
692+
expect(onSelectionChanged.mock.calls[0][0][0]).toBe(selectedItem);
693+
});
694+
695+
test.each(["menu", "chip"])(
696+
"deselect by %s click emits existing ViewModel",
697+
async (method) => {
698+
// Arrange
699+
const model = new ComplexModelViewModel({ singleTestId: 303 });
700+
const onSelectionChanged = vitest.fn();
701+
const wrapper = mountApp(() => (
702+
<CSelect
703+
for="Test"
704+
multiple
705+
onUpdate:modelValue={(v) => (model.tests = v)}
706+
modelValue={model.tests}
707+
onSelectionChanged={onSelectionChanged}
708+
></CSelect>
709+
)).findComponent(CSelect);
710+
711+
// Act
712+
await selectFirstResult(wrapper);
713+
const selectedItem = model.tests[0];
714+
715+
method == "menu"
716+
? await deselectMenuResult(wrapper, 0)
717+
: await deselectChipResult(wrapper, 0);
718+
719+
// Assert
720+
// Emitted event value should exactly equal by instance the resulting value on `model`.
721+
expect(onSelectionChanged.mock.calls[0][0][0]).toBe(selectedItem);
722+
expect(onSelectionChanged.mock.calls[1][0][0]).toBe(selectedItem);
723+
expect(selectedItem).toBeInstanceOf(TestViewModel);
724+
}
725+
);
726+
727+
test("collectionNavigation binding sets inverse nav on select", async () => {
728+
// Arrange
729+
const model = new ComplexModelViewModel().$loadCleanData({
730+
complexModelId: 1,
731+
});
732+
733+
const wrapper = mountApp(() => (
734+
<CSelect model={model} for="tests"></CSelect>
735+
)).findComponent(CSelect);
736+
737+
// Act
738+
await selectFirstResult(wrapper);
739+
740+
// Assert
741+
expect(model.tests).toHaveLength(1);
742+
const added = model.tests[0];
743+
expect(added.complexModel).toBe(model);
744+
expect(added.complexModelId).toBe(model.complexModelId);
745+
746+
expect(model.$bulkSavePreview().items).toEqual([
747+
{
748+
action: "none",
749+
data: { complexModelId: 1 },
750+
refs: { complexModelId: model.$stableId },
751+
root: true,
752+
type: "ComplexModel",
753+
},
754+
{
755+
action: "save",
756+
data: { complexModelId: 1, testId: 101 },
757+
refs: { testId: added.$stableId },
758+
type: "Test",
759+
},
760+
]);
761+
});
762+
763+
test("collectionNavigation clears inverse nav on deselect", async () => {
764+
// Arrange
765+
const model = new ComplexModelViewModel().$loadCleanData({
766+
complexModelId: 1,
767+
tests: [{ testId: 101, testName: "foo 101", complexModelId: 2 }],
768+
});
769+
770+
const wrapper = mountApp(() => (
771+
<CSelect model={model} for="tests"></CSelect>
772+
)).findComponent(CSelect);
773+
774+
// Act
775+
await deselectChipResult(wrapper, 0);
776+
777+
// Assert
778+
expect(model.tests).toHaveLength(0);
779+
const removed = model.$removedItems![0] as TestViewModel;
780+
expect(removed).not.toBeUndefined();
781+
expect(removed).toBeInstanceOf(TestViewModel);
782+
expect(removed.complexModel).toBeNull();
783+
expect(removed.complexModelId).toBeNull();
784+
785+
expect(model.$bulkSavePreview().items).toEqual([
786+
{
787+
action: "none",
788+
data: { complexModelId: 1 },
789+
refs: { complexModelId: model.$stableId },
790+
root: true,
791+
type: "ComplexModel",
792+
},
793+
{
794+
action: "save",
795+
data: { complexModelId: null, testId: 101 },
796+
refs: { testId: removed.$stableId },
797+
type: "Test",
798+
},
799+
]);
683800
});
684801
});
685802

@@ -949,3 +1066,10 @@ async function selectFirstResult(wrapper: VueWrapper) {
9491066
const overlay = await openMenu(wrapper);
9501067
await overlay.find(".v-list-item").trigger("click");
9511068
}
1069+
async function deselectChipResult(wrapper: VueWrapper, idx: number) {
1070+
await wrapper.findAll(".v-chip__close")[idx].trigger("click");
1071+
}
1072+
async function deselectMenuResult(wrapper: VueWrapper, idx: number) {
1073+
const overlay = await openMenu(wrapper);
1074+
await overlay.findAll(".v-list-item--active")[idx].trigger("click");
1075+
}

0 commit comments

Comments
 (0)