@c-rex/components 0.3.0-build.35 → 0.3.0-build.38
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 +32 -36
- package/src/article/article-action-bar.tsx +12 -2
- 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 +7 -25
- package/src/blog/blog-author-card.tsx +116 -0
- 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 +40 -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/navbar/navbar.tsx +16 -30
- package/src/navbar/settings.tsx +1 -1
- package/src/page-wrapper.tsx +3 -3
- 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 +445 -0
- package/src/restriction-menu/restriction-selection-menu.tsx +5 -7
- package/src/restriction-menu/taxonomy-restriction-command-menu.tsx +111 -0
- package/src/restriction-menu/taxonomy-restriction-menu.tsx +19 -12
- package/src/results/filter-navbar.tsx +81 -76
- package/src/results/filter-sidebar/context.tsx +32 -0
- package/src/results/filter-sidebar/index.tsx +40 -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/information-unit-search-results-cards.tsx +169 -69
- package/src/results/pagination.tsx +43 -40
- package/src/search-input.tsx +4 -2
- package/src/toc/toc-breadcrumb.tsx +1 -1
- 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
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import { FC, useEffect, useState } from "react";
|
|
4
4
|
import { FragmentsGetByIdClient } from "../generated/client-components";
|
|
5
|
+
import { Skeleton } from "@c-rex/ui/skeleton";
|
|
5
6
|
import { ExpandableSummary } from "./expandable-summary";
|
|
6
7
|
import type { RenditionModel } from "@c-rex/interfaces";
|
|
8
|
+
import { useTranslations } from "next-intl";
|
|
7
9
|
|
|
8
10
|
interface Props {
|
|
9
11
|
fragmentShortId?: string;
|
|
@@ -48,6 +50,7 @@ const DocumentDescriptionPreviewContent: FC<{
|
|
|
48
50
|
isLoading,
|
|
49
51
|
hasError,
|
|
50
52
|
}) => {
|
|
53
|
+
const t = useTranslations();
|
|
51
54
|
const [text, setText] = useState<string | null>(null);
|
|
52
55
|
const [loadingText, setLoadingText] = useState(false);
|
|
53
56
|
|
|
@@ -84,15 +87,20 @@ const DocumentDescriptionPreviewContent: FC<{
|
|
|
84
87
|
}, [title, viewHref]);
|
|
85
88
|
|
|
86
89
|
if (hasError) {
|
|
87
|
-
return <span className="text-muted-foreground">
|
|
90
|
+
return <span className="text-muted-foreground">{t("noRenditionAvailable")}</span>;
|
|
88
91
|
}
|
|
89
92
|
|
|
90
93
|
if (isLoading || loadingText) {
|
|
91
|
-
return
|
|
94
|
+
return (
|
|
95
|
+
<div className="flex flex-col gap-1 pt-1">
|
|
96
|
+
<Skeleton className="h-4 w-full" />
|
|
97
|
+
<Skeleton className="h-4 w-5/6" />
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
92
100
|
}
|
|
93
101
|
|
|
94
102
|
if (!text) {
|
|
95
|
-
return <span className="text-muted-foreground">
|
|
103
|
+
return <span className="text-muted-foreground">{t("noRenditionAvailable")}</span>;
|
|
96
104
|
}
|
|
97
105
|
|
|
98
106
|
return <ExpandableSummary text={text} />;
|
|
@@ -102,8 +110,10 @@ export const DocumentDescriptionPreview: FC<Props> = ({
|
|
|
102
110
|
fragmentShortId,
|
|
103
111
|
title,
|
|
104
112
|
}) => {
|
|
113
|
+
const t = useTranslations();
|
|
114
|
+
|
|
105
115
|
if (!fragmentShortId) {
|
|
106
|
-
return <span className="text-muted-foreground">
|
|
116
|
+
return <span className="text-muted-foreground">{t("noRenditionAvailable")}</span>;
|
|
107
117
|
}
|
|
108
118
|
|
|
109
119
|
return (
|
|
@@ -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,16 @@ 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
|
-
<
|
|
86
|
+
<Link
|
|
87
|
+
className="hover:underline [overflow-wrap:anywhere] hyphens-auto"
|
|
88
|
+
href={itemLink}
|
|
89
|
+
>
|
|
90
|
+
{title}
|
|
91
|
+
</Link>
|
|
89
92
|
</span>
|
|
90
93
|
|
|
91
94
|
<div>
|
|
@@ -109,36 +112,32 @@ const DocumentResultListRowContent: FC<RowContentProps> = ({
|
|
|
109
112
|
</span>
|
|
110
113
|
</div>
|
|
111
114
|
|
|
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
|
-
)}
|
|
115
|
+
<div className="flex flex-row p-2 ml-auto justify-between self-start gap-2 w-full sm:justify-start sm:w-auto">
|
|
116
|
+
<FileDownloadDropdown renditions={item.renditions} />
|
|
117
|
+
|
|
118
|
+
<FavoriteButton
|
|
119
|
+
id={item.shortId!}
|
|
120
|
+
type={itemType}
|
|
121
|
+
label={title}
|
|
122
|
+
/>
|
|
123
|
+
|
|
124
|
+
{multipleVersions.length > 1 && (
|
|
125
|
+
<Tooltip>
|
|
126
|
+
<TooltipTrigger asChild>
|
|
127
|
+
<Button variant="ghost" size="icon">
|
|
128
|
+
<FileStack />
|
|
129
|
+
</Button>
|
|
130
|
+
</TooltipTrigger>
|
|
131
|
+
<TooltipContent>
|
|
132
|
+
{t("availableIn")}: {multipleVersions.join(", ")}
|
|
133
|
+
</TooltipContent>
|
|
134
|
+
</Tooltip>
|
|
135
|
+
)}
|
|
134
136
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
/>
|
|
140
|
-
)}
|
|
141
|
-
</div>
|
|
137
|
+
<BookmarkButton
|
|
138
|
+
shortId={item.shortId!}
|
|
139
|
+
triggerVariant="ghost"
|
|
140
|
+
/>
|
|
142
141
|
</div>
|
|
143
142
|
</div>
|
|
144
143
|
);
|
|
@@ -152,8 +151,7 @@ export const DocumentsResultListItem: FC<Props> = ({
|
|
|
152
151
|
isLast,
|
|
153
152
|
eager = false,
|
|
154
153
|
}) => {
|
|
155
|
-
const { title,
|
|
156
|
-
const isDocument = itemType === RESULT_TYPES.DOCUMENT;
|
|
154
|
+
const { title, itemType, multipleVersions, packageId } = getResultItemSummary(item);
|
|
157
155
|
const [canLoadDetails, setCanLoadDetails] = useState(eager);
|
|
158
156
|
const rowRef = useRef<HTMLDivElement | null>(null);
|
|
159
157
|
const queryParams: QueryParams[] = [];
|
|
@@ -199,14 +197,12 @@ export const DocumentsResultListItem: FC<Props> = ({
|
|
|
199
197
|
item={item}
|
|
200
198
|
itemLink={itemLink}
|
|
201
199
|
title={title}
|
|
202
|
-
language={language}
|
|
203
200
|
itemType={itemType}
|
|
204
201
|
multipleVersions={multipleVersions}
|
|
205
|
-
isDocument={isDocument}
|
|
206
202
|
isLast={isLast}
|
|
207
203
|
isLoadingDetails={!canLoadDetails}
|
|
208
204
|
/>
|
|
209
|
-
), [canLoadDetails,
|
|
205
|
+
), [canLoadDetails, isLast, item, itemLink, itemType, multipleVersions, title]);
|
|
210
206
|
|
|
211
207
|
return (
|
|
212
208
|
<div ref={rowRef}>
|
|
@@ -238,10 +234,8 @@ export const DocumentsResultListItem: FC<Props> = ({
|
|
|
238
234
|
item={item}
|
|
239
235
|
itemLink={itemLink}
|
|
240
236
|
title={title}
|
|
241
|
-
language={language}
|
|
242
237
|
itemType={itemType}
|
|
243
238
|
multipleVersions={multipleVersions}
|
|
244
|
-
isDocument={isDocument}
|
|
245
239
|
isLast={isLast}
|
|
246
240
|
previewFragmentShortId={previewFragmentShortId}
|
|
247
241
|
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
|
+
};
|