@c-rex/components 0.3.0-build.40 → 0.3.0-build.42

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c-rex/components",
3
- "version": "0.3.0-build.40",
3
+ "version": "0.3.0-build.42",
4
4
  "files": [
5
5
  "src"
6
6
  ],
@@ -53,6 +53,10 @@
53
53
  "types": "./src/info/information-unit-metadata-grid-client.tsx",
54
54
  "import": "./src/info/information-unit-metadata-grid-client.tsx"
55
55
  },
56
+ "./information-unit-authors-grid-client": {
57
+ "types": "./src/info/information-unit-authors-grid-client.tsx",
58
+ "import": "./src/info/information-unit-authors-grid-client.tsx"
59
+ },
56
60
  "./info-card": {
57
61
  "types": "./src/info/info-card.tsx",
58
62
  "import": "./src/info/info-card.tsx"
@@ -145,6 +149,10 @@
145
149
  "types": "./src/stores/search-settings-store.ts",
146
150
  "import": "./src/stores/search-settings-store.ts"
147
151
  },
152
+ "./ui-preferences-store": {
153
+ "types": "./src/stores/ui-preferences-store.ts",
154
+ "import": "./src/stores/ui-preferences-store.ts"
155
+ },
148
156
  "./article-content": {
149
157
  "types": "./src/article/article-content.tsx",
150
158
  "import": "./src/article/article-content.tsx"
@@ -36,6 +36,7 @@ export const AutoComplete = ({
36
36
  const [pkg] = useQueryState("package");
37
37
 
38
38
  const containerRef = useRef<HTMLDivElement>(null);
39
+ const inputRef = useRef<HTMLInputElement>(null);
39
40
  const suggestionRequestIdRef = useRef(0);
40
41
  const suggestionAbortControllerRef = useRef<AbortController | null>(null);
41
42
  const pathname = usePathname();
@@ -56,18 +57,22 @@ export const AutoComplete = ({
56
57
  const contentLang = useSearchSettingsStore.getState().language;
57
58
  const restrictions = searchParams.get("restrict")
58
59
 
59
- if (restrictions) {
60
- if (restrictions.includes("informationSubjects")) {
60
+ const mergedScopes: string[] = [];
61
61
 
62
- const informationSubject = restrictions.split("informationSubjects=")[1]?.split(',') || [];
63
- if (informationSubject.length > 0) {
64
- params.scopes = informationSubject.map(subject => `informationSubjects=${subject}`);
65
- }
66
- }
62
+ if (restrictions?.includes("informationSubjects")) {
63
+ const informationSubject = restrictions.split("informationSubjects=")[1]?.split(",") || [];
64
+ mergedScopes.push(...informationSubject.map((subject) => `informationSubjects=${subject}`));
65
+ }
66
+
67
+ if (pkg != null) {
68
+ mergedScopes.push(pkg);
69
+ }
70
+
71
+ if (mergedScopes.length > 0) {
72
+ params.scopes = mergedScopes as unknown as string[];
67
73
  }
68
74
 
69
75
  if (contentLang) params.lang = contentLang;
70
- if (pkg != null) params.scopes = pkg as unknown as string[];
71
76
 
72
77
  const results = await suggestionRequest({ endpoint, prefix, queryParams: params, signal });
73
78
 
@@ -168,6 +173,7 @@ export const AutoComplete = ({
168
173
  >
169
174
  <InputGroup variant={embedded ? "embedded" : "default"}>
170
175
  <InputGroupInput
176
+ ref={inputRef}
171
177
  variant={embedded ? "embedded" : undefined}
172
178
  className={inputClass}
173
179
  type="text"
@@ -190,7 +196,11 @@ export const AutoComplete = ({
190
196
  size="icon-xs"
191
197
  variant="ghost"
192
198
  aria-label={t("clearSearch")}
193
- onClick={() => setQuery("")}
199
+ onClick={(e) => {
200
+ e.preventDefault();
201
+ setQuery("")
202
+ inputRef.current?.focus();
203
+ }}
194
204
  >
195
205
  <X className="size-3" />
196
206
  </InputGroupButton>
@@ -217,11 +227,7 @@ export const AutoComplete = ({
217
227
  <li
218
228
  key={option}
219
229
  className="px-4 py-2 hover:bg-accent cursor-pointer text-sm"
220
- onClick={() => {
221
- //handleSelect(`"${option}"`)
222
- // TODO: check if the quotes are necessary
223
- handleSelect(option)
224
- }}
230
+ onClick={() => handleSelect(option)}
225
231
  >
226
232
  {option}
227
233
  </li>
@@ -54,6 +54,7 @@ const DocumentResultListRowContent: FC<RowContentProps> = ({
54
54
  isLoadingDetails = false,
55
55
  }) => {
56
56
  const t = useTranslations();
57
+ const tTypes = useTranslations("itemTypes");
57
58
 
58
59
  return (
59
60
  <div
@@ -92,7 +93,7 @@ const DocumentResultListRowContent: FC<RowContentProps> = ({
92
93
  </span>
93
94
 
94
95
  <div>
95
- <Badge>{itemType}</Badge>
96
+ <Badge>{tTypes(itemType.toLowerCase() as any) ?? itemType}</Badge>
96
97
  </div>
97
98
 
98
99
  <span className="text-sm block">
@@ -20,17 +20,20 @@ import {
20
20
  TableRow,
21
21
  } from "@c-rex/ui/table";
22
22
  import { FaRegBookmark } from "react-icons/fa6";
23
+ import { FaFile } from "react-icons/fa6";
23
24
  import { useFavorites } from "./favorites-context";
24
25
 
25
26
  type BookmarkProps = {
26
27
  shortId: string;
27
28
  linkPattern?: string;
29
+ documentLinkPattern?: string;
28
30
  triggerVariant?: ComponentProps<typeof Button>["variant"];
29
31
  };
30
32
 
31
33
  export const BookmarkButton: FC<BookmarkProps> = ({
32
34
  shortId,
33
35
  linkPattern = `/topics/{shortId}/pages`,
36
+ documentLinkPattern,
34
37
  triggerVariant = "outline"
35
38
  }) => {
36
39
  const t = useTranslations("bookmarks");
@@ -76,7 +79,23 @@ export const BookmarkButton: FC<BookmarkProps> = ({
76
79
  </TableRow>
77
80
  )}
78
81
 
79
- {isHydrated && visibleMarkersCount === 0 && (
82
+ {isHydrated && documentLinkPattern && documentLabel && (
83
+ <TableRow className="min-h-12">
84
+ <TableCell>
85
+ <FaFile className="text-primary" />
86
+ </TableCell>
87
+ <TableCell colSpan={2}>
88
+ <Link
89
+ href={documentLinkPattern.replace("{shortId}", shortId).replace("{id}", shortId)}
90
+ className="hover:underline font-medium"
91
+ >
92
+ {documentLabel}
93
+ </Link>
94
+ </TableCell>
95
+ </TableRow>
96
+ )}
97
+
98
+ {isHydrated && visibleMarkersCount === 0 && !documentLinkPattern && (
80
99
  <TableRow>
81
100
  <TableCell colSpan={3} className="text-center">
82
101
  {t("empty")}
@@ -8,12 +8,14 @@ import { MARKER_COLORS, RESULT_TYPES } from "@c-rex/constants";
8
8
  import { ResultTypes } from "@c-rex/types";
9
9
  import { Loader2 } from "lucide-react";
10
10
  import { toast } from "sonner";
11
+ import { useTranslations } from "next-intl";
11
12
 
12
13
  export const FavoriteButton: FC<{
13
14
  id: string;
14
15
  type: ResultTypes;
15
16
  label: string;
16
17
  }> = ({ id, type, label }) => {
18
+ const t = useTranslations();
17
19
  const addFavoriteTopic = useFavorites((state) => state.favoriteTopic);
18
20
  const addFavoriteDocument = useFavorites((state) => state.favoriteDocument);
19
21
  const removeFavoriteTopic = useFavorites((state) => state.unfavoriteTopic);
@@ -22,7 +24,12 @@ export const FavoriteButton: FC<{
22
24
  const favoriteDocumentList = useFavorites((state) => state.documents);
23
25
  const favoriteList = useFavorites((state) => state.favorites);
24
26
  const isHydrated = useFavorites((state) => state.isHydrated);
25
- const isFavorite = favoriteList.find((fav) => fav.id === id);
27
+
28
+ const isFavoriteInList = Boolean(favoriteList.find((fav) => fav.id === id));
29
+ const isFavoriteViaDocument = type === RESULT_TYPES.TOPIC
30
+ ? Object.values(favoriteDocumentList).some((doc) => doc.topics.some((t) => t.id === id))
31
+ : false;
32
+ const isFavorite = isFavoriteInList || isFavoriteViaDocument;
26
33
 
27
34
  const [documentData, setDocumentData] = useState<{ id: string, label: string } | null>(null);
28
35
  const [topicData, setTopicData] = useState<{ id: string, label: string } | null>(null);
@@ -121,7 +128,7 @@ export const FavoriteButton: FC<{
121
128
  await addFavorite(id);
122
129
  }
123
130
  } catch {
124
- toast.error("Não foi possível atualizar os favoritos.");
131
+ toast.error(t("favoritesUpdateError"));
125
132
  } finally {
126
133
  setIsLoading(false);
127
134
  }
@@ -135,6 +142,8 @@ export const FavoriteButton: FC<{
135
142
  size="icon"
136
143
  onClick={handleToggle}
137
144
  disabled={showLoading}
145
+ aria-label={isFavorite ? t("favorites") : t("favorites")}
146
+ aria-pressed={!showLoading && isFavorite}
138
147
  >
139
148
  {showLoading ? (
140
149
  <Loader2 className="animate-spin" />
@@ -264,6 +264,10 @@ const upsertFavorite = (favorites: Favorite[], entry: Favorite): Favorite[] => {
264
264
  }
265
265
 
266
266
  const current = favorites[index];
267
+ if (!current) {
268
+ return [...favorites, entry];
269
+ }
270
+
267
271
  const next = {
268
272
  ...current,
269
273
  label: current.label || entry.label,
@@ -25,6 +25,7 @@ export const SocialLinksBlock = ({ links, className, ariaLabel }: SocialLinksBlo
25
25
  target="_blank"
26
26
  rel="noreferrer"
27
27
  title={link.label}
28
+ aria-label={link.label}
28
29
  className="underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
29
30
  >
30
31
  <SocialIcon label={link.label} className="size-7" />
@@ -1,10 +1,3 @@
1
- /**
2
- * Auto-generated from OpenAPI spec (Client-Side)
3
- * Source: https://staging.c-rex.net/ids/api/swagger/v1/swagger.json
4
- * Generated: 2025-12-11T16:23:03.635Z
5
- * Do not edit manually
6
- */
7
-
8
1
  "use client";
9
2
 
10
3
  import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
@@ -0,0 +1,183 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { Card, CardContent, CardHeader, CardTitle } from "@c-rex/ui/card";
5
+ import { User, Mail, Link as LinkIcon } from "lucide-react";
6
+ import Link from "next/link";
7
+ import { cn } from "@c-rex/utils";
8
+
9
+ type Author = {
10
+ name: string;
11
+ photo: string | null;
12
+ title?: string | null;
13
+ role?: string | null;
14
+ emails?: string[];
15
+ urls?: string[];
16
+ };
17
+
18
+ type Props = {
19
+ title: string;
20
+ vcardUrls: string[];
21
+ vcardFields?: string[];
22
+ embedded?: boolean;
23
+ className?: string;
24
+ };
25
+
26
+ const AuthorAvatar = ({ author }: { author: Author }) => (
27
+ author.photo ? (
28
+ <img
29
+ src={author.photo}
30
+ alt={author.name}
31
+ className="size-10 rounded-full object-cover shrink-0 border border-border"
32
+ />
33
+ ) : (
34
+ <div className="size-10 rounded-full bg-muted shrink-0 flex items-center justify-center border border-border">
35
+ <User className="size-5 text-muted-foreground" />
36
+ </div>
37
+ )
38
+ );
39
+
40
+ const AuthorEntry = ({ author, fields }: { author: Author, fields: string[] }) => {
41
+ const showPhoto = fields.includes("photo");
42
+ const showName = fields.includes("name");
43
+ const showTitle = fields.includes("title");
44
+ const showRole = fields.includes("role");
45
+ const showEmails = fields.includes("emails");
46
+ const showUrls = fields.includes("urls");
47
+
48
+ return (
49
+ <div className="flex items-start gap-3 w-full overflow-hidden">
50
+ {showPhoto && <AuthorAvatar author={author} />}
51
+ <div className="flex flex-col gap-1 min-w-0 flex-1">
52
+ {showName && (
53
+ <span className="text-sm font-semibold text-foreground leading-tight truncate">
54
+ {author.name}
55
+ </span>
56
+ )}
57
+
58
+ {(showTitle && author.title) || (showRole && author.role) ? (
59
+ <span className="text-xs text-muted-foreground leading-tight truncate">
60
+ {(showTitle && author.title) || (showRole && author.role)}
61
+ </span>
62
+ ) : null}
63
+
64
+ <div className="flex flex-col gap-1 mt-1">
65
+ {showEmails && author.emails?.map((email) => (
66
+ <Link
67
+ key={email}
68
+ href={`mailto:${email}`}
69
+ className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-primary transition-colors min-w-0"
70
+ title={email}
71
+ >
72
+ <Mail className="size-3.5 shrink-0" />
73
+ <span className="truncate">{email}</span>
74
+ </Link>
75
+ ))}
76
+
77
+ {showUrls && author.urls?.map((url) => (
78
+ <Link
79
+ key={url}
80
+ href={url}
81
+ target="_blank"
82
+ rel="noopener noreferrer"
83
+ className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-primary transition-colors min-w-0"
84
+ title={url}
85
+ >
86
+ <LinkIcon className="size-3.5 shrink-0" />
87
+ <span className="truncate">{url.replace(/^https?:\/\//, "")}</span>
88
+ </Link>
89
+ ))}
90
+ </div>
91
+ </div>
92
+ </div>
93
+ );
94
+ };
95
+
96
+ export const InformationUnitAuthorsGridClient = ({
97
+ title,
98
+ vcardUrls,
99
+ vcardFields = ["name"],
100
+ embedded = false,
101
+ className,
102
+ }: Props) => {
103
+ const [authors, setAuthors] = useState<Author[]>([]);
104
+ const [loading, setLoading] = useState(true);
105
+
106
+ useEffect(() => {
107
+ if (!vcardUrls?.length) {
108
+ setLoading(false);
109
+ return;
110
+ }
111
+
112
+ const controller = new AbortController();
113
+
114
+ Promise.all(
115
+ vcardUrls.map((url) =>
116
+ fetch(url, { signal: controller.signal })
117
+ .then((r) => r.json())
118
+ .then((vcard) => ({
119
+ name: vcard.fullName || "",
120
+ photo: vcard.photos?.[0]?.source || null,
121
+ title: vcard.titles?.[0] || null,
122
+ role: vcard.roles?.[0] || null,
123
+ emails: vcard.emails?.map((e: any) => e.value) || [],
124
+ urls: vcard.urls?.map((u: any) => u.value) || [],
125
+ }))
126
+ .catch((): null => null)
127
+ )
128
+ ).then((results) => {
129
+ setAuthors(results.filter(Boolean) as Author[]);
130
+ setLoading(false);
131
+ });
132
+
133
+ return () => controller.abort();
134
+ }, [vcardUrls]);
135
+
136
+ if (loading) {
137
+ return (
138
+ <Card className={cn("!gap-0", className)}>
139
+ <CardHeader className="pb-3">
140
+ <div className="h-6 w-24 bg-muted animate-pulse rounded" />
141
+ </CardHeader>
142
+ <CardContent className="space-y-4">
143
+ {[1, 2].map((i) => (
144
+ <div key={i} className="flex items-center gap-3 animate-pulse">
145
+ <div className="size-10 rounded-full bg-muted shrink-0" />
146
+ <div className="space-y-2 flex-1">
147
+ <div className="h-4 w-3/4 bg-muted rounded" />
148
+ <div className="h-3 w-1/2 bg-muted rounded" />
149
+ </div>
150
+ </div>
151
+ ))}
152
+ </CardContent>
153
+ </Card>
154
+ );
155
+ }
156
+
157
+ if (authors.length === 0) return null;
158
+
159
+ const content = (
160
+ <div className="flex flex-col gap-5">
161
+ {authors.map((author, i) => (
162
+ <AuthorEntry key={i} author={author} fields={vcardFields} />
163
+ ))}
164
+ </div>
165
+ );
166
+
167
+ if (embedded) {
168
+ return <div className={cn("p-4", className)}>{content}</div>;
169
+ }
170
+
171
+ return (
172
+ <Card className={cn("!gap-0", className)}>
173
+ <CardHeader className="pb-3">
174
+ <CardTitle className="text-lg font-heading tracking-tight">
175
+ {title}
176
+ </CardTitle>
177
+ </CardHeader>
178
+ <CardContent className="pb-5">
179
+ {content}
180
+ </CardContent>
181
+ </Card>
182
+ );
183
+ };
@@ -27,6 +27,7 @@ import {
27
27
  type Props = {
28
28
  title: string;
29
29
  linkPattern: string;
30
+ bookmarkDocumentLinkPattern?: string;
30
31
  data: CommonItemsModel;
31
32
  metadataIncludeProperties?: Array<keyof InformationUnitModel>;
32
33
  metadataExcludeProperties?: Array<keyof InformationUnitModel>;
@@ -115,6 +116,7 @@ export const InformationUnitMetadataGridClient = ({
115
116
  data,
116
117
  embedded = false,
117
118
  linkPattern,
119
+ bookmarkDocumentLinkPattern,
118
120
  metadataIncludeProperties,
119
121
  metadataExcludeProperties,
120
122
  showBookmarkButton = false,
@@ -150,7 +152,10 @@ export const InformationUnitMetadataGridClient = ({
150
152
  <h4 className="text-sm font-medium">{t("favorites")}</h4>
151
153
  </TableCell>
152
154
  <TableCell className="min-h-12 pt-3 text-xs text-muted-foreground align-top break-words whitespace-normal">
153
- <BookmarkButton shortId={data.shortId!} />
155
+ <BookmarkButton
156
+ shortId={data.shortId!}
157
+ documentLinkPattern={bookmarkDocumentLinkPattern}
158
+ />
154
159
  </TableCell>
155
160
  </TableRow>
156
161
  )}
@@ -211,7 +216,12 @@ export const InformationUnitMetadataGridClient = ({
211
216
  <CardHeader>
212
217
  <CardTitle className="text-lg flex justify-between items-end">
213
218
  {title}
214
- {showBookmarkButton && <BookmarkButton shortId={data.shortId!} />}
219
+ {showBookmarkButton && (
220
+ <BookmarkButton
221
+ shortId={data.shortId!}
222
+ documentLinkPattern={bookmarkDocumentLinkPattern}
223
+ />
224
+ )}
215
225
  </CardTitle>
216
226
  </CardHeader>
217
227
  {cardContent}
@@ -4,10 +4,8 @@ import { SignInBtn } from "./sign-in-out-btns";
4
4
  import { getServerSession } from "next-auth";
5
5
  import { SettingsMenu } from "./settings";
6
6
  import { UserMenu } from "./user-menu";
7
- import { SearchInput } from "../search-input";
8
7
  import { CrexSDK } from "@c-rex/core/sdk";
9
8
  import * as AutocompleteOptions from "../generated/suggestions";
10
- import { cn } from "@c-rex/utils";
11
9
  import { getTranslations } from "next-intl/server";
12
10
  import { Button } from "@c-rex/ui/button";
13
11
  import { DropdownHoverItem } from "@c-rex/ui/dropdown-hover-item";
@@ -0,0 +1,148 @@
1
+ import { resolveMenuState } from "../restriction-menu-logic";
2
+
3
+ const BASE = { maxItemsByRow: 7, itemCount: 0 };
4
+
5
+ describe("resolveMenuState – restrict construction", () => {
6
+ it("hierarchy mode (components): prepends hasInformationUnits=true when onlyUsedEntries=true", () => {
7
+ const { restrict } = resolveMenuState({ ...BASE, queryRestrict: [], onlyUsedEntries: true });
8
+ expect(restrict).toEqual(["hasInformationUnits=true"]);
9
+ });
10
+
11
+ it("flat mode (informationSubjects): prepends hasInformationUnits=true when onlyUsedEntries=true", () => {
12
+ const { restrict } = resolveMenuState({
13
+ ...BASE,
14
+ queryRestrict: ["someOtherFilter=x"],
15
+ onlyUsedEntries: true,
16
+ });
17
+ expect(restrict[0]).toBe("hasInformationUnits=true");
18
+ expect(restrict).toContain("someOtherFilter=x");
19
+ });
20
+
21
+ it("omits hasInformationUnits filter when onlyUsedEntries=false", () => {
22
+ const { restrict } = resolveMenuState({ ...BASE, queryRestrict: [], onlyUsedEntries: false });
23
+ expect(restrict).not.toContain("hasInformationUnits=true");
24
+ });
25
+
26
+ it("passes through existing queryRestrict entries when onlyUsedEntries=false", () => {
27
+ const { restrict } = resolveMenuState({
28
+ ...BASE,
29
+ queryRestrict: ["category=foo"],
30
+ onlyUsedEntries: false,
31
+ });
32
+ expect(restrict).toEqual(["category=foo"]);
33
+ });
34
+ });
35
+
36
+ describe("resolveMenuState – resolvedPageSize", () => {
37
+ it("deferred mode, not loaded: uses maxItemsByRow as page size", () => {
38
+ const { resolvedPageSize } = resolveMenuState({
39
+ ...BASE,
40
+ maxItemsByRow: 7,
41
+ fetchMode: "deferred",
42
+ loadAll: false,
43
+ });
44
+ expect(resolvedPageSize).toBe(7);
45
+ });
46
+
47
+ it("deferred mode, loadAll=true: uses 100", () => {
48
+ const { resolvedPageSize } = resolveMenuState({
49
+ ...BASE,
50
+ maxItemsByRow: 7,
51
+ fetchMode: "deferred",
52
+ loadAll: true,
53
+ });
54
+ expect(resolvedPageSize).toBe(100);
55
+ });
56
+
57
+ it("all mode: uses 100 regardless of loadAll", () => {
58
+ const { resolvedPageSize } = resolveMenuState({
59
+ ...BASE,
60
+ maxItemsByRow: 7,
61
+ fetchMode: "all",
62
+ loadAll: false,
63
+ });
64
+ expect(resolvedPageSize).toBe(100);
65
+ });
66
+
67
+ it("explicit pageSize overrides deferred mode", () => {
68
+ const { resolvedPageSize } = resolveMenuState({
69
+ ...BASE,
70
+ maxItemsByRow: 7,
71
+ fetchMode: "deferred",
72
+ loadAll: false,
73
+ explicitPageSize: 25,
74
+ });
75
+ expect(resolvedPageSize).toBe(25);
76
+ });
77
+ });
78
+
79
+ describe("resolveMenuState – hasMoreItems", () => {
80
+ it("deferred mode: hasMoreItems=true when itemCount equals resolvedPageSize and not loaded", () => {
81
+ const { hasMoreItems } = resolveMenuState({
82
+ ...BASE,
83
+ maxItemsByRow: 7,
84
+ fetchMode: "deferred",
85
+ loadAll: false,
86
+ itemCount: 7,
87
+ });
88
+ expect(hasMoreItems).toBe(true);
89
+ });
90
+
91
+ it("deferred mode: hasMoreItems=false after loadAll=true", () => {
92
+ const { hasMoreItems } = resolveMenuState({
93
+ ...BASE,
94
+ maxItemsByRow: 7,
95
+ fetchMode: "deferred",
96
+ loadAll: true,
97
+ itemCount: 7,
98
+ });
99
+ expect(hasMoreItems).toBe(false);
100
+ });
101
+
102
+ it("server-side pagination: hasMoreItems=true when totalItemCount > itemCount", () => {
103
+ const { hasMoreItems } = resolveMenuState({
104
+ ...BASE,
105
+ maxItemsByRow: 7,
106
+ fetchMode: "deferred",
107
+ loadAll: false,
108
+ itemCount: 7,
109
+ totalItemCount: 20,
110
+ });
111
+ expect(hasMoreItems).toBe(true);
112
+ });
113
+
114
+ it("server-side pagination: hasMoreItems=false when all items loaded", () => {
115
+ const { hasMoreItems } = resolveMenuState({
116
+ ...BASE,
117
+ maxItemsByRow: 7,
118
+ fetchMode: "deferred",
119
+ loadAll: false,
120
+ itemCount: 7,
121
+ totalItemCount: 7,
122
+ });
123
+ expect(hasMoreItems).toBe(false);
124
+ });
125
+
126
+ it("explicit pageSize: hasMoreItems driven only by server totalItemCount", () => {
127
+ const noPagination = resolveMenuState({
128
+ ...BASE,
129
+ maxItemsByRow: 7,
130
+ fetchMode: "deferred",
131
+ loadAll: false,
132
+ explicitPageSize: 25,
133
+ itemCount: 25,
134
+ });
135
+ expect(noPagination.hasMoreItems).toBe(false);
136
+
137
+ const withMore = resolveMenuState({
138
+ ...BASE,
139
+ maxItemsByRow: 7,
140
+ fetchMode: "deferred",
141
+ loadAll: false,
142
+ explicitPageSize: 25,
143
+ itemCount: 25,
144
+ totalItemCount: 50,
145
+ });
146
+ expect(withMore.hasMoreItems).toBe(true);
147
+ });
148
+ });
@@ -1,4 +1,4 @@
1
- export type RestrictionHierarchyNode = {
1
+ export type RestrictionHierarchyNode = object & {
2
2
  isStructural?: boolean;
3
3
  };
4
4