@jskit-ai/users-web 0.1.134 → 0.1.135
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fixtures/crud-list-date-filters/App.vue +107 -0
- package/fixtures/crud-list-date-filters/index.html +12 -0
- package/fixtures/crud-list-date-filters/main.js +24 -0
- package/fixtures/crud-list-date-filters/vite.config.mjs +18 -0
- package/package.descriptor.mjs +2 -2
- package/package.json +2 -2
- package/src/client/components/CrudListDateFilterControl.vue +188 -0
- package/src/client/components/CrudListFilterSurface.vue +46 -35
- package/src/client/composables/useCrudListFilters.js +11 -5
- package/src/client/support/crudListDateFilterSupport.js +91 -0
- package/test/crudListDateFilterSupport.test.js +92 -0
- package/test/crudListDateFilterSurface.browser.test.js +273 -0
- package/test/crudListFilterSurface.test.js +45 -0
- package/test/useCrudListFilters.test.js +49 -1
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed } from "vue";
|
|
3
|
+
import CrudListFilterSurface from "../../src/client/components/CrudListFilterSurface.vue";
|
|
4
|
+
import { useCrudListFilters } from "../../src/client/composables/useCrudListFilters.js";
|
|
5
|
+
|
|
6
|
+
const filters = {
|
|
7
|
+
status: {
|
|
8
|
+
key: "status",
|
|
9
|
+
queryKey: "status",
|
|
10
|
+
type: "enum",
|
|
11
|
+
label: "Status",
|
|
12
|
+
options: [
|
|
13
|
+
{ value: "active", label: "Active" },
|
|
14
|
+
{ value: "archived", label: "Archived" }
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
submittedOn: {
|
|
18
|
+
key: "submittedOn",
|
|
19
|
+
queryKey: "submittedOn",
|
|
20
|
+
type: "date",
|
|
21
|
+
label: "Submitted date"
|
|
22
|
+
},
|
|
23
|
+
arrivalDate: {
|
|
24
|
+
key: "arrivalDate",
|
|
25
|
+
queryKey: "arrivalDate",
|
|
26
|
+
type: "dateRange",
|
|
27
|
+
label: "Arrival"
|
|
28
|
+
},
|
|
29
|
+
assignment: {
|
|
30
|
+
key: "assignment",
|
|
31
|
+
queryKey: "assignment",
|
|
32
|
+
type: "presence",
|
|
33
|
+
label: "Assignment",
|
|
34
|
+
options: [
|
|
35
|
+
{ value: "present", label: "Assigned" },
|
|
36
|
+
{ value: "missing", label: "Unassigned" }
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const runtime = useCrudListFilters(filters);
|
|
41
|
+
const query = new URLSearchParams(window.location.search);
|
|
42
|
+
|
|
43
|
+
for (const [key, param] of Object.entries(runtime.queryParams)) {
|
|
44
|
+
if (query.has(key)) {
|
|
45
|
+
param.value = query.get(key);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const contractSnapshot = computed(() => JSON.stringify({
|
|
50
|
+
values: {
|
|
51
|
+
submittedOn: runtime.values.submittedOn,
|
|
52
|
+
arrivalDate: {
|
|
53
|
+
from: runtime.values.arrivalDate.from,
|
|
54
|
+
to: runtime.values.arrivalDate.to
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
query: {
|
|
58
|
+
submittedOn: runtime.queryParams.submittedOn.value,
|
|
59
|
+
arrivalDate: runtime.queryParams.arrivalDate.value
|
|
60
|
+
}
|
|
61
|
+
}));
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<template>
|
|
65
|
+
<v-app>
|
|
66
|
+
<v-main>
|
|
67
|
+
<v-container class="date-filter-fixture py-8">
|
|
68
|
+
<div class="mb-6">
|
|
69
|
+
<div class="text-overline text-primary">JSKIT users-web</div>
|
|
70
|
+
<h1 class="text-h4 mb-2">Date filter controls</h1>
|
|
71
|
+
<p class="text-body-1 text-medium-emphasis mb-0">
|
|
72
|
+
Date and date-range filters beside ordinary Vuetify selects.
|
|
73
|
+
</p>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<CrudListFilterSurface :filters="filters" :runtime="runtime" />
|
|
77
|
+
|
|
78
|
+
<output data-testid="date-filter-contract" class="date-filter-fixture__contract">
|
|
79
|
+
{{ contractSnapshot }}
|
|
80
|
+
</output>
|
|
81
|
+
</v-container>
|
|
82
|
+
</v-main>
|
|
83
|
+
</v-app>
|
|
84
|
+
</template>
|
|
85
|
+
|
|
86
|
+
<style>
|
|
87
|
+
html {
|
|
88
|
+
overflow-x: hidden;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
body {
|
|
92
|
+
background: rgb(var(--v-theme-background));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.date-filter-fixture {
|
|
96
|
+
max-width: 90rem;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.date-filter-fixture__contract {
|
|
100
|
+
display: block;
|
|
101
|
+
font-family: monospace;
|
|
102
|
+
height: 1px;
|
|
103
|
+
overflow: hidden;
|
|
104
|
+
position: absolute;
|
|
105
|
+
width: 1px;
|
|
106
|
+
}
|
|
107
|
+
</style>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>CRUD date filter visual regression</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="app"></div>
|
|
10
|
+
<script type="module" src="/main.js"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createApp } from "vue";
|
|
2
|
+
import "vuetify/styles";
|
|
3
|
+
import { createVuetify } from "vuetify";
|
|
4
|
+
import * as components from "vuetify/components";
|
|
5
|
+
import * as directives from "vuetify/directives";
|
|
6
|
+
import { aliases as mdiAliases, mdi } from "vuetify/iconsets/mdi-svg";
|
|
7
|
+
import App from "./App.vue";
|
|
8
|
+
|
|
9
|
+
const vuetify = createVuetify({
|
|
10
|
+
components,
|
|
11
|
+
directives,
|
|
12
|
+
theme: {
|
|
13
|
+
defaultTheme: "light"
|
|
14
|
+
},
|
|
15
|
+
icons: {
|
|
16
|
+
defaultSet: "mdi",
|
|
17
|
+
aliases: mdiAliases,
|
|
18
|
+
sets: { mdi }
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
createApp(App)
|
|
23
|
+
.use(vuetify)
|
|
24
|
+
.mount("#app");
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { defineConfig } from "vite";
|
|
4
|
+
import vue from "@vitejs/plugin-vue";
|
|
5
|
+
|
|
6
|
+
const fixtureRoot = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
|
|
8
|
+
export default defineConfig({
|
|
9
|
+
root: fixtureRoot,
|
|
10
|
+
plugins: [vue()],
|
|
11
|
+
resolve: {
|
|
12
|
+
preserveSymlinks: true
|
|
13
|
+
},
|
|
14
|
+
server: {
|
|
15
|
+
host: "127.0.0.1",
|
|
16
|
+
strictPort: true
|
|
17
|
+
}
|
|
18
|
+
});
|
package/package.descriptor.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { HOME_COG_OUTLET } from "./src/shared/toolsOutletContracts.js";
|
|
|
3
3
|
export default Object.freeze({
|
|
4
4
|
packageVersion: 1,
|
|
5
5
|
packageId: "@jskit-ai/users-web",
|
|
6
|
-
version: "0.1.
|
|
6
|
+
version: "0.1.135",
|
|
7
7
|
kind: "runtime",
|
|
8
8
|
description: "Users web module: account/profile UI plus shared users web widgets.",
|
|
9
9
|
dependsOn: [
|
|
@@ -291,7 +291,7 @@ export default Object.freeze({
|
|
|
291
291
|
"@jskit-ai/kernel": "0.1.120",
|
|
292
292
|
"@jskit-ai/shell-web": "0.1.119",
|
|
293
293
|
"@jskit-ai/uploads-image-web": "0.1.96",
|
|
294
|
-
"@jskit-ai/users-core": "0.1.
|
|
294
|
+
"@jskit-ai/users-core": "0.1.129"
|
|
295
295
|
},
|
|
296
296
|
dev: {}
|
|
297
297
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/users-web",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.135",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test"
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"@jskit-ai/realtime": "0.1.117",
|
|
52
52
|
"@jskit-ai/shell-web": "0.1.119",
|
|
53
53
|
"@jskit-ai/uploads-image-web": "0.1.96",
|
|
54
|
-
"@jskit-ai/users-core": "0.1.
|
|
54
|
+
"@jskit-ai/users-core": "0.1.129"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
57
57
|
"@tanstack/vue-query": "^5.90.5",
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, nextTick, ref, useAttrs, useId } from "vue";
|
|
3
|
+
import { useLocale } from "vuetify";
|
|
4
|
+
import {
|
|
5
|
+
parseDateOnlyValue,
|
|
6
|
+
formatDateOnlyValue,
|
|
7
|
+
formatDateOnlyDisplay
|
|
8
|
+
} from "../support/crudListDateFilterSupport.js";
|
|
9
|
+
|
|
10
|
+
defineOptions({
|
|
11
|
+
inheritAttrs: false
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const props = defineProps({
|
|
15
|
+
modelValue: {
|
|
16
|
+
type: String,
|
|
17
|
+
default: ""
|
|
18
|
+
},
|
|
19
|
+
label: {
|
|
20
|
+
type: String,
|
|
21
|
+
required: true
|
|
22
|
+
},
|
|
23
|
+
controlId: {
|
|
24
|
+
type: String,
|
|
25
|
+
default: ""
|
|
26
|
+
},
|
|
27
|
+
placeholder: {
|
|
28
|
+
type: String,
|
|
29
|
+
default: "Select date"
|
|
30
|
+
},
|
|
31
|
+
disabled: {
|
|
32
|
+
type: Boolean,
|
|
33
|
+
default: false
|
|
34
|
+
},
|
|
35
|
+
readonly: {
|
|
36
|
+
type: Boolean,
|
|
37
|
+
default: false
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const emit = defineEmits(["update:modelValue"]);
|
|
42
|
+
const attrs = useAttrs();
|
|
43
|
+
const { current: currentLocale } = useLocale();
|
|
44
|
+
const generatedId = useId();
|
|
45
|
+
const menuOpen = ref(false);
|
|
46
|
+
const textFieldRef = ref(null);
|
|
47
|
+
|
|
48
|
+
const resolvedControlId = computed(() => props.controlId || `crud-list-date-${generatedId}`);
|
|
49
|
+
const pickerId = computed(() => `${resolvedControlId.value}-picker`);
|
|
50
|
+
const pickerValue = computed(() => parseDateOnlyValue(props.modelValue));
|
|
51
|
+
const displayValue = computed(() => formatDateOnlyDisplay(props.modelValue, {
|
|
52
|
+
locale: currentLocale?.value
|
|
53
|
+
}));
|
|
54
|
+
const pickerTitle = computed(() => `${props.label} calendar`);
|
|
55
|
+
|
|
56
|
+
function restoreFocus() {
|
|
57
|
+
void nextTick(() => {
|
|
58
|
+
textFieldRef.value?.focus?.();
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function openMenu() {
|
|
63
|
+
if (props.disabled || props.readonly) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
menuOpen.value = true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function closeMenu() {
|
|
70
|
+
menuOpen.value = false;
|
|
71
|
+
restoreFocus();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function clearDate() {
|
|
75
|
+
if (props.disabled || props.readonly) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
emit("update:modelValue", "");
|
|
79
|
+
closeMenu();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function selectDate(value) {
|
|
83
|
+
const nextValue = formatDateOnlyValue(value);
|
|
84
|
+
if (!nextValue) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
emit("update:modelValue", nextValue);
|
|
88
|
+
closeMenu();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function handleActivatorKeydown(event) {
|
|
92
|
+
if (!["Enter", " ", "ArrowDown"].includes(event.key)) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
event.preventDefault();
|
|
96
|
+
openMenu();
|
|
97
|
+
}
|
|
98
|
+
</script>
|
|
99
|
+
|
|
100
|
+
<template>
|
|
101
|
+
<v-menu
|
|
102
|
+
v-model="menuOpen"
|
|
103
|
+
:close-on-content-click="false"
|
|
104
|
+
:open-on-click="false"
|
|
105
|
+
location="bottom start"
|
|
106
|
+
:offset="6"
|
|
107
|
+
min-width="0"
|
|
108
|
+
>
|
|
109
|
+
<template #activator="{ props: activatorProps }">
|
|
110
|
+
<v-text-field
|
|
111
|
+
v-bind="{ ...attrs, ...activatorProps }"
|
|
112
|
+
:id="resolvedControlId"
|
|
113
|
+
ref="textFieldRef"
|
|
114
|
+
:model-value="displayValue"
|
|
115
|
+
:label="label"
|
|
116
|
+
:placeholder="placeholder"
|
|
117
|
+
:aria-label="label"
|
|
118
|
+
:aria-controls="pickerId"
|
|
119
|
+
:aria-expanded="String(menuOpen)"
|
|
120
|
+
aria-haspopup="dialog"
|
|
121
|
+
:data-date-filter-control="resolvedControlId"
|
|
122
|
+
append-inner-icon="$calendar"
|
|
123
|
+
:disabled="disabled"
|
|
124
|
+
:clearable="Boolean(modelValue) && !disabled && !readonly"
|
|
125
|
+
persistent-clear
|
|
126
|
+
persistent-placeholder
|
|
127
|
+
readonly
|
|
128
|
+
inputmode="none"
|
|
129
|
+
variant="outlined"
|
|
130
|
+
density="comfortable"
|
|
131
|
+
hide-details="auto"
|
|
132
|
+
class="crud-list-date-filter-control"
|
|
133
|
+
@click:control="openMenu"
|
|
134
|
+
@click:append-inner="openMenu"
|
|
135
|
+
@click:clear.stop="clearDate"
|
|
136
|
+
@keydown="handleActivatorKeydown"
|
|
137
|
+
/>
|
|
138
|
+
</template>
|
|
139
|
+
|
|
140
|
+
<v-card
|
|
141
|
+
:id="pickerId"
|
|
142
|
+
:aria-label="pickerTitle"
|
|
143
|
+
role="dialog"
|
|
144
|
+
class="crud-list-date-filter-control__picker"
|
|
145
|
+
@keydown.esc.stop.prevent="closeMenu"
|
|
146
|
+
>
|
|
147
|
+
<v-date-picker
|
|
148
|
+
:model-value="pickerValue"
|
|
149
|
+
:title="pickerTitle"
|
|
150
|
+
:header="label"
|
|
151
|
+
:disabled="disabled || readonly"
|
|
152
|
+
color="primary"
|
|
153
|
+
show-adjacent-months
|
|
154
|
+
width="100%"
|
|
155
|
+
@update:model-value="selectDate"
|
|
156
|
+
/>
|
|
157
|
+
<v-divider />
|
|
158
|
+
<v-card-actions>
|
|
159
|
+
<v-btn
|
|
160
|
+
variant="text"
|
|
161
|
+
:disabled="disabled || readonly || !modelValue"
|
|
162
|
+
@click="clearDate"
|
|
163
|
+
>
|
|
164
|
+
Clear
|
|
165
|
+
</v-btn>
|
|
166
|
+
<v-spacer />
|
|
167
|
+
<v-btn color="primary" variant="text" @click="closeMenu">Close</v-btn>
|
|
168
|
+
</v-card-actions>
|
|
169
|
+
</v-card>
|
|
170
|
+
</v-menu>
|
|
171
|
+
</template>
|
|
172
|
+
|
|
173
|
+
<style scoped>
|
|
174
|
+
.crud-list-date-filter-control {
|
|
175
|
+
min-width: 0;
|
|
176
|
+
width: 100%;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.crud-list-date-filter-control__picker {
|
|
180
|
+
max-width: calc(100vw - 2rem);
|
|
181
|
+
overflow: hidden;
|
|
182
|
+
width: 22rem;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.crud-list-date-filter-control__picker :deep(.v-date-picker) {
|
|
186
|
+
max-width: 100%;
|
|
187
|
+
}
|
|
188
|
+
</style>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { computed, ref } from "vue";
|
|
3
3
|
import { useDisplay } from "vuetify";
|
|
4
|
+
import CrudListDateFilterControl from "./CrudListDateFilterControl.vue";
|
|
4
5
|
|
|
5
6
|
const props = defineProps({
|
|
6
7
|
filters: {
|
|
@@ -137,22 +138,25 @@ function clearFilters() {
|
|
|
137
138
|
hide-details="auto"
|
|
138
139
|
class="crud-list-filter-surface__control"
|
|
139
140
|
/>
|
|
140
|
-
<div
|
|
141
|
-
|
|
141
|
+
<div
|
|
142
|
+
v-else-if="filter.type === 'dateRange'"
|
|
143
|
+
class="crud-list-filter-surface__range crud-list-filter-surface__range--date"
|
|
144
|
+
role="group"
|
|
145
|
+
:aria-label="`${filter.label} date range`"
|
|
146
|
+
:data-filter-key="filter.key"
|
|
147
|
+
data-filter-type="date-range"
|
|
148
|
+
>
|
|
149
|
+
<CrudListDateFilterControl
|
|
142
150
|
v-model="runtimeValues[filter.key].from"
|
|
143
151
|
:label="`${filter.label} from`"
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
density="comfortable"
|
|
147
|
-
hide-details="auto"
|
|
152
|
+
:control-id="`${filter.key}-from`"
|
|
153
|
+
:placeholder="placeholder(filter, 'Select start date')"
|
|
148
154
|
/>
|
|
149
|
-
<
|
|
155
|
+
<CrudListDateFilterControl
|
|
150
156
|
v-model="runtimeValues[filter.key].to"
|
|
151
157
|
:label="`${filter.label} to`"
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
density="comfortable"
|
|
155
|
-
hide-details="auto"
|
|
158
|
+
:control-id="`${filter.key}-to`"
|
|
159
|
+
:placeholder="placeholder(filter, 'Select end date')"
|
|
156
160
|
/>
|
|
157
161
|
</div>
|
|
158
162
|
<div v-else-if="filter.type === 'numberRange'" class="crud-list-filter-surface__range">
|
|
@@ -173,15 +177,12 @@ function clearFilters() {
|
|
|
173
177
|
hide-details="auto"
|
|
174
178
|
/>
|
|
175
179
|
</div>
|
|
176
|
-
<
|
|
180
|
+
<CrudListDateFilterControl
|
|
177
181
|
v-else-if="filter.type === 'date'"
|
|
178
182
|
v-model="runtimeValues[filter.key]"
|
|
179
183
|
:label="filter.label"
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
variant="outlined"
|
|
183
|
-
density="comfortable"
|
|
184
|
-
hide-details="auto"
|
|
184
|
+
:control-id="filter.key"
|
|
185
|
+
:placeholder="placeholder(filter, 'Select date')"
|
|
185
186
|
class="crud-list-filter-surface__control"
|
|
186
187
|
/>
|
|
187
188
|
</template>
|
|
@@ -244,22 +245,25 @@ function clearFilters() {
|
|
|
244
245
|
density="comfortable"
|
|
245
246
|
hide-details="auto"
|
|
246
247
|
/>
|
|
247
|
-
<div
|
|
248
|
-
|
|
248
|
+
<div
|
|
249
|
+
v-else-if="filter.type === 'dateRange'"
|
|
250
|
+
class="crud-list-filter-surface__range crud-list-filter-surface__range--date"
|
|
251
|
+
role="group"
|
|
252
|
+
:aria-label="`${filter.label} date range`"
|
|
253
|
+
:data-filter-key="filter.key"
|
|
254
|
+
data-filter-type="date-range"
|
|
255
|
+
>
|
|
256
|
+
<CrudListDateFilterControl
|
|
249
257
|
v-model="runtimeValues[filter.key].from"
|
|
250
258
|
:label="`${filter.label} from`"
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
density="comfortable"
|
|
254
|
-
hide-details="auto"
|
|
259
|
+
:control-id="`${filter.key}-from`"
|
|
260
|
+
:placeholder="placeholder(filter, 'Select start date')"
|
|
255
261
|
/>
|
|
256
|
-
<
|
|
262
|
+
<CrudListDateFilterControl
|
|
257
263
|
v-model="runtimeValues[filter.key].to"
|
|
258
264
|
:label="`${filter.label} to`"
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
density="comfortable"
|
|
262
|
-
hide-details="auto"
|
|
265
|
+
:control-id="`${filter.key}-to`"
|
|
266
|
+
:placeholder="placeholder(filter, 'Select end date')"
|
|
263
267
|
/>
|
|
264
268
|
</div>
|
|
265
269
|
<div v-else-if="filter.type === 'numberRange'" class="crud-list-filter-surface__range">
|
|
@@ -280,15 +284,12 @@ function clearFilters() {
|
|
|
280
284
|
hide-details="auto"
|
|
281
285
|
/>
|
|
282
286
|
</div>
|
|
283
|
-
<
|
|
287
|
+
<CrudListDateFilterControl
|
|
284
288
|
v-else-if="filter.type === 'date'"
|
|
285
289
|
v-model="runtimeValues[filter.key]"
|
|
286
290
|
:label="filter.label"
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
variant="outlined"
|
|
290
|
-
density="comfortable"
|
|
291
|
-
hide-details="auto"
|
|
291
|
+
:control-id="filter.key"
|
|
292
|
+
:placeholder="placeholder(filter, 'Select date')"
|
|
292
293
|
/>
|
|
293
294
|
</template>
|
|
294
295
|
</div>
|
|
@@ -337,7 +338,7 @@ function clearFilters() {
|
|
|
337
338
|
.crud-list-filter-surface__controls {
|
|
338
339
|
display: grid;
|
|
339
340
|
gap: 0.75rem;
|
|
340
|
-
grid-template-columns: repeat(auto-fit, minmax(
|
|
341
|
+
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
|
|
341
342
|
}
|
|
342
343
|
|
|
343
344
|
.crud-list-filter-surface__controls--stacked {
|
|
@@ -352,6 +353,16 @@ function clearFilters() {
|
|
|
352
353
|
display: grid;
|
|
353
354
|
gap: 0.75rem;
|
|
354
355
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
356
|
+
min-width: 0;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
.crud-list-filter-surface__range--date {
|
|
360
|
+
grid-column: span 2;
|
|
361
|
+
grid-template-columns: repeat(2, minmax(14rem, 1fr));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
.crud-list-filter-surface__controls--stacked .crud-list-filter-surface__range--date {
|
|
365
|
+
grid-column: auto;
|
|
355
366
|
}
|
|
356
367
|
|
|
357
368
|
.crud-list-filter-surface__sheet {
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
resolveCrudListFilterOptionLabel,
|
|
14
14
|
CRUD_LIST_FILTER_TYPE_FLAG
|
|
15
15
|
} from "@jskit-ai/kernel/shared/support/crudListFilters";
|
|
16
|
+
import { formatCrudListDateFilterChipLabel } from "../support/crudListDateFilterSupport.js";
|
|
16
17
|
|
|
17
18
|
function normalizeFunctionMap(value = {}) {
|
|
18
19
|
const source = value && typeof value === "object" && !Array.isArray(value)
|
|
@@ -147,6 +148,15 @@ function resolveAtomicValueLabel(filter = {}, value = "", labelResolvers = {}) {
|
|
|
147
148
|
});
|
|
148
149
|
}
|
|
149
150
|
|
|
151
|
+
function formatDefaultChipLabel(filter = {}, chipValue, labelResolvers = {}) {
|
|
152
|
+
return formatCrudListDateFilterChipLabel(filter, chipValue)
|
|
153
|
+
|| formatCrudListFilterDefaultChipLabel(filter, chipValue, {
|
|
154
|
+
resolveAtomicValue(value) {
|
|
155
|
+
return resolveAtomicValueLabel(filter, value, labelResolvers);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
150
160
|
function useCrudListFilters(definitions = {}, { labelResolvers = {}, chipLabels = {}, presets = [] } = {}) {
|
|
151
161
|
const filters = defineCrudListFilters(definitions);
|
|
152
162
|
const filterEntries = Object.values(filters);
|
|
@@ -179,11 +189,7 @@ function useCrudListFilters(definitions = {}, { labelResolvers = {}, chipLabels
|
|
|
179
189
|
...(isCrudListFilterMultiValue(filter) ? { value: chipValue } : {}),
|
|
180
190
|
label: normalizeText(customChipLabel?.(chipValue, filter, values))
|
|
181
191
|
|| normalizeText(filter.chipLabel?.(chipValue, filter, values))
|
|
182
|
-
||
|
|
183
|
-
resolveAtomicValue(value) {
|
|
184
|
-
return resolveAtomicValueLabel(filter, value, normalizedLabelResolvers);
|
|
185
|
-
}
|
|
186
|
-
})
|
|
192
|
+
|| formatDefaultChipLabel(filter, chipValue, normalizedLabelResolvers)
|
|
187
193
|
});
|
|
188
194
|
}
|
|
189
195
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
const DATE_ONLY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/u;
|
|
2
|
+
|
|
3
|
+
function parseDateOnlyValue(value = "") {
|
|
4
|
+
const match = DATE_ONLY_PATTERN.exec(String(value || "").trim());
|
|
5
|
+
if (!match) {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const year = Number(match[1]);
|
|
10
|
+
const month = Number(match[2]);
|
|
11
|
+
const day = Number(match[3]);
|
|
12
|
+
const date = new Date(0);
|
|
13
|
+
date.setHours(12, 0, 0, 0);
|
|
14
|
+
date.setFullYear(year, month - 1, day);
|
|
15
|
+
|
|
16
|
+
if (
|
|
17
|
+
date.getFullYear() !== year ||
|
|
18
|
+
date.getMonth() !== month - 1 ||
|
|
19
|
+
date.getDate() !== day
|
|
20
|
+
) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return date;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function formatDateOnlyValue(value = null) {
|
|
28
|
+
const source = Array.isArray(value) ? value[0] : value;
|
|
29
|
+
const date = source instanceof Date
|
|
30
|
+
? source
|
|
31
|
+
: parseDateOnlyValue(source);
|
|
32
|
+
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const year = date.getFullYear();
|
|
37
|
+
if (year < 0 || year > 9999) {
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return [
|
|
42
|
+
String(year).padStart(4, "0"),
|
|
43
|
+
String(date.getMonth() + 1).padStart(2, "0"),
|
|
44
|
+
String(date.getDate()).padStart(2, "0")
|
|
45
|
+
].join("-");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function formatDateOnlyDisplay(value = "", { locale = undefined } = {}) {
|
|
49
|
+
const date = parseDateOnlyValue(value);
|
|
50
|
+
if (!date) {
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
return new Intl.DateTimeFormat(locale || undefined, {
|
|
56
|
+
year: "numeric",
|
|
57
|
+
month: "short",
|
|
58
|
+
day: "numeric"
|
|
59
|
+
}).format(date);
|
|
60
|
+
} catch {
|
|
61
|
+
return formatDateOnlyValue(date);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatCrudListDateFilterChipLabel(filter = {}, rawValue, { locale = undefined } = {}) {
|
|
66
|
+
if (filter.type === "date") {
|
|
67
|
+
const dateLabel = formatDateOnlyDisplay(rawValue, { locale }) || String(rawValue || "");
|
|
68
|
+
return dateLabel ? `${filter.label}: ${dateLabel}` : "";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (filter.type !== "dateRange") {
|
|
72
|
+
return "";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const from = formatDateOnlyDisplay(rawValue?.from, { locale }) || String(rawValue?.from || "");
|
|
76
|
+
const to = formatDateOnlyDisplay(rawValue?.to, { locale }) || String(rawValue?.to || "");
|
|
77
|
+
if (from && to) {
|
|
78
|
+
return `${filter.label}: ${from} to ${to}`;
|
|
79
|
+
}
|
|
80
|
+
if (from) {
|
|
81
|
+
return `${filter.label}: from ${from}`;
|
|
82
|
+
}
|
|
83
|
+
return to ? `${filter.label}: to ${to}` : "";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export {
|
|
87
|
+
parseDateOnlyValue,
|
|
88
|
+
formatDateOnlyValue,
|
|
89
|
+
formatDateOnlyDisplay,
|
|
90
|
+
formatCrudListDateFilterChipLabel
|
|
91
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import {
|
|
5
|
+
parseDateOnlyValue,
|
|
6
|
+
formatDateOnlyValue,
|
|
7
|
+
formatDateOnlyDisplay,
|
|
8
|
+
formatCrudListDateFilterChipLabel
|
|
9
|
+
} from "../src/client/support/crudListDateFilterSupport.js";
|
|
10
|
+
|
|
11
|
+
test("date filter picker values round-trip through the YYYY-MM-DD contract", () => {
|
|
12
|
+
const parsed = parseDateOnlyValue("2026-04-18");
|
|
13
|
+
|
|
14
|
+
assert.ok(parsed instanceof Date);
|
|
15
|
+
assert.equal(parsed.getFullYear(), 2026);
|
|
16
|
+
assert.equal(parsed.getMonth(), 3);
|
|
17
|
+
assert.equal(parsed.getDate(), 18);
|
|
18
|
+
assert.equal(formatDateOnlyValue(parsed), "2026-04-18");
|
|
19
|
+
assert.equal(formatDateOnlyValue([new Date(2026, 4, 9)]), "2026-05-09");
|
|
20
|
+
assert.equal(formatDateOnlyValue("2026-11-03"), "2026-11-03");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("date filter parsing rejects impossible calendar dates", () => {
|
|
24
|
+
assert.equal(parseDateOnlyValue("2026-02-29"), null);
|
|
25
|
+
assert.ok(parseDateOnlyValue("2028-02-29") instanceof Date);
|
|
26
|
+
assert.equal(parseDateOnlyValue("04/18/2026"), null);
|
|
27
|
+
assert.equal(parseDateOnlyValue(""), null);
|
|
28
|
+
assert.equal(formatDateOnlyValue(new Date("invalid")), "");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("date filter display and chip labels are locale-aware without exposing Date strings", () => {
|
|
32
|
+
assert.equal(formatDateOnlyDisplay("2026-04-18", { locale: "en-US" }), "Apr 18, 2026");
|
|
33
|
+
assert.equal(formatDateOnlyDisplay("2026-04-18", { locale: "en-GB" }), "18 Apr 2026");
|
|
34
|
+
assert.equal(
|
|
35
|
+
formatCrudListDateFilterChipLabel(
|
|
36
|
+
{ type: "date", label: "Submitted" },
|
|
37
|
+
"2026-04-18",
|
|
38
|
+
{ locale: "en-US" }
|
|
39
|
+
),
|
|
40
|
+
"Submitted: Apr 18, 2026"
|
|
41
|
+
);
|
|
42
|
+
assert.equal(
|
|
43
|
+
formatCrudListDateFilterChipLabel(
|
|
44
|
+
{ type: "dateRange", label: "Arrival" },
|
|
45
|
+
{ from: "2026-05-04", to: "2026-05-10" },
|
|
46
|
+
{ locale: "en-US" }
|
|
47
|
+
),
|
|
48
|
+
"Arrival: May 4, 2026 to May 10, 2026"
|
|
49
|
+
);
|
|
50
|
+
assert.equal(
|
|
51
|
+
formatCrudListDateFilterChipLabel(
|
|
52
|
+
{ type: "dateRange", label: "Arrival" },
|
|
53
|
+
{ from: "", to: "2026-05-10" },
|
|
54
|
+
{ locale: "en-US" }
|
|
55
|
+
),
|
|
56
|
+
"Arrival: to May 10, 2026"
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("date-only conversion does not shift across time zones", () => {
|
|
61
|
+
const supportModuleUrl = new URL(
|
|
62
|
+
"../src/client/support/crudListDateFilterSupport.js",
|
|
63
|
+
import.meta.url
|
|
64
|
+
).href;
|
|
65
|
+
const script = `
|
|
66
|
+
import { parseDateOnlyValue, formatDateOnlyValue } from ${JSON.stringify(supportModuleUrl)};
|
|
67
|
+
const parsed = parseDateOnlyValue("2026-01-01");
|
|
68
|
+
process.stdout.write(JSON.stringify({
|
|
69
|
+
value: formatDateOnlyValue(parsed),
|
|
70
|
+
year: parsed.getFullYear(),
|
|
71
|
+
month: parsed.getMonth(),
|
|
72
|
+
day: parsed.getDate()
|
|
73
|
+
}));
|
|
74
|
+
`;
|
|
75
|
+
|
|
76
|
+
for (const timezone of ["UTC", "America/Los_Angeles", "Pacific/Kiritimati"]) {
|
|
77
|
+
const result = spawnSync(process.execPath, ["--input-type=module", "--eval", script], {
|
|
78
|
+
encoding: "utf8",
|
|
79
|
+
env: {
|
|
80
|
+
...process.env,
|
|
81
|
+
TZ: timezone
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
assert.equal(result.status, 0, result.stderr);
|
|
85
|
+
assert.deepEqual(JSON.parse(result.stdout), {
|
|
86
|
+
value: "2026-01-01",
|
|
87
|
+
year: 2026,
|
|
88
|
+
month: 0,
|
|
89
|
+
day: 1
|
|
90
|
+
}, timezone);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { chromium } from "@playwright/test";
|
|
9
|
+
|
|
10
|
+
const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const REPOSITORY_ROOT = path.resolve(TEST_DIRECTORY, "../../..");
|
|
12
|
+
const PACKAGE_ROOT = path.resolve(TEST_DIRECTORY, "..");
|
|
13
|
+
const FIXTURE_ROOT = path.join(PACKAGE_ROOT, "fixtures", "crud-list-date-filters");
|
|
14
|
+
const VITE_CLI = path.join(REPOSITORY_ROOT, "node_modules", "vite", "bin", "vite.js");
|
|
15
|
+
const RUN_BROWSER_TEST = process.env.JSKIT_USERS_WEB_DATE_FILTER_VISUAL_INTEGRATION === "1";
|
|
16
|
+
const VIEWPORT_WIDTHS = Object.freeze([375, 768, 1280, 1440]);
|
|
17
|
+
const QUERY = "?status=active&submittedOn=2026-04-18&arrivalDate=2026-05-04..2026-05-10";
|
|
18
|
+
|
|
19
|
+
function startCapturedProcess(command, args, { cwd } = {}) {
|
|
20
|
+
const child = spawn(command, args, {
|
|
21
|
+
cwd,
|
|
22
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
23
|
+
env: {
|
|
24
|
+
...process.env,
|
|
25
|
+
NO_COLOR: "1",
|
|
26
|
+
FORCE_COLOR: "0"
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
let output = "";
|
|
30
|
+
|
|
31
|
+
child.stdout.on("data", (chunk) => {
|
|
32
|
+
output += chunk.toString("utf8");
|
|
33
|
+
});
|
|
34
|
+
child.stderr.on("data", (chunk) => {
|
|
35
|
+
output += chunk.toString("utf8");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
function waitFor(pattern, timeout = 30_000) {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const startedAt = Date.now();
|
|
41
|
+
const timer = setInterval(() => {
|
|
42
|
+
if (pattern.test(output)) {
|
|
43
|
+
clearInterval(timer);
|
|
44
|
+
resolve();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (child.exitCode !== null) {
|
|
48
|
+
clearInterval(timer);
|
|
49
|
+
reject(new Error(`Vite exited before ${pattern}.\n${output}`));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (Date.now() - startedAt >= timeout) {
|
|
53
|
+
clearInterval(timer);
|
|
54
|
+
reject(new Error(`Timed out waiting for ${pattern}.\n${output}`));
|
|
55
|
+
}
|
|
56
|
+
}, 50);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
child,
|
|
62
|
+
readOutput: () => output,
|
|
63
|
+
waitFor
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function stopProcess(runtime) {
|
|
68
|
+
const child = runtime?.child;
|
|
69
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
await new Promise((resolve) => {
|
|
74
|
+
const forceTimer = setTimeout(() => child.kill("SIGKILL"), 5_000);
|
|
75
|
+
child.once("exit", () => {
|
|
76
|
+
clearTimeout(forceTimer);
|
|
77
|
+
resolve();
|
|
78
|
+
});
|
|
79
|
+
child.kill("SIGTERM");
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function reservePort() {
|
|
84
|
+
const server = net.createServer();
|
|
85
|
+
await new Promise((resolve, reject) => {
|
|
86
|
+
server.once("error", reject);
|
|
87
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
88
|
+
});
|
|
89
|
+
const address = server.address();
|
|
90
|
+
assert.ok(address && typeof address === "object");
|
|
91
|
+
await new Promise((resolve, reject) => {
|
|
92
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
93
|
+
});
|
|
94
|
+
return address.port;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function readContract(page) {
|
|
98
|
+
return JSON.parse(await page.getByTestId("date-filter-contract").textContent());
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function openCompactFilters(page) {
|
|
102
|
+
const openButton = page.getByRole("button", { name: /^Filters/u });
|
|
103
|
+
if (await openButton.isVisible()) {
|
|
104
|
+
await openButton.click();
|
|
105
|
+
await page.getByRole("dialog").waitFor({ state: "visible" });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function assertResponsiveDateControls(page, width) {
|
|
110
|
+
await page.setViewportSize({ width, height: 900 });
|
|
111
|
+
await page.goto(`http://127.0.0.1:${page.__jskitPort}/${QUERY}`, {
|
|
112
|
+
waitUntil: "networkidle"
|
|
113
|
+
});
|
|
114
|
+
await openCompactFilters(page);
|
|
115
|
+
|
|
116
|
+
for (const name of ["Submitted date", "Arrival from", "Arrival to"]) {
|
|
117
|
+
const field = page.getByLabel(name, { exact: true });
|
|
118
|
+
await field.waitFor({ state: "visible" });
|
|
119
|
+
const box = await field.locator("xpath=ancestor::*[contains(@class, 'v-input')][1]").boundingBox();
|
|
120
|
+
assert.ok(box, `${name} is missing a visible field at ${width}px.`);
|
|
121
|
+
assert.ok(box.width >= 220, `${name} is only ${box.width}px wide at ${width}px.`);
|
|
122
|
+
assert.ok(box.x >= 0 && box.x + box.width <= width, `${name} overflows at ${width}px.`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const layout = await page.evaluate(() => ({
|
|
126
|
+
clientWidth: document.documentElement.clientWidth,
|
|
127
|
+
scrollWidth: document.documentElement.scrollWidth,
|
|
128
|
+
clippedFields: Array.from(document.querySelectorAll(".crud-list-date-filter-control .v-field"))
|
|
129
|
+
.filter((field) => field.scrollWidth > field.clientWidth + 1)
|
|
130
|
+
.length,
|
|
131
|
+
visibleCalendarIcons: Array.from(document.querySelectorAll(
|
|
132
|
+
".crud-list-date-filter-control .v-field__append-inner"
|
|
133
|
+
)).filter((icon) => {
|
|
134
|
+
const rect = icon.getBoundingClientRect();
|
|
135
|
+
return rect.width > 0 && rect.right <= window.innerWidth;
|
|
136
|
+
}).length
|
|
137
|
+
}));
|
|
138
|
+
assert.equal(layout.scrollWidth, layout.clientWidth, `Horizontal overflow at ${width}px.`);
|
|
139
|
+
assert.equal(layout.clippedFields, 0, `A date field is clipped at ${width}px.`);
|
|
140
|
+
assert.equal(layout.visibleCalendarIcons, 3, `Calendar affordance missing at ${width}px.`);
|
|
141
|
+
|
|
142
|
+
const screenshot = await page.screenshot({ fullPage: true });
|
|
143
|
+
assert.ok(screenshot.length > 5_000, `Visual evidence was empty at ${width}px.`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function selectPickerDayWithKeyboard(picker, day) {
|
|
147
|
+
const dayButton = picker
|
|
148
|
+
.locator(".v-date-picker-month__day-btn")
|
|
149
|
+
.filter({ hasText: new RegExp(`^${day}$`, "u") })
|
|
150
|
+
.first();
|
|
151
|
+
await dayButton.focus();
|
|
152
|
+
await dayButton.press("Space");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
test("CRUD date filters render and operate without clipping across responsive layouts", {
|
|
156
|
+
skip: RUN_BROWSER_TEST
|
|
157
|
+
? false
|
|
158
|
+
: "set JSKIT_USERS_WEB_DATE_FILTER_VISUAL_INTEGRATION=1 to run the browser visual regression",
|
|
159
|
+
timeout: 180_000
|
|
160
|
+
}, async () => {
|
|
161
|
+
const port = await reservePort();
|
|
162
|
+
const viteRuntime = startCapturedProcess(process.execPath, [
|
|
163
|
+
VITE_CLI,
|
|
164
|
+
"--config",
|
|
165
|
+
path.join(FIXTURE_ROOT, "vite.config.mjs"),
|
|
166
|
+
"--port",
|
|
167
|
+
String(port),
|
|
168
|
+
"--clearScreen",
|
|
169
|
+
"false"
|
|
170
|
+
], { cwd: FIXTURE_ROOT });
|
|
171
|
+
let browser = null;
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
await viteRuntime.waitFor(new RegExp(`http://127\\.0\\.0\\.1:${port}/`, "u"));
|
|
175
|
+
const chromiumExecutablePath = String(
|
|
176
|
+
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || ""
|
|
177
|
+
).trim();
|
|
178
|
+
browser = await chromium.launch({
|
|
179
|
+
headless: true,
|
|
180
|
+
...(chromiumExecutablePath ? { executablePath: chromiumExecutablePath } : {})
|
|
181
|
+
});
|
|
182
|
+
const context = await browser.newContext({ locale: "en-US" });
|
|
183
|
+
const page = await context.newPage();
|
|
184
|
+
page.__jskitPort = port;
|
|
185
|
+
|
|
186
|
+
for (const width of VIEWPORT_WIDTHS) {
|
|
187
|
+
await assertResponsiveDateControls(page, width);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
await page.setViewportSize({ width: 1280, height: 900 });
|
|
191
|
+
await page.goto(`http://127.0.0.1:${port}/${QUERY}`, { waitUntil: "networkidle" });
|
|
192
|
+
assert.deepEqual(await readContract(page), {
|
|
193
|
+
values: {
|
|
194
|
+
submittedOn: "2026-04-18",
|
|
195
|
+
arrivalDate: { from: "2026-05-04", to: "2026-05-10" }
|
|
196
|
+
},
|
|
197
|
+
query: {
|
|
198
|
+
submittedOn: "2026-04-18",
|
|
199
|
+
arrivalDate: "2026-05-04..2026-05-10"
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
await page.getByText("Submitted date: Apr 18, 2026", { exact: true }).waitFor();
|
|
203
|
+
await page.getByText("Arrival: May 4, 2026 to May 10, 2026", { exact: true }).waitFor();
|
|
204
|
+
|
|
205
|
+
const submittedField = page.getByLabel("Submitted date", { exact: true });
|
|
206
|
+
await submittedField.focus();
|
|
207
|
+
await submittedField.press("Enter");
|
|
208
|
+
const submittedPicker = page.getByRole("dialog", { name: "Submitted date calendar" });
|
|
209
|
+
await submittedPicker.waitFor({ state: "visible" });
|
|
210
|
+
await selectPickerDayWithKeyboard(submittedPicker, 21);
|
|
211
|
+
assert.equal((await readContract(page)).values.submittedOn, "2026-04-21");
|
|
212
|
+
assert.equal((await readContract(page)).query.submittedOn, "2026-04-21");
|
|
213
|
+
assert.equal(await submittedField.evaluate((element) => element === document.activeElement), true);
|
|
214
|
+
|
|
215
|
+
const arrivalToField = page.getByLabel("Arrival to", { exact: true });
|
|
216
|
+
await arrivalToField.focus();
|
|
217
|
+
await arrivalToField.press("ArrowDown");
|
|
218
|
+
const arrivalToPicker = page.getByRole("dialog", { name: "Arrival to calendar" });
|
|
219
|
+
await arrivalToPicker.waitFor({ state: "visible" });
|
|
220
|
+
await selectPickerDayWithKeyboard(arrivalToPicker, 12);
|
|
221
|
+
assert.deepEqual((await readContract(page)).values.arrivalDate, {
|
|
222
|
+
from: "2026-05-04",
|
|
223
|
+
to: "2026-05-12"
|
|
224
|
+
});
|
|
225
|
+
assert.equal((await readContract(page)).query.arrivalDate, "2026-05-04..2026-05-12");
|
|
226
|
+
|
|
227
|
+
await arrivalToField.focus();
|
|
228
|
+
await arrivalToField.press("Enter");
|
|
229
|
+
const clearButton = page
|
|
230
|
+
.getByRole("dialog", { name: "Arrival to calendar" })
|
|
231
|
+
.getByRole("button", { name: "Clear", exact: true });
|
|
232
|
+
await clearButton.focus();
|
|
233
|
+
await clearButton.press("Space");
|
|
234
|
+
assert.deepEqual((await readContract(page)).values.arrivalDate, {
|
|
235
|
+
from: "2026-05-04",
|
|
236
|
+
to: ""
|
|
237
|
+
});
|
|
238
|
+
assert.equal((await readContract(page)).query.arrivalDate, "2026-05-04..");
|
|
239
|
+
assert.equal(await arrivalToField.evaluate((element) => element === document.activeElement), true);
|
|
240
|
+
|
|
241
|
+
const arrivalFromField = page.getByLabel("Arrival from", { exact: true });
|
|
242
|
+
await arrivalFromField.focus();
|
|
243
|
+
await arrivalFromField.press(" ");
|
|
244
|
+
await page.getByRole("dialog", { name: "Arrival from calendar" }).waitFor();
|
|
245
|
+
await page.keyboard.press("Escape");
|
|
246
|
+
assert.equal(await arrivalFromField.evaluate((element) => element === document.activeElement), true);
|
|
247
|
+
|
|
248
|
+
const clearAll = page.getByRole("button", { name: "Clear all", exact: true });
|
|
249
|
+
await clearAll.focus();
|
|
250
|
+
await clearAll.press("Space");
|
|
251
|
+
assert.deepEqual((await readContract(page)).values, {
|
|
252
|
+
submittedOn: "",
|
|
253
|
+
arrivalDate: { from: "", to: "" }
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
await page.setViewportSize({ width: 375, height: 812 });
|
|
257
|
+
await page.reload({ waitUntil: "networkidle" });
|
|
258
|
+
const openFilters = page.getByRole("button", { name: /^Filters/u });
|
|
259
|
+
const openButtonBox = await openFilters.boundingBox();
|
|
260
|
+
assert.ok(openButtonBox && openButtonBox.height >= 48);
|
|
261
|
+
await openFilters.click();
|
|
262
|
+
const mobileSheet = page.getByRole("dialog");
|
|
263
|
+
await mobileSheet.waitFor({ state: "visible" });
|
|
264
|
+
const mobileSheetBox = await mobileSheet.boundingBox();
|
|
265
|
+
assert.ok(mobileSheetBox && mobileSheetBox.width <= 375);
|
|
266
|
+
await page.getByLabel("Arrival from", { exact: true }).waitFor({ state: "visible" });
|
|
267
|
+
} finally {
|
|
268
|
+
if (browser) {
|
|
269
|
+
await browser.close();
|
|
270
|
+
}
|
|
271
|
+
await stopProcess(viteRuntime);
|
|
272
|
+
}
|
|
273
|
+
});
|
|
@@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import test from "node:test";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { compileScript, compileTemplate, parse } from "@vue/compiler-sfc";
|
|
6
7
|
|
|
7
8
|
const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
|
|
8
9
|
const PACKAGE_DIR = path.resolve(TEST_DIRECTORY, "..");
|
|
@@ -40,6 +41,50 @@ test("CrudListFilterSurface provides adaptive controls without owning server sem
|
|
|
40
41
|
assert.match(source, /clearFilters/);
|
|
41
42
|
assert.match(source, /v-dialog/);
|
|
42
43
|
assert.match(source, /min-height:\s*48px/);
|
|
44
|
+
assert.match(source, /CrudListDateFilterControl/);
|
|
45
|
+
assert.match(source, /crud-list-filter-surface__range--date/);
|
|
46
|
+
assert.match(source, /grid-column:\s*span 2/);
|
|
47
|
+
assert.doesNotMatch(source, /type="date"/);
|
|
43
48
|
assert.doesNotMatch(source, /useCrudList\(/);
|
|
44
49
|
assert.doesNotMatch(source, /apiSuffix|server|repository/);
|
|
45
50
|
});
|
|
51
|
+
|
|
52
|
+
test("CrudListDateFilterControl composes stable Vuetify picker components accessibly", async () => {
|
|
53
|
+
const componentPath = path.join(
|
|
54
|
+
PACKAGE_DIR,
|
|
55
|
+
"src",
|
|
56
|
+
"client",
|
|
57
|
+
"components",
|
|
58
|
+
"CrudListDateFilterControl.vue"
|
|
59
|
+
);
|
|
60
|
+
const source = await readFile(componentPath, "utf8");
|
|
61
|
+
|
|
62
|
+
assert.match(source, /<v-menu/);
|
|
63
|
+
assert.match(source, /<v-text-field/);
|
|
64
|
+
assert.match(source, /<v-date-picker/);
|
|
65
|
+
assert.match(source, /append-inner-icon="\$calendar"/);
|
|
66
|
+
assert.match(source, /:aria-label="label"/);
|
|
67
|
+
assert.match(source, /aria-haspopup="dialog"/);
|
|
68
|
+
assert.match(source, /v-bind="\{ \.\.\.attrs, \.\.\.activatorProps \}"/);
|
|
69
|
+
assert.match(source, /\["Enter", " ", "ArrowDown"\]/);
|
|
70
|
+
assert.match(source, /role="dialog"/);
|
|
71
|
+
assert.match(source, />\s*Clear\s*</u);
|
|
72
|
+
assert.match(source, />\s*Close\s*</u);
|
|
73
|
+
assert.doesNotMatch(source, /VDateInput|vuetify\/labs|type="date"/);
|
|
74
|
+
|
|
75
|
+
const { descriptor, errors: parseErrors } = parse(source, { filename: componentPath });
|
|
76
|
+
assert.deepEqual(parseErrors, []);
|
|
77
|
+
const script = compileScript(descriptor, {
|
|
78
|
+
id: "crud-list-date-filter-control"
|
|
79
|
+
});
|
|
80
|
+
const compiled = compileTemplate({
|
|
81
|
+
id: "crud-list-date-filter-control",
|
|
82
|
+
filename: componentPath,
|
|
83
|
+
source: descriptor.template.content,
|
|
84
|
+
scoped: descriptor.styles.some((style) => style.scoped),
|
|
85
|
+
compilerOptions: {
|
|
86
|
+
bindingMetadata: script.bindings
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
assert.deepEqual(compiled.errors, []);
|
|
90
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
+
import { formatDateOnlyDisplay } from "../src/client/support/crudListDateFilterSupport.js";
|
|
3
4
|
|
|
4
5
|
test("useCrudListFilters manages values, query params, chips, and presets", async () => {
|
|
5
6
|
const { useCrudListFilters } = await import("@jskit-ai/users-web/client/composables/useCrudListFilters");
|
|
@@ -61,7 +62,7 @@ test("useCrudListFilters manages values, query params, chips, and presets", asyn
|
|
|
61
62
|
"Staff",
|
|
62
63
|
"Status: Active",
|
|
63
64
|
"Supplier: Pollen Partners",
|
|
64
|
-
|
|
65
|
+
`Arrival Date: from ${formatDateOnlyDisplay("2026-04-01")}`
|
|
65
66
|
]
|
|
66
67
|
);
|
|
67
68
|
|
|
@@ -209,3 +210,50 @@ test("useCrudListFilters hydrates single-key range query params into structured
|
|
|
209
210
|
max: "18"
|
|
210
211
|
});
|
|
211
212
|
});
|
|
213
|
+
|
|
214
|
+
test("useCrudListFilters preserves scalar, complete, partial, and cleared date contracts", async () => {
|
|
215
|
+
const { useCrudListFilters } = await import("@jskit-ai/users-web/client/composables/useCrudListFilters");
|
|
216
|
+
const filters = useCrudListFilters({
|
|
217
|
+
submittedOn: {
|
|
218
|
+
type: "date",
|
|
219
|
+
label: "Submitted"
|
|
220
|
+
},
|
|
221
|
+
arrivalDate: {
|
|
222
|
+
type: "dateRange",
|
|
223
|
+
label: "Arrival"
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
filters.queryParams.submittedOn.value = "2026-04-18";
|
|
228
|
+
filters.queryParams.arrivalDate.value = "2026-05-04..2026-05-10";
|
|
229
|
+
|
|
230
|
+
assert.equal(filters.values.submittedOn, "2026-04-18");
|
|
231
|
+
assert.deepEqual(filters.values.arrivalDate, {
|
|
232
|
+
from: "2026-05-04",
|
|
233
|
+
to: "2026-05-10"
|
|
234
|
+
});
|
|
235
|
+
assert.equal(filters.queryParams.submittedOn.value, "2026-04-18");
|
|
236
|
+
assert.equal(filters.queryParams.arrivalDate.value, "2026-05-04..2026-05-10");
|
|
237
|
+
assert.deepEqual(
|
|
238
|
+
filters.activeChips.value.map((chip) => chip.label),
|
|
239
|
+
[
|
|
240
|
+
`Submitted: ${formatDateOnlyDisplay("2026-04-18")}`,
|
|
241
|
+
`Arrival: ${formatDateOnlyDisplay("2026-05-04")} to ${formatDateOnlyDisplay("2026-05-10")}`
|
|
242
|
+
]
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
filters.values.arrivalDate.to = "";
|
|
246
|
+
assert.equal(filters.queryParams.arrivalDate.value, "2026-05-04..");
|
|
247
|
+
assert.equal(
|
|
248
|
+
filters.activeChips.value.at(-1).label,
|
|
249
|
+
`Arrival: from ${formatDateOnlyDisplay("2026-05-04")}`
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
filters.clearFilter("submittedOn");
|
|
253
|
+
assert.equal(filters.values.submittedOn, "");
|
|
254
|
+
assert.equal(filters.queryParams.submittedOn.value, "");
|
|
255
|
+
|
|
256
|
+
filters.clearFilters();
|
|
257
|
+
assert.deepEqual(filters.values.arrivalDate, { from: "", to: "" });
|
|
258
|
+
assert.equal(filters.hasActiveFilters.value, false);
|
|
259
|
+
});
|