@7365admin1/layer-common 4.2.2 → 4.2.4
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/CHANGELOG.md +38 -4
- package/components/Input/DateTimePicker.vue +78 -155
- package/components/VehicleForm.vue +22 -17
- package/components/VehicleManagement.vue +9 -19
- package/package.json +1 -1
- package/utils/data.ts +108 -28
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.2.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 23b3b39: Fix the shared date/time field: a date the user types or picks now reaches the payload.
|
|
8
|
+
|
|
9
|
+
`Input/DateTimePicker` displayed a read-only Vuetify text field over a hidden native input. Typing went into Vuetify's own copy of the value — it displayed, it passed the required rule, and both models stayed empty, so `startDate: ""` and `endDate: ""` were submitted. Picking from the calendar drove the invisible input, whose segments never completed, and set nothing. The field is now the native input itself, two-way bound.
|
|
10
|
+
|
|
11
|
+
Affects every form that takes a date: bulletin board, people, vehicle season pass, building unit lease and the cleaning schedule filters.
|
|
12
|
+
|
|
13
|
+
- 05428e8: Send vehicle season pass dates as real instants, not zoneless wall-clock strings
|
|
14
|
+
|
|
15
|
+
A pass entered as expiring 05/12/2026 at 6:45 pm was stored as 06/12/2026 at
|
|
16
|
+
2:45 am. The season pass form sent `start` and `end` as `YYYY-MM-DDTHH:mm` with
|
|
17
|
+
no timezone, so the server parsed them in its own zone (UTC) while the browser
|
|
18
|
+
rendered the result back in SGT: exactly +8h on both fields, with the expiry
|
|
19
|
+
rolling into the next day.
|
|
20
|
+
|
|
21
|
+
Both date fields now bind the picker's `utc` model, the same binding the
|
|
22
|
+
bulletin board and people forms already use, and the subscription preset buttons
|
|
23
|
+
write a UTC instant instead of a wall clock. The picker also prefers a value
|
|
24
|
+
written by the parent over its own copy of the last typed one, so a preset
|
|
25
|
+
clicked after typing no longer leaves the old date on screen.
|
|
26
|
+
|
|
27
|
+
## 4.2.3
|
|
28
|
+
|
|
29
|
+
### Patch Changes
|
|
30
|
+
|
|
31
|
+
- b2d7f77: Render a plain sentence when a request fails without reaching the API.
|
|
32
|
+
|
|
33
|
+
`errorConverter` — the app-wide error mapper used by 29 call sites in this layer — only understood a JSON API error. A gateway 502, a 503/504, an nginx or Cloudflare HTML error page, a timeout or a dropped connection fell through it, so `ofetch`'s own developer string reached the screen: `[PUT] "/api/vehicles/6a9691f25648d7633171c9e3": 502`. It now maps those to a sentence chosen from the status, never renders HTML, a JSON dump or a raw object, and still returns our own API's message unchanged whenever there is one.
|
|
34
|
+
|
|
35
|
+
The vehicle dialogs had their own local copy of that logic (`readServerMessage`), which fell back to `error.message` and was where the leak was seen. It is gone; delete, restore, update-type, approve and the add/edit form all use the shared mapper.
|
|
36
|
+
|
|
3
37
|
## 4.2.2
|
|
4
38
|
|
|
5
39
|
### Patch Changes
|
|
@@ -458,11 +492,11 @@
|
|
|
458
492
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
459
493
|
own rule from two endpoints the console already calls, unprojected:
|
|
460
494
|
|
|
461
|
-
|
|
462
|
-
|
|
495
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
496
|
+
GET /api/roles/id/:role that membership's role document
|
|
463
497
|
|
|
464
|
-
|
|
465
|
-
|
|
498
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
499
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
466
500
|
|
|
467
501
|
`role.default` is the marker because it is the only property of a platform
|
|
468
502
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -1,42 +1,54 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
<template #append-inner>
|
|
15
|
-
<v-icon icon="mdi-calendar" @click.stop="openDatePicker" />
|
|
16
|
-
</template>
|
|
17
|
-
</v-text-field>
|
|
18
|
-
<div class="w-100 d-flex align-end ga-3 hidden-input">
|
|
19
|
-
<input
|
|
20
|
-
ref="dateInput"
|
|
21
|
-
:type="inputType"
|
|
22
|
-
v-model="dateTime"
|
|
23
|
-
:min="minValue"
|
|
24
|
-
/>
|
|
25
|
-
</div>
|
|
26
|
-
</div>
|
|
2
|
+
<v-text-field
|
|
3
|
+
v-bind="$attrs"
|
|
4
|
+
ref="dateTimePickerRef"
|
|
5
|
+
class="filter-field"
|
|
6
|
+
v-model="fieldValue"
|
|
7
|
+
:type="inputType"
|
|
8
|
+
:label="placeholder || undefined"
|
|
9
|
+
:min="minValue"
|
|
10
|
+
:rules="rules"
|
|
11
|
+
persistent-placeholder
|
|
12
|
+
autocomplete="off"
|
|
13
|
+
/>
|
|
27
14
|
</template>
|
|
28
15
|
|
|
29
16
|
<script setup lang="ts">
|
|
30
|
-
import { ref, computed
|
|
17
|
+
import { ref, computed } from "vue";
|
|
18
|
+
|
|
19
|
+
/*
|
|
20
|
+
* The field the user edits IS the native date input now.
|
|
21
|
+
*
|
|
22
|
+
* It used to be two elements: a read-only Vuetify text field for show, and a
|
|
23
|
+
* zero-height `opacity: 0` native input behind it that `showPicker()` was
|
|
24
|
+
* called on. That arrangement broke every way a user can enter a date:
|
|
25
|
+
*
|
|
26
|
+
* - TYPING went into Vuetify's own internal copy of the value (the text field
|
|
27
|
+
* was bound with `:model-value` and nothing listened for the update), so the
|
|
28
|
+
* typed date DISPLAYED and PASSED the required-rule while both models here
|
|
29
|
+
* stayed empty - `startDate: ""` went to the API, and nothing could ever be
|
|
30
|
+
* Upcoming.
|
|
31
|
+
* - PICKING a date drove the invisible input. A partly-filled
|
|
32
|
+
* `datetime-local` has an empty value until every segment is set, and the
|
|
33
|
+
* segments were invisible, so the picker closed and set nothing.
|
|
34
|
+
* - An End Date that was typed stayed invalid for the same reason as the
|
|
35
|
+
* first point, so an announcement with a real end date could not be created
|
|
36
|
+
* at all.
|
|
37
|
+
*
|
|
38
|
+
* One visible native input has the browser's own picker, its own typing, and a
|
|
39
|
+
* real `v-model`, which is all three fixes.
|
|
40
|
+
*/
|
|
31
41
|
|
|
32
42
|
const prop = defineProps({
|
|
33
43
|
rules: {
|
|
34
44
|
type: Array as PropType<Array<any>>,
|
|
35
45
|
default: () => [],
|
|
36
46
|
},
|
|
47
|
+
// Rendered as the field's label: a native date input always draws its own
|
|
48
|
+
// dd/mm/yyyy segments, so a placeholder would never be seen.
|
|
37
49
|
placeholder: {
|
|
38
50
|
type: String,
|
|
39
|
-
default: "
|
|
51
|
+
default: "",
|
|
40
52
|
},
|
|
41
53
|
dateOnly: {
|
|
42
54
|
type: Boolean,
|
|
@@ -48,11 +60,10 @@ const prop = defineProps({
|
|
|
48
60
|
},
|
|
49
61
|
});
|
|
50
62
|
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
const dateTimeUTC = defineModel<string | null>("utc", { default: null }); // UTC format
|
|
63
|
+
const dateTime = defineModel<string | null>({ default: null }); // 2025-10-10T13:09, local
|
|
64
|
+
const dateTimeUTC = defineModel<string | null>("utc", { default: null }); // ISO 8601, UTC
|
|
54
65
|
|
|
55
|
-
const
|
|
66
|
+
const dateTimePickerRef = ref<HTMLInputElement | null>(null);
|
|
56
67
|
const inputType = computed(() => (prop.dateOnly ? "date" : "datetime-local"));
|
|
57
68
|
|
|
58
69
|
const minValue = computed(() => {
|
|
@@ -62,148 +73,60 @@ const minValue = computed(() => {
|
|
|
62
73
|
}
|
|
63
74
|
return prop.min;
|
|
64
75
|
});
|
|
65
|
-
const inputPlaceholder = computed(
|
|
66
|
-
() =>
|
|
67
|
-
prop.placeholder ||
|
|
68
|
-
(prop.dateOnly ? "MM/DD/YYYY" : "DD/MM/YYYY, HH:MM AM/PM")
|
|
69
|
-
);
|
|
70
|
-
|
|
71
|
-
const dateInput = ref<HTMLInputElement | null>(null);
|
|
72
|
-
const dateTimePickerRef = ref<HTMLInputElement | null>(null);
|
|
73
|
-
let nativeInput: HTMLInputElement | null = null;
|
|
74
|
-
|
|
75
|
-
const isInitialLoad = ref(true);
|
|
76
|
-
|
|
77
|
-
function handleNativeInputClick(e: MouseEvent) {
|
|
78
|
-
e.stopPropagation();
|
|
79
|
-
openDatePicker();
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function openDatePicker() {
|
|
83
|
-
setTimeout(() => {
|
|
84
|
-
dateInput.value?.showPicker?.();
|
|
85
|
-
}, 0);
|
|
86
|
-
}
|
|
87
76
|
|
|
88
77
|
function validate() {
|
|
89
78
|
(dateTimePickerRef.value as any)?.validate();
|
|
90
79
|
}
|
|
91
80
|
|
|
92
|
-
|
|
93
|
-
if (!dateStr) return "";
|
|
81
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
94
82
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const year = date.getFullYear();
|
|
101
|
-
|
|
102
|
-
if (prop.dateOnly) {
|
|
103
|
-
return `${day}/${month}/${year}`;
|
|
83
|
+
// What the native input accepts: `YYYY-MM-DD` or `YYYY-MM-DDTHH:mm`, local time.
|
|
84
|
+
function toInputValue(value: string | null): string {
|
|
85
|
+
if (!value) return "";
|
|
86
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
87
|
+
return prop.dateOnly ? value : `${value}T00:00`;
|
|
104
88
|
}
|
|
105
|
-
|
|
106
|
-
let hours = date.getHours();
|
|
107
|
-
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
108
|
-
|
|
109
|
-
const ampm = hours >= 12 ? "PM" : "AM";
|
|
110
|
-
hours = hours % 12;
|
|
111
|
-
hours = hours ? hours : 12;
|
|
112
|
-
|
|
113
|
-
const formattedTime = `${String(hours).padStart(2, "0")}:${minutes} ${ampm}`;
|
|
114
|
-
|
|
115
|
-
return `${day}/${month}/${year}, ${formattedTime}`;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function toDateOnlyInputValue(dateStr: string): string {
|
|
119
|
-
if (!dateStr) return "";
|
|
120
|
-
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) return dateStr;
|
|
121
|
-
const date = new Date(dateStr);
|
|
89
|
+
const date = new Date(value);
|
|
122
90
|
if (Number.isNaN(date.getTime())) return "";
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
91
|
+
const day = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
92
|
+
return prop.dateOnly
|
|
93
|
+
? day
|
|
94
|
+
: `${day}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
127
95
|
}
|
|
128
96
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const localDate = new Date(dateDefault);
|
|
138
|
-
dateTimeUTC.value = localDate.toISOString();
|
|
139
|
-
}
|
|
140
|
-
} else if (dateUTC) {
|
|
141
|
-
dateTimeFormattedReadOnly.value = convertToReadableFormat(dateUTC);
|
|
142
|
-
if (prop.dateOnly) {
|
|
143
|
-
dateTime.value = toDateOnlyInputValue(dateUTC);
|
|
144
|
-
} else {
|
|
145
|
-
const localDate = new Date(dateUTC);
|
|
146
|
-
dateTime.value = formatDateISO8601(localDate);
|
|
147
|
-
}
|
|
148
|
-
} else {
|
|
149
|
-
dateTimeFormattedReadOnly.value = null;
|
|
150
|
-
}
|
|
97
|
+
// What the API stores: a date-only field stays `YYYY-MM-DD`, everything else
|
|
98
|
+
// becomes UTC.
|
|
99
|
+
function toUTCValue(value: string | null): string | null {
|
|
100
|
+
if (!value) return null;
|
|
101
|
+
if (prop.dateOnly) return toInputValue(value).slice(0, 10) || null;
|
|
102
|
+
const date = new Date(value);
|
|
103
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
104
|
+
return date.toISOString();
|
|
151
105
|
}
|
|
152
106
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if (
|
|
165
|
-
dateTimeUTC.value
|
|
166
|
-
} else {
|
|
167
|
-
const localDate = new Date(dateVal);
|
|
168
|
-
dateTimeUTC.value = localDate.toISOString();
|
|
107
|
+
// The one value the field is bound to. A getter and a setter instead of a pair
|
|
108
|
+
// of watchers: no initial-load flag, no chance of the two models fighting each
|
|
109
|
+
// other, and it behaves the same on the server as in the browser.
|
|
110
|
+
const fieldValue = computed({
|
|
111
|
+
get: () => {
|
|
112
|
+
// The two models only agree when the setter below wrote them together. When
|
|
113
|
+
// a PARENT writes one of them - the season pass dialog's subscription
|
|
114
|
+
// buttons write the UTC one - the other still holds this component's copy
|
|
115
|
+
// of whatever was in the field before, and reading that copy first would
|
|
116
|
+
// leave the field showing a date the form is no longer holding.
|
|
117
|
+
// Disagreement means the parent wrote, so the parent wins.
|
|
118
|
+
if (dateTimeUTC.value && toUTCValue(dateTime.value) !== dateTimeUTC.value) {
|
|
119
|
+
return toInputValue(dateTimeUTC.value);
|
|
169
120
|
}
|
|
121
|
+
return toInputValue(dateTime.value || dateTimeUTC.value);
|
|
170
122
|
},
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
watch(
|
|
175
|
-
dateTimeUTC,
|
|
176
|
-
() => {
|
|
177
|
-
handleInitialDate();
|
|
123
|
+
set: (value: string | null) => {
|
|
124
|
+
dateTime.value = value || null;
|
|
125
|
+
dateTimeUTC.value = toUTCValue(value);
|
|
178
126
|
},
|
|
179
|
-
{ immediate: true }
|
|
180
|
-
);
|
|
181
|
-
|
|
182
|
-
onMounted(async () => {
|
|
183
|
-
await nextTick();
|
|
184
|
-
isInitialLoad.value = false;
|
|
185
|
-
// Wait until Vuetify renders its internal input
|
|
186
|
-
nativeInput = (dateTimePickerRef.value as any)?.$el?.querySelector(
|
|
187
|
-
"input"
|
|
188
|
-
);
|
|
189
|
-
if (nativeInput) {
|
|
190
|
-
nativeInput.addEventListener("click", handleNativeInputClick);
|
|
191
|
-
}
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
onBeforeUnmount(() => {
|
|
195
|
-
nativeInput?.removeEventListener("click", handleNativeInputClick);
|
|
196
|
-
nativeInput = null;
|
|
197
127
|
});
|
|
198
128
|
|
|
199
129
|
defineExpose({
|
|
200
130
|
validate,
|
|
201
131
|
});
|
|
202
132
|
</script>
|
|
203
|
-
|
|
204
|
-
<style scoped>
|
|
205
|
-
.hidden-input {
|
|
206
|
-
opacity: 0;
|
|
207
|
-
height: 0;
|
|
208
|
-
}
|
|
209
|
-
</style>
|
|
@@ -268,7 +268,7 @@
|
|
|
268
268
|
/>
|
|
269
269
|
<InputDateTimePicker
|
|
270
270
|
ref="startDateRef"
|
|
271
|
-
v-model="vehicle.start"
|
|
271
|
+
v-model:utc="vehicle.start"
|
|
272
272
|
:rules="[validStartDateRule]"
|
|
273
273
|
/>
|
|
274
274
|
</v-col>
|
|
@@ -281,7 +281,7 @@
|
|
|
281
281
|
/>
|
|
282
282
|
<InputDateTimePicker
|
|
283
283
|
ref="expiryDateRef"
|
|
284
|
-
v-model="vehicle.end"
|
|
284
|
+
v-model:utc="vehicle.end"
|
|
285
285
|
:rules="[validExpiryDateRule]"
|
|
286
286
|
/>
|
|
287
287
|
</v-col>
|
|
@@ -411,6 +411,7 @@
|
|
|
411
411
|
</template>
|
|
412
412
|
|
|
413
413
|
<script setup lang="ts">
|
|
414
|
+
import { errorConverter } from "../utils/data";
|
|
414
415
|
const prop = defineProps({
|
|
415
416
|
type: {
|
|
416
417
|
type: String as PropType<TVehicleType | "seasonpass">,
|
|
@@ -438,7 +439,7 @@ const prop = defineProps({
|
|
|
438
439
|
},
|
|
439
440
|
});
|
|
440
441
|
|
|
441
|
-
const { requiredRule,
|
|
442
|
+
const { requiredRule, debounce } = useUtils();
|
|
442
443
|
const {
|
|
443
444
|
addVehicle,
|
|
444
445
|
getCustomSeasonPassTypes,
|
|
@@ -865,12 +866,11 @@ async function submit() {
|
|
|
865
866
|
// A refused create is fail-closed on the server and its message names the
|
|
866
867
|
// camera that would not take the plate. `$fetch` puts it on `data`, `ofetch`
|
|
867
868
|
// on `response._data`; reading only one of them dropped the reason.
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
} vehicle. Please try again.`;
|
|
869
|
+
// `errorConverter` reads both shapes and, when the failure never reached
|
|
870
|
+
// the API at all (a gateway 502 answers with HTML, a dropped connection
|
|
871
|
+
// with nothing), returns a plain sentence instead of ofetch's
|
|
872
|
+
// `[POST] "/api/vehicles": 502`.
|
|
873
|
+
errorMessage.value = errorConverter(error);
|
|
874
874
|
} finally {
|
|
875
875
|
processing.value = false;
|
|
876
876
|
}
|
|
@@ -881,15 +881,20 @@ async function submit() {
|
|
|
881
881
|
|
|
882
882
|
// }
|
|
883
883
|
|
|
884
|
+
/*
|
|
885
|
+
* The subscription buttons write the same thing the picker writes: a real
|
|
886
|
+
* instant.
|
|
887
|
+
*
|
|
888
|
+
* `formatDateISO8601` returns `YYYY-MM-DDTHH:mm` with no zone - the browser's
|
|
889
|
+
* WALL CLOCK, not a moment in time. Sent to the API as-is, the server parsed it
|
|
890
|
+
* in its own zone (UTC) and a pass entered as 05/12/2026 6:45 pm came back as
|
|
891
|
+
* 06/12/2026 2:45 am. These two fields now carry UTC end to end, so the preset
|
|
892
|
+
* and the picker cannot disagree about what "now" meant.
|
|
893
|
+
*/
|
|
884
894
|
watch(selectedSubscriptionDuration, (duration) => {
|
|
885
|
-
const dateNowISO =
|
|
886
|
-
const weeksLaterISO = (week: number) =>
|
|
887
|
-
|
|
888
|
-
const dateWeekLater = new Date(
|
|
889
|
-
now.getTime() + week * 7 * 24 * 60 * 60 * 1000
|
|
890
|
-
);
|
|
891
|
-
return formatDateISO8601(dateWeekLater);
|
|
892
|
-
};
|
|
895
|
+
const dateNowISO = new Date().toISOString();
|
|
896
|
+
const weeksLaterISO = (week: number) =>
|
|
897
|
+
new Date(Date.now() + week * 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
893
898
|
|
|
894
899
|
if (duration === "custom") {
|
|
895
900
|
vehicle.start = "";
|
|
@@ -210,6 +210,11 @@ import {
|
|
|
210
210
|
vehiclePlateActions,
|
|
211
211
|
type VehicleActionKey,
|
|
212
212
|
} from '../utils/vehicle-actions';
|
|
213
|
+
// The app-wide error mapper. A gateway 502 answers with an HTML page, not a
|
|
214
|
+
// JSON API error, so reading `_data.message` and then falling back to
|
|
215
|
+
// `error.message` put ofetch's own developer string in the delete dialog:
|
|
216
|
+
// `[PUT] "/api/vehicles/6a9691f25648d7633171c9e3": 502`.
|
|
217
|
+
import { errorConverter } from '../utils/data';
|
|
213
218
|
|
|
214
219
|
|
|
215
220
|
|
|
@@ -289,21 +294,6 @@ function closeActionDialog(key: "approveVehicle" | "restoreVehicle" | "updateVeh
|
|
|
289
294
|
actionError.value = "";
|
|
290
295
|
}
|
|
291
296
|
|
|
292
|
-
/**
|
|
293
|
-
* The server's own sentence, or a plain fallback.
|
|
294
|
-
*
|
|
295
|
-
* `error.response._data` is undefined on a network failure, so reading it
|
|
296
|
-
* unguarded threw out of the catch and the operator saw nothing at all.
|
|
297
|
-
*/
|
|
298
|
-
function readServerMessage(error: any, fallback: string) {
|
|
299
|
-
return (
|
|
300
|
-
error?.response?._data?.message ||
|
|
301
|
-
error?.data?.message ||
|
|
302
|
-
error?.message ||
|
|
303
|
-
fallback
|
|
304
|
-
);
|
|
305
|
-
}
|
|
306
|
-
|
|
307
297
|
function showMessage(msg: string, color: string) {
|
|
308
298
|
message.value = msg;
|
|
309
299
|
messageColor.value = color;
|
|
@@ -566,7 +556,7 @@ async function submitDelete() {
|
|
|
566
556
|
// request is refused. Say so. This used to assign to `message` WITHOUT
|
|
567
557
|
// raising the snackbar - and `error.response._data` throws on a network
|
|
568
558
|
// error - so a refused delete showed the operator nothing at all.
|
|
569
|
-
deleteError.value =
|
|
559
|
+
deleteError.value = errorConverter(error);
|
|
570
560
|
// The dialog stays open and the row stays in the table - nothing is removed
|
|
571
561
|
// optimistically, so what is on screen still matches the database.
|
|
572
562
|
} finally {
|
|
@@ -595,7 +585,7 @@ async function submitRestore() {
|
|
|
595
585
|
showMessage(res.message, "success");
|
|
596
586
|
await getVehiclesRefresh();
|
|
597
587
|
} catch (error: any) {
|
|
598
|
-
actionError.value =
|
|
588
|
+
actionError.value = errorConverter(error);
|
|
599
589
|
} finally {
|
|
600
590
|
restoringVehicle.value = false;
|
|
601
591
|
}
|
|
@@ -642,7 +632,7 @@ async function submitUpdateType() {
|
|
|
642
632
|
showMessage(res.message, "success");
|
|
643
633
|
await getVehiclesRefresh();
|
|
644
634
|
} catch (error: any) {
|
|
645
|
-
actionError.value =
|
|
635
|
+
actionError.value = errorConverter(error);
|
|
646
636
|
} finally {
|
|
647
637
|
updatingType.value = false;
|
|
648
638
|
}
|
|
@@ -667,7 +657,7 @@ async function submitApprove() {
|
|
|
667
657
|
showMessage(res.message, "success");
|
|
668
658
|
await getVehiclesRefresh();
|
|
669
659
|
} catch (error: any) {
|
|
670
|
-
actionError.value =
|
|
660
|
+
actionError.value = errorConverter(error);
|
|
671
661
|
} finally {
|
|
672
662
|
approvingVehicle.value = false;
|
|
673
663
|
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@7365admin1/layer-common",
|
|
3
3
|
"license": "MIT",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "4.2.
|
|
5
|
+
"version": "4.2.4",
|
|
6
6
|
"author": "7365admin1",
|
|
7
7
|
"main": "./nuxt.config.ts",
|
|
8
8
|
"//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
|
package/utils/data.ts
CHANGED
|
@@ -1,33 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app-wide "turn a failed request into a sentence" helper. 29 call sites in
|
|
3
|
+
* this layer alone, so every module gets whatever this returns.
|
|
4
|
+
*
|
|
5
|
+
* It only ever understood a JSON API error - `{ message: "..." }` from our own
|
|
6
|
+
* Express error handler. Anything else fell through to a developer string. A
|
|
7
|
+
* gateway 502 put this in front of an operator, verbatim, inside the delete
|
|
8
|
+
* dialog:
|
|
9
|
+
*
|
|
10
|
+
* [PUT] "/api/vehicles/6a9691f25648d7633171c9e3": 502
|
|
11
|
+
*
|
|
12
|
+
* That is ofetch's own `error.message`. It names an internal route and a record
|
|
13
|
+
* id, and tells the person nothing they can do. nginx and Cloudflare answer
|
|
14
|
+
* with HTML, not JSON, so `_data` has no `.message` at all - and on a dropped
|
|
15
|
+
* connection there is no `response` either.
|
|
16
|
+
*
|
|
17
|
+
* The rules below, in order:
|
|
18
|
+
* 1. our own API said something -> say exactly that, unchanged. This is the
|
|
19
|
+
* path every working screen already takes and it must not move.
|
|
20
|
+
* 2. otherwise -> a plain sentence chosen from the status.
|
|
21
|
+
* 3. never -> HTML, a JSON dump, an ofetch `[METHOD] "url": status` string,
|
|
22
|
+
* or a raw object.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** ofetch builds `[GET] "/api/thing": 502 Bad Gateway`. Never show it. */
|
|
26
|
+
const OFETCH_MESSAGE = /^\[[A-Z]+\]\s+"/;
|
|
27
|
+
|
|
28
|
+
/** A body that is a document, not an API error. */
|
|
29
|
+
const LOOKS_LIKE_HTML = /^\s*(<!doctype|<html|<)/i;
|
|
30
|
+
|
|
31
|
+
const GENERIC =
|
|
32
|
+
"Something went wrong. Please try again or contact support if the problem persists.";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The server's own sentence, if there is one that is safe to show.
|
|
36
|
+
*
|
|
37
|
+
* Returns `""` when the body is not one of ours - an HTML error page, a JSON
|
|
38
|
+
* object with no `message`, or an empty response.
|
|
39
|
+
*/
|
|
40
|
+
function apiMessage(error: any): string {
|
|
41
|
+
const data = error?.response?._data ?? error?.data ?? error?._data;
|
|
42
|
+
|
|
43
|
+
const candidate =
|
|
44
|
+
typeof data === "string" ? data : (data?.message ?? data?.error);
|
|
45
|
+
|
|
46
|
+
if (typeof candidate !== "string") return "";
|
|
47
|
+
|
|
48
|
+
const text = candidate.trim();
|
|
49
|
+
if (!text) return "";
|
|
50
|
+
if (LOOKS_LIKE_HTML.test(text)) return "";
|
|
51
|
+
// A whole HTML page that starts with whitespace/JSON-ish noise still has no
|
|
52
|
+
// business on screen; anything this long is a document, not a message.
|
|
53
|
+
if (text.length > 400) return "";
|
|
54
|
+
|
|
55
|
+
return text;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A plain sentence for a request that failed without telling us anything. */
|
|
59
|
+
function sentenceForStatus(status: number | null): string {
|
|
60
|
+
if (status === null) {
|
|
61
|
+
// No response at all: DNS, a dropped connection, a cancelled request.
|
|
62
|
+
return "Could not reach the server. Check your internet connection and try again.";
|
|
63
|
+
}
|
|
64
|
+
if (status === 401) return "Your session has expired. Please sign in again.";
|
|
65
|
+
if (status === 403) {
|
|
66
|
+
return "You do not have permission to do this. Ask your iService365 administrator for access.";
|
|
67
|
+
}
|
|
68
|
+
if (status === 404) return "That record no longer exists. Refresh the page and try again.";
|
|
69
|
+
if (status === 408 || status === 504) {
|
|
70
|
+
return "The server took too long to answer. Please try again in a few moments.";
|
|
71
|
+
}
|
|
72
|
+
if (status === 429) return "Too many attempts in a short time. Please wait a moment and try again.";
|
|
73
|
+
if (status === 502 || status === 503) {
|
|
74
|
+
return "The server is temporarily unavailable. Please try again in a few moments.";
|
|
75
|
+
}
|
|
76
|
+
if (status >= 500) {
|
|
77
|
+
return "The server could not complete this request. Please try again, and contact support if it keeps happening.";
|
|
78
|
+
}
|
|
79
|
+
return GENERIC;
|
|
80
|
+
}
|
|
81
|
+
|
|
1
82
|
export const errorConverter = (data: any): string => {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
if (
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
console.log("converter: ", data);
|
|
28
|
-
error = data?.message;
|
|
83
|
+
// 1. Our own API's sentence wins, whatever the status is. Unchanged
|
|
84
|
+
// behaviour for every screen that works today.
|
|
85
|
+
const fromApi = apiMessage(data);
|
|
86
|
+
if (fromApi) return fromApi;
|
|
87
|
+
|
|
88
|
+
// 2. No usable body. Decide from the status alone.
|
|
89
|
+
const status =
|
|
90
|
+
data?.response?.status ?? data?.statusCode ?? data?.status ?? null;
|
|
91
|
+
|
|
92
|
+
if (typeof status === "number") return sentenceForStatus(status);
|
|
93
|
+
|
|
94
|
+
// 3. No status either. An ofetch error with no response means the request
|
|
95
|
+
// never completed at all - DNS, a dropped connection, a cancelled
|
|
96
|
+
// request - and its `message` is the developer string that leaked.
|
|
97
|
+
const message = typeof data?.message === "string" ? data.message.trim() : "";
|
|
98
|
+
|
|
99
|
+
if (OFETCH_MESSAGE.test(message) || data?.name === "FetchError") {
|
|
100
|
+
return sentenceForStatus(null);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 4. A plain `throw new Error("...")` from our own code. Several of those are
|
|
104
|
+
// written FOR the toast (see `findServiceProviderIdBySite` below), so they
|
|
105
|
+
// still pass through unchanged.
|
|
106
|
+
if (message && message.length <= 400 && !LOOKS_LIKE_HTML.test(message)) {
|
|
107
|
+
return message;
|
|
29
108
|
}
|
|
30
|
-
|
|
109
|
+
|
|
110
|
+
return GENERIC;
|
|
31
111
|
};
|
|
32
112
|
|
|
33
113
|
/**
|