Skip to content

Commit 86eae3d

Browse files
committed
feat: add support for DateOnly and TimeOnly filtering in data sources and UI components
1 parent 88e0fdd commit 86eae3d

File tree

8 files changed

+213
-53
lines changed

8 files changed

+213
-53
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# 6.1.1
22
- Fix `AddUrlHelper` to create a more full ActionContext when operating outside an MVC action.
33
- Fix errors thrown when filtering and searching on `System.DateOnly` properties.
4+
- `c-select` now properly performs key equality operations on date primary keys.
5+
- `c-list-filters` now properly handles filtering by date foreign keys.
46

57
# 6.1.0
68
- Added support for .NET 10

src/IntelliTect.Coalesce.Tests/Tests/Api/DataSources/StandardDataSourceTests.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,30 @@ public void ApplyListPropertyFilter_WhenPropIsDateOnly_FiltersProp(
219219
Assert.Equal(shouldMatch ? 1 : 0, query.Count());
220220
}
221221

222+
public static IEnumerable<object[]> Filter_MatchesTimeOnlyData = new[]
223+
{
224+
new object[] { true, "12:34:56", new TimeOnly(12, 34, 56) },
225+
new object[] { true, "12:34", new TimeOnly(12, 34, 00) },
226+
new object[] { false, "12:34:56", new TimeOnly(12, 34, 55) },
227+
new object[] { false, "12:34:56", new TimeOnly(12, 34, 57) },
228+
new object[] { false, "can't parse", new TimeOnly(12, 34, 56) },
229+
230+
// Null or empty inputs always do nothing - these will always match.
231+
new object[] { true, "", new TimeOnly(12, 34, 56) },
232+
new object[] { true, null, new TimeOnly(12, 34, 56) },
233+
};
234+
235+
[Theory]
236+
[MemberData(nameof(Filter_MatchesTimeOnlyData))]
237+
public void ApplyListPropertyFilter_WhenPropIsTimeOnly_FiltersProp(
238+
bool shouldMatch, string inputValue, TimeOnly fieldValue)
239+
{
240+
var (prop, query) = PropertyFiltersTestHelper<ComplexModel, TimeOnly>(
241+
m => m.SystemTimeOnly, fieldValue, inputValue);
242+
243+
Assert.Equal(shouldMatch ? 1 : 0, query.Count());
244+
}
245+
222246

223247
public static IEnumerable<object[]> Filter_MatchesDateTimeOffsetsData = new[]
224248
{

src/IntelliTect.Coalesce.Tests/Tests/Api/SearchTests.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ public void Search_DateTime_IsTimeZoneAgnostic(bool expectedMatch, string search
103103
{
104104
new object[] { true, "2017", new DateOnly(2017, 08, 15) },
105105
new object[] { false, "2018", new DateOnly(2017, 08, 15) },
106+
new object[] { false, "2018", new DateOnly(2017, 12, 31) },
107+
new object[] { false, "2018", new DateOnly(2019, 1, 1) },
106108
new object[] { true, "2017-08", new DateOnly(2017, 08, 01) },
107109
new object[] { true, "2017-08", new DateOnly(2017, 08, 15) },
108110
new object[] { false, "2017-08", new DateOnly(2017, 07, 31) },

src/IntelliTect.Coalesce/Api/DataSources/QueryableDataSourceBase`1.cs

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,9 @@ protected virtual IQueryable<T> ApplyListPropertyFilter(
9797
}
9898

9999
TypeViewModel propType = prop.Type;
100-
if (propType.IsDate)
100+
if (propType.IsDate &&
101+
// DateOnly is handled by the fallback case
102+
!propType.NullableValueUnderlyingType.IsA<DateOnly>())
101103
{
102104
// Literal string "null" should match null values if the prop is nullable.
103105
if (value.Trim().Equals("null", StringComparison.InvariantCultureIgnoreCase))
@@ -112,15 +114,6 @@ protected virtual IQueryable<T> ApplyListPropertyFilter(
112114
return query.Where(_ => false);
113115
}
114116

115-
// Handle DateOnly separately since it can't cast from DateTime
116-
if (propType.NullableValueUnderlyingType.IsA<DateOnly>() && DateOnly.TryParse(value, out DateOnly parsedDateOnly))
117-
{
118-
var dateParam = parsedDateOnly.AsQueryParam(propType);
119-
return query.WhereExpression(it =>
120-
Expression.Equal(it.Prop(prop), dateParam)
121-
);
122-
}
123-
124117
// See if they just passed in a date or a date and a time
125118
if (DateTime.TryParse(value, out DateTime parsedValue))
126119
{

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

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -54,23 +54,11 @@
5454

5555
<!-- Foreign key / primary key / reference navigation dropdown -->
5656
<c-select
57-
v-else-if="
58-
filter.propMeta &&
59-
(filter.propMeta.role == 'foreignKey' ||
60-
filter.propMeta.role == 'primaryKey' ||
61-
filter.propMeta.role == 'referenceNavigation')
62-
"
57+
v-else-if="filter.selectFor"
6358
v-model:keyValue="filter.value"
64-
:for="
65-
filter.propMeta.role == 'primaryKey'
66-
? list.$metadata.name
67-
: filter.propMeta.role == 'referenceNavigation'
68-
? filter.propMeta
69-
: (filter.propMeta.navigationProp ??
70-
filter.propMeta.principalType)
71-
"
59+
:for="filter.selectFor"
60+
:multiple="filter.selectForMultiple"
7261
clearable
73-
:multiple="filter.propMeta.type != 'string'"
7462
autofocus
7563
hide-details
7664
density="compact"

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

Lines changed: 95 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1-
import { flushPromises, getWrapper, mountApp } from "@test/util";
1+
import { flushPromises, getWrapper, mockEndpoint, mountApp } from "@test/util";
22
import { watch } from "vue";
33
import { CListFilters } from "..";
4-
import { ComplexModelListViewModel } from "@test-targets/viewmodels.g";
4+
import {
5+
ComplexModelListViewModel,
6+
DateOnlyPkListViewModel,
7+
} from "@test-targets/viewmodels.g";
8+
import { DateOnlyPk } from "@test-targets/models.g";
59

610
describe("CListFilters", () => {
711
function setupListAndWatcher(initialFilter?: Record<string, any>) {
@@ -25,23 +29,27 @@ describe("CListFilters", () => {
2529
return wrapper;
2630
}
2731

28-
async function openNameFilter(wrapper: any) {
32+
async function openPropertyFilter(wrapper: any, propertyName: string) {
2933
// Open the filters menu by clicking the filter button
3034
const filterButton = wrapper.find(".c-list-filters");
3135
await filterButton.trigger("click");
3236
await flushPromises();
3337

34-
// Find and click on the filter button for the "Name" property
35-
const nameFilterButton = getWrapper(".v-overlay__content")
38+
// Find and click on the filter button for the specified property
39+
const propFilterButton = getWrapper(".v-overlay__content")
3640
.findAll(".v-list-item")
37-
.find((item) => item.text().includes("Name"))!
41+
.find((item) => item.text().includes(propertyName))!
3842
.find(".fa-filter")
3943
.element.closest("button");
40-
expect(nameFilterButton).toBeTruthy();
44+
expect(propFilterButton).toBeTruthy();
4145

42-
nameFilterButton!.click();
46+
propFilterButton!.click();
4347
await flushPromises();
4448
}
49+
50+
async function openNameFilter(wrapper: any) {
51+
return openPropertyFilter(wrapper, "Name");
52+
}
4553
test("doesn't mutate list.$params without user interaction", async () => {
4654
// There was a bug where c-list-filters was initializing list.$params.filters
4755
// (if it wasn't set) on mount, which was then incorrectly triggering list autoload.
@@ -144,4 +152,83 @@ describe("CListFilters", () => {
144152
expect(watchTracker).toBeCalledTimes(1);
145153
expect(list.$filter.name).toBe("");
146154
});
155+
156+
test("supports multiselect filtering for DateOnly primary keys", async () => {
157+
// DateOnly primary keys support multiselect (comma-separated values) filtering
158+
159+
const items = [
160+
new DateOnlyPk({ dateOnlyPkId: new Date(2024, 0, 15), name: "Item 1" }),
161+
new DateOnlyPk({ dateOnlyPkId: new Date(2024, 1, 20), name: "Item 2" }),
162+
new DateOnlyPk({ dateOnlyPkId: new Date(2024, 2, 25), name: "Item 3" }),
163+
];
164+
165+
// Mock the DateOnlyPk list endpoint
166+
mockEndpoint("/DateOnlyPk/list", () => ({
167+
wasSuccessful: true,
168+
list: items,
169+
}));
170+
171+
const list = new DateOnlyPkListViewModel();
172+
const watchTracker = vitest.fn();
173+
watch(() => list.$params, watchTracker, { deep: true });
174+
175+
// Mount the component
176+
const wrapper = mountApp(() => (
177+
<CListFilters list={list} columnSelection></CListFilters>
178+
));
179+
await flushPromises();
180+
expect(watchTracker).toBeCalledTimes(0);
181+
182+
// Open the filter for the Date Only Pk Id property
183+
await openPropertyFilter(wrapper, "Date Only Pk Id");
184+
185+
// Verify that the filter menu shows a c-select component (for multiselect)
186+
const filterMenu = getWrapper(".c-list-filter--prop-menu");
187+
expect(filterMenu.exists()).toBe(true);
188+
189+
// Find the c-select component within the filter
190+
const selectComponent = filterMenu.find(".c-select");
191+
expect(selectComponent.exists()).toBe(true);
192+
193+
// Actually select multiple date values through the UI by opening the c-select menu
194+
const selectInput = selectComponent.find("input");
195+
await selectInput.trigger("focus");
196+
await selectInput.trigger("click");
197+
198+
// Find all overlays and get the last one (which should be the c-select menu)
199+
const overlays = getWrapper("body").findAll(".v-overlay__content");
200+
const selectMenu = overlays.at(-1)!;
201+
202+
// The menu should show the available DateOnlyPk items by their display name
203+
expect(selectMenu.text()).toContain("Item 1");
204+
expect(selectMenu.text()).toContain("Item 2");
205+
expect(selectMenu.text()).toContain("Item 3");
206+
207+
// Click on the list items to select them
208+
const listItems = selectMenu.findAll(".v-list-item");
209+
210+
// Select Item 1
211+
await listItems.find((i) => i.text().includes("Item 1"))!.trigger("click");
212+
expect(list.$filter.dateOnlyPkId).toBe("2024-01-15");
213+
214+
// Select Item 2
215+
await listItems.find((i) => i.text().includes("Item 2"))!.trigger("click");
216+
expect(list.$filter.dateOnlyPkId).toBe("2024-01-15,2024-02-20");
217+
218+
// Select Item 3
219+
await listItems.find((i) => i.text().includes("Item 3"))!.trigger("click");
220+
expect(list.$filter.dateOnlyPkId).toBe("2024-01-15,2024-02-20,2024-03-25");
221+
222+
// Deselect Item 2 (middle item)
223+
await listItems.find((i) => i.text().includes("Item 2"))!.trigger("click");
224+
expect(list.$filter.dateOnlyPkId).toBe("2024-01-15,2024-03-25");
225+
226+
// Deselect Item 1 (first item)
227+
await listItems.find((i) => i.text().includes("Item 1"))!.trigger("click");
228+
expect(list.$filter.dateOnlyPkId).toBe("2024-03-25");
229+
230+
// Deselect Item 3 (last remaining item)
231+
await listItems.find((i) => i.text().includes("Item 3"))!.trigger("click");
232+
expect(list.$filter.dateOnlyPkId).toBe("");
233+
});
147234
});

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

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -559,16 +559,25 @@ const pendingSearchSelect = ref(false);
559559
560560
/** The models representing the current selected item(s)
561561
* in the case that only the PK was provided to the component.
562+
* Uses normalized keys to handle Date objects.
562563
*/
563564
const internallyFetchedModels = new Map<
564-
SelectedPkTypeSingle,
565+
any,
565566
WeakRef<SelectedModelTypeSingle>
566567
>();
567568
568569
function toArray<T>(x: T | T[] | null | undefined) {
569570
return Array.isArray(x) ? x : x == null ? [] : [x];
570571
}
571572
573+
/** Normalizes a key value for use in equality comparisons.
574+
* Converts Date objects to ISO strings to enable proper equality checks.
575+
*/
576+
function normalizeKey(key: any): any {
577+
if (key instanceof Date) return key.toISOString();
578+
return key;
579+
}
580+
572581
/** The effective clearability state of the dropdown. */
573582
const isClearable = computed((): boolean => {
574583
if (typeof props.clearable == "boolean")
@@ -697,29 +706,32 @@ const internalModelValue = computed((): SelectedModelTypeSingle[] => {
697706
// Storing this object prevents it from flipping between different instances
698707
// obtained from either getCaller or listCaller,
699708
// which causes vuetify to reset its search when the object passed to v-select's `modelValue` prop changes.
700-
const keyFetchedModel = internallyFetchedModels.get(key)?.deref();
709+
const normalizedKey = normalizeKey(key);
710+
const keyFetchedModel = internallyFetchedModels.get(normalizedKey)?.deref();
701711
if (keyFetchedModel) {
702712
ret.push(keyFetchedModel);
703713
continue;
704714
}
705715
706716
// All we have is the PK. First, check if it is already in our item array.
707717
// If so, capture it. If not, request the object from the server.
708-
const item = items.value.filter(
709-
(i) => key === i[modelObjectMeta.value.keyProp.name],
710-
)[0];
718+
const item = items.value.find(
719+
(i) =>
720+
normalizeKey(i[modelObjectMeta.value.keyProp.name]) === normalizedKey,
721+
);
711722
if (item) {
712-
internallyFetchedModels.set(key, new WeakRef(item));
723+
internallyFetchedModels.set(normalizedKey, new WeakRef(item));
713724
ret.push(item);
714725
continue;
715726
}
716727
717728
// See if we obtained the item via getCaller.
718729
const singleItem = getCaller.result?.find(
719-
(x) => key === x[modelObjectMeta.value.keyProp.name],
730+
(x) =>
731+
normalizeKey(x[modelObjectMeta.value.keyProp.name]) === normalizedKey,
720732
);
721733
if (singleItem) {
722-
internallyFetchedModels.set(key, new WeakRef(singleItem));
734+
internallyFetchedModels.set(normalizedKey, new WeakRef(singleItem));
723735
ret.push(singleItem);
724736
continue;
725737
}
@@ -730,7 +742,12 @@ const internalModelValue = computed((): SelectedModelTypeSingle[] => {
730742
if (
731743
!listCaller.isLoading &&
732744
!getCaller.isLoading &&
733-
needsLoad.some((needed) => !getCaller.args.ids.includes(needed))
745+
needsLoad.some(
746+
(needed) =>
747+
!getCaller.args.ids.some(
748+
(id) => normalizeKey(id) === normalizeKey(needed),
749+
),
750+
)
734751
) {
735752
// Only request the item if the list isn't currently loading,
736753
// since the item may end up coming back from a pending list call.
@@ -762,12 +779,15 @@ const internalKeyValue = computed((): SelectedPkTypeSingle[] => {
762779
return value.map((v) => mapValueToModel(v, modelObjectMeta.value.keyProp));
763780
});
764781
782+
/** A Set of normalized primary keys representing all currently selected items.
783+
* Keys are normalized via `normalizeKey()` to handle Date objects and ensure proper equality comparisons.
784+
*/
765785
const selectedKeysSet = computed(
766786
(): Set<any> =>
767787
new Set([
768-
...internalKeyValue.value,
769-
...internalModelValue.value.map(
770-
(x) => x[modelObjectMeta.value.keyProp.name],
788+
...internalKeyValue.value.map(normalizeKey),
789+
...internalModelValue.value.map((x) =>
790+
normalizeKey(x[modelObjectMeta.value.keyProp.name]),
771791
),
772792
]),
773793
);
@@ -818,7 +838,7 @@ const listItems = computed(() => {
818838
return items.value.map((item) => ({
819839
model: item,
820840
key: item[pkName],
821-
selected: selectedKeysSet.value.has(item[pkName]),
841+
selected: selectedKeysSet.value.has(normalizeKey(item[pkName])),
822842
}));
823843
});
824844
@@ -971,18 +991,21 @@ function onInput(
971991
const selectedModels = [...internalModelValue.value];
972992
973993
if (key != null) {
974-
const idx = selectedKeys.indexOf(key);
994+
const normalizedKey = normalizeKey(key);
995+
const idx = selectedKeys.indexOf(normalizedKey);
975996
if (idx === -1) {
976997
const newValue = convertValue(value)!;
977998
978-
selectedKeys.push(key);
999+
selectedKeys.push(normalizedKey);
9791000
selectedModels.push(newValue);
980-
internallyFetchedModels.set(key, new WeakRef(newValue));
1001+
internallyFetchedModels.set(normalizedKey, new WeakRef(newValue));
9811002
selectionChanged([newValue], true);
9821003
} else {
9831004
if (!props.canDeselect) return;
9841005
selectedKeys.splice(idx, 1);
985-
const modelIdx = selectedModels.findIndex((x) => x[pkName] == key);
1006+
const modelIdx = selectedModels.findIndex(
1007+
(x) => normalizeKey(x[pkName]) === normalizedKey,
1008+
);
9861009
if (modelIdx !== -1) {
9871010
value = selectedModels[modelIdx] ?? value;
9881011
selectedModels.splice(idx, 1);
@@ -1014,8 +1037,8 @@ function onInput(
10141037
const newValue = convertValue(value);
10151038
newObjectValue = newValue;
10161039
newKey = key;
1017-
if (newValue) {
1018-
internallyFetchedModels.set(key, new WeakRef(newValue));
1040+
if (newValue && key != null) {
1041+
internallyFetchedModels.set(normalizeKey(key), new WeakRef(newValue));
10191042
}
10201043
}
10211044

0 commit comments

Comments
 (0)