@7365admin1/layer-common 3.2.2-staging.191 → 3.2.2-staging.193

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.
@@ -11,7 +11,7 @@
11
11
  </PageHeader>
12
12
  </v-col>
13
13
 
14
- <TableMain :headers="headers" :items="paginatePlaceholderItem" v-model:search="searchInput"
14
+ <TableMain :headers="headers" :items="items" v-model:search="searchInput"
15
15
  :loading="getAnnouncementPending" :page="page" :pages="pages" :pageRange="pageRange"
16
16
  @refresh="getAnnouncementsRefresh" :show-header="APP_CONSTANTS.RESIDENT === props.recipients" @update:page="handleUpdatePage" @row-click="handleRowClick">
17
17
  <!--
@@ -27,6 +27,15 @@
27
27
  </button>
28
28
  </template>
29
29
 
30
+ <!--
31
+ A failed request is not an empty bulletin board. Without this the
32
+ table drew its neutral "No data available" for a 500, which reads
33
+ as "there are no announcements" - a statement the server never made.
34
+ -->
35
+ <template v-if="getAnnouncementError" #no-data>
36
+ <DashboardEmptyState error />
37
+ </template>
38
+
30
39
  <template v-slot:item.createdAt="{ item }">
31
40
  <span class="d-flex align-center ga-2">
32
41
  <v-icon icon="mdi-calendar-start" color="green" size="20" />
@@ -75,6 +84,10 @@
75
84
  <script setup lang="ts">
76
85
  import useBulletin from '../composables/useBulletin';
77
86
  import { APP_CONSTANTS } from '../constants/app';
87
+ // Imported by relative path, not left to Nuxt's auto-import: this layer's
88
+ // components always import their utils explicitly, and a missing import here
89
+ // is a ReferenceError that blanks the screen in every consuming app.
90
+ import { readListPage } from '../utils/list-page';
78
91
 
79
92
  definePageMeta({
80
93
  memberOnly: true,
@@ -135,6 +148,13 @@ const headers = computed(() => {
135
148
  return arr;
136
149
 
137
150
  });
151
+ // THE SERVER PAGES THIS LIST. `getAll` is called with `page`, so `items` is
152
+ // already one page of rows and `pages` / `pageRange` are the server's own
153
+ // answer for the whole list. A computed used to re-derive both from
154
+ // `items.value.length` - i.e. from the page it had just been handed - so 47
155
+ // announcements read "1-10 of 10", the pager was told there was 1 page, and
156
+ // page 2 was unreachable. Nothing here recomputes them any more; the watcher
157
+ // below is the only writer.
138
158
  const items = ref<TAnnouncement[]>([]);
139
159
  const page = ref(1);
140
160
  const pages = ref(0);
@@ -163,28 +183,11 @@ const activeAnnouncementObj = computed(() => {
163
183
  return items.value.find(x => x._id === selectedAnnouncementId.value)
164
184
  })
165
185
 
166
- const paginatePlaceholderItem = computed(() => {
167
- const pageSize = 10
168
- const total = items.value.length
169
- const currentPage = page.value
170
-
171
- const start = (currentPage - 1) * pageSize
172
- const end = start + pageSize
173
-
174
- const from = total === 0 ? 0 : start + 1
175
- const to = Math.min(end, total)
176
- pageRange.value = `${from}-${to} of ${total}`
177
-
178
- pages.value = Math.ceil(total / pageSize)
179
-
180
- return items.value.slice(start, end)
181
- })
182
-
183
-
184
186
  const {
185
187
  data: getAnnouncementReq,
186
188
  refresh: getAnnouncementsRefresh,
187
189
  pending: getAnnouncementPending,
190
+ error: getAnnouncementError,
188
191
  } = await useLazyAsyncData(
189
192
  `get-all-announcements-${page.value}`,
190
193
  () => getAll({ page: page.value, site: siteId, status: status.value, recipients: props.recipients ?? "" }),
@@ -194,9 +197,10 @@ const {
194
197
  );
195
198
 
196
199
  watch(getAnnouncementReq, (newVal) => {
197
- items.value = newVal?.items || [];
198
- pages.value = newVal?.pages || 0;
199
- pageRange.value = newVal?.pageRange || "-- - -- of --";
200
+ const listPage = readListPage<TAnnouncement>(newVal);
201
+ items.value = listPage.items;
202
+ pages.value = listPage.pages;
203
+ pageRange.value = listPage.pageRange;
200
204
  });
201
205
 
202
206
 
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "3.2.2-staging.191",
5
+ "version": "3.2.2-staging.193",
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.",
@@ -0,0 +1,72 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import { findServiceProviderIdBySite } from "./data.ts";
5
+
6
+ const SITE = "68f05fcc0c11062938dadada";
7
+ const OTHER_SITE = "68f05fcc0c11062938d00001";
8
+ const PROVIDER = "68f05fcc0c11062938d00002";
9
+
10
+ test("returns the provider that covers this site", () => {
11
+ assert.equal(
12
+ findServiceProviderIdBySite(
13
+ "service-provider",
14
+ [{ _id: PROVIDER, siteId: SITE }],
15
+ SITE
16
+ ),
17
+ PROVIDER
18
+ );
19
+ });
20
+
21
+ test("a non-service-provider account is not scoped by provider at all", () => {
22
+ assert.equal(findServiceProviderIdBySite("customer", [], SITE), null);
23
+ assert.equal(findServiceProviderIdBySite("", undefined, SITE), null);
24
+ });
25
+
26
+ /**
27
+ * Returning null here would drop the provider filter and show the whole site -
28
+ * another provider's work orders included. Refusing is the correct behaviour.
29
+ */
30
+ test("refuses rather than falling back to an unscoped request", () => {
31
+ assert.throws(() =>
32
+ findServiceProviderIdBySite(
33
+ "service-provider",
34
+ [{ _id: PROVIDER, siteId: OTHER_SITE }],
35
+ SITE
36
+ )
37
+ );
38
+ });
39
+
40
+ /**
41
+ * The thrown message is rendered verbatim in a toast on six applications, so it
42
+ * is part of the user interface. Assert what it SAYS, not that it throws: the
43
+ * old message passed a "throws" assertion while quoting a raw ObjectId at a
44
+ * security officer and naming no action.
45
+ */
46
+ test("the message names an action and never quotes an internal id", () => {
47
+ for (const providers of [[{ _id: PROVIDER, siteId: OTHER_SITE }], [], undefined]) {
48
+ let message = "";
49
+ try {
50
+ findServiceProviderIdBySite("service-provider", providers, SITE);
51
+ assert.fail("expected it to refuse");
52
+ } catch (error: any) {
53
+ message = error.message;
54
+ }
55
+ assert.doesNotMatch(message, /[0-9a-f]{24}/i, `leaks an id: ${message}`);
56
+ assert.doesNotMatch(message, /siteId/i, `internal wording: ${message}`);
57
+ assert.match(message, /not linked to this site/i);
58
+ assert.match(message, /administrator/i);
59
+ }
60
+ });
61
+
62
+ /**
63
+ * An account with no serviceProviders field at all used to die on
64
+ * `undefined.find(...)` - a TypeError, which the toast renders as a raw
65
+ * JavaScript error instead of the sentence above.
66
+ */
67
+ test("an account with no provider list still gets the sentence, not a TypeError", () => {
68
+ assert.throws(
69
+ () => findServiceProviderIdBySite("service-provider", undefined, SITE),
70
+ (error: any) => error.constructor === Error && !/undefined/.test(error.message)
71
+ );
72
+ });
package/utils/data.ts CHANGED
@@ -30,17 +30,40 @@ export const errorConverter = (data: any): string => {
30
30
  return error;
31
31
  };
32
32
 
33
+ /**
34
+ * A service-provider account can only be shown ITS OWN work orders, feedbacks
35
+ * and key logs, so every one of those screens scopes its request by the
36
+ * provider record that links this account to the site currently in the URL.
37
+ *
38
+ * When that link does not exist there is nothing to scope by, and returning
39
+ * null instead would drop the filter and show the WHOLE site - another
40
+ * provider's work orders included. So this still refuses, deliberately.
41
+ *
42
+ * What it refuses WITH is the part that matters. The message is thrown straight
43
+ * into a toast on six applications, so it is written for the person reading it:
44
+ * it says what is missing and what to do about it. It used to read
45
+ * "Service provider with siteId 68f0...dadada not found", which quotes an
46
+ * internal identifier at a security officer and names no action - the screen
47
+ * looked broken when the truth was simply that this site is not one of theirs.
48
+ *
49
+ * The site list in the sidebar offers EVERY site in the organisation, not only
50
+ * the ones this account covers, so an officer can walk into this by picking a
51
+ * neighbouring site. That is why the wording assumes an ordinary mistake and
52
+ * not a fault.
53
+ */
33
54
  export const findServiceProviderIdBySite = (
34
55
  type: string,
35
56
  serviceProviders: any,
36
57
  siteId: string
37
58
  ) => {
38
59
  if (type === "service-provider") {
39
- const result = serviceProviders.find(
60
+ const result = serviceProviders?.find(
40
61
  (item: Record<string, any>) => item.siteId === siteId
41
62
  );
42
63
  if (!result?._id) {
43
- throw new Error(`Service provider with siteId ${siteId} not found`);
64
+ throw new Error(
65
+ "Your service provider account is not linked to this site, so there is nothing to show here. Choose a site your company covers, or ask your administrator to link your company to this one."
66
+ );
44
67
  }
45
68
  return result?._id;
46
69
  }
@@ -0,0 +1,60 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readListPage, NO_PAGE_RANGE } from "./list-page.ts";
4
+
5
+ const rows = (n: number, from = 1) =>
6
+ Array.from({ length: n }, (_, i) => ({ _id: `row-${from + i}` }));
7
+
8
+ describe("readListPage", () => {
9
+ it("keeps the server's total when it is bigger than the page - the failing case", () => {
10
+ // 47 announcements served 10 at a time. Deriving from the page gives
11
+ // "10" and one page, which is the defect this replaces.
12
+ const got = readListPage({ items: rows(10), pages: 5, pageRange: "1-10 of 47" });
13
+
14
+ assert.equal(got.items.length, 10);
15
+ assert.equal(got.pages, 5);
16
+ assert.equal(got.pageRange, "1-10 of 47");
17
+ });
18
+
19
+ it("keeps the server's page 2 range rather than re-slicing", () => {
20
+ const got = readListPage({ items: rows(10, 11), pages: 5, pageRange: "11-20 of 47" });
21
+
22
+ assert.equal(got.items.length, 10);
23
+ assert.equal(got.items[0]._id, "row-11");
24
+ assert.equal(got.pageRange, "11-20 of 47");
25
+ });
26
+
27
+ it("passes a single page through unchanged", () => {
28
+ const got = readListPage({ items: rows(7), pages: 1, pageRange: "1-7 of 7" });
29
+
30
+ assert.deepEqual(
31
+ { pages: got.pages, pageRange: got.pageRange, n: got.items.length },
32
+ { pages: 1, pageRange: "1-7 of 7", n: 7 }
33
+ );
34
+ });
35
+
36
+ it("reports a genuinely empty list as the server described it", () => {
37
+ const got = readListPage({ items: [], pages: 0, pageRange: "0 - 0 of 0" });
38
+
39
+ assert.deepEqual(got, { items: [], pages: 0, pageRange: "0 - 0 of 0" });
40
+ });
41
+
42
+ it("does not invent a zero when the request failed and there is no reply", () => {
43
+ for (const reply of [null, undefined]) {
44
+ const got = readListPage(reply);
45
+
46
+ assert.deepEqual(got.items, []);
47
+ assert.equal(got.pages, 0);
48
+ // "0 of 0" would be a statement about the data. This is not one.
49
+ assert.equal(got.pageRange, NO_PAGE_RANGE);
50
+ }
51
+ });
52
+
53
+ it("does not fall back to the page length when the envelope omits its totals", () => {
54
+ const got = readListPage({ items: rows(10) });
55
+
56
+ assert.equal(got.pages, 0);
57
+ assert.equal(got.pageRange, NO_PAGE_RANGE);
58
+ assert.notEqual(got.pageRange, "1-10 of 10");
59
+ });
60
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * READING A PAGINATED LIST REPLY.
3
+ *
4
+ * Every paginated endpoint in this product answers with an envelope: the rows
5
+ * for the page that was asked for, plus `pages` and `pageRange` describing the
6
+ * WHOLE list. Screens kept re-deriving those two from the rows they had just
7
+ * been handed - `items.length` is the size of one page, not the size of the
8
+ * list - so a list of 47 announcements presented itself as 10 and its pager
9
+ * was told there was one page.
10
+ *
11
+ * This is the one place that reads the envelope, so the rule is stated once:
12
+ * take the server's own totals, and when the reply is missing (a failed
13
+ * request) say so with a placeholder rather than inventing a zero.
14
+ */
15
+
16
+ export const NO_PAGE_RANGE = "-- - -- of --";
17
+
18
+ export type TListPageReply<T> = {
19
+ items?: T[] | null;
20
+ pages?: number | null;
21
+ pageRange?: string | null;
22
+ } | null | undefined;
23
+
24
+ export type TListPage<T> = {
25
+ items: T[];
26
+ pages: number;
27
+ pageRange: string;
28
+ };
29
+
30
+ export function readListPage<T>(reply: TListPageReply<T>): TListPage<T> {
31
+ return {
32
+ items: reply?.items ?? [],
33
+ // The server's page COUNT for the whole list. Never ceil(items/limit).
34
+ pages: reply?.pages ?? 0,
35
+ // The server's own "1-10 of 47" string. Never rebuilt from the page.
36
+ pageRange: reply?.pageRange || NO_PAGE_RANGE,
37
+ };
38
+ }