@anzusystems/common-admin 2.0.0-rc.6 → 2.0.0-rc.8

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 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' : '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":""}
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":""}
@@ -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 p from "./AFilterAdvancedButton.js";
11
- import te from "./FiltersSelected.js";
12
- import { Fragment as ne, computed as m, createBlock as h, createCommentVNode as g, createElementBlock as _, createElementVNode as v, createVNode as y, defineComponent as b, inject as x, mergeModels as S, nextTick as C, normalizeClass as w, openBlock as T, provide as E, ref as D, renderList as re, renderSlot as O, resolveDynamicComponent as ie, unref as k, useModel as A, vShow as j, withCtx as M, withDirectives as N, withModifiers as P } from "vue";
13
- import { VCol as F, VRow as I } from "vuetify/components/VGrid";
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__*/ b({
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__*/ S({
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__*/ S([
60
+ emits: /*@__PURE__*/ x([
60
61
  "submit",
61
62
  "reset",
62
63
  "bookmarkLoadAfter"
63
64
  ], ["update:datatableHiddenColumns", "update:showDetail"]),
64
- setup(b, { expose: S, emit: H }) {
65
- let U = b, W = H, G = A(b, "datatableHiddenColumns"), K = A(b, "showDetail"), q = x(ee), J = x(r);
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 = D({
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 = D(0);
73
- E(a, X);
74
- let Z = D(/* @__PURE__ */ new Map());
75
- E(i, Z);
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++, C(() => {
78
+ X.value++, S(() => {
78
79
  W("submit");
79
80
  });
80
- }, { clearAll: ae, clearAllFilterSelected: oe } = o(), se = () => {
81
- C(() => {
81
+ }, { clearAll: oe, clearAllFilterSelected: se } = o(), ce = () => {
82
+ S(() => {
82
83
  X.value++, W("bookmarkLoadAfter");
83
84
  });
84
85
  }, $ = () => {
85
- ae(J, q), oe(J, q, Z), C(() => {
86
+ oe(J, q), se(J, q, Z), S(() => {
86
87
  X.value++, W("reset");
87
88
  });
88
- }, ce = () => {
89
+ }, le = () => {
89
90
  K.value = !K.value;
90
- }, le = m(() => q.touched), ue = m(() => Object.entries(q.fields).filter(([, e]) => !e.render.skip).map(([e]) => e));
91
- return U.alwaysVisible && (K.value = !0), S({
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) => (T(), h(L, {
95
- name: b.formName,
96
- onSubmit: P(Q, ["prevent"])
95
+ }), (e, n) => (w(), m(L, {
96
+ name: y.formName,
97
+ onSubmit: N(Q, ["prevent"])
97
98
  }, {
98
- default: M(() => [
99
- b.enableTop ? (T(), h(I, {
99
+ default: j(() => [
100
+ y.enableTop ? (w(), m(F, {
100
101
  key: 0,
101
102
  density: "compact"
102
103
  }, {
103
- default: M(() => [y(F, null, {
104
- default: M(() => [O(e.$slots, "top")]),
104
+ default: j(() => [v(P, null, {
105
+ default: j(() => [D(e.$slots, "top")]),
105
106
  _: 3
106
107
  })]),
107
108
  _: 3
108
- })) : g("", !0),
109
- y(I, { density: "compact" }, {
110
- default: M(() => [b.store && b.userId && k(t)(b.client) ? (T(), h(F, { key: 0 }, {
111
- default: M(() => [O(e.$slots, "bookmarks", {}, () => [v("div", R, [Y.value.system && Y.value.subject && b.userId && k(t)(b.client) ? (T(), h(d, {
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: b.client,
116
+ client: y.client,
116
117
  system: Y.value.system,
117
118
  subject: Y.value.subject,
118
- "user-id": b.userId,
119
- onSubmit: se
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
- ])) : g("", !0)])])]),
127
+ ])) : h("", !0)])])]),
127
128
  _: 3
128
- })) : g("", !0)]),
129
+ })) : h("", !0)]),
129
130
  _: 3
130
131
  }),
131
- b.alwaysVisible ? g("", !0) : (T(), h(I, {
132
+ y.alwaysVisible ? h("", !0) : (w(), m(F, {
132
133
  key: 1,
133
134
  density: "compact"
134
135
  }, {
135
- default: M(() => [
136
- b.hideMore ? g("", !0) : (T(), h(F, {
136
+ default: j(() => [
137
+ y.hideMore ? h("", !0) : (w(), m(P, {
137
138
  key: 0,
138
139
  cols: "auto"
139
140
  }, {
140
- default: M(() => [y(p, {
141
+ default: j(() => [v(te, {
141
142
  "button-active": K.value,
142
- onAdvancedFilter: ce
143
+ onAdvancedFilter: le
143
144
  }, null, 8, ["button-active"])]),
144
145
  _: 1
145
146
  })),
146
- y(F, null, {
147
- default: M(() => [v("div", z, [v("div", B, [O(e.$slots, "search")]), y(te)])]),
147
+ v(P, null, {
148
+ default: j(() => [_("div", z, [_("div", B, [D(e.$slots, "search")]), v(ne)])]),
148
149
  _: 3
149
150
  }),
150
- b.hideButtons ? g("", !0) : (T(), h(F, {
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: M(() => [O(e.$slots, "buttons", {}, () => [
156
- y(s, { touched: le.value }, null, 8, ["touched"]),
157
- y(c, { onReset: $ }),
158
- Y.value.system && Y.value.subject && b.userId && k(t)(b.client) ? (T(), h(f, {
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: b.client,
161
- user: b.userId,
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
- ])) : g("", !0)
172
+ ])) : h("", !0)
172
173
  ])]),
173
174
  _: 3
174
175
  }))
175
176
  ]),
176
177
  _: 3
177
178
  })),
178
- b.hideMore ? g("", !0) : (T(), _("div", V, [(T(), h(ie(b.alwaysVisible ? "div" : "VSlideYTransition"), null, {
179
- default: M(() => [N(v("div", { class: w({ "mt-6 pa-4 border": !b.alwaysVisible }) }, [O(e.$slots, "detail", {}, () => [y(I, null, {
180
- default: M(() => [(T(!0), _(ne, null, re(ue.value, (t) => (T(), h(F, {
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: k(q).fields[t].render.xs || 12,
183
- sm: k(q).fields[t].render.sm || 6,
184
- md: k(q).fields[t].render.md || 4,
185
- lg: k(q).fields[t].render.lg || 3,
186
- xl: k(q).fields[t].render.xl || 2
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: M(() => [O(e.$slots, k(l)(t), { itemConfig: k(q).fields[t] }, () => [y(u, { name: t }, null, 8, ["name"])])]),
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), [[j, K.value]])]),
199
+ })])], 2), [[A, K.value]])]),
199
200
  _: 3
200
201
  }))]))
201
202
  ]),
@@ -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":"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"}
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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAuBA,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,IAAA;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,QAAWnC,EAAAoF,CAAA,CAAiB,GAAA,MAAA;KACvD,SAAA1F,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,EAAAyF,CAAA,CAAiB,CAACxB,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"}
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.6",
43
+ "version": "2.0.0-rc.8",
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.6",
83
+ "@anzusystems/common-admin": "2.0.0-rc.8",
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.75.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.17",
141
+ "vue-i18n": "11.4.18",
136
142
  "vue-router": "5.4.0",
137
143
  "vue-tsc": "3.3.14",
138
- "vuetify": "4.3.1"
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,4 @@
1
+ export declare const CARD_LOADER_SELECTOR: string
2
+ export declare const NOT_FOUND_SELECTOR: string
3
+ export declare const ROW_SELECTOR: string
4
+ export declare const NO_DATA_SELECTOR: string
@@ -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,8 @@
1
+ export type {
2
+ RouteWalkIgnore,
3
+ RouteWalkOptions,
4
+ RouteWalkPlaywright,
5
+ RouteWalkProblem,
6
+ RouteWalkTimings,
7
+ } from './types.mjs'
8
+ export { DEFAULT_TIMINGS, defineRouteWalk, defineRouteWalkBoot, readDeclaredRoutes } from './routeWalk.mjs'
@@ -0,0 +1 @@
1
+ export { DEFAULT_TIMINGS, defineRouteWalk, defineRouteWalkBoot, readDeclaredRoutes } from './routeWalk.mjs'
@@ -0,0 +1,44 @@
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. An answer the listing gives every time (a record, no record, not a page) is kept in a file in
42
+ * `outputDir`, so a record's detail and edit share one visit of the listing; one a failed or empty load gave is not.
43
+ */
44
+ export declare const firstRecordOf: FirstRecordOf
@@ -0,0 +1,617 @@
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, without a `main` or with
352
+ * only the start-up loader's empty one, is reloaded once: a cold boot can hit a transient failure that the next
353
+ * one does not. A page that stays empty after that is reported as one.
354
+ *
355
+ * @param {Page} page
356
+ * @param {PageWatcher} watcher
357
+ * @param {string} target
358
+ * @param {RouteWalkTimings} timings
359
+ */
360
+ const openRoute = async (page, watcher, target, timings) => {
361
+ await page.goto('about:blank')
362
+ watcher.reset(timings)
363
+ await page.goto(target)
364
+ const first = await settle(page, watcher, timings).catch((/** @type {Error} */ error) => error)
365
+ if (typeof first === 'boolean' && (await pageState(page))?.hasText) return first
366
+ // A loader that never ends on a page that rendered is the page's failure, not the boot's.
367
+ if (first instanceof Error && (await page.locator('main').count()) > 0) throw first
368
+ watcher.reset(timings)
369
+ await page.reload()
370
+ return settle(page, watcher, timings)
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
+ // `keep`: an answer the listing gives every time. One that a failed load or a page still loading gave is not kept,
409
+ // so the routes after it look again instead of all failing on what went wrong once.
410
+ /** @type {{ lookup: RecordLookup; keep: boolean }} */
411
+ const { lookup, keep } = await (async () => {
412
+ const opened = await openRoute(page, watcher, listing, withDefaults(timings)).then(
413
+ () => undefined,
414
+ (/** @type {Error} */ error) => error
415
+ )
416
+ if (opened) return { lookup: { missing: `${listing} did not load: ${opened.message}` }, keep: false }
417
+ if ((await page.locator(NOT_FOUND_SELECTOR).count()) > 0) {
418
+ return { lookup: { missing: `${listing} is not a page` }, keep: true }
419
+ }
420
+ if (!(await pageState(page))?.hasText) return { lookup: { missing: `${listing} came up empty` }, keep: false }
421
+
422
+ const fromLink = await page.evaluate(
423
+ ({ prefix, source }) => {
424
+ const matches = new RegExp(source)
425
+ return Array.from(document.querySelectorAll(`main a[href^="${prefix}/"]`))
426
+ .map((link) => (link.getAttribute('href') ?? '').slice(prefix.length + 1).split(/[/?#]/)[0] ?? '')
427
+ .find((candidate) => matches.test(candidate))
428
+ },
429
+ { prefix: listing, source: pattern.source }
430
+ )
431
+ if (fromLink !== undefined) return { lookup: { id: fromLink }, keep: true }
432
+
433
+ const firstRow = page.locator(rowSelector).first()
434
+ if ((await firstRow.count()) === 0) {
435
+ // Empty only when the table says so: a row selector that matches nothing must not pass for no data.
436
+ if (await page.locator(NO_DATA_SELECTOR).first().isVisible()) {
437
+ return { lookup: { missing: `${listing} shows no record`, empty: /** @type {const} */ (true) }, keep: true }
438
+ }
439
+ return { lookup: { missing: `nothing in ${listing} matches the row selector` }, keep: false }
440
+ }
441
+ // The corner of the first cell, inside its padding: a cell can hold a link to another record
442
+ // (a site group, a super author), and a click on it would open that one instead.
443
+ await firstRow
444
+ .locator('td')
445
+ .first()
446
+ .click({ position: { x: 3, y: 3 } })
447
+ await page.waitForURL((url) => url.pathname.startsWith(`${listing}/`), { timeout: 10_000 }).catch(() => {})
448
+ const candidate = new URL(page.url()).pathname.slice(listing.length + 1).split('/')[0] ?? ''
449
+ if (!pattern.test(candidate)) {
450
+ return { lookup: { missing: `clicking the first row of ${listing} opened ${page.url()}` }, keep: false }
451
+ }
452
+
453
+ return { lookup: { id: candidate }, keep: true }
454
+ })()
455
+ if (keep) {
456
+ // Written aside and renamed: another worker reading meanwhile sees the whole file or none.
457
+ fs.mkdirSync(path.dirname(file), { recursive: true })
458
+ const written = `${file}.${process.pid}.tmp`
459
+ fs.writeFileSync(written, JSON.stringify(lookup))
460
+ fs.renameSync(written, file)
461
+ }
462
+
463
+ return lookup
464
+ }
465
+
466
+ /** @type {import('./types.mjs').DefineRouteWalk} */
467
+ export function defineRouteWalkBoot({ test, expect }, options) {
468
+ const timings = withDefaults(options.timings)
469
+ test(`${options.title} - the app boots`, async ({ browser }, testInfo) => {
470
+ fs.rmSync(recordDir(testInfo.project.outputDir), { recursive: true, force: true })
471
+ const { baseURL, viewport, ignoreHTTPSErrors } = testInfo.project.use
472
+ const context = await browser.newContext({ baseURL, viewport, ignoreHTTPSErrors })
473
+ const page = await context.newPage()
474
+ /** @type {string[]} */
475
+ const errors = []
476
+ page.on('pageerror', (error) => errors.push(error.message))
477
+ await options.login(page)
478
+ await page.goto('/')
479
+ // The start-up loader is a `main` too, without text: wait for a page that says something.
480
+ await expect
481
+ .poll(
482
+ async () =>
483
+ (
484
+ await page
485
+ .locator('main')
486
+ .first()
487
+ .innerText()
488
+ .catch(() => '')
489
+ ).trim().length,
490
+ { message: 'the app did not render a page', timeout: timings.app }
491
+ )
492
+ .toBeGreaterThan(0)
493
+ const landedOn = normalizePath(new URL(page.url()).pathname)
494
+ expect(options.problemPages ?? PROBLEM_PAGES, `the app started on ${landedOn}`).not.toContain(landedOn)
495
+ expect(errors, 'uncaught errors while booting').toEqual([])
496
+ await context.close()
497
+ })
498
+ }
499
+
500
+ /** @type {import('./types.mjs').DefineRouteWalk} */
501
+ export function defineRouteWalk({ test: base, expect }, options) {
502
+ const routes = readDeclaredRoutes(options.declarationFile)
503
+ const skip = options.skip ?? {}
504
+ const noRecord = options.noRecord ?? {}
505
+ const resolve = options.resolve ?? {}
506
+ const ignore = options.ignore ?? []
507
+ const timings = withDefaults(options.timings)
508
+
509
+ // One logged-in page per worker: logging in for each of hundreds of routes would double the run.
510
+ const test = base.extend(
511
+ /**
512
+ * @type {import('playwright/test').Fixtures<
513
+ * {},
514
+ * { walker: { page: Page; watcher: PageWatcher } },
515
+ * import('playwright/test').PlaywrightTestArgs & import('playwright/test').PlaywrightTestOptions,
516
+ * import('playwright/test').PlaywrightWorkerArgs & import('playwright/test').PlaywrightWorkerOptions
517
+ * >}
518
+ */ ({
519
+ walker: [
520
+ async ({ browser }, use, workerInfo) => {
521
+ const { baseURL, viewport, ignoreHTTPSErrors, navigationTimeout } = workerInfo.project.use
522
+ const context = await browser.newContext({ baseURL, viewport, ignoreHTTPSErrors })
523
+ if (navigationTimeout) context.setDefaultNavigationTimeout(navigationTimeout)
524
+ const page = await context.newPage()
525
+ const watcher = watchPage(page, { openRequests: options.openRequests, timings })
526
+ await options.login(page)
527
+ await use({ page, watcher })
528
+ await context.close()
529
+ },
530
+ { scope: 'worker' },
531
+ ],
532
+ })
533
+ )
534
+
535
+ test.describe(options.title, () => {
536
+ test.describe.configure({ mode: 'parallel' })
537
+
538
+ test.afterEach(async ({ walker }, testInfo) => {
539
+ if (testInfo.status === testInfo.expectedStatus) return
540
+ await testInfo.attach('screenshot', { body: await walker.page.screenshot(), contentType: 'image/png' })
541
+ })
542
+
543
+ test('reads every route', () => {
544
+ // A declaration the regex no longer matches would otherwise walk nothing and pass.
545
+ expect(routes.length, `routes in ${options.declarationFile}`).toBe(countPageFiles(options.pagesDir) - 1)
546
+ const configured = [skip, noRecord, resolve, options.redirects ?? {}, options.routeTimings ?? {}].flatMap(
547
+ (byRoute) => Object.keys(byRoute)
548
+ )
549
+ const unknown = configured.filter((routePath) => !routes.includes(routePath))
550
+ expect(unknown, 'configured routes the app no longer has').toEqual([])
551
+ })
552
+
553
+ for (const routePath of routes) {
554
+ test(`opens ${routePath}`, async ({ walker: { page, watcher } }, testInfo) => {
555
+ test.setTimeout(ROUTE_TIMEOUT)
556
+ test.skip(routePath in skip, skip[routePath])
557
+ const own = options.routeTimings?.[routePath]
558
+ const routeTimings = own ? { ...timings, ...own.timings } : timings
559
+ if (own)
560
+ testInfo.annotations.push({ type: 'timings', description: `${JSON.stringify(own.timings)}: ${own.reason}` })
561
+ for (const rule of options.skipIf ?? []) {
562
+ if (rule.routes.test(routePath)) test.skip(await rule.when(page), rule.reason)
563
+ }
564
+
565
+ let target
566
+ const resolver = resolve[routePath]
567
+ if (resolver) {
568
+ const resolved = await resolver(page)
569
+ test.skip(resolved === undefined, 'no record to open')
570
+ target = /** @type {string} */ (resolved)
571
+ } else {
572
+ const { filled, unresolved } = fillKnown(routePath, options.params ?? {})
573
+ expect(unresolved.length, 'parameters only `params` or `resolve` can give').toBeLessThanOrEqual(1)
574
+ target = filled
575
+ const [parameter] = unresolved
576
+ if (parameter) {
577
+ const listing = filled.slice(0, filled.indexOf(`/:${parameter.name}`))
578
+ const record = await firstRecordOf(page, watcher, listing, parameter.pattern, {
579
+ outputDir: testInfo.project.outputDir,
580
+ rowSelector: options.rowSelector,
581
+ timings,
582
+ })
583
+ if ('missing' in record) {
584
+ test.skip(record.empty === true, record.missing)
585
+ test.skip(routePath in noRecord, noRecord[routePath])
586
+ throw new Error(
587
+ `no record to open: ${record.missing}. List the route in \`noRecord\` if that is expected.`
588
+ )
589
+ }
590
+ expect(routePath in noRecord, 'listed in `noRecord`, but a record was found').toBe(false)
591
+ target = filled.replace(PARAM, record.id)
592
+ }
593
+ }
594
+
595
+ const visit = await visitRoute(page, watcher, target, {
596
+ redirect: options.redirects?.[routePath],
597
+ timings: routeTimings,
598
+ })
599
+ if (!visit.settled) {
600
+ testInfo.annotations.push({ type: 'kept requesting', description: `after ${routeTimings.settleLimit} ms` })
601
+ }
602
+ if (visit.apiAnswered === 0) {
603
+ testInfo.annotations.push({ type: 'no data', description: 'the page made no successful API request' })
604
+ }
605
+
606
+ const problems = visit.problems.filter((problem) => {
607
+ const accepted = ignore.find(
608
+ (rule) => rule.problem.test(problem) && (!rule.routes || rule.routes.test(routePath))
609
+ )
610
+ if (accepted) testInfo.annotations.push({ type: 'ignored', description: `${problem} — ${accepted.reason}` })
611
+ return !accepted
612
+ })
613
+ expect(problems, target).toEqual([])
614
+ })
615
+ }
616
+ })
617
+ }
@@ -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>