@c-rex/components 0.3.0-build.35 → 0.3.0-build.36
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 +28 -36
- package/src/article/article-action-bar.tsx +4 -1
- package/src/{check-article-lang.tsx → article/check-article-lang.tsx} +1 -1
- package/src/article/render-article-highlight.tsx +108 -0
- package/src/article/render-article.tsx +28 -0
- package/src/autocomplete.tsx +2 -2
- package/src/carousel/carousel.tsx +5 -2
- package/src/carousel/information-unit-carousel-item.tsx +1 -1
- package/src/content-unavailable.tsx +20 -0
- package/src/directoryNodes/directory-tree-context.tsx +9 -4
- package/src/documents/description-preview.tsx +14 -4
- package/src/documents/result-list-item.tsx +35 -46
- package/src/favorites/__tests__/favorites-hydration.test.tsx +245 -0
- package/src/favorites/bookmark-button.tsx +38 -20
- package/src/favorites/favorite-button.tsx +23 -24
- package/src/favorites/favorites-context.tsx +287 -0
- package/src/icons/file-icon.tsx +9 -26
- package/src/info/information-unit-metadata-grid-client.tsx +21 -21
- package/src/page-wrapper.tsx +1 -1
- package/src/renditions/html-client.tsx +8 -6
- package/src/renditions/html.tsx +3 -1
- package/src/restriction-menu/restriction-menu-item.tsx +48 -58
- package/src/restriction-menu/restriction-selection-command-menu.tsx +444 -0
- package/src/restriction-menu/restriction-selection-menu.tsx +3 -5
- package/src/restriction-menu/taxonomy-restriction-command-menu.tsx +111 -0
- package/src/restriction-menu/taxonomy-restriction-menu.tsx +1 -7
- package/src/results/filter-navbar.tsx +81 -76
- package/src/results/filter-sidebar/context.tsx +32 -0
- package/src/results/filter-sidebar/index.tsx +44 -35
- package/src/results/generic/search-results-client.tsx +5 -4
- package/src/results/generic/table-result-list.tsx +16 -16
- package/src/results/information-unit-search-results-card-list.tsx +4 -1
- package/src/results/pagination.tsx +43 -40
- package/src/search-input.tsx +4 -2
- package/src/toc/toc-browse-controls.tsx +2 -2
- package/src/toc/toc-tree-panel.tsx +19 -16
- package/src/article/article-content.tsx +0 -19
- package/src/breadcrumb.tsx +0 -124
- package/src/directoryNodes/tree-of-content.tsx +0 -68
- package/src/render-article.tsx +0 -75
- package/src/restriction-menu/restriction-menu-container.tsx +0 -4
- package/src/restriction-menu/restriction-menu.tsx +0 -4
- package/src/stores/__tests__/favorites-store.test.ts +0 -54
- package/src/stores/favorites-store.ts +0 -163
- /package/src/{render-article.module.css → article/render-article.module.css} +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { FC, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
4
5
|
import { CommonItemsModel } from "@c-rex/interfaces";
|
|
5
6
|
import { FileStack } from "lucide-react";
|
|
6
7
|
import { cn, findRelatedFragmentShortId, generateQueryParams } from "@c-rex/utils";
|
|
7
8
|
import { Button } from "@c-rex/ui/button";
|
|
8
9
|
import { Badge } from "@c-rex/ui/badge";
|
|
9
10
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@c-rex/ui/tooltip";
|
|
10
|
-
import { RESULT_TYPES } from "@c-rex/constants";
|
|
11
11
|
import { FileDownloadDropdown } from "@c-rex/components/file-download";
|
|
12
12
|
import { FavoriteButton } from "@c-rex/components/favorite-button";
|
|
13
13
|
import { ImageRenditionContainer } from "../renditions/image/container";
|
|
@@ -34,10 +34,8 @@ type RowContentProps = {
|
|
|
34
34
|
item: CommonItemsModel;
|
|
35
35
|
itemLink: string;
|
|
36
36
|
title: string;
|
|
37
|
-
language: string;
|
|
38
37
|
itemType: ResultTypes;
|
|
39
38
|
multipleVersions: string[];
|
|
40
|
-
isDocument: boolean;
|
|
41
39
|
isLast: boolean;
|
|
42
40
|
previewFragmentShortId?: string;
|
|
43
41
|
descriptionFragmentShortId?: string;
|
|
@@ -48,15 +46,15 @@ const DocumentResultListRowContent: FC<RowContentProps> = ({
|
|
|
48
46
|
item,
|
|
49
47
|
itemLink,
|
|
50
48
|
title,
|
|
51
|
-
language,
|
|
52
49
|
itemType,
|
|
53
50
|
multipleVersions,
|
|
54
|
-
isDocument,
|
|
55
51
|
isLast,
|
|
56
52
|
previewFragmentShortId,
|
|
57
53
|
descriptionFragmentShortId,
|
|
58
54
|
isLoadingDetails = false,
|
|
59
55
|
}) => {
|
|
56
|
+
const t = useTranslations();
|
|
57
|
+
|
|
60
58
|
return (
|
|
61
59
|
<div
|
|
62
60
|
className={cn(
|
|
@@ -65,14 +63,14 @@ const DocumentResultListRowContent: FC<RowContentProps> = ({
|
|
|
65
63
|
`c-rex-result-item c-rex-result-${itemType}`
|
|
66
64
|
)}
|
|
67
65
|
>
|
|
68
|
-
<div className="w-20 sm:w-24 flex shrink-0
|
|
66
|
+
<div className="w-20 sm:w-24 flex shrink-0 justify-center pt-1">
|
|
69
67
|
{isLoadingDetails ? (
|
|
70
68
|
<Skeleton className="w-20 sm:w-24 aspect-[210/297]" />
|
|
71
69
|
) : (
|
|
72
|
-
<Link href={itemLink} className="
|
|
70
|
+
<Link href={itemLink} className="w-full h-full block">
|
|
73
71
|
<ImageRenditionContainer
|
|
74
72
|
fragmentShortId={previewFragmentShortId}
|
|
75
|
-
emptyImageStyle="w-
|
|
73
|
+
emptyImageStyle="w-full h-full"
|
|
76
74
|
imageStyle="block w-full h-auto max-w-full object-contain object-top mx-auto"
|
|
77
75
|
/>
|
|
78
76
|
</Link>
|
|
@@ -81,11 +79,11 @@ const DocumentResultListRowContent: FC<RowContentProps> = ({
|
|
|
81
79
|
|
|
82
80
|
<div className="flex-1 p-2 flex flex-col justify-start">
|
|
83
81
|
<span className="text-sm text-muted-foreground">
|
|
84
|
-
{item.revision}
|
|
82
|
+
{item.revision}
|
|
85
83
|
</span>
|
|
86
84
|
|
|
87
85
|
<span className="text-lg font-medium">
|
|
88
|
-
<a className="hover:underline" href={itemLink}>{title}</a>
|
|
86
|
+
<a className="hover:underline [overflow-wrap:anywhere] hyphens-auto" href={itemLink}>{title}</a>
|
|
89
87
|
</span>
|
|
90
88
|
|
|
91
89
|
<div>
|
|
@@ -109,36 +107,32 @@ const DocumentResultListRowContent: FC<RowContentProps> = ({
|
|
|
109
107
|
</span>
|
|
110
108
|
</div>
|
|
111
109
|
|
|
112
|
-
<div className="flex flex-
|
|
113
|
-
<
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
<
|
|
124
|
-
<
|
|
125
|
-
<
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
)}
|
|
110
|
+
<div className="flex flex-row p-2 ml-auto justify-between self-start gap-2 w-full sm:justify-start sm:w-auto">
|
|
111
|
+
<FileDownloadDropdown renditions={item.renditions} />
|
|
112
|
+
|
|
113
|
+
<FavoriteButton
|
|
114
|
+
id={item.shortId!}
|
|
115
|
+
type={itemType}
|
|
116
|
+
label={title}
|
|
117
|
+
/>
|
|
118
|
+
|
|
119
|
+
{multipleVersions.length > 1 && (
|
|
120
|
+
<Tooltip>
|
|
121
|
+
<TooltipTrigger asChild>
|
|
122
|
+
<Button variant="ghost" size="icon">
|
|
123
|
+
<FileStack />
|
|
124
|
+
</Button>
|
|
125
|
+
</TooltipTrigger>
|
|
126
|
+
<TooltipContent>
|
|
127
|
+
{t("availableIn")}: {multipleVersions.join(", ")}
|
|
128
|
+
</TooltipContent>
|
|
129
|
+
</Tooltip>
|
|
130
|
+
)}
|
|
134
131
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
/>
|
|
140
|
-
)}
|
|
141
|
-
</div>
|
|
132
|
+
<BookmarkButton
|
|
133
|
+
shortId={item.shortId!}
|
|
134
|
+
triggerVariant="ghost"
|
|
135
|
+
/>
|
|
142
136
|
</div>
|
|
143
137
|
</div>
|
|
144
138
|
);
|
|
@@ -152,8 +146,7 @@ export const DocumentsResultListItem: FC<Props> = ({
|
|
|
152
146
|
isLast,
|
|
153
147
|
eager = false,
|
|
154
148
|
}) => {
|
|
155
|
-
const { title,
|
|
156
|
-
const isDocument = itemType === RESULT_TYPES.DOCUMENT;
|
|
149
|
+
const { title, itemType, multipleVersions, packageId } = getResultItemSummary(item);
|
|
157
150
|
const [canLoadDetails, setCanLoadDetails] = useState(eager);
|
|
158
151
|
const rowRef = useRef<HTMLDivElement | null>(null);
|
|
159
152
|
const queryParams: QueryParams[] = [];
|
|
@@ -199,14 +192,12 @@ export const DocumentsResultListItem: FC<Props> = ({
|
|
|
199
192
|
item={item}
|
|
200
193
|
itemLink={itemLink}
|
|
201
194
|
title={title}
|
|
202
|
-
language={language}
|
|
203
195
|
itemType={itemType}
|
|
204
196
|
multipleVersions={multipleVersions}
|
|
205
|
-
isDocument={isDocument}
|
|
206
197
|
isLast={isLast}
|
|
207
198
|
isLoadingDetails={!canLoadDetails}
|
|
208
199
|
/>
|
|
209
|
-
), [canLoadDetails,
|
|
200
|
+
), [canLoadDetails, isLast, item, itemLink, itemType, multipleVersions, title]);
|
|
210
201
|
|
|
211
202
|
return (
|
|
212
203
|
<div ref={rowRef}>
|
|
@@ -238,10 +229,8 @@ export const DocumentsResultListItem: FC<Props> = ({
|
|
|
238
229
|
item={item}
|
|
239
230
|
itemLink={itemLink}
|
|
240
231
|
title={title}
|
|
241
|
-
language={language}
|
|
242
232
|
itemType={itemType}
|
|
243
233
|
multipleVersions={multipleVersions}
|
|
244
|
-
isDocument={isDocument}
|
|
245
234
|
isLast={isLast}
|
|
246
235
|
previewFragmentShortId={previewFragmentShortId}
|
|
247
236
|
descriptionFragmentShortId={descriptionFragmentShortId}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
|
|
3
|
+
import React, { useEffect } from "react";
|
|
4
|
+
import { TextDecoder, TextEncoder } from "util";
|
|
5
|
+
import { act } from "react";
|
|
6
|
+
import { createRoot, hydrateRoot } from "react-dom/client";
|
|
7
|
+
import { FavoriteButton } from "../favorite-button";
|
|
8
|
+
import { BookmarkButton } from "../bookmark-button";
|
|
9
|
+
import {
|
|
10
|
+
FAVORITES_STORAGE_KEY,
|
|
11
|
+
FavoritesProvider,
|
|
12
|
+
type FavoritesState,
|
|
13
|
+
useFavorites,
|
|
14
|
+
} from "../favorites-context";
|
|
15
|
+
|
|
16
|
+
global.TextEncoder = TextEncoder;
|
|
17
|
+
global.TextDecoder = TextDecoder as typeof global.TextDecoder;
|
|
18
|
+
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
19
|
+
|
|
20
|
+
const { renderToString } = require("react-dom/server");
|
|
21
|
+
|
|
22
|
+
jest.mock("next-intl", () => ({
|
|
23
|
+
useTranslations: () => (key: string) => {
|
|
24
|
+
const messages: Record<string, string> = {
|
|
25
|
+
title: "Bookmarks",
|
|
26
|
+
description: "Manage your bookmarks here",
|
|
27
|
+
empty: "No bookmarks yet.",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
return messages[key] ?? key;
|
|
31
|
+
},
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
jest.mock("@c-rex/utils", () => ({
|
|
35
|
+
getLocalStorageJson: (key: string) => {
|
|
36
|
+
const value = window.localStorage.getItem(key);
|
|
37
|
+
return value ? JSON.parse(value) : null;
|
|
38
|
+
},
|
|
39
|
+
setLocalStorageJson: (key: string, value: unknown) => {
|
|
40
|
+
window.localStorage.setItem(key, JSON.stringify(value));
|
|
41
|
+
},
|
|
42
|
+
removeLocalStorageItem: (key: string) => {
|
|
43
|
+
window.localStorage.removeItem(key);
|
|
44
|
+
},
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
jest.mock("next/link", () => ({
|
|
48
|
+
__esModule: true,
|
|
49
|
+
default: ({ children, href, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
|
|
50
|
+
<a href={href} {...props}>{children}</a>
|
|
51
|
+
),
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
jest.mock("@c-rex/ui/button", () => ({
|
|
55
|
+
Button: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
|
|
56
|
+
<button {...props}>{children}</button>
|
|
57
|
+
),
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
jest.mock("@c-rex/ui/dialog", () => ({
|
|
61
|
+
Dialog: ({ children }: { children: React.ReactNode }) => <div data-testid="dialog">{children}</div>,
|
|
62
|
+
DialogTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
63
|
+
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
64
|
+
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
65
|
+
DialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
66
|
+
DialogDescription: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
jest.mock("@c-rex/ui/table", () => ({
|
|
70
|
+
Table: ({ children }: { children: React.ReactNode }) => <table>{children}</table>,
|
|
71
|
+
TableBody: ({ children }: { children: React.ReactNode }) => <tbody>{children}</tbody>,
|
|
72
|
+
TableRow: ({ children, ...props }: React.HTMLAttributes<HTMLTableRowElement>) => <tr {...props}>{children}</tr>,
|
|
73
|
+
TableCell: ({ children, ...props }: React.TdHTMLAttributes<HTMLTableCellElement>) => <td {...props}>{children}</td>,
|
|
74
|
+
}));
|
|
75
|
+
|
|
76
|
+
jest.mock("sonner", () => ({
|
|
77
|
+
toast: {
|
|
78
|
+
error: jest.fn(),
|
|
79
|
+
},
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
const persistedState: FavoritesState = {
|
|
83
|
+
favorites: [
|
|
84
|
+
{ id: "doc-1", label: "Document 1", color: "" },
|
|
85
|
+
{ id: "topic-1", label: "Topic 1", color: "red-500" },
|
|
86
|
+
],
|
|
87
|
+
documents: {
|
|
88
|
+
"doc-1": {
|
|
89
|
+
label: "Document 1",
|
|
90
|
+
topics: [{ id: "topic-1", label: "Topic 1", color: "red-500" }],
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const ProviderProbe = () => {
|
|
96
|
+
const isHydrated = useFavorites((state) => state.isHydrated);
|
|
97
|
+
const favoritesCount = useFavorites((state) => state.favorites.length);
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div
|
|
101
|
+
data-hydrated={String(isHydrated)}
|
|
102
|
+
data-favorites-count={String(favoritesCount)}
|
|
103
|
+
/>
|
|
104
|
+
);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const EffectProbe = ({ onHydrated }: { onHydrated: (payload: { hydrated: boolean; count: number }) => void }) => {
|
|
108
|
+
const isHydrated = useFavorites((state) => state.isHydrated);
|
|
109
|
+
const favoritesCount = useFavorites((state) => state.favorites.length);
|
|
110
|
+
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
onHydrated({ hydrated: isHydrated, count: favoritesCount });
|
|
113
|
+
}, [favoritesCount, isHydrated, onHydrated]);
|
|
114
|
+
|
|
115
|
+
return null;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
describe("favorites hydration", () => {
|
|
119
|
+
beforeEach(() => {
|
|
120
|
+
window.localStorage.clear();
|
|
121
|
+
jest.clearAllMocks();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("keeps server markup stable before hydration and loads persisted state after mount", async () => {
|
|
125
|
+
window.localStorage.setItem(FAVORITES_STORAGE_KEY, JSON.stringify(persistedState));
|
|
126
|
+
|
|
127
|
+
const serverMarkup = renderToString(
|
|
128
|
+
<FavoritesProvider>
|
|
129
|
+
<ProviderProbe />
|
|
130
|
+
</FavoritesProvider>
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
expect(serverMarkup).toContain('data-hydrated="false"');
|
|
134
|
+
expect(serverMarkup).toContain('data-favorites-count="0"');
|
|
135
|
+
|
|
136
|
+
const container = document.createElement("div");
|
|
137
|
+
container.innerHTML = serverMarkup;
|
|
138
|
+
|
|
139
|
+
await act(async () => {
|
|
140
|
+
hydrateRoot(
|
|
141
|
+
container,
|
|
142
|
+
<FavoritesProvider>
|
|
143
|
+
<ProviderProbe />
|
|
144
|
+
</FavoritesProvider>
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const probe = container.querySelector("div[data-hydrated]");
|
|
149
|
+
|
|
150
|
+
expect(probe?.getAttribute("data-hydrated")).toBe("true");
|
|
151
|
+
expect(probe?.getAttribute("data-favorites-count")).toBe("2");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("renders favorite button as loading on the server and shows persisted favorite after hydration", async () => {
|
|
155
|
+
window.localStorage.setItem(FAVORITES_STORAGE_KEY, JSON.stringify(persistedState));
|
|
156
|
+
|
|
157
|
+
const serverMarkup = renderToString(
|
|
158
|
+
<FavoritesProvider>
|
|
159
|
+
<FavoriteButton id="doc-1" type="DOCUMENT" label="Document 1" />
|
|
160
|
+
</FavoritesProvider>
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
expect(serverMarkup).toContain("Loading favorites");
|
|
164
|
+
expect(serverMarkup).not.toContain("Remove favorite");
|
|
165
|
+
|
|
166
|
+
const container = document.createElement("div");
|
|
167
|
+
document.body.appendChild(container);
|
|
168
|
+
const root = createRoot(container);
|
|
169
|
+
|
|
170
|
+
await act(async () => {
|
|
171
|
+
root.render(
|
|
172
|
+
<FavoritesProvider>
|
|
173
|
+
<FavoriteButton id="doc-1" type="DOCUMENT" label="Document 1" />
|
|
174
|
+
</FavoritesProvider>
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const button = container.querySelector("button");
|
|
179
|
+
|
|
180
|
+
expect(button?.getAttribute("aria-label")).toBe("Remove favorite");
|
|
181
|
+
expect(button?.hasAttribute("disabled")).toBe(false);
|
|
182
|
+
|
|
183
|
+
await act(async () => {
|
|
184
|
+
root.unmount();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("hides bookmark count on the server and reveals persisted bookmarks after hydration", async () => {
|
|
189
|
+
window.localStorage.setItem(FAVORITES_STORAGE_KEY, JSON.stringify(persistedState));
|
|
190
|
+
|
|
191
|
+
const serverMarkup = renderToString(
|
|
192
|
+
<FavoritesProvider>
|
|
193
|
+
<BookmarkButton shortId="doc-1" />
|
|
194
|
+
</FavoritesProvider>
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
expect(serverMarkup).not.toContain(">1<");
|
|
198
|
+
expect(serverMarkup).toContain("Loading...");
|
|
199
|
+
|
|
200
|
+
const container = document.createElement("div");
|
|
201
|
+
document.body.appendChild(container);
|
|
202
|
+
const root = createRoot(container);
|
|
203
|
+
|
|
204
|
+
await act(async () => {
|
|
205
|
+
root.render(
|
|
206
|
+
<FavoritesProvider>
|
|
207
|
+
<BookmarkButton shortId="doc-1" />
|
|
208
|
+
</FavoritesProvider>
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
expect(container.textContent).toContain("1");
|
|
213
|
+
expect(container.textContent).toContain("Topic 1");
|
|
214
|
+
expect(container.textContent).not.toContain("Loading...");
|
|
215
|
+
|
|
216
|
+
await act(async () => {
|
|
217
|
+
root.unmount();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("marks the provider as hydrated and exposes persisted favorites to consumers after mount", async () => {
|
|
222
|
+
window.localStorage.setItem(FAVORITES_STORAGE_KEY, JSON.stringify(persistedState));
|
|
223
|
+
const onHydrated = jest.fn();
|
|
224
|
+
const container = document.createElement("div");
|
|
225
|
+
document.body.appendChild(container);
|
|
226
|
+
const root = createRoot(container);
|
|
227
|
+
|
|
228
|
+
await act(async () => {
|
|
229
|
+
root.render(
|
|
230
|
+
<FavoritesProvider>
|
|
231
|
+
<EffectProbe onHydrated={onHydrated} />
|
|
232
|
+
</FavoritesProvider>
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
expect(onHydrated).toHaveBeenLastCalledWith({
|
|
237
|
+
hydrated: true,
|
|
238
|
+
count: 2,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
await act(async () => {
|
|
242
|
+
root.unmount();
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
});
|
|
@@ -2,27 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
import { ComponentProps, FC } from "react";
|
|
4
4
|
import { Button } from "@c-rex/ui/button";
|
|
5
|
-
import { Trash } from "lucide-react";
|
|
6
|
-
import { cn } from "@c-rex/utils";
|
|
5
|
+
import { Loader2, Trash } from "lucide-react";
|
|
7
6
|
import Link from "next/link";
|
|
8
|
-
import {
|
|
9
|
-
|
|
7
|
+
import {
|
|
8
|
+
Dialog,
|
|
9
|
+
DialogContent,
|
|
10
|
+
DialogDescription,
|
|
11
|
+
DialogHeader,
|
|
12
|
+
DialogTitle,
|
|
13
|
+
DialogTrigger
|
|
14
|
+
} from "@c-rex/ui/dialog";
|
|
10
15
|
import { useTranslations } from "next-intl";
|
|
11
16
|
import {
|
|
12
17
|
Table,
|
|
13
18
|
TableBody,
|
|
14
19
|
TableCell,
|
|
15
20
|
TableRow,
|
|
16
|
-
} from "@c-rex/ui/table"
|
|
21
|
+
} from "@c-rex/ui/table";
|
|
17
22
|
import { FaRegBookmark } from "react-icons/fa6";
|
|
18
|
-
|
|
19
|
-
const EMPTY_TOPICS: { id: string; label: string; color: string }[] = [];
|
|
23
|
+
import { useFavorites } from "./favorites-context";
|
|
20
24
|
|
|
21
25
|
type BookmarkProps = {
|
|
22
26
|
shortId: string;
|
|
23
27
|
linkPattern?: string;
|
|
24
28
|
triggerVariant?: ComponentProps<typeof Button>["variant"];
|
|
25
|
-
}
|
|
29
|
+
};
|
|
26
30
|
|
|
27
31
|
export const BookmarkButton: FC<BookmarkProps> = ({
|
|
28
32
|
shortId,
|
|
@@ -30,35 +34,49 @@ export const BookmarkButton: FC<BookmarkProps> = ({
|
|
|
30
34
|
triggerVariant = "outline"
|
|
31
35
|
}) => {
|
|
32
36
|
const t = useTranslations("bookmarks");
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
const
|
|
37
|
+
const tRoot = useTranslations();
|
|
38
|
+
const removeFavoriteTopic = useFavorites((state) => state.unfavoriteTopic);
|
|
39
|
+
const documentLabel = useFavorites((state) => state.documents[shortId]?.label);
|
|
40
|
+
const markersList = useFavorites((state) => state.documents[shortId]?.topics) ?? [];
|
|
41
|
+
const isHydrated = useFavorites((state) => state.isHydrated);
|
|
42
|
+
const visibleMarkersCount = isHydrated ? markersList.length : 0;
|
|
43
|
+
|
|
36
44
|
return (
|
|
37
45
|
<Dialog>
|
|
38
46
|
<DialogTrigger asChild>
|
|
39
|
-
<Button variant={triggerVariant} size="icon" className="relative">
|
|
47
|
+
<Button variant={triggerVariant} size="icon" className="relative" aria-label={t("title")}>
|
|
40
48
|
<FaRegBookmark className="text-primary !size-4" />
|
|
41
49
|
|
|
42
|
-
{
|
|
50
|
+
{isHydrated && visibleMarkersCount > 0 && (
|
|
43
51
|
<span
|
|
44
52
|
className="absolute -top-[10px] -right-[10px] min-w-5 min-h-5 bg-primary text-white rounded-full"
|
|
45
53
|
>
|
|
46
|
-
{
|
|
54
|
+
{visibleMarkersCount}
|
|
47
55
|
</span>
|
|
48
56
|
)}
|
|
49
57
|
</Button>
|
|
50
|
-
|
|
51
58
|
</DialogTrigger>
|
|
52
59
|
<DialogContent>
|
|
53
60
|
<DialogHeader>
|
|
54
61
|
<DialogTitle>{t("title")}</DialogTitle>
|
|
55
62
|
<DialogDescription>
|
|
56
|
-
{documentLabel || t("description")}
|
|
63
|
+
{isHydrated ? (documentLabel || t("description")) : t("description")}
|
|
57
64
|
</DialogDescription>
|
|
58
65
|
</DialogHeader>
|
|
59
66
|
<Table>
|
|
60
67
|
<TableBody>
|
|
61
|
-
{
|
|
68
|
+
{!isHydrated && (
|
|
69
|
+
<TableRow>
|
|
70
|
+
<TableCell colSpan={3} className="text-center">
|
|
71
|
+
<span className="inline-flex items-center gap-2">
|
|
72
|
+
<Loader2 className="size-4 animate-spin" />
|
|
73
|
+
{tRoot("loading")}
|
|
74
|
+
</span>
|
|
75
|
+
</TableCell>
|
|
76
|
+
</TableRow>
|
|
77
|
+
)}
|
|
78
|
+
|
|
79
|
+
{isHydrated && visibleMarkersCount === 0 && (
|
|
62
80
|
<TableRow>
|
|
63
81
|
<TableCell colSpan={3} className="text-center">
|
|
64
82
|
{t("empty")}
|
|
@@ -66,7 +84,7 @@ export const BookmarkButton: FC<BookmarkProps> = ({
|
|
|
66
84
|
</TableRow>
|
|
67
85
|
)}
|
|
68
86
|
|
|
69
|
-
{markersList.map((item) => (
|
|
87
|
+
{isHydrated && markersList.map((item) => (
|
|
70
88
|
<TableRow key={item.id} className="min-h-12">
|
|
71
89
|
<TableCell>
|
|
72
90
|
<FaRegBookmark className={`text-${item.color}`} />
|
|
@@ -95,5 +113,5 @@ export const BookmarkButton: FC<BookmarkProps> = ({
|
|
|
95
113
|
</Table>
|
|
96
114
|
</DialogContent>
|
|
97
115
|
</Dialog>
|
|
98
|
-
)
|
|
99
|
-
}
|
|
116
|
+
);
|
|
117
|
+
};
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { FC, useState } from "react";
|
|
4
4
|
import { Button } from "@c-rex/ui/button";
|
|
5
5
|
import { FaStar, FaRegStar } from "react-icons/fa";
|
|
6
|
-
import {
|
|
6
|
+
import { useFavorites } from "./favorites-context";
|
|
7
7
|
import { MARKER_COLORS, RESULT_TYPES } from "@c-rex/constants";
|
|
8
8
|
import { ResultTypes } from "@c-rex/types";
|
|
9
9
|
import { Loader2 } from "lucide-react";
|
|
@@ -14,14 +14,16 @@ export const FavoriteButton: FC<{
|
|
|
14
14
|
type: ResultTypes;
|
|
15
15
|
label: string;
|
|
16
16
|
}> = ({ id, type, label }) => {
|
|
17
|
-
const addFavoriteTopic =
|
|
18
|
-
const addFavoriteDocument =
|
|
19
|
-
const removeFavoriteTopic =
|
|
20
|
-
const removeFavoriteDocument =
|
|
21
|
-
|
|
22
|
-
const favoriteDocumentList =
|
|
23
|
-
const favoriteList =
|
|
17
|
+
const addFavoriteTopic = useFavorites((state) => state.favoriteTopic);
|
|
18
|
+
const addFavoriteDocument = useFavorites((state) => state.favoriteDocument);
|
|
19
|
+
const removeFavoriteTopic = useFavorites((state) => state.unfavoriteTopic);
|
|
20
|
+
const removeFavoriteDocument = useFavorites((state) => state.unfavoriteDocument);
|
|
21
|
+
|
|
22
|
+
const favoriteDocumentList = useFavorites((state) => state.documents);
|
|
23
|
+
const favoriteList = useFavorites((state) => state.favorites);
|
|
24
|
+
const isHydrated = useFavorites((state) => state.isHydrated);
|
|
24
25
|
const isFavorite = favoriteList.find((fav) => fav.id === id);
|
|
26
|
+
|
|
25
27
|
const [documentData, setDocumentData] = useState<{ id: string, label: string } | null>(null);
|
|
26
28
|
const [topicData, setTopicData] = useState<{ id: string, label: string } | null>(null);
|
|
27
29
|
const [isLoading, setIsLoading] = useState(false);
|
|
@@ -109,7 +111,7 @@ export const FavoriteButton: FC<{
|
|
|
109
111
|
};
|
|
110
112
|
|
|
111
113
|
const handleToggle = async () => {
|
|
112
|
-
if (isLoading) return;
|
|
114
|
+
if (!isHydrated || isLoading) return;
|
|
113
115
|
setIsLoading(true);
|
|
114
116
|
|
|
115
117
|
try {
|
|
@@ -125,25 +127,22 @@ export const FavoriteButton: FC<{
|
|
|
125
127
|
}
|
|
126
128
|
};
|
|
127
129
|
|
|
128
|
-
|
|
129
|
-
return (
|
|
130
|
-
<Button variant="ghost" size="icon" onClick={handleToggle} disabled={isLoading}>
|
|
131
|
-
{isLoading ? (
|
|
132
|
-
<Loader2 className="animate-spin" />
|
|
133
|
-
) : (
|
|
134
|
-
<FaStar className="color-primary" />
|
|
135
|
-
)}
|
|
136
|
-
</Button>
|
|
137
|
-
);
|
|
138
|
-
}
|
|
130
|
+
const showLoading = !isHydrated || isLoading;
|
|
139
131
|
|
|
140
132
|
return (
|
|
141
|
-
<Button
|
|
142
|
-
|
|
133
|
+
<Button
|
|
134
|
+
variant="ghost"
|
|
135
|
+
size="icon"
|
|
136
|
+
onClick={handleToggle}
|
|
137
|
+
disabled={showLoading}
|
|
138
|
+
>
|
|
139
|
+
{showLoading ? (
|
|
143
140
|
<Loader2 className="animate-spin" />
|
|
141
|
+
) : isFavorite ? (
|
|
142
|
+
<FaStar className="color-primary" />
|
|
144
143
|
) : (
|
|
145
144
|
<FaRegStar />
|
|
146
145
|
)}
|
|
147
146
|
</Button>
|
|
148
|
-
)
|
|
149
|
-
}
|
|
147
|
+
);
|
|
148
|
+
};
|