@anzusystems/common-admin 2.0.0-rc.6 → 2.0.0-rc.7
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/README.md +4 -0
- package/dist/domains/filters/components/AFilterWrapper.js.map +1 -1
- package/dist/domains/filters/components/AFilterWrapper.vue_vue_type_script_setup_true_lang.js +63 -62
- package/dist/domains/filters/components/AFilterWrapper.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/package.json +15 -5
- package/src/e2e/conventions.d.mts +4 -0
- package/src/e2e/conventions.mjs +15 -0
- package/src/e2e/index.d.mts +8 -0
- package/src/e2e/index.mjs +1 -0
- package/src/e2e/routeWalk.d.mts +43 -0
- package/src/e2e/routeWalk.mjs +606 -0
- package/src/e2e/types.d.mts +161 -0
package/README.md
CHANGED
|
@@ -49,6 +49,10 @@ A feature may be deprecated in any release when a better replacement exists. Dep
|
|
|
49
49
|
|
|
50
50
|
To take only fixes automatically, use a range that locks the current minor (`~2.1.0`) and upgrade to a new minor by hand after reading its changelog. `^2.1.0` takes minor releases too.
|
|
51
51
|
|
|
52
|
+
## Updating dependencies
|
|
53
|
+
|
|
54
|
+
`yarn deps:upgrade` runs `upgrade-interactive`, then `dedupe`.
|
|
55
|
+
|
|
52
56
|
## Releasing
|
|
53
57
|
|
|
54
58
|
Releases are made with [release-tools](https://github.com/anzusystems/release-tools) ([guide](https://github.com/anzusystems/release-tools/blob/main/docs/guide.md)); the rules and examples for this package are in [doc/RELEASING.md](doc/RELEASING.md):
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AFilterWrapper.js","names":["_sfc_main","AFilterWrapper_vue_vue_type_script_setup_true_lang_default","AFilterWrapper_default"],"sources":["../../../../src/domains/filters/components/AFilterWrapper.vue"],"sourcesContent":["<script lang=\"ts\" setup>\nimport AFilterAdvancedButton from '@/domains/filters/components/AFilterAdvancedButton.vue'\nimport AFilterSubmitButton from '@/domains/filters/components/AFilterSubmitButton.vue'\nimport AFilterResetButton from '@/domains/filters/components/AFilterResetButton.vue'\nimport { computed, inject, nextTick, provide, ref } from 'vue'\nimport {\n FilterConfigKey,\n FilterDataKey,\n FilterSelectedKey,\n FilterSubmitResetCounterKey,\n} from '@/domains/filters/utils/filterInjectionKeys'\nimport FiltersSelected from '@/domains/filters/components/FiltersSelected.vue'\nimport type { ValueObjectOption } from '@/shared/types/ValueObject'\nimport { isBoolean, isDefined, isUndefined } from '@/shared/utils/common'\nimport { type FilterStoreIdentifier, useFilterClearHelpers } from '@/domains/filters/composables/filterFactory'\nimport { datatableSlotName } from '@/domains/filters/datatable/utils/datatable'\nimport FilterDetailItem from '@/domains/filters/components/FilterDetailItem.vue'\nimport AFilterBookmarkButton from '@/domains/filters/bookmarks/components/AFilterBookmarkButton.vue'\nimport FilterBookmarks from '@/domains/filters/bookmarks/components/FilterBookmarks.vue'\nimport type { IntegerIdNullable } from '@/shared/types/common'\nimport { type AxiosInstance } from 'axios'\n\nconst props = withDefaults(\n defineProps<{\n enableTop?: boolean\n hideButtons?: boolean\n formName?: string\n userId?: IntegerIdNullable | undefined\n client?: (() => AxiosInstance) | undefined\n store?: FilterStoreIdentifier | boolean // false to disable, FilterStoreIdentifier to custom store key\n alwaysVisible?: boolean\n hideMore?: boolean\n }>(),\n {\n enableTop: false,\n hideButtons: false,\n formName: 'search',\n userId: undefined,\n client: undefined,\n store: true,\n alwaysVisible: false,\n hideMore: false,\n }\n)\nconst emit = defineEmits<{\n (e: 'submit'): void\n (e: 'reset'): void\n (e: 'bookmarkLoadAfter'): void\n}>()\n\nconst datatableHiddenColumns = defineModel<string[] | undefined>('datatableHiddenColumns', {\n default: undefined,\n required: false,\n})\nconst showDetail = defineModel<boolean>('showDetail', { default: false, required: false })\n\nconst filterConfig = inject(FilterConfigKey)\nconst filterData = inject(FilterDataKey)\nif (isUndefined(filterConfig) || isUndefined(filterData)) {\n throw new Error('Incorrect provide/inject config.')\n}\nconst identifier = ref<Partial<FilterStoreIdentifier>>({\n system: filterConfig.general.system,\n subject: filterConfig.general.subject,\n})\n// eslint-disable-next-line vue/no-setup-props-reactivity-loss\nif (!isBoolean(props.store)) {\n identifier.value.system = props.store.system\n identifier.value.subject = props.store.subject\n} else if (false === props.store) {\n identifier.value.system = undefined\n identifier.value.subject = undefined\n}\nconst submitResetCounter = ref(0)\nprovide(FilterSubmitResetCounterKey, submitResetCounter)\nconst filterSelected = ref<Map<string, ValueObjectOption<string | number>[]>>(new Map())\nprovide(FilterSelectedKey, filterSelected)\n\nconst submitFilter = () => {\n submitResetCounter.value++\n nextTick(() => {\n emit('submit')\n })\n}\n\nconst { clearAll, clearAllFilterSelected } = useFilterClearHelpers()\n\nconst submitFilterBookmark = () => {\n nextTick(() => {\n submitResetCounter.value++\n emit('bookmarkLoadAfter')\n })\n}\n\nconst resetFilter = () => {\n clearAll(filterData, filterConfig)\n clearAllFilterSelected(filterData, filterConfig, filterSelected)\n nextTick(() => {\n submitResetCounter.value++\n emit('reset')\n })\n}\n\nconst toggleFilterDetail = () => {\n showDetail.value = !showDetail.value\n}\n\nconst touched = computed(() => {\n return filterConfig.touched\n})\n\n// Names of `filterConfig.fields` itself, so the template's lookups by them always hit.\nconst renderedFieldNames = computed(() => {\n return Object.entries(filterConfig.fields)\n .filter(([, field]) => !field.render.skip)\n .map(([fieldName]) => fieldName)\n})\n\nif (props.alwaysVisible) {\n showDetail.value = true\n}\n\ndefineExpose({\n submit: submitFilter,\n reset: resetFilter,\n})\n</script>\n\n<template>\n <VForm\n :name=\"formName\"\n @submit.prevent=\"submitFilter\"\n >\n <VRow\n v-if=\"enableTop\"\n density=\"compact\"\n >\n <VCol>\n <slot name=\"top\" />\n </VCol>\n </VRow>\n <VRow density=\"compact\">\n <VCol v-if=\"store && userId && isDefined(client)\">\n <slot name=\"bookmarks\">\n <div class=\"d-flex flex-wrap align-center\">\n <FilterBookmarks\n v-if=\"identifier.system && identifier.subject && userId && isDefined(client)\"\n v-model:datatable-hidden-columns=\"datatableHiddenColumns\"\n :client=\"client\"\n :system=\"identifier.system\"\n :subject=\"identifier.subject\"\n :user-id=\"userId\"\n @submit=\"submitFilterBookmark\"\n />\n </div>\n </slot>\n </VCol>\n </VRow>\n <VRow\n v-if=\"!alwaysVisible\"\n density=\"compact\"\n >\n <VCol\n v-if=\"!hideMore\"\n cols=\"auto\"\n >\n <AFilterAdvancedButton\n :button-active=\"showDetail\"\n @advanced-filter=\"toggleFilterDetail\"\n />\n </VCol>\n <VCol>\n <div class=\"a-filter__container\">\n <div class=\"a-filter__search\">\n <slot name=\"search\" />\n </div>\n <FiltersSelected />\n </div>\n </VCol>\n <VCol\n v-if=\"!hideButtons\"\n class=\"text-right\"\n cols=\"auto\"\n >\n <slot name=\"buttons\">\n <AFilterSubmitButton :touched=\"touched\" />\n <AFilterResetButton @reset=\"resetFilter\" />\n <AFilterBookmarkButton\n v-if=\"identifier.system && identifier.subject && userId && isDefined(client)\"\n :client=\"client\"\n :user=\"userId\"\n :system=\"identifier.system\"\n :subject=\"identifier.subject\"\n :datatable-hidden-columns=\"datatableHiddenColumns\"\n />\n </slot>\n </VCol>\n </VRow>\n <div v-if=\"!hideMore\">\n <component :is=\"alwaysVisible ? 'div' :
|
|
1
|
+
{"version":3,"file":"AFilterWrapper.js","names":["_sfc_main","AFilterWrapper_vue_vue_type_script_setup_true_lang_default","AFilterWrapper_default"],"sources":["../../../../src/domains/filters/components/AFilterWrapper.vue"],"sourcesContent":["<script lang=\"ts\" setup>\nimport AFilterAdvancedButton from '@/domains/filters/components/AFilterAdvancedButton.vue'\nimport AFilterSubmitButton from '@/domains/filters/components/AFilterSubmitButton.vue'\nimport AFilterResetButton from '@/domains/filters/components/AFilterResetButton.vue'\nimport { computed, inject, nextTick, provide, ref } from 'vue'\nimport {\n FilterConfigKey,\n FilterDataKey,\n FilterSelectedKey,\n FilterSubmitResetCounterKey,\n} from '@/domains/filters/utils/filterInjectionKeys'\nimport FiltersSelected from '@/domains/filters/components/FiltersSelected.vue'\nimport type { ValueObjectOption } from '@/shared/types/ValueObject'\nimport { isBoolean, isDefined, isUndefined } from '@/shared/utils/common'\nimport { type FilterStoreIdentifier, useFilterClearHelpers } from '@/domains/filters/composables/filterFactory'\nimport { datatableSlotName } from '@/domains/filters/datatable/utils/datatable'\nimport FilterDetailItem from '@/domains/filters/components/FilterDetailItem.vue'\nimport AFilterBookmarkButton from '@/domains/filters/bookmarks/components/AFilterBookmarkButton.vue'\nimport FilterBookmarks from '@/domains/filters/bookmarks/components/FilterBookmarks.vue'\nimport type { IntegerIdNullable } from '@/shared/types/common'\nimport { type AxiosInstance } from 'axios'\nimport { VSlideYTransition } from 'vuetify/components/transitions'\n\nconst props = withDefaults(\n defineProps<{\n enableTop?: boolean\n hideButtons?: boolean\n formName?: string\n userId?: IntegerIdNullable | undefined\n client?: (() => AxiosInstance) | undefined\n store?: FilterStoreIdentifier | boolean // false to disable, FilterStoreIdentifier to custom store key\n alwaysVisible?: boolean\n hideMore?: boolean\n }>(),\n {\n enableTop: false,\n hideButtons: false,\n formName: 'search',\n userId: undefined,\n client: undefined,\n store: true,\n alwaysVisible: false,\n hideMore: false,\n }\n)\nconst emit = defineEmits<{\n (e: 'submit'): void\n (e: 'reset'): void\n (e: 'bookmarkLoadAfter'): void\n}>()\n\nconst datatableHiddenColumns = defineModel<string[] | undefined>('datatableHiddenColumns', {\n default: undefined,\n required: false,\n})\nconst showDetail = defineModel<boolean>('showDetail', { default: false, required: false })\n\nconst filterConfig = inject(FilterConfigKey)\nconst filterData = inject(FilterDataKey)\nif (isUndefined(filterConfig) || isUndefined(filterData)) {\n throw new Error('Incorrect provide/inject config.')\n}\nconst identifier = ref<Partial<FilterStoreIdentifier>>({\n system: filterConfig.general.system,\n subject: filterConfig.general.subject,\n})\n// eslint-disable-next-line vue/no-setup-props-reactivity-loss\nif (!isBoolean(props.store)) {\n identifier.value.system = props.store.system\n identifier.value.subject = props.store.subject\n} else if (false === props.store) {\n identifier.value.system = undefined\n identifier.value.subject = undefined\n}\nconst submitResetCounter = ref(0)\nprovide(FilterSubmitResetCounterKey, submitResetCounter)\nconst filterSelected = ref<Map<string, ValueObjectOption<string | number>[]>>(new Map())\nprovide(FilterSelectedKey, filterSelected)\n\nconst submitFilter = () => {\n submitResetCounter.value++\n nextTick(() => {\n emit('submit')\n })\n}\n\nconst { clearAll, clearAllFilterSelected } = useFilterClearHelpers()\n\nconst submitFilterBookmark = () => {\n nextTick(() => {\n submitResetCounter.value++\n emit('bookmarkLoadAfter')\n })\n}\n\nconst resetFilter = () => {\n clearAll(filterData, filterConfig)\n clearAllFilterSelected(filterData, filterConfig, filterSelected)\n nextTick(() => {\n submitResetCounter.value++\n emit('reset')\n })\n}\n\nconst toggleFilterDetail = () => {\n showDetail.value = !showDetail.value\n}\n\nconst touched = computed(() => {\n return filterConfig.touched\n})\n\n// Names of `filterConfig.fields` itself, so the template's lookups by them always hit.\nconst renderedFieldNames = computed(() => {\n return Object.entries(filterConfig.fields)\n .filter(([, field]) => !field.render.skip)\n .map(([fieldName]) => fieldName)\n})\n\nif (props.alwaysVisible) {\n showDetail.value = true\n}\n\ndefineExpose({\n submit: submitFilter,\n reset: resetFilter,\n})\n</script>\n\n<template>\n <VForm\n :name=\"formName\"\n @submit.prevent=\"submitFilter\"\n >\n <VRow\n v-if=\"enableTop\"\n density=\"compact\"\n >\n <VCol>\n <slot name=\"top\" />\n </VCol>\n </VRow>\n <VRow density=\"compact\">\n <VCol v-if=\"store && userId && isDefined(client)\">\n <slot name=\"bookmarks\">\n <div class=\"d-flex flex-wrap align-center\">\n <FilterBookmarks\n v-if=\"identifier.system && identifier.subject && userId && isDefined(client)\"\n v-model:datatable-hidden-columns=\"datatableHiddenColumns\"\n :client=\"client\"\n :system=\"identifier.system\"\n :subject=\"identifier.subject\"\n :user-id=\"userId\"\n @submit=\"submitFilterBookmark\"\n />\n </div>\n </slot>\n </VCol>\n </VRow>\n <VRow\n v-if=\"!alwaysVisible\"\n density=\"compact\"\n >\n <VCol\n v-if=\"!hideMore\"\n cols=\"auto\"\n >\n <AFilterAdvancedButton\n :button-active=\"showDetail\"\n @advanced-filter=\"toggleFilterDetail\"\n />\n </VCol>\n <VCol>\n <div class=\"a-filter__container\">\n <div class=\"a-filter__search\">\n <slot name=\"search\" />\n </div>\n <FiltersSelected />\n </div>\n </VCol>\n <VCol\n v-if=\"!hideButtons\"\n class=\"text-right\"\n cols=\"auto\"\n >\n <slot name=\"buttons\">\n <AFilterSubmitButton :touched=\"touched\" />\n <AFilterResetButton @reset=\"resetFilter\" />\n <AFilterBookmarkButton\n v-if=\"identifier.system && identifier.subject && userId && isDefined(client)\"\n :client=\"client\"\n :user=\"userId\"\n :system=\"identifier.system\"\n :subject=\"identifier.subject\"\n :datatable-hidden-columns=\"datatableHiddenColumns\"\n />\n </slot>\n </VCol>\n </VRow>\n <div v-if=\"!hideMore\">\n <component :is=\"alwaysVisible ? 'div' : VSlideYTransition\">\n <div\n v-show=\"showDetail\"\n :class=\"{ 'mt-6 pa-4 border': !alwaysVisible }\"\n >\n <slot name=\"detail\">\n <VRow>\n <VCol\n v-for=\"fieldName in renderedFieldNames\"\n :key=\"fieldName\"\n :cols=\"filterConfig.fields[fieldName]!.render.xs || 12\"\n :sm=\"filterConfig.fields[fieldName]!.render.sm || 6\"\n :md=\"filterConfig.fields[fieldName]!.render.md || 4\"\n :lg=\"filterConfig.fields[fieldName]!.render.lg || 3\"\n :xl=\"filterConfig.fields[fieldName]!.render.xl || 2\"\n >\n <slot\n :name=\"datatableSlotName(fieldName)\"\n :item-config=\"filterConfig.fields[fieldName]!\"\n >\n <FilterDetailItem :name=\"fieldName\" />\n </slot>\n </VCol>\n </VRow>\n </slot>\n </div>\n </component>\n </div>\n </VForm>\n</template>\n\n<style lang=\"scss\">\n@use 'vuetify/tools' as *;\n\n.a-filter {\n &__container {\n width: 100%;\n }\n\n &__search {\n display: inline-flex;\n vertical-align: middle;\n min-width: 100%;\n height: 34px;\n\n @include media-breakpoint-up(sm) {\n min-width: 50%;\n margin-right: 8px;\n }\n\n @include media-breakpoint-up(md) {\n min-width: 40%;\n }\n\n @include media-breakpoint-up(lg) {\n min-width: 30%;\n }\n }\n}\n</style>\n"],"mappings":""}
|
package/dist/domains/filters/components/AFilterWrapper.vue_vue_type_script_setup_true_lang.js
CHANGED
|
@@ -7,16 +7,17 @@ import { datatableSlotName as l } from "../datatable/utils/datatable.js";
|
|
|
7
7
|
import u from "./FilterDetailItem.js";
|
|
8
8
|
import d from "../bookmarks/components/FilterBookmarks.js";
|
|
9
9
|
import f from "../bookmarks/components/AFilterBookmarkButton.js";
|
|
10
|
-
import
|
|
11
|
-
import
|
|
12
|
-
import { Fragment as
|
|
13
|
-
import { VCol as
|
|
10
|
+
import te from "./AFilterAdvancedButton.js";
|
|
11
|
+
import ne from "./FiltersSelected.js";
|
|
12
|
+
import { Fragment as re, computed as p, createBlock as m, createCommentVNode as h, createElementBlock as g, createElementVNode as _, createVNode as v, defineComponent as y, inject as b, mergeModels as x, nextTick as S, normalizeClass as C, openBlock as w, provide as T, ref as E, renderList as ie, renderSlot as D, resolveDynamicComponent as ae, unref as O, useModel as k, vShow as A, withCtx as j, withDirectives as M, withModifiers as N } from "vue";
|
|
13
|
+
import { VCol as P, VRow as F } from "vuetify/components/VGrid";
|
|
14
14
|
import "axios";
|
|
15
|
+
import { VSlideYTransition as I } from "vuetify/components/transitions";
|
|
15
16
|
import { VForm as L } from "vuetify/components/VForm";
|
|
16
17
|
// src/domains/filters/components/AFilterWrapper.vue?vue&type=script&setup=true&lang.ts
|
|
17
|
-
var R = { class: "d-flex flex-wrap align-center" }, z = { class: "a-filter__container" }, B = { class: "a-filter__search" }, V = { key: 2 }, H = /*@__PURE__*/
|
|
18
|
+
var R = { class: "d-flex flex-wrap align-center" }, z = { class: "a-filter__container" }, B = { class: "a-filter__search" }, V = { key: 2 }, H = /*@__PURE__*/ y({
|
|
18
19
|
__name: "AFilterWrapper",
|
|
19
|
-
props: /*@__PURE__*/
|
|
20
|
+
props: /*@__PURE__*/ x({
|
|
20
21
|
enableTop: {
|
|
21
22
|
type: Boolean,
|
|
22
23
|
default: !1
|
|
@@ -56,109 +57,109 @@ var R = { class: "d-flex flex-wrap align-center" }, z = { class: "a-filter__cont
|
|
|
56
57
|
},
|
|
57
58
|
showDetailModifiers: {}
|
|
58
59
|
}),
|
|
59
|
-
emits: /*@__PURE__*/
|
|
60
|
+
emits: /*@__PURE__*/ x([
|
|
60
61
|
"submit",
|
|
61
62
|
"reset",
|
|
62
63
|
"bookmarkLoadAfter"
|
|
63
64
|
], ["update:datatableHiddenColumns", "update:showDetail"]),
|
|
64
|
-
setup(
|
|
65
|
-
let U =
|
|
65
|
+
setup(y, { expose: x, emit: H }) {
|
|
66
|
+
let U = y, W = H, G = k(y, "datatableHiddenColumns"), K = k(y, "showDetail"), q = b(ee), J = b(r);
|
|
66
67
|
if (n(q) || n(J)) throw Error("Incorrect provide/inject config.");
|
|
67
|
-
let Y =
|
|
68
|
+
let Y = E({
|
|
68
69
|
system: q.general.system,
|
|
69
70
|
subject: q.general.subject
|
|
70
71
|
});
|
|
71
72
|
e(U.store) ? !1 === U.store && (Y.value.system = void 0, Y.value.subject = void 0) : (Y.value.system = U.store.system, Y.value.subject = U.store.subject);
|
|
72
|
-
let X =
|
|
73
|
-
|
|
74
|
-
let Z =
|
|
75
|
-
|
|
73
|
+
let X = E(0);
|
|
74
|
+
T(a, X);
|
|
75
|
+
let Z = E(/* @__PURE__ */ new Map());
|
|
76
|
+
T(i, Z);
|
|
76
77
|
let Q = () => {
|
|
77
|
-
X.value++,
|
|
78
|
+
X.value++, S(() => {
|
|
78
79
|
W("submit");
|
|
79
80
|
});
|
|
80
|
-
}, { clearAll:
|
|
81
|
-
|
|
81
|
+
}, { clearAll: oe, clearAllFilterSelected: se } = o(), ce = () => {
|
|
82
|
+
S(() => {
|
|
82
83
|
X.value++, W("bookmarkLoadAfter");
|
|
83
84
|
});
|
|
84
85
|
}, $ = () => {
|
|
85
|
-
|
|
86
|
+
oe(J, q), se(J, q, Z), S(() => {
|
|
86
87
|
X.value++, W("reset");
|
|
87
88
|
});
|
|
88
|
-
},
|
|
89
|
+
}, le = () => {
|
|
89
90
|
K.value = !K.value;
|
|
90
|
-
},
|
|
91
|
-
return U.alwaysVisible && (K.value = !0),
|
|
91
|
+
}, ue = p(() => q.touched), de = p(() => Object.entries(q.fields).filter(([, e]) => !e.render.skip).map(([e]) => e));
|
|
92
|
+
return U.alwaysVisible && (K.value = !0), x({
|
|
92
93
|
submit: Q,
|
|
93
94
|
reset: $
|
|
94
|
-
}), (e, n) => (
|
|
95
|
-
name:
|
|
96
|
-
onSubmit:
|
|
95
|
+
}), (e, n) => (w(), m(L, {
|
|
96
|
+
name: y.formName,
|
|
97
|
+
onSubmit: N(Q, ["prevent"])
|
|
97
98
|
}, {
|
|
98
|
-
default:
|
|
99
|
-
|
|
99
|
+
default: j(() => [
|
|
100
|
+
y.enableTop ? (w(), m(F, {
|
|
100
101
|
key: 0,
|
|
101
102
|
density: "compact"
|
|
102
103
|
}, {
|
|
103
|
-
default:
|
|
104
|
-
default:
|
|
104
|
+
default: j(() => [v(P, null, {
|
|
105
|
+
default: j(() => [D(e.$slots, "top")]),
|
|
105
106
|
_: 3
|
|
106
107
|
})]),
|
|
107
108
|
_: 3
|
|
108
|
-
})) :
|
|
109
|
-
|
|
110
|
-
default:
|
|
111
|
-
default:
|
|
109
|
+
})) : h("", !0),
|
|
110
|
+
v(F, { density: "compact" }, {
|
|
111
|
+
default: j(() => [y.store && y.userId && O(t)(y.client) ? (w(), m(P, { key: 0 }, {
|
|
112
|
+
default: j(() => [D(e.$slots, "bookmarks", {}, () => [_("div", R, [Y.value.system && Y.value.subject && y.userId && O(t)(y.client) ? (w(), m(d, {
|
|
112
113
|
key: 0,
|
|
113
114
|
"datatable-hidden-columns": G.value,
|
|
114
115
|
"onUpdate:datatableHiddenColumns": n[0] ||= (e) => G.value = e,
|
|
115
|
-
client:
|
|
116
|
+
client: y.client,
|
|
116
117
|
system: Y.value.system,
|
|
117
118
|
subject: Y.value.subject,
|
|
118
|
-
"user-id":
|
|
119
|
-
onSubmit:
|
|
119
|
+
"user-id": y.userId,
|
|
120
|
+
onSubmit: ce
|
|
120
121
|
}, null, 8, [
|
|
121
122
|
"datatable-hidden-columns",
|
|
122
123
|
"client",
|
|
123
124
|
"system",
|
|
124
125
|
"subject",
|
|
125
126
|
"user-id"
|
|
126
|
-
])) :
|
|
127
|
+
])) : h("", !0)])])]),
|
|
127
128
|
_: 3
|
|
128
|
-
})) :
|
|
129
|
+
})) : h("", !0)]),
|
|
129
130
|
_: 3
|
|
130
131
|
}),
|
|
131
|
-
|
|
132
|
+
y.alwaysVisible ? h("", !0) : (w(), m(F, {
|
|
132
133
|
key: 1,
|
|
133
134
|
density: "compact"
|
|
134
135
|
}, {
|
|
135
|
-
default:
|
|
136
|
-
|
|
136
|
+
default: j(() => [
|
|
137
|
+
y.hideMore ? h("", !0) : (w(), m(P, {
|
|
137
138
|
key: 0,
|
|
138
139
|
cols: "auto"
|
|
139
140
|
}, {
|
|
140
|
-
default:
|
|
141
|
+
default: j(() => [v(te, {
|
|
141
142
|
"button-active": K.value,
|
|
142
|
-
onAdvancedFilter:
|
|
143
|
+
onAdvancedFilter: le
|
|
143
144
|
}, null, 8, ["button-active"])]),
|
|
144
145
|
_: 1
|
|
145
146
|
})),
|
|
146
|
-
|
|
147
|
-
default:
|
|
147
|
+
v(P, null, {
|
|
148
|
+
default: j(() => [_("div", z, [_("div", B, [D(e.$slots, "search")]), v(ne)])]),
|
|
148
149
|
_: 3
|
|
149
150
|
}),
|
|
150
|
-
|
|
151
|
+
y.hideButtons ? h("", !0) : (w(), m(P, {
|
|
151
152
|
key: 1,
|
|
152
153
|
class: "text-right",
|
|
153
154
|
cols: "auto"
|
|
154
155
|
}, {
|
|
155
|
-
default:
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
Y.value.system && Y.value.subject &&
|
|
156
|
+
default: j(() => [D(e.$slots, "buttons", {}, () => [
|
|
157
|
+
v(s, { touched: ue.value }, null, 8, ["touched"]),
|
|
158
|
+
v(c, { onReset: $ }),
|
|
159
|
+
Y.value.system && Y.value.subject && y.userId && O(t)(y.client) ? (w(), m(f, {
|
|
159
160
|
key: 0,
|
|
160
|
-
client:
|
|
161
|
-
user:
|
|
161
|
+
client: y.client,
|
|
162
|
+
user: y.userId,
|
|
162
163
|
system: Y.value.system,
|
|
163
164
|
subject: Y.value.subject,
|
|
164
165
|
"datatable-hidden-columns": G.value
|
|
@@ -168,24 +169,24 @@ var R = { class: "d-flex flex-wrap align-center" }, z = { class: "a-filter__cont
|
|
|
168
169
|
"system",
|
|
169
170
|
"subject",
|
|
170
171
|
"datatable-hidden-columns"
|
|
171
|
-
])) :
|
|
172
|
+
])) : h("", !0)
|
|
172
173
|
])]),
|
|
173
174
|
_: 3
|
|
174
175
|
}))
|
|
175
176
|
]),
|
|
176
177
|
_: 3
|
|
177
178
|
})),
|
|
178
|
-
|
|
179
|
-
default:
|
|
180
|
-
default:
|
|
179
|
+
y.hideMore ? h("", !0) : (w(), g("div", V, [(w(), m(ae(y.alwaysVisible ? "div" : O(I)), null, {
|
|
180
|
+
default: j(() => [M(_("div", { class: C({ "mt-6 pa-4 border": !y.alwaysVisible }) }, [D(e.$slots, "detail", {}, () => [v(F, null, {
|
|
181
|
+
default: j(() => [(w(!0), g(re, null, ie(de.value, (t) => (w(), m(P, {
|
|
181
182
|
key: t,
|
|
182
|
-
cols:
|
|
183
|
-
sm:
|
|
184
|
-
md:
|
|
185
|
-
lg:
|
|
186
|
-
xl:
|
|
183
|
+
cols: O(q).fields[t].render.xs || 12,
|
|
184
|
+
sm: O(q).fields[t].render.sm || 6,
|
|
185
|
+
md: O(q).fields[t].render.md || 4,
|
|
186
|
+
lg: O(q).fields[t].render.lg || 3,
|
|
187
|
+
xl: O(q).fields[t].render.xl || 2
|
|
187
188
|
}, {
|
|
188
|
-
default:
|
|
189
|
+
default: j(() => [D(e.$slots, O(l)(t), { itemConfig: O(q).fields[t] }, () => [v(u, { name: t }, null, 8, ["name"])])]),
|
|
189
190
|
_: 2
|
|
190
191
|
}, 1032, [
|
|
191
192
|
"cols",
|
|
@@ -195,7 +196,7 @@ var R = { class: "d-flex flex-wrap align-center" }, z = { class: "a-filter__cont
|
|
|
195
196
|
"xl"
|
|
196
197
|
]))), 128))]),
|
|
197
198
|
_: 3
|
|
198
|
-
})])], 2), [[
|
|
199
|
+
})])], 2), [[A, K.value]])]),
|
|
199
200
|
_: 3
|
|
200
201
|
}))]))
|
|
201
202
|
]),
|
package/dist/domains/filters/components/AFilterWrapper.vue_vue_type_script_setup_true_lang.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AFilterWrapper.vue_vue_type_script_setup_true_lang.js","names":["_useModel","_openBlock","_createBlock","_component_VForm","_withModifiers","_withCtx","_component_VRow","_createVNode","_component_VCol","_renderSlot","_createCommentVNode","_unref","_createElementVNode","FilterBookmarks","AFilterAdvancedButton","FiltersSelected","AFilterSubmitButton","AFilterResetButton","AFilterBookmarkButton","_createElementBlock","_resolveDynamicComponent","_withDirectives","_normalizeClass","_Fragment","_renderList","FilterDetailItem","_vShow","AFilterSubmitButton_default","AFilterResetButton_default","FilterDetailItem_default","FilterBookmarks_default","AFilterBookmarkButton_default","AFilterAdvancedButton_default","FiltersSelected_default","_hoisted_1","_hoisted_2","_hoisted_3","_hoisted_4","AFilterWrapper_vue_vue_type_script_setup_true_lang_default","\"datatableHiddenColumns\"","\"datatableHiddenColumnsModifiers\"","\"showDetail\"","\"showDetailModifiers\"","__expose","__emit","props","__props","emit","datatableHiddenColumns","useModel","showDetail","filterConfig","inject","FilterConfigKey","filterData","FilterDataKey","isUndefined","identifier","ref","isBoolean","submitResetCounter","FilterSubmitResetCounterKey","filterSelected","FilterSelectedKey","submitFilter","nextTick","useFilterClearHelpers","submitFilterBookmark","resetFilter","clearAll","clearAllFilterSelected","toggleFilterDetail","touched","computed","renderedFieldNames","field","fieldName","openBlock","createBlock","VForm","withModifiers","withCtx","VRow","createVNode","VCol","renderSlot","_ctx","createCommentVNode","unref","isDefined","createElementVNode","_cache","$event","createElementBlock","resolveDynamicComponent","withDirectives","normalizeClass","Fragment","renderList","datatableSlotName","vShow"],"sources":["../../../../src/domains/filters/components/AFilterWrapper.vue"],"sourcesContent":["<script lang=\"ts\" setup>\nimport AFilterAdvancedButton from '@/domains/filters/components/AFilterAdvancedButton.vue'\nimport AFilterSubmitButton from '@/domains/filters/components/AFilterSubmitButton.vue'\nimport AFilterResetButton from '@/domains/filters/components/AFilterResetButton.vue'\nimport { computed, inject, nextTick, provide, ref } from 'vue'\nimport {\n FilterConfigKey,\n FilterDataKey,\n FilterSelectedKey,\n FilterSubmitResetCounterKey,\n} from '@/domains/filters/utils/filterInjectionKeys'\nimport FiltersSelected from '@/domains/filters/components/FiltersSelected.vue'\nimport type { ValueObjectOption } from '@/shared/types/ValueObject'\nimport { isBoolean, isDefined, isUndefined } from '@/shared/utils/common'\nimport { type FilterStoreIdentifier, useFilterClearHelpers } from '@/domains/filters/composables/filterFactory'\nimport { datatableSlotName } from '@/domains/filters/datatable/utils/datatable'\nimport FilterDetailItem from '@/domains/filters/components/FilterDetailItem.vue'\nimport AFilterBookmarkButton from '@/domains/filters/bookmarks/components/AFilterBookmarkButton.vue'\nimport FilterBookmarks from '@/domains/filters/bookmarks/components/FilterBookmarks.vue'\nimport type { IntegerIdNullable } from '@/shared/types/common'\nimport { type AxiosInstance } from 'axios'\n\nconst props = withDefaults(\n defineProps<{\n enableTop?: boolean\n hideButtons?: boolean\n formName?: string\n userId?: IntegerIdNullable | undefined\n client?: (() => AxiosInstance) | undefined\n store?: FilterStoreIdentifier | boolean // false to disable, FilterStoreIdentifier to custom store key\n alwaysVisible?: boolean\n hideMore?: boolean\n }>(),\n {\n enableTop: false,\n hideButtons: false,\n formName: 'search',\n userId: undefined,\n client: undefined,\n store: true,\n alwaysVisible: false,\n hideMore: false,\n }\n)\nconst emit = defineEmits<{\n (e: 'submit'): void\n (e: 'reset'): void\n (e: 'bookmarkLoadAfter'): void\n}>()\n\nconst datatableHiddenColumns = defineModel<string[] | undefined>('datatableHiddenColumns', {\n default: undefined,\n required: false,\n})\nconst showDetail = defineModel<boolean>('showDetail', { default: false, required: false })\n\nconst filterConfig = inject(FilterConfigKey)\nconst filterData = inject(FilterDataKey)\nif (isUndefined(filterConfig) || isUndefined(filterData)) {\n throw new Error('Incorrect provide/inject config.')\n}\nconst identifier = ref<Partial<FilterStoreIdentifier>>({\n system: filterConfig.general.system,\n subject: filterConfig.general.subject,\n})\n// eslint-disable-next-line vue/no-setup-props-reactivity-loss\nif (!isBoolean(props.store)) {\n identifier.value.system = props.store.system\n identifier.value.subject = props.store.subject\n} else if (false === props.store) {\n identifier.value.system = undefined\n identifier.value.subject = undefined\n}\nconst submitResetCounter = ref(0)\nprovide(FilterSubmitResetCounterKey, submitResetCounter)\nconst filterSelected = ref<Map<string, ValueObjectOption<string | number>[]>>(new Map())\nprovide(FilterSelectedKey, filterSelected)\n\nconst submitFilter = () => {\n submitResetCounter.value++\n nextTick(() => {\n emit('submit')\n })\n}\n\nconst { clearAll, clearAllFilterSelected } = useFilterClearHelpers()\n\nconst submitFilterBookmark = () => {\n nextTick(() => {\n submitResetCounter.value++\n emit('bookmarkLoadAfter')\n })\n}\n\nconst resetFilter = () => {\n clearAll(filterData, filterConfig)\n clearAllFilterSelected(filterData, filterConfig, filterSelected)\n nextTick(() => {\n submitResetCounter.value++\n emit('reset')\n })\n}\n\nconst toggleFilterDetail = () => {\n showDetail.value = !showDetail.value\n}\n\nconst touched = computed(() => {\n return filterConfig.touched\n})\n\n// Names of `filterConfig.fields` itself, so the template's lookups by them always hit.\nconst renderedFieldNames = computed(() => {\n return Object.entries(filterConfig.fields)\n .filter(([, field]) => !field.render.skip)\n .map(([fieldName]) => fieldName)\n})\n\nif (props.alwaysVisible) {\n showDetail.value = true\n}\n\ndefineExpose({\n submit: submitFilter,\n reset: resetFilter,\n})\n</script>\n\n<template>\n <VForm\n :name=\"formName\"\n @submit.prevent=\"submitFilter\"\n >\n <VRow\n v-if=\"enableTop\"\n density=\"compact\"\n >\n <VCol>\n <slot name=\"top\" />\n </VCol>\n </VRow>\n <VRow density=\"compact\">\n <VCol v-if=\"store && userId && isDefined(client)\">\n <slot name=\"bookmarks\">\n <div class=\"d-flex flex-wrap align-center\">\n <FilterBookmarks\n v-if=\"identifier.system && identifier.subject && userId && isDefined(client)\"\n v-model:datatable-hidden-columns=\"datatableHiddenColumns\"\n :client=\"client\"\n :system=\"identifier.system\"\n :subject=\"identifier.subject\"\n :user-id=\"userId\"\n @submit=\"submitFilterBookmark\"\n />\n </div>\n </slot>\n </VCol>\n </VRow>\n <VRow\n v-if=\"!alwaysVisible\"\n density=\"compact\"\n >\n <VCol\n v-if=\"!hideMore\"\n cols=\"auto\"\n >\n <AFilterAdvancedButton\n :button-active=\"showDetail\"\n @advanced-filter=\"toggleFilterDetail\"\n />\n </VCol>\n <VCol>\n <div class=\"a-filter__container\">\n <div class=\"a-filter__search\">\n <slot name=\"search\" />\n </div>\n <FiltersSelected />\n </div>\n </VCol>\n <VCol\n v-if=\"!hideButtons\"\n class=\"text-right\"\n cols=\"auto\"\n >\n <slot name=\"buttons\">\n <AFilterSubmitButton :touched=\"touched\" />\n <AFilterResetButton @reset=\"resetFilter\" />\n <AFilterBookmarkButton\n v-if=\"identifier.system && identifier.subject && userId && isDefined(client)\"\n :client=\"client\"\n :user=\"userId\"\n :system=\"identifier.system\"\n :subject=\"identifier.subject\"\n :datatable-hidden-columns=\"datatableHiddenColumns\"\n />\n </slot>\n </VCol>\n </VRow>\n <div v-if=\"!hideMore\">\n <component :is=\"alwaysVisible ? 'div' : 'VSlideYTransition'\">\n <div\n v-show=\"showDetail\"\n :class=\"{ 'mt-6 pa-4 border': !alwaysVisible }\"\n >\n <slot name=\"detail\">\n <VRow>\n <VCol\n v-for=\"fieldName in renderedFieldNames\"\n :key=\"fieldName\"\n :cols=\"filterConfig.fields[fieldName]!.render.xs || 12\"\n :sm=\"filterConfig.fields[fieldName]!.render.sm || 6\"\n :md=\"filterConfig.fields[fieldName]!.render.md || 4\"\n :lg=\"filterConfig.fields[fieldName]!.render.lg || 3\"\n :xl=\"filterConfig.fields[fieldName]!.render.xl || 2\"\n >\n <slot\n :name=\"datatableSlotName(fieldName)\"\n :item-config=\"filterConfig.fields[fieldName]!\"\n >\n <FilterDetailItem :name=\"fieldName\" />\n </slot>\n </VCol>\n </VRow>\n </slot>\n </div>\n </component>\n </div>\n </VForm>\n</template>\n\n<style lang=\"scss\">\n@use 'vuetify/tools' as *;\n\n.a-filter {\n &__container {\n width: 100%;\n }\n\n &__search {\n display: inline-flex;\n vertical-align: middle;\n min-width: 100%;\n height: 34px;\n\n @include media-breakpoint-up(sm) {\n min-width: 50%;\n margin-right: 8px;\n }\n\n @include media-breakpoint-up(md) {\n min-width: 40%;\n }\n\n @include media-breakpoint-up(lg) {\n min-width: 30%;\n }\n }\n}\n</style>\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAsBA,IAAM6C,IAAQC,GAsBRC,IAAOH,GAMPI,IAAyBhD,EAAiC8C,GAAC,wBAGhE,GACKI,IAAalD,EAAoB8C,GAAC,YAAiD,GAEnFK,IAAeC,EAAOC,EAAe,GACrCC,IAAaF,EAAOG,CAAa;EACvC,IAAIC,EAAYL,CAAY,KAAKK,EAAYF,CAAU,GACrD,MAAU,MAAM,kCAAkC;EAEpD,IAAMG,IAAaC,EAAoC;GACrD,QAAQP,EAAa,QAAQ;GAC7B,SAASA,EAAa,QAAQ;EAChC,CAAC;EAED,AAAKQ,EAAUd,EAAM,KAAK,IAGf,OAAUA,EAAM,UACzB,EAAW,MAAM,SAAS,KAAA,GAC1B,EAAW,MAAM,UAAU,KAAA,MAJ3B,EAAW,MAAM,SAASA,EAAM,MAAM,QACtC,EAAW,MAAM,UAAUA,EAAM,MAAM;EAKzC,IAAMe,IAAqBF,EAAI,CAAC;EAChC,EAAQG,GAA6BD,CAAkB;EACvD,IAAME,IAAiBJ,kBAAuD,IAAI,IAAI,CAAC;EACvF,EAAQK,GAAmBD,CAAc;EAEzC,IAAME,UAAqB;GAEzB,AADA,EAAmB,SACnBC,QAAe;IACb,EAAK,QAAQ;GACf,CAAC;EACH,GAEM,EAAE,cAAU,+BAA2BC,EAAsB,GAE7DC,WAA6B;GACjC,QAAe;IAEb,AADA,EAAmB,SACnBpB,EAAK,mBAAmB;GAC1B,CAAC;EACH,GAEMqB,UAAoB;GAGxB,AAFAC,GAASf,GAAYH,CAAY,GACjCmB,GAAuBhB,GAAYH,GAAcW,CAAc,GAC/DG,QAAe;IAEb,AADA,EAAmB,SACnBlB,EAAK,OAAO;GACd,CAAC;EACH,GAEMwB,WAA2B;GAC/B,EAAW,QAAQ,CAACrB,EAAW;EACjC,GAEMsB,KAAUC,QACPtB,EAAa,OACrB,GAGKuB,KAAqBD,QAClB,OAAO,QAAQtB,EAAa,MAAM,CAAA,CACtC,QAAQ,GAAGwB,OAAW,CAACA,EAAM,OAAO,IAAI,CAAA,CACxC,KAAK,CAACC,OAAeA,CAAS,CAClC;E,OAEG/B,EAAM,kBACR,EAAW,QAAQ,KAGrBF,EAAa;GACX,QAAQqB;GACR,OAAOI;EACT,CAAC,I,UAICnE,EAAA,GAAAC,EAkGQC,GAAA;GAjGL,MAAM2C,EAAA;GACN,UAAM1C,EAAU4D,GAAY,CAAA,SAAA,CAAA;;GAE7B,SAAA3D,QAOO;IANCyC,EAAA,aADR7C,EAAA,GAAAC,EAOOI,GAAA;;KALL,SAAQ;;KAER,SAAAD,QAEO,CAFPE,EAEOC,GAAA,MAAA;MADL,SAAAH,QAAmB,CAAnBI,EAAmB6E,EAAA,QAAA,KAAA,C;;;;;IAGvB/E,EAgBOD,GAAA,EAhBD,SAAQ,UAAS,GAAA;KACrB,SAAAD,QAcO,CAdKyC,EAAA,SAASA,EAAA,UAAUnC,EAAA8E,CAAA,CAAS,CAAC3C,EAAA,MAAM,KAA/C7C,EAAA,GAAAC,EAcOM,GAAA,EAAA,KAAA,EAAA,GAAA;MAbL,SAAAH,QAYO,CAZPI,EAYO6E,EAAA,QAAA,aAAA,CAAA,SAAA,CAXL1E,EAUM,OAVNsB,GAUM,CARIuB,EAAA,MAAW,UAAUA,EAAA,MAAW,WAAWX,EAAA,UAAUnC,EAAA8E,CAAA,CAAS,CAAC3C,EAAA,MAAM,KAD7E7C,EAAA,GAAAC,EAQEW,GAAA;;OANQ,4BAA0BmC,EAAA;OAAA,mCAAA,AAAA2C,EAAA,QAAA,MAAA,EAAsB,QAAAC;OACvD,QAAQ9C,EAAA;OACR,QAAQW,EAAA,MAAW;OACnB,SAASA,EAAA,MAAW;OACpB,WAASX,EAAA;OACT,UAAQqB;;;;;;;;;;;;IAOVrB,EAAA,gB,aADT7C,EAAA,GAAAC,EAuCOI,GAAA;;KArCL,SAAQ;;KAER,SAAAD,QAQO;MAPEyC,EAAA,W,aADT7C,EAAA,GAAAC,EAQOM,GAAA;;OANL,MAAK;;OAEL,SAAAH,QAGE,CAHFE,EAGEO,GAAA;QAFC,iBAAeoC,EAAA;QACf,kBAAiBqB;;;;MAGtBhE,EAOOC,GAAA,MAAA;OANL,SAAAH,QAKM,CALNO,EAKM,OALNuB,GAKM,CAJJvB,EAEM,OAFNwB,GAEM,CADJ3B,EAAsB6E,EAAA,QAAA,QAAA,C,IAExB/E,EAAmBQ,EAAA,C;;;MAId+B,EAAA,c,aADT7C,EAAA,GAAAC,EAiBOM,GAAA;;OAfL,OAAM;OACN,MAAK;;OAEL,SAAAH,QAWO,CAXPI,EAWO6E,EAAA,QAAA,WAAA,CAAA,SAAA;QAVL/E,EAA0CS,GAAA,EAApB,SAASwD,GAAA,MAAO,GAAA,MAAA,GAAA,CAAA,SAAA,CAAA;QACtCjE,EAA2CU,GAAA,EAAtB,SAAOmD,EAAW,CAAA;QAE/BX,EAAA,MAAW,UAAUA,EAAA,MAAW,WAAWX,EAAA,UAAUnC,EAAA8E,CAAA,CAAS,CAAC3C,EAAA,MAAM,KAD7E7C,EAAA,GAAAC,EAOEgB,GAAA;;SALC,QAAQ4B,EAAA;SACR,MAAMA,EAAA;SACN,QAAQW,EAAA,MAAW;SACnB,SAASA,EAAA,MAAW;SACpB,4BAA0BT,EAAA;;;;;;;;;;;;;;IAKvBF,EAAA,W,aAAZ7C,EAAA,GAAAkB,EA4BM,OAAAkB,GAAA,EA3BJpC,EAAA,GAAAC,EA0BYkB,GA1BI0B,EAAA,gBAAa,QAAA,mBAAA,GAAA,MAAA;KAC3B,SAAAzC,QAwBM,CAxBNgB,EAAAT,EAwBM,OAAA,EAtBH,OAAKU,EAAA,EAAA,oBAAA,CAAyBwB,EAAA,cAAa,CAAA,E,IAE5CrC,EAmBO6E,EAAA,QAAA,UAAA,CAAA,SAAA,CAlBL/E,EAiBOD,GAAA,MAAA;MAfH,SAAAD,QAAuC,EADzCJ,EAAA,EAAA,GAAAkB,EAeOI,IAAA,MAAAC,GAdekD,GAAA,QAAb,OADTzE,EAAA,GAAAC,EAeOM,GAAA;OAbJ,KAAKoE;OACL,MAAMjE,EAAAwC,CAAA,CAAY,CAAC,OAAOyB,EAAS,CAAG,OAAO,MAAE;OAC/C,IAAIjE,EAAAwC,CAAA,CAAY,CAAC,OAAOyB,EAAS,CAAG,OAAO,MAAE;OAC7C,IAAIjE,EAAAwC,CAAA,CAAY,CAAC,OAAOyB,EAAS,CAAG,OAAO,MAAE;OAC7C,IAAIjE,EAAAwC,CAAA,CAAY,CAAC,OAAOyB,EAAS,CAAG,OAAO,MAAE;OAC7C,IAAIjE,EAAAwC,CAAA,CAAY,CAAC,OAAOyB,EAAS,CAAG,OAAO,MAAE;;OAE9C,SAAAvE,QAKO,CALPI,EAKO6E,EAAA,QAJE3E,EAAAwF,CAAA,CAAiB,CAACvB,CAAS,GAAA,EACjC,YAAajE,EAAAwC,CAAA,CAAY,CAAC,OAAOyB,GAFpC,SAKO,CADLrE,EAAsCkB,GAAA,EAAnB,MAAMmD,EAAS,GAAA,MAAA,GAAA,CAAA,MAAA,CAAA,C;;;;;;;;;;K,YAlBlC,CAAAlD,GAAAwB,EAAA,KAAU,C"}
|
|
1
|
+
{"version":3,"file":"AFilterWrapper.vue_vue_type_script_setup_true_lang.js","names":["_useModel","_openBlock","_createBlock","_component_VForm","_withModifiers","_withCtx","_component_VRow","_createVNode","_component_VCol","_renderSlot","_createCommentVNode","_unref","_createElementVNode","FilterBookmarks","AFilterAdvancedButton","FiltersSelected","AFilterSubmitButton","AFilterResetButton","AFilterBookmarkButton","_createElementBlock","_resolveDynamicComponent","_withDirectives","_normalizeClass","_Fragment","_renderList","FilterDetailItem","_vShow","AFilterSubmitButton_default","AFilterResetButton_default","FilterDetailItem_default","FilterBookmarks_default","AFilterBookmarkButton_default","AFilterAdvancedButton_default","FiltersSelected_default","_hoisted_1","_hoisted_2","_hoisted_3","_hoisted_4","AFilterWrapper_vue_vue_type_script_setup_true_lang_default","\"datatableHiddenColumns\"","\"datatableHiddenColumnsModifiers\"","\"showDetail\"","\"showDetailModifiers\"","__expose","__emit","props","__props","emit","datatableHiddenColumns","useModel","showDetail","filterConfig","inject","FilterConfigKey","filterData","FilterDataKey","isUndefined","identifier","ref","isBoolean","submitResetCounter","FilterSubmitResetCounterKey","filterSelected","FilterSelectedKey","submitFilter","nextTick","useFilterClearHelpers","submitFilterBookmark","resetFilter","clearAll","clearAllFilterSelected","toggleFilterDetail","touched","computed","renderedFieldNames","field","fieldName","openBlock","createBlock","VForm","withModifiers","withCtx","VRow","createVNode","VCol","renderSlot","_ctx","createCommentVNode","unref","isDefined","createElementVNode","_cache","$event","createElementBlock","resolveDynamicComponent","VSlideYTransition","withDirectives","normalizeClass","Fragment","renderList","datatableSlotName","vShow"],"sources":["../../../../src/domains/filters/components/AFilterWrapper.vue"],"sourcesContent":["<script lang=\"ts\" setup>\nimport AFilterAdvancedButton from '@/domains/filters/components/AFilterAdvancedButton.vue'\nimport AFilterSubmitButton from '@/domains/filters/components/AFilterSubmitButton.vue'\nimport AFilterResetButton from '@/domains/filters/components/AFilterResetButton.vue'\nimport { computed, inject, nextTick, provide, ref } from 'vue'\nimport {\n FilterConfigKey,\n FilterDataKey,\n FilterSelectedKey,\n FilterSubmitResetCounterKey,\n} from '@/domains/filters/utils/filterInjectionKeys'\nimport FiltersSelected from '@/domains/filters/components/FiltersSelected.vue'\nimport type { ValueObjectOption } from '@/shared/types/ValueObject'\nimport { isBoolean, isDefined, isUndefined } from '@/shared/utils/common'\nimport { type FilterStoreIdentifier, useFilterClearHelpers } from '@/domains/filters/composables/filterFactory'\nimport { datatableSlotName } from '@/domains/filters/datatable/utils/datatable'\nimport FilterDetailItem from '@/domains/filters/components/FilterDetailItem.vue'\nimport AFilterBookmarkButton from '@/domains/filters/bookmarks/components/AFilterBookmarkButton.vue'\nimport FilterBookmarks from '@/domains/filters/bookmarks/components/FilterBookmarks.vue'\nimport type { IntegerIdNullable } from '@/shared/types/common'\nimport { type AxiosInstance } from 'axios'\nimport { VSlideYTransition } from 'vuetify/components/transitions'\n\nconst props = withDefaults(\n defineProps<{\n enableTop?: boolean\n hideButtons?: boolean\n formName?: string\n userId?: IntegerIdNullable | undefined\n client?: (() => AxiosInstance) | undefined\n store?: FilterStoreIdentifier | boolean // false to disable, FilterStoreIdentifier to custom store key\n alwaysVisible?: boolean\n hideMore?: boolean\n }>(),\n {\n enableTop: false,\n hideButtons: false,\n formName: 'search',\n userId: undefined,\n client: undefined,\n store: true,\n alwaysVisible: false,\n hideMore: false,\n }\n)\nconst emit = defineEmits<{\n (e: 'submit'): void\n (e: 'reset'): void\n (e: 'bookmarkLoadAfter'): void\n}>()\n\nconst datatableHiddenColumns = defineModel<string[] | undefined>('datatableHiddenColumns', {\n default: undefined,\n required: false,\n})\nconst showDetail = defineModel<boolean>('showDetail', { default: false, required: false })\n\nconst filterConfig = inject(FilterConfigKey)\nconst filterData = inject(FilterDataKey)\nif (isUndefined(filterConfig) || isUndefined(filterData)) {\n throw new Error('Incorrect provide/inject config.')\n}\nconst identifier = ref<Partial<FilterStoreIdentifier>>({\n system: filterConfig.general.system,\n subject: filterConfig.general.subject,\n})\n// eslint-disable-next-line vue/no-setup-props-reactivity-loss\nif (!isBoolean(props.store)) {\n identifier.value.system = props.store.system\n identifier.value.subject = props.store.subject\n} else if (false === props.store) {\n identifier.value.system = undefined\n identifier.value.subject = undefined\n}\nconst submitResetCounter = ref(0)\nprovide(FilterSubmitResetCounterKey, submitResetCounter)\nconst filterSelected = ref<Map<string, ValueObjectOption<string | number>[]>>(new Map())\nprovide(FilterSelectedKey, filterSelected)\n\nconst submitFilter = () => {\n submitResetCounter.value++\n nextTick(() => {\n emit('submit')\n })\n}\n\nconst { clearAll, clearAllFilterSelected } = useFilterClearHelpers()\n\nconst submitFilterBookmark = () => {\n nextTick(() => {\n submitResetCounter.value++\n emit('bookmarkLoadAfter')\n })\n}\n\nconst resetFilter = () => {\n clearAll(filterData, filterConfig)\n clearAllFilterSelected(filterData, filterConfig, filterSelected)\n nextTick(() => {\n submitResetCounter.value++\n emit('reset')\n })\n}\n\nconst toggleFilterDetail = () => {\n showDetail.value = !showDetail.value\n}\n\nconst touched = computed(() => {\n return filterConfig.touched\n})\n\n// Names of `filterConfig.fields` itself, so the template's lookups by them always hit.\nconst renderedFieldNames = computed(() => {\n return Object.entries(filterConfig.fields)\n .filter(([, field]) => !field.render.skip)\n .map(([fieldName]) => fieldName)\n})\n\nif (props.alwaysVisible) {\n showDetail.value = true\n}\n\ndefineExpose({\n submit: submitFilter,\n reset: resetFilter,\n})\n</script>\n\n<template>\n <VForm\n :name=\"formName\"\n @submit.prevent=\"submitFilter\"\n >\n <VRow\n v-if=\"enableTop\"\n density=\"compact\"\n >\n <VCol>\n <slot name=\"top\" />\n </VCol>\n </VRow>\n <VRow density=\"compact\">\n <VCol v-if=\"store && userId && isDefined(client)\">\n <slot name=\"bookmarks\">\n <div class=\"d-flex flex-wrap align-center\">\n <FilterBookmarks\n v-if=\"identifier.system && identifier.subject && userId && isDefined(client)\"\n v-model:datatable-hidden-columns=\"datatableHiddenColumns\"\n :client=\"client\"\n :system=\"identifier.system\"\n :subject=\"identifier.subject\"\n :user-id=\"userId\"\n @submit=\"submitFilterBookmark\"\n />\n </div>\n </slot>\n </VCol>\n </VRow>\n <VRow\n v-if=\"!alwaysVisible\"\n density=\"compact\"\n >\n <VCol\n v-if=\"!hideMore\"\n cols=\"auto\"\n >\n <AFilterAdvancedButton\n :button-active=\"showDetail\"\n @advanced-filter=\"toggleFilterDetail\"\n />\n </VCol>\n <VCol>\n <div class=\"a-filter__container\">\n <div class=\"a-filter__search\">\n <slot name=\"search\" />\n </div>\n <FiltersSelected />\n </div>\n </VCol>\n <VCol\n v-if=\"!hideButtons\"\n class=\"text-right\"\n cols=\"auto\"\n >\n <slot name=\"buttons\">\n <AFilterSubmitButton :touched=\"touched\" />\n <AFilterResetButton @reset=\"resetFilter\" />\n <AFilterBookmarkButton\n v-if=\"identifier.system && identifier.subject && userId && isDefined(client)\"\n :client=\"client\"\n :user=\"userId\"\n :system=\"identifier.system\"\n :subject=\"identifier.subject\"\n :datatable-hidden-columns=\"datatableHiddenColumns\"\n />\n </slot>\n </VCol>\n </VRow>\n <div v-if=\"!hideMore\">\n <component :is=\"alwaysVisible ? 'div' : VSlideYTransition\">\n <div\n v-show=\"showDetail\"\n :class=\"{ 'mt-6 pa-4 border': !alwaysVisible }\"\n >\n <slot name=\"detail\">\n <VRow>\n <VCol\n v-for=\"fieldName in renderedFieldNames\"\n :key=\"fieldName\"\n :cols=\"filterConfig.fields[fieldName]!.render.xs || 12\"\n :sm=\"filterConfig.fields[fieldName]!.render.sm || 6\"\n :md=\"filterConfig.fields[fieldName]!.render.md || 4\"\n :lg=\"filterConfig.fields[fieldName]!.render.lg || 3\"\n :xl=\"filterConfig.fields[fieldName]!.render.xl || 2\"\n >\n <slot\n :name=\"datatableSlotName(fieldName)\"\n :item-config=\"filterConfig.fields[fieldName]!\"\n >\n <FilterDetailItem :name=\"fieldName\" />\n </slot>\n </VCol>\n </VRow>\n </slot>\n </div>\n </component>\n </div>\n </VForm>\n</template>\n\n<style lang=\"scss\">\n@use 'vuetify/tools' as *;\n\n.a-filter {\n &__container {\n width: 100%;\n }\n\n &__search {\n display: inline-flex;\n vertical-align: middle;\n min-width: 100%;\n height: 34px;\n\n @include media-breakpoint-up(sm) {\n min-width: 50%;\n margin-right: 8px;\n }\n\n @include media-breakpoint-up(md) {\n min-width: 40%;\n }\n\n @include media-breakpoint-up(lg) {\n min-width: 30%;\n }\n }\n}\n</style>\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
"packageManager": "yarn@4.18.1",
|
|
4
4
|
"files": [
|
|
5
5
|
"dist",
|
|
6
|
+
"src/e2e/**",
|
|
6
7
|
"src/eslint/**",
|
|
7
8
|
"src/vite/**"
|
|
8
9
|
],
|
|
@@ -20,6 +21,10 @@
|
|
|
20
21
|
"types": "./dist/common-admin.css.d.ts",
|
|
21
22
|
"default": "./dist/common-admin.css"
|
|
22
23
|
},
|
|
24
|
+
"./e2e": {
|
|
25
|
+
"types": "./src/e2e/index.d.mts",
|
|
26
|
+
"default": "./src/e2e/index.mjs"
|
|
27
|
+
},
|
|
23
28
|
"./eslint": {
|
|
24
29
|
"types": "./src/eslint/plugin.d.mts",
|
|
25
30
|
"default": "./src/eslint/plugin.mjs"
|
|
@@ -35,7 +40,7 @@
|
|
|
35
40
|
},
|
|
36
41
|
"./package.json": "./package.json"
|
|
37
42
|
},
|
|
38
|
-
"version": "2.0.0-rc.
|
|
43
|
+
"version": "2.0.0-rc.7",
|
|
39
44
|
"license": "Apache-2.0",
|
|
40
45
|
"repository": {
|
|
41
46
|
"type": "git",
|
|
@@ -69,12 +74,13 @@
|
|
|
69
74
|
"test:eslint": "vitest run --config vitest.config.node.mts",
|
|
70
75
|
"test:run": "run-s --print-name test:browser test:eslint",
|
|
71
76
|
"test:ui": "vitest --ui",
|
|
77
|
+
"deps:upgrade": "yarn upgrade-interactive && yarn dedupe",
|
|
72
78
|
"release:start": "npx -y --allow-git=all github:anzusystems/release-tools#main start",
|
|
73
79
|
"release:publish": "npx -y --allow-git=all github:anzusystems/release-tools#main publish",
|
|
74
80
|
"release:cleanup": "npx -y --allow-git=all github:anzusystems/release-tools#main cleanup"
|
|
75
81
|
},
|
|
76
82
|
"devDependencies": {
|
|
77
|
-
"@anzusystems/common-admin": "2.0.0-rc.
|
|
83
|
+
"@anzusystems/common-admin": "2.0.0-rc.7",
|
|
78
84
|
"@intlify/unplugin-vue-i18n": "^11.2.8",
|
|
79
85
|
"@mdi/font": "^7.4.47",
|
|
80
86
|
"@sentry/vite-plugin": "11.10.0",
|
|
@@ -108,7 +114,7 @@
|
|
|
108
114
|
"eslint-plugin-vue": "10.12.0",
|
|
109
115
|
"eslint-plugin-vuetify": "^2.7.3",
|
|
110
116
|
"npm-run-all2": "^9.0.4",
|
|
111
|
-
"oxfmt": "^0.
|
|
117
|
+
"oxfmt": "^0.76.0",
|
|
112
118
|
"oxlint": "1.90.0",
|
|
113
119
|
"pinia": "4.1.0",
|
|
114
120
|
"playwright": "^1.64.1",
|
|
@@ -132,10 +138,10 @@
|
|
|
132
138
|
"vitest": "^5.1.0",
|
|
133
139
|
"vue": "3.5.44",
|
|
134
140
|
"vue-eslint-parser": "^10.4.1",
|
|
135
|
-
"vue-i18n": "11.4.
|
|
141
|
+
"vue-i18n": "11.4.18",
|
|
136
142
|
"vue-router": "5.4.0",
|
|
137
143
|
"vue-tsc": "3.3.14",
|
|
138
|
-
"vuetify": "4.3.
|
|
144
|
+
"vuetify": "4.3.2"
|
|
139
145
|
},
|
|
140
146
|
"peerDependencies": {
|
|
141
147
|
"@mdi/font": "^7.4.47",
|
|
@@ -156,6 +162,7 @@
|
|
|
156
162
|
"eslint-plugin-vuetify": "^2.7.3",
|
|
157
163
|
"oxlint": "^1.90.0",
|
|
158
164
|
"pinia": "^4.1.0",
|
|
165
|
+
"playwright": "^1.64.1",
|
|
159
166
|
"socket.io-client": "^4.8.4",
|
|
160
167
|
"sortablejs": "^1.15.7",
|
|
161
168
|
"vite": "^8.3.5",
|
|
@@ -193,6 +200,9 @@
|
|
|
193
200
|
"oxlint": {
|
|
194
201
|
"optional": true
|
|
195
202
|
},
|
|
203
|
+
"playwright": {
|
|
204
|
+
"optional": true
|
|
205
|
+
},
|
|
196
206
|
"socket.io-client": {
|
|
197
207
|
"optional": true
|
|
198
208
|
},
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// What the route walk knows of common-admin's views. Apart from the walk, which is node code, so that a browser
|
|
2
|
+
// test can hold them against the components (src/test/routeWalkConventions.test.ts).
|
|
3
|
+
|
|
4
|
+
/** `ACard`'s loader, shown while the card loads. */
|
|
5
|
+
export const CARD_LOADER_SELECTOR = '.a-card-loader'
|
|
6
|
+
|
|
7
|
+
/** The back button of `ANotFoundView`. */
|
|
8
|
+
export const NOT_FOUND_SELECTOR = '[data-cy="not-found-back"]'
|
|
9
|
+
|
|
10
|
+
/** The rows of a listing: Vuetify's data table, without its "no data" and loading rows. */
|
|
11
|
+
export const ROW_SELECTOR =
|
|
12
|
+
'main .v-data-table tbody tr:not(.v-data-table-rows-no-data):not(.v-data-table-rows-loading)'
|
|
13
|
+
|
|
14
|
+
/** The row a listing without records shows. */
|
|
15
|
+
export const NO_DATA_SELECTOR = 'main .v-data-table-rows-no-data'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { DEFAULT_TIMINGS, defineRouteWalk, defineRouteWalkBoot, readDeclaredRoutes } from './routeWalk.mjs'
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DefineRouteWalk,
|
|
3
|
+
FirstRecordOf,
|
|
4
|
+
ReadDeclaredRoutes,
|
|
5
|
+
RouteWalkTimings,
|
|
6
|
+
VisitRoute,
|
|
7
|
+
WatchPage,
|
|
8
|
+
} from './types.mjs'
|
|
9
|
+
|
|
10
|
+
export type * from './types.mjs'
|
|
11
|
+
|
|
12
|
+
export declare const DEFAULT_TIMINGS: Readonly<RouteWalkTimings>
|
|
13
|
+
|
|
14
|
+
/** The declared paths, constraints unescaped. The catch-all is left out. */
|
|
15
|
+
export declare const readDeclaredRoutes: ReadDeclaredRoutes
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Registers the boot check the walk depends on (a Playwright project dependency): the app logs in, starts and
|
|
19
|
+
* shows a page of its own. Without it, an app that never boots would hold every route for the full app timeout.
|
|
20
|
+
* It also empties the record cache of an earlier run.
|
|
21
|
+
*/
|
|
22
|
+
export declare const defineRouteWalkBoot: DefineRouteWalk
|
|
23
|
+
|
|
24
|
+
/** Registers the walk: a guard that the declaration was read, then one test per route. */
|
|
25
|
+
export declare const defineRouteWalk: DefineRouteWalk
|
|
26
|
+
|
|
27
|
+
// What the walk is made of, for its tests.
|
|
28
|
+
|
|
29
|
+
/** Watches a page for what it does wrong, for the whole session of a worker. */
|
|
30
|
+
export declare const watchPage: WatchPage
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Opens the target on a fresh document, waits until it has loaded, and collects what it did wrong: problems the
|
|
34
|
+
* watcher saw, landing elsewhere than the target (or than `redirect` allows), the 404 view, an empty page and
|
|
35
|
+
* unresolved components. Throws when the app does not render or a card loader never finishes.
|
|
36
|
+
*/
|
|
37
|
+
export declare const visitRoute: VisitRoute
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The first record a listing shows: from a link to it, else by clicking the first row and reading where the
|
|
41
|
+
* app went. Kept in a file in `outputDir`, so a record's detail and edit share one visit of the listing.
|
|
42
|
+
*/
|
|
43
|
+
export declare const firstRecordOf: FirstRecordOf
|
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { CARD_LOADER_SELECTOR, NO_DATA_SELECTOR, NOT_FOUND_SELECTOR, ROW_SELECTOR } from './conventions.mjs'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The route walk: one test per route of an admin, run against the real API and its data. Each test opens the
|
|
8
|
+
* page, waits until it has loaded, and fails on what it shows or logs wrong.
|
|
9
|
+
*
|
|
10
|
+
* It knows the conventions of common-admin (the generated route declaration, the record views, the card
|
|
11
|
+
* loader; see `conventions.mjs`), not an admin's code: whatever is particular to an admin comes through
|
|
12
|
+
* `RouteWalkOptions`. Node code, shipped as it is like `src/vite`: an admin's e2e project loads it without
|
|
13
|
+
* a build.
|
|
14
|
+
*
|
|
15
|
+
* @typedef {import('playwright/test').Page} Page
|
|
16
|
+
* @typedef {import('playwright/test').Request} Request
|
|
17
|
+
* @typedef {import('./types.mjs').PageWatcher} PageWatcher
|
|
18
|
+
* @typedef {import('./types.mjs').RecordLookup} RecordLookup
|
|
19
|
+
* @typedef {import('./types.mjs').RouteVisit} RouteVisit
|
|
20
|
+
* @typedef {import('./types.mjs').RouteWalkOptions} RouteWalkOptions
|
|
21
|
+
* @typedef {import('./types.mjs').RouteWalkPlaywright} RouteWalkPlaywright
|
|
22
|
+
* @typedef {import('./types.mjs').RouteWalkProblem} RouteWalkProblem
|
|
23
|
+
* @typedef {import('./types.mjs').RouteWalkTimings} RouteWalkTimings
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** @type {Readonly<RouteWalkTimings>} */
|
|
27
|
+
export const DEFAULT_TIMINGS = Object.freeze({
|
|
28
|
+
app: 30_000,
|
|
29
|
+
loader: 30_000,
|
|
30
|
+
quiet: 700,
|
|
31
|
+
settleLimit: 20_000,
|
|
32
|
+
slowRequest: 10_000,
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// Generous on purpose: a parameter route visits its listing first, and each visit may wait out the navigation,
|
|
36
|
+
// the app, the settle limit, the loaders and one reload. Only a page that hangs reaches it.
|
|
37
|
+
const ROUTE_TIMEOUT = 240_000
|
|
38
|
+
// A notification stream stays open for the whole session, so it is neither waited for nor checked.
|
|
39
|
+
const OPEN_REQUESTS = /\/notifications\/stream\//
|
|
40
|
+
const PROBLEM_PAGES = ['/unauthorized', '/error', '/login']
|
|
41
|
+
|
|
42
|
+
const PARAM = /:([a-zA-Z]+)(\([^)]*\))?/g
|
|
43
|
+
const OPTIONAL_PARAM = /\/:[a-zA-Z]+(\([^)]*\))?\?/g
|
|
44
|
+
|
|
45
|
+
/** @param {Partial<RouteWalkTimings> | undefined} timings */
|
|
46
|
+
const withDefaults = (timings) => ({ ...DEFAULT_TIMINGS, ...timings })
|
|
47
|
+
|
|
48
|
+
/** @type {import('./types.mjs').ReadDeclaredRoutes} */
|
|
49
|
+
export function readDeclaredRoutes(declarationFile) {
|
|
50
|
+
const declaration = fs.readFileSync(declarationFile, 'utf8')
|
|
51
|
+
return (
|
|
52
|
+
[...declaration.matchAll(/'[^']+':\s*RouteRecordInfo<\s*'[^']*',\s*'([^']+)'/g)]
|
|
53
|
+
// The declaration is TypeScript source: a `\d` constraint is written there with an escaped backslash.
|
|
54
|
+
.map(([, routePath]) => (routePath ?? '').replace(/\\\\/g, '\\'))
|
|
55
|
+
.filter((routePath) => !routePath.includes('pathMatch'))
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {string} dir
|
|
61
|
+
* @returns {number}
|
|
62
|
+
*/
|
|
63
|
+
const countPageFiles = (dir) =>
|
|
64
|
+
fs
|
|
65
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
66
|
+
.reduce(
|
|
67
|
+
(count, entry) =>
|
|
68
|
+
count +
|
|
69
|
+
(entry.isDirectory() ? countPageFiles(path.join(dir, entry.name)) : entry.name.endsWith('.vue') ? 1 : 0),
|
|
70
|
+
0
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Fills the parameters known by name, then drops the optional ones still unknown. What is left is opened with
|
|
75
|
+
* the first record of its listing: the address up to that parameter (`/keywords/:id(\d+)/edit` -> `/keywords`).
|
|
76
|
+
*
|
|
77
|
+
* @param {string} routePath
|
|
78
|
+
* @param {Record<string, string>} known
|
|
79
|
+
*/
|
|
80
|
+
const fillKnown = (routePath, known) => {
|
|
81
|
+
/** @type {{ name: string; pattern: RegExp }[]} */
|
|
82
|
+
const unresolved = []
|
|
83
|
+
const filled = routePath
|
|
84
|
+
.replace(OPTIONAL_PARAM, (match) => {
|
|
85
|
+
const name = (match.slice(2).split('(')[0] ?? '').replace(/\?$/, '')
|
|
86
|
+
return known[name] !== undefined ? `/${known[name]}` : ''
|
|
87
|
+
})
|
|
88
|
+
.replace(PARAM, (match, /** @type {string} */ name, /** @type {string | undefined} */ constraint) => {
|
|
89
|
+
const value = known[name]
|
|
90
|
+
if (value !== undefined) return value
|
|
91
|
+
unresolved.push({ name, pattern: constraint ? new RegExp(`^${constraint.slice(1, -1)}$`) : /^[^/]+$/ })
|
|
92
|
+
return match
|
|
93
|
+
})
|
|
94
|
+
return { filled, unresolved }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A pathname without a trailing slash, so that `/x/` and `/x` compare equal.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} pathname
|
|
101
|
+
*/
|
|
102
|
+
const normalizePath = (pathname) => (pathname.length > 1 ? pathname.replace(/\/+$/, '') : pathname)
|
|
103
|
+
|
|
104
|
+
/** @type {import('./types.mjs').WatchPage} */
|
|
105
|
+
export function watchPage(page, { openRequests = OPEN_REQUESTS, timings } = {}) {
|
|
106
|
+
const ownSlowRequest = withDefaults(timings).slowRequest
|
|
107
|
+
let slowRequest = ownSlowRequest
|
|
108
|
+
/** @type {RouteWalkProblem[]} */
|
|
109
|
+
let problems = []
|
|
110
|
+
/** Chromium's "Failed to load resource" lines, by the url they name: reported only when nothing else said so. */
|
|
111
|
+
/** @type {{ line: RouteWalkProblem; url: string }[]} */
|
|
112
|
+
let loadFailures = []
|
|
113
|
+
/** Every url that answered 4xx/5xx or failed, whatever its type and whether reported or not. */
|
|
114
|
+
/** @type {Set<string>} */
|
|
115
|
+
const failedUrls = new Set()
|
|
116
|
+
let apiAnswered = 0
|
|
117
|
+
/** @type {Map<Request, number>} */
|
|
118
|
+
const inFlight = new Map()
|
|
119
|
+
// When a request last started or ended: one that does both between two looks at `inFlight` still counts.
|
|
120
|
+
let lastActivity = 0
|
|
121
|
+
|
|
122
|
+
/** @param {Request} request */
|
|
123
|
+
const isApi = (request) => ['xhr', 'fetch'].includes(request.resourceType()) && !openRequests.test(request.url())
|
|
124
|
+
/**
|
|
125
|
+
* What the page itself needs to show: its document, and the scripts, styles and images of its own origin. Not
|
|
126
|
+
* another origin's (an embed in a record, an image from DAM): they depend on the data and the environment. Not
|
|
127
|
+
* fonts: the app loads them for all pages alike, and they have never broken.
|
|
128
|
+
*
|
|
129
|
+
* @param {Request} request
|
|
130
|
+
*/
|
|
131
|
+
const isAsset = (request) => {
|
|
132
|
+
const type = request.resourceType()
|
|
133
|
+
if (type === 'document') return request.frame() === page.mainFrame()
|
|
134
|
+
if (!['script', 'stylesheet', 'image'].includes(type)) return false
|
|
135
|
+
return new URL(request.url()).origin === new URL(page.url()).origin
|
|
136
|
+
}
|
|
137
|
+
/** @param {Request} request */
|
|
138
|
+
const ended = (request) => {
|
|
139
|
+
const startedAt = inFlight.get(request)
|
|
140
|
+
if (startedAt === undefined) return undefined
|
|
141
|
+
inFlight.delete(request)
|
|
142
|
+
lastActivity = Date.now()
|
|
143
|
+
return lastActivity - startedAt
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
page.on('pageerror', (error) => problems.push(`pageerror: ${error.message}`))
|
|
147
|
+
page.on('console', (message) => {
|
|
148
|
+
if (message.type() !== 'error') return
|
|
149
|
+
const at = message.location().url
|
|
150
|
+
const line = `console.error: ${message.text()}${at ? ` (${at})` : ''}`
|
|
151
|
+
if (message.text().startsWith('Failed to load resource:')) loadFailures.push({ line, url: at })
|
|
152
|
+
else problems.push(line)
|
|
153
|
+
})
|
|
154
|
+
page.on('request', (request) => {
|
|
155
|
+
// A page's own scripts count too: until its chunk has loaded, it has neither mounted nor asked for data.
|
|
156
|
+
if (isApi(request) || ['script', 'stylesheet'].includes(request.resourceType())) {
|
|
157
|
+
lastActivity = Date.now()
|
|
158
|
+
inFlight.set(request, lastActivity)
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
page.on('requestfinished', (request) => {
|
|
162
|
+
const tookMs = ended(request)
|
|
163
|
+
// Answered, but past the clients' patience: the page waited that long.
|
|
164
|
+
if (tookMs !== undefined && tookMs >= slowRequest && isApi(request)) {
|
|
165
|
+
problems.push(`answered after ${Math.round(tookMs / 1000)} s: ${request.method()} ${request.url()}`)
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
page.on('requestfailed', (request) => {
|
|
169
|
+
const ranFor = ended(request) ?? 0
|
|
170
|
+
const failure = request.failure()?.errorText ?? ''
|
|
171
|
+
if (failure === 'net::ERR_ABORTED') {
|
|
172
|
+
// Cancelled by the page itself, which is no failure, unless it ran long enough to be its client's timeout:
|
|
173
|
+
// that failure reaches no console, the page only shows an alert. A previous page's requests that its
|
|
174
|
+
// navigation away cancelled count neither: `reset` clears what they reported.
|
|
175
|
+
if (ranFor >= slowRequest) {
|
|
176
|
+
problems.push(`no answer, cancelled after ${Math.round(ranFor / 1000)} s: ${request.method()} ${request.url()}`)
|
|
177
|
+
}
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
failedUrls.add(request.url())
|
|
181
|
+
if (isApi(request) || isAsset(request)) {
|
|
182
|
+
problems.push(`request failed: ${failure} ${request.method()} ${request.url()}`)
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
page.on('response', (response) => {
|
|
186
|
+
const request = response.request()
|
|
187
|
+
if (response.status() >= 400) failedUrls.add(response.url())
|
|
188
|
+
if (!isApi(request) && !isAsset(request)) return
|
|
189
|
+
if (response.status() >= 400) {
|
|
190
|
+
problems.push(`${request.resourceType()} ${response.status()}: ${request.method()} ${response.url()}`)
|
|
191
|
+
} else if (isApi(request)) {
|
|
192
|
+
apiAnswered++
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
reset({ slowRequest: forPage } = {}) {
|
|
198
|
+
slowRequest = forPage ?? ownSlowRequest
|
|
199
|
+
problems = []
|
|
200
|
+
loadFailures = []
|
|
201
|
+
failedUrls.clear()
|
|
202
|
+
apiAnswered = 0
|
|
203
|
+
inFlight.clear()
|
|
204
|
+
lastActivity = 0
|
|
205
|
+
},
|
|
206
|
+
push(problem) {
|
|
207
|
+
problems.push(problem)
|
|
208
|
+
},
|
|
209
|
+
problems() {
|
|
210
|
+
const failed = [...failedUrls]
|
|
211
|
+
// Same origin and path; a url named without its query stands for any query of that path. A url that does
|
|
212
|
+
// not parse accounts for nothing, so its line is reported.
|
|
213
|
+
/** @param {string} failedUrl @param {string} url */
|
|
214
|
+
const sameResource = (failedUrl, url) => {
|
|
215
|
+
try {
|
|
216
|
+
const failedAt = new URL(failedUrl)
|
|
217
|
+
const at = new URL(url)
|
|
218
|
+
if (failedAt.origin !== at.origin || failedAt.pathname !== at.pathname) return false
|
|
219
|
+
return !at.search || failedAt.search === at.search
|
|
220
|
+
} catch {
|
|
221
|
+
return false
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/** @param {string} url */
|
|
225
|
+
const accountedFor = (url) => failed.some((failedUrl) => sameResource(failedUrl, url))
|
|
226
|
+
const unaccounted = loadFailures.filter(({ url }) => !url || !accountedFor(url)).map(({ line }) => line)
|
|
227
|
+
// An `Api error` can also come from a 200 whose handling threw: that one is kept.
|
|
228
|
+
return [...problems, ...unaccounted].filter((problem) => {
|
|
229
|
+
const apiErrorUrl = /^console\.error: Api error: (\S+)/.exec(problem)?.[1]
|
|
230
|
+
return !apiErrorUrl || !accountedFor(apiErrorUrl)
|
|
231
|
+
})
|
|
232
|
+
},
|
|
233
|
+
get apiAnswered() {
|
|
234
|
+
return apiAnswered
|
|
235
|
+
},
|
|
236
|
+
get lastActivity() {
|
|
237
|
+
return lastActivity
|
|
238
|
+
},
|
|
239
|
+
inFlight,
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Whether the card loaders are gone, and whether `main` says anything: the app's start-up loader is a `main`
|
|
245
|
+
* without text. Undefined while the page cannot answer (a navigation interrupted the evaluation).
|
|
246
|
+
*
|
|
247
|
+
* @param {Page} page
|
|
248
|
+
* @returns {Promise<{ loadersFinished: boolean; hasText: boolean } | undefined>}
|
|
249
|
+
*/
|
|
250
|
+
const pageState = (page) =>
|
|
251
|
+
page
|
|
252
|
+
.evaluate(
|
|
253
|
+
(selector) => ({
|
|
254
|
+
loadersFinished: Array.from(document.querySelectorAll(selector)).every(
|
|
255
|
+
(loader) => getComputedStyle(loader).display === 'none' || !(/** @type {HTMLElement} */ (loader).offsetParent)
|
|
256
|
+
),
|
|
257
|
+
hasText: !!document.querySelector('main')?.textContent?.trim(),
|
|
258
|
+
}),
|
|
259
|
+
CARD_LOADER_SELECTOR
|
|
260
|
+
)
|
|
261
|
+
.catch(() => undefined)
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* After `settleLimit`, the requests then running are watched until they end or have run `slowRequest`: one still
|
|
265
|
+
* without an answer then is a problem, however late it started. A page that keeps requesting (autorefresh) ends
|
|
266
|
+
* its requests soon and is not reported.
|
|
267
|
+
*
|
|
268
|
+
* @param {Page} page
|
|
269
|
+
* @param {PageWatcher} watcher
|
|
270
|
+
* @param {RouteWalkTimings} timings
|
|
271
|
+
*/
|
|
272
|
+
const reportUnanswered = async (page, watcher, timings) => {
|
|
273
|
+
const running = [...watcher.inFlight]
|
|
274
|
+
const until = Math.max(0, ...running.map(([, startedAt]) => startedAt + timings.slowRequest))
|
|
275
|
+
while (Date.now() < until && running.some(([request]) => watcher.inFlight.has(request))) {
|
|
276
|
+
await page.waitForTimeout(100)
|
|
277
|
+
}
|
|
278
|
+
for (const [request, startedAt] of running) {
|
|
279
|
+
if (!watcher.inFlight.has(request)) continue
|
|
280
|
+
const runningFor = Date.now() - startedAt
|
|
281
|
+
watcher.push(`no answer after ${Math.round(runningFor / 1000)} s: ${request.method()} ${request.url()}`)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The page has loaded: for a whole `quiet` interval no API request, script or style has run, no card loader has
|
|
287
|
+
* shown, and `main` has said something. All three at once, since each can start the next: a page's chunk mounts
|
|
288
|
+
* a loader, a loader that ends can be followed by a request. A page that keeps requesting (autorefresh) is not
|
|
289
|
+
* waited for past `settleLimit`, so this returns whether it settled. The hard waits throw: a blank page, or a
|
|
290
|
+
* loader still showing `loader` ms after that, is a failure.
|
|
291
|
+
*
|
|
292
|
+
* @param {Page} page
|
|
293
|
+
* @param {PageWatcher} watcher
|
|
294
|
+
* @param {RouteWalkTimings} timings
|
|
295
|
+
* @returns {Promise<boolean>}
|
|
296
|
+
*/
|
|
297
|
+
const settle = async (page, watcher, timings) => {
|
|
298
|
+
await page
|
|
299
|
+
.locator('main')
|
|
300
|
+
.first()
|
|
301
|
+
.waitFor({ state: 'visible', timeout: timings.app })
|
|
302
|
+
.catch((error) => {
|
|
303
|
+
throw new Error('the app did not render', { cause: error })
|
|
304
|
+
})
|
|
305
|
+
const deadline = Date.now() + timings.settleLimit
|
|
306
|
+
let busyAt = Date.now()
|
|
307
|
+
let settled = false
|
|
308
|
+
while (Date.now() < deadline) {
|
|
309
|
+
const state = await pageState(page)
|
|
310
|
+
const now = Date.now()
|
|
311
|
+
if (watcher.inFlight.size > 0 || !state?.loadersFinished || !state.hasText) busyAt = now
|
|
312
|
+
else if (now - Math.max(busyAt, watcher.lastActivity) >= timings.quiet) {
|
|
313
|
+
settled = true
|
|
314
|
+
break
|
|
315
|
+
}
|
|
316
|
+
await page.waitForTimeout(100)
|
|
317
|
+
}
|
|
318
|
+
if (!settled) await reportUnanswered(page, watcher, timings)
|
|
319
|
+
const loaderDeadline = Date.now() + timings.loader
|
|
320
|
+
while (!(await pageState(page))?.loadersFinished) {
|
|
321
|
+
if (Date.now() >= loaderDeadline) throw new Error('a card loader never finished')
|
|
322
|
+
await page.waitForTimeout(100)
|
|
323
|
+
}
|
|
324
|
+
return settled
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Elements no component stands behind: a component the template names but nothing registers or imports.
|
|
329
|
+
* A production build renders it as an empty element of that name and warns nowhere.
|
|
330
|
+
*
|
|
331
|
+
* @param {Page} page
|
|
332
|
+
* @returns {Promise<string[]>}
|
|
333
|
+
*/
|
|
334
|
+
const unresolvedComponents = (page) =>
|
|
335
|
+
page.evaluate(() => [
|
|
336
|
+
...new Set(
|
|
337
|
+
Array.from(document.body.querySelectorAll('*'))
|
|
338
|
+
.filter(
|
|
339
|
+
(element) =>
|
|
340
|
+
element instanceof HTMLUnknownElement ||
|
|
341
|
+
(element instanceof HTMLElement &&
|
|
342
|
+
element.localName.includes('-') &&
|
|
343
|
+
!customElements.get(element.localName))
|
|
344
|
+
)
|
|
345
|
+
.map((element) => element.localName)
|
|
346
|
+
),
|
|
347
|
+
])
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Opens the route on a fresh document. `about:blank` first, so that what the previous page still logs or
|
|
351
|
+
* requests while it unloads is not blamed on this one. An app that comes up blank is reloaded once: a cold
|
|
352
|
+
* boot can hit a transient failure that the next one does not.
|
|
353
|
+
*
|
|
354
|
+
* @param {Page} page
|
|
355
|
+
* @param {PageWatcher} watcher
|
|
356
|
+
* @param {string} target
|
|
357
|
+
* @param {RouteWalkTimings} timings
|
|
358
|
+
*/
|
|
359
|
+
const openRoute = async (page, watcher, target, timings) => {
|
|
360
|
+
await page.goto('about:blank')
|
|
361
|
+
watcher.reset(timings)
|
|
362
|
+
await page.goto(target)
|
|
363
|
+
try {
|
|
364
|
+
return await settle(page, watcher, timings)
|
|
365
|
+
} catch (error) {
|
|
366
|
+
if ((await page.locator('main').count()) > 0) throw error
|
|
367
|
+
watcher.reset(timings)
|
|
368
|
+
await page.reload()
|
|
369
|
+
return settle(page, watcher, timings)
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** @type {import('./types.mjs').VisitRoute} */
|
|
374
|
+
export async function visitRoute(page, watcher, target, { redirect, timings } = {}) {
|
|
375
|
+
const settled = await openRoute(page, watcher, target, withDefaults(timings))
|
|
376
|
+
const landedOn = normalizePath(new URL(page.url()).pathname)
|
|
377
|
+
const expected = normalizePath(new URL(target, page.url()).pathname)
|
|
378
|
+
if (landedOn !== expected && !redirect?.test(landedOn)) watcher.push(`redirected to ${landedOn}`)
|
|
379
|
+
if ((await page.locator(NOT_FOUND_SELECTOR).count()) > 0) watcher.push('the 404 view')
|
|
380
|
+
if (!(await page.locator('main').first().innerText()).trim()) watcher.push('an empty page')
|
|
381
|
+
for (const tag of await unresolvedComponents(page)) watcher.push(`an unresolved component <${tag}>`)
|
|
382
|
+
return { settled, landedOn, problems: watcher.problems(), apiAnswered: watcher.apiAnswered }
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Where a listing's first record is kept for the rest of the run: a file in the run's output folder, which the
|
|
387
|
+
* boot check empties (Playwright does too when a run starts, but not in `--ui` mode). Shared by the workers, so
|
|
388
|
+
* all of them open the same record, and kept when a failed test restarts its worker.
|
|
389
|
+
*
|
|
390
|
+
* @param {string} outputDir
|
|
391
|
+
*/
|
|
392
|
+
const recordDir = (outputDir) => path.join(outputDir, 'route-walk-records')
|
|
393
|
+
/** @param {string} outputDir @param {string} listing @param {RegExp} pattern */
|
|
394
|
+
const recordFile = (outputDir, listing, pattern) =>
|
|
395
|
+
path.join(recordDir(outputDir), `${Buffer.from(`${listing} ${pattern.source}`).toString('base64url')}.json`)
|
|
396
|
+
|
|
397
|
+
/** @type {import('./types.mjs').FirstRecordOf} */
|
|
398
|
+
export async function firstRecordOf(
|
|
399
|
+
page,
|
|
400
|
+
watcher,
|
|
401
|
+
listing,
|
|
402
|
+
pattern,
|
|
403
|
+
{ outputDir, rowSelector = ROW_SELECTOR, timings }
|
|
404
|
+
) {
|
|
405
|
+
const file = recordFile(outputDir, listing, pattern)
|
|
406
|
+
if (fs.existsSync(file)) return JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
407
|
+
|
|
408
|
+
const lookup = await (async () => {
|
|
409
|
+
const listingTimings = withDefaults(timings)
|
|
410
|
+
watcher.reset(listingTimings)
|
|
411
|
+
await page.goto(listing)
|
|
412
|
+
await settle(page, watcher, listingTimings).catch(() => false)
|
|
413
|
+
if ((await page.locator(NOT_FOUND_SELECTOR).count()) > 0) return { missing: `${listing} is not a page` }
|
|
414
|
+
|
|
415
|
+
const fromLink = await page.evaluate(
|
|
416
|
+
({ prefix, source }) => {
|
|
417
|
+
const matches = new RegExp(source)
|
|
418
|
+
return Array.from(document.querySelectorAll(`main a[href^="${prefix}/"]`))
|
|
419
|
+
.map((link) => (link.getAttribute('href') ?? '').slice(prefix.length + 1).split(/[/?#]/)[0] ?? '')
|
|
420
|
+
.find((candidate) => matches.test(candidate))
|
|
421
|
+
},
|
|
422
|
+
{ prefix: listing, source: pattern.source }
|
|
423
|
+
)
|
|
424
|
+
if (fromLink !== undefined) return { id: fromLink }
|
|
425
|
+
|
|
426
|
+
const firstRow = page.locator(rowSelector).first()
|
|
427
|
+
if ((await firstRow.count()) === 0) {
|
|
428
|
+
// Empty only when the table says so: a row selector that matches nothing must not pass for no data.
|
|
429
|
+
if (await page.locator(NO_DATA_SELECTOR).first().isVisible()) {
|
|
430
|
+
return { missing: `${listing} shows no record`, empty: /** @type {const} */ (true) }
|
|
431
|
+
}
|
|
432
|
+
return { missing: `nothing in ${listing} matches the row selector` }
|
|
433
|
+
}
|
|
434
|
+
// The corner of the first cell, inside its padding: a cell can hold a link to another record
|
|
435
|
+
// (a site group, a super author), and a click on it would open that one instead.
|
|
436
|
+
await firstRow
|
|
437
|
+
.locator('td')
|
|
438
|
+
.first()
|
|
439
|
+
.click({ position: { x: 3, y: 3 } })
|
|
440
|
+
await page.waitForURL((url) => url.pathname.startsWith(`${listing}/`), { timeout: 10_000 }).catch(() => {})
|
|
441
|
+
const candidate = new URL(page.url()).pathname.slice(listing.length + 1).split('/')[0] ?? ''
|
|
442
|
+
if (!pattern.test(candidate)) return { missing: `clicking the first row of ${listing} opened ${page.url()}` }
|
|
443
|
+
|
|
444
|
+
return { id: candidate }
|
|
445
|
+
})()
|
|
446
|
+
// Written aside and renamed: another worker reading meanwhile sees the whole file or none.
|
|
447
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
448
|
+
const written = `${file}.${process.pid}.tmp`
|
|
449
|
+
fs.writeFileSync(written, JSON.stringify(lookup))
|
|
450
|
+
fs.renameSync(written, file)
|
|
451
|
+
|
|
452
|
+
return lookup
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** @type {import('./types.mjs').DefineRouteWalk} */
|
|
456
|
+
export function defineRouteWalkBoot({ test, expect }, options) {
|
|
457
|
+
const timings = withDefaults(options.timings)
|
|
458
|
+
test(`${options.title} - the app boots`, async ({ browser }, testInfo) => {
|
|
459
|
+
fs.rmSync(recordDir(testInfo.project.outputDir), { recursive: true, force: true })
|
|
460
|
+
const { baseURL, viewport, ignoreHTTPSErrors } = testInfo.project.use
|
|
461
|
+
const context = await browser.newContext({ baseURL, viewport, ignoreHTTPSErrors })
|
|
462
|
+
const page = await context.newPage()
|
|
463
|
+
/** @type {string[]} */
|
|
464
|
+
const errors = []
|
|
465
|
+
page.on('pageerror', (error) => errors.push(error.message))
|
|
466
|
+
await options.login(page)
|
|
467
|
+
await page.goto('/')
|
|
468
|
+
// The start-up loader is a `main` too, without text: wait for a page that says something.
|
|
469
|
+
await expect
|
|
470
|
+
.poll(
|
|
471
|
+
async () =>
|
|
472
|
+
(
|
|
473
|
+
await page
|
|
474
|
+
.locator('main')
|
|
475
|
+
.first()
|
|
476
|
+
.innerText()
|
|
477
|
+
.catch(() => '')
|
|
478
|
+
).trim().length,
|
|
479
|
+
{ message: 'the app did not render a page', timeout: timings.app }
|
|
480
|
+
)
|
|
481
|
+
.toBeGreaterThan(0)
|
|
482
|
+
const landedOn = normalizePath(new URL(page.url()).pathname)
|
|
483
|
+
expect(options.problemPages ?? PROBLEM_PAGES, `the app started on ${landedOn}`).not.toContain(landedOn)
|
|
484
|
+
expect(errors, 'uncaught errors while booting').toEqual([])
|
|
485
|
+
await context.close()
|
|
486
|
+
})
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** @type {import('./types.mjs').DefineRouteWalk} */
|
|
490
|
+
export function defineRouteWalk({ test: base, expect }, options) {
|
|
491
|
+
const routes = readDeclaredRoutes(options.declarationFile)
|
|
492
|
+
const skip = options.skip ?? {}
|
|
493
|
+
const noRecord = options.noRecord ?? {}
|
|
494
|
+
const resolve = options.resolve ?? {}
|
|
495
|
+
const ignore = options.ignore ?? []
|
|
496
|
+
const timings = withDefaults(options.timings)
|
|
497
|
+
|
|
498
|
+
// One logged-in page per worker: logging in for each of hundreds of routes would double the run.
|
|
499
|
+
const test = base.extend(
|
|
500
|
+
/**
|
|
501
|
+
* @type {import('playwright/test').Fixtures<
|
|
502
|
+
* {},
|
|
503
|
+
* { walker: { page: Page; watcher: PageWatcher } },
|
|
504
|
+
* import('playwright/test').PlaywrightTestArgs & import('playwright/test').PlaywrightTestOptions,
|
|
505
|
+
* import('playwright/test').PlaywrightWorkerArgs & import('playwright/test').PlaywrightWorkerOptions
|
|
506
|
+
* >}
|
|
507
|
+
*/ ({
|
|
508
|
+
walker: [
|
|
509
|
+
async ({ browser }, use, workerInfo) => {
|
|
510
|
+
const { baseURL, viewport, ignoreHTTPSErrors, navigationTimeout } = workerInfo.project.use
|
|
511
|
+
const context = await browser.newContext({ baseURL, viewport, ignoreHTTPSErrors })
|
|
512
|
+
if (navigationTimeout) context.setDefaultNavigationTimeout(navigationTimeout)
|
|
513
|
+
const page = await context.newPage()
|
|
514
|
+
const watcher = watchPage(page, { openRequests: options.openRequests, timings })
|
|
515
|
+
await options.login(page)
|
|
516
|
+
await use({ page, watcher })
|
|
517
|
+
await context.close()
|
|
518
|
+
},
|
|
519
|
+
{ scope: 'worker' },
|
|
520
|
+
],
|
|
521
|
+
})
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
test.describe(options.title, () => {
|
|
525
|
+
test.describe.configure({ mode: 'parallel' })
|
|
526
|
+
|
|
527
|
+
test.afterEach(async ({ walker }, testInfo) => {
|
|
528
|
+
if (testInfo.status === testInfo.expectedStatus) return
|
|
529
|
+
await testInfo.attach('screenshot', { body: await walker.page.screenshot(), contentType: 'image/png' })
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
test('reads every route', () => {
|
|
533
|
+
// A declaration the regex no longer matches would otherwise walk nothing and pass.
|
|
534
|
+
expect(routes.length, `routes in ${options.declarationFile}`).toBe(countPageFiles(options.pagesDir) - 1)
|
|
535
|
+
const configured = [skip, noRecord, resolve, options.redirects ?? {}, options.routeTimings ?? {}].flatMap(
|
|
536
|
+
(byRoute) => Object.keys(byRoute)
|
|
537
|
+
)
|
|
538
|
+
const unknown = configured.filter((routePath) => !routes.includes(routePath))
|
|
539
|
+
expect(unknown, 'configured routes the app no longer has').toEqual([])
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
for (const routePath of routes) {
|
|
543
|
+
test(`opens ${routePath}`, async ({ walker: { page, watcher } }, testInfo) => {
|
|
544
|
+
test.setTimeout(ROUTE_TIMEOUT)
|
|
545
|
+
test.skip(routePath in skip, skip[routePath])
|
|
546
|
+
const own = options.routeTimings?.[routePath]
|
|
547
|
+
const routeTimings = own ? { ...timings, ...own.timings } : timings
|
|
548
|
+
if (own)
|
|
549
|
+
testInfo.annotations.push({ type: 'timings', description: `${JSON.stringify(own.timings)}: ${own.reason}` })
|
|
550
|
+
for (const rule of options.skipIf ?? []) {
|
|
551
|
+
if (rule.routes.test(routePath)) test.skip(await rule.when(page), rule.reason)
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
let target
|
|
555
|
+
const resolver = resolve[routePath]
|
|
556
|
+
if (resolver) {
|
|
557
|
+
const resolved = await resolver(page)
|
|
558
|
+
test.skip(resolved === undefined, 'no record to open')
|
|
559
|
+
target = /** @type {string} */ (resolved)
|
|
560
|
+
} else {
|
|
561
|
+
const { filled, unresolved } = fillKnown(routePath, options.params ?? {})
|
|
562
|
+
expect(unresolved.length, 'parameters only `params` or `resolve` can give').toBeLessThanOrEqual(1)
|
|
563
|
+
target = filled
|
|
564
|
+
const [parameter] = unresolved
|
|
565
|
+
if (parameter) {
|
|
566
|
+
const listing = filled.slice(0, filled.indexOf(`/:${parameter.name}`))
|
|
567
|
+
const record = await firstRecordOf(page, watcher, listing, parameter.pattern, {
|
|
568
|
+
outputDir: testInfo.project.outputDir,
|
|
569
|
+
rowSelector: options.rowSelector,
|
|
570
|
+
timings,
|
|
571
|
+
})
|
|
572
|
+
if ('missing' in record) {
|
|
573
|
+
test.skip(record.empty === true, record.missing)
|
|
574
|
+
test.skip(routePath in noRecord, noRecord[routePath])
|
|
575
|
+
throw new Error(
|
|
576
|
+
`no record to open: ${record.missing}. List the route in \`noRecord\` if that is expected.`
|
|
577
|
+
)
|
|
578
|
+
}
|
|
579
|
+
expect(routePath in noRecord, 'listed in `noRecord`, but a record was found').toBe(false)
|
|
580
|
+
target = filled.replace(PARAM, record.id)
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const visit = await visitRoute(page, watcher, target, {
|
|
585
|
+
redirect: options.redirects?.[routePath],
|
|
586
|
+
timings: routeTimings,
|
|
587
|
+
})
|
|
588
|
+
if (!visit.settled) {
|
|
589
|
+
testInfo.annotations.push({ type: 'kept requesting', description: `after ${routeTimings.settleLimit} ms` })
|
|
590
|
+
}
|
|
591
|
+
if (visit.apiAnswered === 0) {
|
|
592
|
+
testInfo.annotations.push({ type: 'no data', description: 'the page made no successful API request' })
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const problems = visit.problems.filter((problem) => {
|
|
596
|
+
const accepted = ignore.find(
|
|
597
|
+
(rule) => rule.problem.test(problem) && (!rule.routes || rule.routes.test(routePath))
|
|
598
|
+
)
|
|
599
|
+
if (accepted) testInfo.annotations.push({ type: 'ignored', description: `${problem} — ${accepted.reason}` })
|
|
600
|
+
return !accepted
|
|
601
|
+
})
|
|
602
|
+
expect(problems, target).toEqual([])
|
|
603
|
+
})
|
|
604
|
+
}
|
|
605
|
+
})
|
|
606
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Expect,
|
|
3
|
+
Page,
|
|
4
|
+
PlaywrightTestArgs,
|
|
5
|
+
PlaywrightTestOptions,
|
|
6
|
+
PlaywrightWorkerArgs,
|
|
7
|
+
PlaywrightWorkerOptions,
|
|
8
|
+
Request,
|
|
9
|
+
TestType,
|
|
10
|
+
} from 'playwright/test'
|
|
11
|
+
|
|
12
|
+
/** Something a page did wrong, matched by `RouteWalkIgnore.problem` as text. */
|
|
13
|
+
export type RouteWalkProblem = string
|
|
14
|
+
|
|
15
|
+
export interface RouteWalkIgnore {
|
|
16
|
+
/** Matched against the problem's text, e.g. `/^console\.error: .*ResizeObserver/`. */
|
|
17
|
+
problem: RegExp
|
|
18
|
+
/** Only on these routes (their declared path); everywhere when left out. */
|
|
19
|
+
routes?: RegExp
|
|
20
|
+
/** Why it is not a failure. Required, so that an exception never goes unexplained. */
|
|
21
|
+
reason: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** How long the walk waits, in milliseconds. */
|
|
25
|
+
export interface RouteWalkTimings {
|
|
26
|
+
/** For the app to render its `main`. */
|
|
27
|
+
app: number
|
|
28
|
+
/** For the card loaders to finish, once the page has settled. */
|
|
29
|
+
loader: number
|
|
30
|
+
/** How long no API request, script or style may be running before the page counts as loaded. */
|
|
31
|
+
quiet: number
|
|
32
|
+
/** How long a page that keeps requesting (autorefresh) is waited for. */
|
|
33
|
+
settleLimit: number
|
|
34
|
+
/**
|
|
35
|
+
* An API request that takes this long, answered or not, is a problem: it hung, or its client gave up on it (the
|
|
36
|
+
* clients time out at 15–30 s).
|
|
37
|
+
*/
|
|
38
|
+
slowRequest: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The admin's own `test` and `expect` from `@playwright/test`. The walk does not import Playwright Test: an admin
|
|
43
|
+
* keeps it in its e2e project, out of reach of this package, and a second copy in one run fails.
|
|
44
|
+
*/
|
|
45
|
+
export interface RouteWalkPlaywright {
|
|
46
|
+
test: TestType<PlaywrightTestArgs & PlaywrightTestOptions, PlaywrightWorkerArgs & PlaywrightWorkerOptions>
|
|
47
|
+
expect: Expect
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface RouteWalkOptions {
|
|
51
|
+
/** Title of the describe block, e.g. `ADMIN-CMS (LOCAL) - Routes`. */
|
|
52
|
+
title: string
|
|
53
|
+
/** The app's `src/typed-router.d.ts`. */
|
|
54
|
+
declarationFile: string
|
|
55
|
+
/** The app's `src/pages`. The declaration must hold one route per page file but the catch-all. */
|
|
56
|
+
pagesDir: string
|
|
57
|
+
/** Logs the worker's page in. The page has the project's `baseURL`. */
|
|
58
|
+
login: (page: Page) => Promise<void>
|
|
59
|
+
/** Routes not opened at all, by declared path, with the reason. */
|
|
60
|
+
skip?: Record<string, string>
|
|
61
|
+
/**
|
|
62
|
+
* Routes not opened while a condition holds, e.g. a system the app's `config.json` disables: its routes
|
|
63
|
+
* send the visitor to `/unauthorized` by design.
|
|
64
|
+
*/
|
|
65
|
+
skipIf?: { routes: RegExp; reason: string; when: (page: Page) => Promise<boolean> }[]
|
|
66
|
+
/** Values of parameters by name, used wherever the parameter appears (e.g. `{ system: 'cms' }`). */
|
|
67
|
+
params?: Record<string, string>
|
|
68
|
+
/**
|
|
69
|
+
* Routes whose address cannot come from a listing, by declared path: returns the path to open, or
|
|
70
|
+
* `undefined` when there is no record to open (the route is skipped, as for an empty listing).
|
|
71
|
+
*/
|
|
72
|
+
resolve?: Record<string, (page: Page) => Promise<string | undefined>>
|
|
73
|
+
/**
|
|
74
|
+
* Routes whose listing cannot give a record, by declared path, with the reason: the address before the
|
|
75
|
+
* parameter is not a page, or its rows open something else. Any other such route fails. An empty listing
|
|
76
|
+
* only skips its routes, since what a listing holds depends on the environment's data.
|
|
77
|
+
*/
|
|
78
|
+
noRecord?: Record<string, string>
|
|
79
|
+
/**
|
|
80
|
+
* Routes that forward by design, by declared path: where they may land (a pathname pattern). Landing
|
|
81
|
+
* anywhere but the address opened fails any other route, so a page that gives up and goes back is seen.
|
|
82
|
+
*/
|
|
83
|
+
redirects?: Record<string, RegExp>
|
|
84
|
+
/** Problems that do not fail a route. */
|
|
85
|
+
ignore?: RouteWalkIgnore[]
|
|
86
|
+
/** The rows of a listing, clicked to find a record when no link to one is found. Default: Vuetify's data table. */
|
|
87
|
+
rowSelector?: string
|
|
88
|
+
/**
|
|
89
|
+
* Requests that stay open for the whole session, e.g. a server-sent event stream: neither waited for nor
|
|
90
|
+
* checked. Default: a notification stream (`/notifications/stream/`).
|
|
91
|
+
*/
|
|
92
|
+
openRequests?: RegExp
|
|
93
|
+
/**
|
|
94
|
+
* Where the app sends a visitor it cannot serve. The boot check must not end on one of them.
|
|
95
|
+
* Default: `/unauthorized`, `/error` and `/login`.
|
|
96
|
+
*/
|
|
97
|
+
problemPages?: string[]
|
|
98
|
+
/** Overrides of `DEFAULT_TIMINGS`. */
|
|
99
|
+
timings?: Partial<RouteWalkTimings>
|
|
100
|
+
/**
|
|
101
|
+
* Timings of single routes, by declared path, over `timings`, with the reason: a page known to be slow gets
|
|
102
|
+
* more time without every route getting it.
|
|
103
|
+
*/
|
|
104
|
+
routeTimings?: Record<string, { timings: Partial<RouteWalkTimings>; reason: string }>
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** What a page did while the walk watched it. */
|
|
108
|
+
export interface PageWatcher {
|
|
109
|
+
/** Forgets everything seen so far, before the next page; `slowRequest` for that page, else the watcher's own. */
|
|
110
|
+
reset(options?: { slowRequest?: number }): void
|
|
111
|
+
push(problem: RouteWalkProblem): void
|
|
112
|
+
/**
|
|
113
|
+
* The problems, once each: common-admin's `Api error: <url> …` and Chromium's "Failed to load resource" repeat a
|
|
114
|
+
* failed response the walk reports itself, so they are kept only for a url no response accounts for.
|
|
115
|
+
*/
|
|
116
|
+
problems(): RouteWalkProblem[]
|
|
117
|
+
/** API requests answered with a success. */
|
|
118
|
+
readonly apiAnswered: number
|
|
119
|
+
/** When an API request, script or style last started or ended (ms since the epoch); 0 after `reset`. */
|
|
120
|
+
readonly lastActivity: number
|
|
121
|
+
/** The API requests, scripts and styles still running, with when they started. */
|
|
122
|
+
readonly inFlight: ReadonlyMap<Request, number>
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface RouteVisit {
|
|
126
|
+
/** Whether no request was running at the end; a page that keeps requesting does not settle. */
|
|
127
|
+
settled: boolean
|
|
128
|
+
/** The pathname the page ended on. */
|
|
129
|
+
landedOn: string
|
|
130
|
+
problems: RouteWalkProblem[]
|
|
131
|
+
apiAnswered: number
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export type RecordLookup = { id: string } | { missing: string; empty?: true }
|
|
135
|
+
|
|
136
|
+
// The walk's functions, declared once: `routeWalk.d.mts` exports them by these types, and `routeWalk.mjs` gives
|
|
137
|
+
// each its type, so that the type check holds the code to its declaration.
|
|
138
|
+
|
|
139
|
+
export type ReadDeclaredRoutes = (declarationFile: string) => string[]
|
|
140
|
+
|
|
141
|
+
export type DefineRouteWalk = (playwright: RouteWalkPlaywright, options: RouteWalkOptions) => void
|
|
142
|
+
|
|
143
|
+
export type WatchPage = (
|
|
144
|
+
page: Page,
|
|
145
|
+
options?: { openRequests?: RegExp; timings?: Partial<RouteWalkTimings> }
|
|
146
|
+
) => PageWatcher
|
|
147
|
+
|
|
148
|
+
export type VisitRoute = (
|
|
149
|
+
page: Page,
|
|
150
|
+
watcher: PageWatcher,
|
|
151
|
+
target: string,
|
|
152
|
+
options?: { redirect?: RegExp; timings?: Partial<RouteWalkTimings> }
|
|
153
|
+
) => Promise<RouteVisit>
|
|
154
|
+
|
|
155
|
+
export type FirstRecordOf = (
|
|
156
|
+
page: Page,
|
|
157
|
+
watcher: PageWatcher,
|
|
158
|
+
listing: string,
|
|
159
|
+
pattern: RegExp,
|
|
160
|
+
options: { outputDir: string; rowSelector?: string; timings?: Partial<RouteWalkTimings> }
|
|
161
|
+
) => Promise<RecordLookup>
|