Skip to content

Commit f471954

Browse files
committed
fix: c-admin-editor more elegantly handles shared-key one-to-one relationships.
1 parent f8dd42e commit f471954

File tree

14 files changed

+240
-52
lines changed

14 files changed

+240
-52
lines changed

CHANGELOG.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
# 6.0.2
22
- Fix error in c-select when keypress event has no key.
33
- Accept problem details error messages in API Callers.
4-
- Fix `c-list-page` not correctly handling `noCount: true` lists.
5-
- Fix `c-admin-editor-page` briefly showing editors while the item is initially loading.
4+
- `c-list-page` once again correctly handles `noCount: true` lists.
65
- `c-input` no longer wraps default slot fallback content in a superfluous div.
7-
- `c-input` no longer attempts to delegate shared-key one-to-one properties to `c-select`, a scenario that is not possible to select values for.
6+
- `c-table` no longer attempts to render input fields for server-generated PK fields in edit mode.
7+
- `c-admin-editor` has better support for shared-key one-to-one relationships.
8+
- `c-admin-editor`: added `props` prop to control listed fields.
9+
- `c-admin-editor-page` no longer briefly shows editors while the item is initially loading.
810
- DTOs no longer generate MapToNew methods with unsatisfied `required` constraints.
9-
- Fix results from `useResponseCaching` in `ViewModel`/`ListViewModel` `$load` results would get treated as dirty data.
11+
- Fix results from `useResponseCaching` on `ViewModel`/`ListViewModel` `$load` callers getting flagged as dirty.
1012
- Separate-key one-to-one relationships, which already weren't supported by Coalesce, will no longer emit incorrect metadata for the navigation prop on the principal side of the relationship. These properties now emit as "value" properties instead of as "referenceNavigation" properties.
1113

1214
# 6.0.1

docs/stacks/vue/coalesce-vue-vuetify/components/c-admin-editor.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,9 @@ Does not automatically enable [auto-save](/stacks/vue/layers/viewmodels.md) - if
2020

2121
The [ViewModel](/stacks/vue/layers/viewmodels.md) to render an editor for.
2222

23+
<Prop def="props?: string[]" lang="ts" />
24+
25+
An array of property names to include. If provided, only these properties will be shown. If omitted, all non-hidden properties will be shown.
26+
2327

2428

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,39 @@
1+
a {
2+
text-decoration: none;
3+
}
14

2-
@media (max-width: 767px) {
3-
/* On small screens, the nav menu spans the full width of the screen. Leave a space for it. */
4-
body {
5-
padding-top: 50px;
6-
}
7-
}
5+
// Vuetify3 lost the padding for ul/ol that Vuetify2 had. Restore it.
6+
.v-application {
7+
ul,
8+
ol {
9+
padding-left: 24px;
10+
}
11+
}
12+
13+
// Details under inputs are bottom-aligned by default. Undo that.
14+
.v-input__details {
15+
align-items: normal;
16+
padding-top: 3px;
17+
overflow: visible;
18+
}
19+
20+
.v-icon {
21+
// Vuetify v-icon expects to be inline-flex,
22+
// but fontawesome defaults to inline-block through this var.
23+
// Without setting this, icon appearance can vary with CSS load order
24+
--fa-display: inline-flex;
25+
}
26+
27+
// Visually distinguish readonly inputs
28+
.v-input--readonly {
29+
.v-switch__track,
30+
.v-switch__thumb {
31+
border: 1px dashed rgba(var(--v-theme-on-surface), 0.7);
32+
}
33+
.v-field__outline__start,
34+
.v-field__outline__end,
35+
.v-field__outline__notch::before,
36+
.v-field__outline__notch::after {
37+
border-style: dashed !important;
38+
}
39+
}

playground/Coalesce.Web.Vue3/src/metadata.g.ts

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/IntelliTect.Coalesce.Tests/Tests/TypeDefinition/PropertyViewModelTests.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,7 @@ public void OneToOne_ParentNavigations_HasCorrectMetadata(PropertyViewModelData
341341
Assert.Equal(PropertyRole.ReferenceNavigation, vm.Role);
342342
Assert.Equal(vm.Parent.PropertyByName(nameof(OneToOneParent.Id)), vm.ForeignKeyProperty);
343343
Assert.Equal(nameof(OneToOneSharedKeyChild1.Parent), vm.InverseProperty.Name);
344+
Assert.False(vm.EffectiveParent.PrimaryKey.IsCreateOnly);
344345
}
345346

346347
[Theory]
@@ -352,6 +353,7 @@ public void OneToOne_ChildNavigations_HasCorrectMetadata(PropertyViewModelData d
352353
Assert.Equal(PropertyRole.ReferenceNavigation, vm.Role);
353354
Assert.Equal(vm.Parent.PropertyByName("ParentId"), vm.ForeignKeyProperty);
354355
Assert.Equal(inverse, vm.InverseProperty.Name);
356+
Assert.True(vm.EffectiveParent.PrimaryKey.IsCreateOnly);
355357
}
356358

357359
[Theory]

src/IntelliTect.Coalesce/TypeDefinition/PropertyViewModel.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,10 @@ public DatabaseGeneratedOption DatabaseGenerated
459459
{
460460
if (Type.IsEnum) return DatabaseGeneratedOption.None;
461461

462+
// If the PK is also an FK, it can't be database generated.
463+
// This happens for shared-key one-to-one relationships.
464+
if (this.HasAttribute<ForeignKeyAttribute>()) return DatabaseGeneratedOption.None;
465+
462466
return DatabaseGeneratedOption.Identity;
463467
}
464468

src/coalesce-vue-vuetify3/src/components/admin/c-admin-editor.spec.tsx

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import { CAdminEditor } from "..";
33
import {
44
PersonViewModel,
55
PersonListViewModel,
6+
OneToOneParentViewModel,
7+
OneToOneSharedKeyChild1ViewModel,
68
} from "@test-targets/viewmodels.g";
9+
import { mockEndpoint, mount } from "@test/util";
710

811
describe("CAdminEditor", () => {
912
test("types", () => {
@@ -17,4 +20,119 @@ describe("CAdminEditor", () => {
1720
//@ts-expect-error list not allowed
1821
() => <CAdminEditor model={list} />;
1922
});
23+
24+
describe("shared-key one-to-one parent", () => {
25+
mockEndpoint(
26+
"/OneToOneSharedKeyChild1/get/42",
27+
vitest.fn(() => ({ wasSuccessful: true })),
28+
);
29+
mockEndpoint(
30+
"/OneToOneSharedKeyChild1/list",
31+
vitest.fn(() => ({ wasSuccessful: true, list: [] })),
32+
);
33+
34+
test("without value renders readonly c-select and link to create", () => {
35+
const vm = new OneToOneParentViewModel();
36+
vm.$loadCleanData({ id: 42 });
37+
vm.$load.wasSuccessful = true;
38+
const wrapper = mount(() => (
39+
<CAdminEditor model={vm} props={["sharedKeyChild1"]} />
40+
));
41+
42+
// Find the row for SharedKeyChild1
43+
const row = wrapper.find(".prop-sharedKeyChild1");
44+
expect(row.exists()).toBeTruthy();
45+
46+
// Should contain a readonly c-select
47+
const select = row.find(".c-select");
48+
expect(select.exists()).toBeTruthy();
49+
expect(select.classes()).toContain("v-input--readonly");
50+
51+
// Should contain a link to the child item with filter for parent's id
52+
// to allow for creation of the child item
53+
const link = row.find(".c-admin-editor--ref-nav-link");
54+
expect(link.exists()).toBeTruthy();
55+
expect(link.attributes("href")).toBe(
56+
"/admin/OneToOneSharedKeyChild1/item?filter.parentId=42",
57+
);
58+
});
59+
60+
test("with value renders readonly c-select and link to edit", () => {
61+
const vm = new OneToOneParentViewModel();
62+
vm.$loadCleanData({ id: 42, sharedKeyChild1: { parentId: 42 } });
63+
vm.$load.wasSuccessful = true;
64+
const wrapper = mount(() => (
65+
<CAdminEditor model={vm} props={["sharedKeyChild1"]} />
66+
));
67+
68+
// Find the row for SharedKeyChild1
69+
const row = wrapper.find(".prop-sharedKeyChild1");
70+
expect(row.exists()).toBeTruthy();
71+
72+
// Should contain a readonly c-select
73+
const select = row.find(".c-select");
74+
expect(select.exists()).toBeTruthy();
75+
expect(select.classes()).toContain("v-input--readonly");
76+
77+
// Should contain a link to the child item
78+
const link = row.find(".c-admin-editor--ref-nav-link");
79+
expect(link.exists()).toBeTruthy();
80+
expect(link.attributes("href")).toBe(
81+
"/admin/OneToOneSharedKeyChild1/item/42",
82+
);
83+
});
84+
});
85+
86+
describe("shared-key one-to-one child", () => {
87+
mockEndpoint(
88+
"/OneToOneParent/get/42",
89+
vitest.fn(() => ({ wasSuccessful: true, object: { id: 42 } })),
90+
);
91+
mockEndpoint(
92+
"/OneToOneParent/list",
93+
vitest.fn(() => ({ wasSuccessful: true, list: [] })),
94+
);
95+
96+
test.each([
97+
// Simulate being rendered by c-admin-editor-page with `filter.parentId=42`
98+
{ parentId: 42 },
99+
// Simulate direct navigation to create page
100+
{},
101+
])("unsaved item renders selectable PK", (initialData) => {
102+
const vm = new OneToOneSharedKeyChild1ViewModel();
103+
vm.$loadDirtyData(initialData);
104+
105+
const wrapper = mount(() => <CAdminEditor model={vm} />);
106+
107+
// Find the row for Parent
108+
const row = wrapper.find(".prop-parent");
109+
expect(row.exists()).toBeTruthy();
110+
111+
// Should contain an editable c-select
112+
const select = row.find(".c-select");
113+
expect(select.exists()).toBeTruthy();
114+
expect(select.classes()).not.toContain("v-input--readonly");
115+
});
116+
117+
test("saved item renders readonly c-select and link to edit", () => {
118+
const vm = new OneToOneSharedKeyChild1ViewModel();
119+
vm.$loadCleanData({ parentId: 42, parent: { id: 42 } });
120+
121+
const wrapper = mount(() => <CAdminEditor model={vm} />);
122+
123+
// Find the row for Parent
124+
const row = wrapper.find(".prop-parent");
125+
expect(row.exists()).toBeTruthy();
126+
127+
// Should contain a readonly c-select
128+
const select = row.find(".c-select");
129+
expect(select.exists()).toBeTruthy();
130+
expect(select.classes()).toContain("v-input--readonly");
131+
132+
// Should contain a link to the parent item
133+
const link = row.find(".c-admin-editor--ref-nav-link");
134+
expect(link.exists()).toBeTruthy();
135+
expect(link.attributes("href")).toBe("/admin/OneToOneParent/item/42");
136+
});
137+
});
20138
});

src/coalesce-vue-vuetify3/src/components/admin/c-admin-editor.vue

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,8 @@ const props = withDefaults(
212212
color?: string;
213213
/** Whether or not a delete button is shown. Default true if the provided model allows deletes. */
214214
deletable?: boolean;
215+
/** An array of property names to display. If provided, only these properties will be shown. If omitted, all non-hidden properties will be shown. */
216+
props?: string[];
215217
}>(),
216218
{
217219
deletable: true,
@@ -293,11 +295,17 @@ const canDelete = computed(() => {
293295
const showProps = computed(() => {
294296
if (!props.model) return [];
295297
296-
return Object.values(metadata.value.props).filter(
298+
let filtered = Object.values(metadata.value.props).filter(
297299
(p: Property) =>
298300
p.hidden === undefined || (p.hidden & HiddenAreas.Edit) == 0,
299301
// && (!p.dontSerialize || p.role == "referenceNavigation" || p.role == "collectionNavigation")
300302
);
303+
304+
if (props.props) {
305+
filtered = filtered.filter((p) => props.props!.includes(p.name));
306+
}
307+
308+
return filtered;
301309
});
302310
303311
const isBulkSaveDirty = computed(() => {

src/coalesce-vue-vuetify3/src/components/admin/util.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
ModelReferenceNavigationProperty,
77
ViewModel,
88
ModelValueProperty,
9+
ModelType,
910
} from "coalesce-vue";
1011
import { Router } from "vue-router";
1112

@@ -20,14 +21,29 @@ export function getRefNavRoute(
2021
? (owner as any)[prop.foreignKey?.name]
2122
: undefined) ?? item?.[prop.typeDef.keyProp.name];
2223

23-
if (!fk) return;
24+
const meta: ModelType = item?.$metadata ?? prop.typeDef;
2425

25-
// Resolve to an href to allow overriding of admin routes in userspace.
26-
// If we just gave a named raw location, it would always use the coalesce admin route
27-
// instead of the user-overridden one (that the user overrides by declaring another
28-
// route with the same path).
2926
try {
30-
const meta = item?.$metadata ?? prop.typeDef;
27+
if (!item && "foreignKey" in prop && prop.foreignKey.role == "primaryKey") {
28+
// This is a shared-key one-to-one, and the model isn't loaded.
29+
// That most likely means that the model doesn't exist
30+
// (or it failed to be .Included() in the response).
31+
// We want to route to a create editor, not an edit editor.
32+
return router.resolve({
33+
name: "coalesce-admin-item",
34+
params: {
35+
type: meta.name,
36+
},
37+
query: { ["filter." + meta.keyProp.name]: fk },
38+
}).fullPath;
39+
}
40+
41+
if (!fk) return;
42+
43+
// Resolve to an href to allow overriding of admin routes in userspace.
44+
// If we just gave a named raw location, it would always use the coalesce admin route
45+
// instead of the user-overridden one (that the user overrides by declaring another
46+
// route with the same path).
3147
return router.resolve({
3248
name: "coalesce-admin-item",
3349
params: {

src/coalesce-vue-vuetify3/src/components/input/c-input.vue

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -296,17 +296,6 @@ function render() {
296296
return h(CDatetimePicker, data, vuetifySlots);
297297
298298
case "model":
299-
if (
300-
valueMeta.role == "referenceNavigation" &&
301-
"foreignKey" in valueMeta &&
302-
valueMeta.foreignKey.role == "primaryKey"
303-
) {
304-
// This is a shared-key one-to-one. Its not possible to select values with c-select,
305-
// since doing so would mean changing the PK of the child, which is nonsensical.
306-
// The only thing we could *maybe* do here is show a "create" button if the prop is null
307-
// (but if the prop is null because the object wasn't loaded, then that's wrong).
308-
break;
309-
}
310299
data.model = props.model;
311300
data.for = props.for;
312301
addHandler(data, "update:modelValue", (v: any) =>

0 commit comments

Comments
 (0)