@carlonicora/nextjs-jsonapi 5.4.0 → 5.6.0
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/dist/{BlockNoteEditor-4YCAIEPT.js → BlockNoteEditor-AE3ZTEEH.js} +20 -20
- package/dist/{BlockNoteEditor-4YCAIEPT.js.map → BlockNoteEditor-AE3ZTEEH.js.map} +1 -1
- package/dist/{BlockNoteEditor-YQL2SGFR.mjs → BlockNoteEditor-NEYG5L6W.mjs} +5 -5
- package/dist/{BlockNoteViewer-AEJKJ27Q.mjs → BlockNoteViewer-3OUT5UFY.mjs} +3 -3
- package/dist/{BlockNoteViewer-YL3J7J2Y.js → BlockNoteViewer-4CFKQM7U.js} +4 -4
- package/dist/{BlockNoteViewer-YL3J7J2Y.js.map → BlockNoteViewer-4CFKQM7U.js.map} +1 -1
- package/dist/billing/index.d.mts +186 -25
- package/dist/billing/index.d.ts +186 -25
- package/dist/billing/index.js +1149 -588
- package/dist/billing/index.js.map +1 -1
- package/dist/billing/index.mjs +786 -225
- package/dist/billing/index.mjs.map +1 -1
- package/dist/{chunk-5PPG6PL7.js → chunk-3I5AAJXV.js} +3 -3
- package/dist/{chunk-5PPG6PL7.js.map → chunk-3I5AAJXV.js.map} +1 -1
- package/dist/{chunk-YW4ZCEWB.js → chunk-53BAULDP.js} +4 -4
- package/dist/{chunk-YW4ZCEWB.js.map → chunk-53BAULDP.js.map} +1 -1
- package/dist/{chunk-XCHSX3VC.mjs → chunk-BTPUYUQ3.mjs} +2 -2
- package/dist/{chunk-WC63PJSC.mjs → chunk-EYA2F6AN.mjs} +756 -24
- package/dist/chunk-EYA2F6AN.mjs.map +1 -0
- package/dist/{chunk-NFBOSPNJ.js → chunk-HKZAESS6.js} +954 -222
- package/dist/chunk-HKZAESS6.js.map +1 -0
- package/dist/{chunk-6DC2WVVB.mjs → chunk-KQHBONL4.mjs} +2 -2
- package/dist/{chunk-3NLAXVIN.js → chunk-LQ4DK4JF.js} +1988 -1089
- package/dist/chunk-LQ4DK4JF.js.map +1 -0
- package/dist/{chunk-YNCQSWQW.js → chunk-RL3PL3KV.js} +8 -8
- package/dist/{chunk-YNCQSWQW.js.map → chunk-RL3PL3KV.js.map} +1 -1
- package/dist/{chunk-EUKTBOI2.mjs → chunk-SFYPXCK4.mjs} +3382 -2483
- package/dist/chunk-SFYPXCK4.mjs.map +1 -0
- package/dist/{chunk-QMYQRPQC.mjs → chunk-Y7RR57EW.mjs} +2 -2
- package/dist/client/index.js +5 -5
- package/dist/client/index.js.map +1 -1
- package/dist/client/index.mjs +4 -4
- package/dist/components/editors/server.d.mts +15 -0
- package/dist/components/editors/server.d.ts +15 -0
- package/dist/components/editors/server.js +38 -0
- package/dist/components/editors/server.js.map +1 -0
- package/dist/components/editors/server.mjs +38 -0
- package/dist/components/editors/server.mjs.map +1 -0
- package/dist/components/index.d.mts +33 -10
- package/dist/components/index.d.ts +33 -10
- package/dist/components/index.js +7 -5
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +8 -6
- package/dist/contexts/index.d.mts +9 -1
- package/dist/contexts/index.d.ts +9 -1
- package/dist/contexts/index.js +5 -5
- package/dist/contexts/index.mjs +4 -4
- package/dist/core/index.d.mts +242 -3
- package/dist/core/index.d.ts +242 -3
- package/dist/core/index.js +36 -4
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +35 -3
- package/dist/email-automations/index.d.mts +3 -0
- package/dist/email-automations/index.d.ts +3 -0
- package/dist/email-automations/index.js +99 -95
- package/dist/email-automations/index.js.map +1 -1
- package/dist/email-automations/index.mjs +30 -26
- package/dist/email-automations/index.mjs.map +1 -1
- package/dist/features/analytics/index.d.mts +11 -4
- package/dist/features/analytics/index.d.ts +11 -4
- package/dist/features/analytics/index.js +127 -135
- package/dist/features/analytics/index.js.map +1 -1
- package/dist/features/analytics/index.mjs +16 -24
- package/dist/features/analytics/index.mjs.map +1 -1
- package/dist/features/analytics/server.d.mts +5 -2
- package/dist/features/analytics/server.d.ts +5 -2
- package/dist/features/analytics/server.js +7 -7
- package/dist/features/analytics/server.js.map +1 -1
- package/dist/features/analytics/server.mjs +5 -5
- package/dist/features/analytics/server.mjs.map +1 -1
- package/dist/features/blog/index.d.mts +12 -2
- package/dist/features/blog/index.d.ts +12 -2
- package/dist/features/blog/index.js +206 -195
- package/dist/features/blog/index.js.map +1 -1
- package/dist/features/blog/index.mjs +71 -60
- package/dist/features/blog/index.mjs.map +1 -1
- package/dist/features/blog/server.d.mts +4 -1
- package/dist/features/blog/server.d.ts +4 -1
- package/dist/features/blog/server.js +49 -21
- package/dist/features/blog/server.js.map +1 -1
- package/dist/features/blog/server.mjs +36 -8
- package/dist/features/blog/server.mjs.map +1 -1
- package/dist/features/help/index.js +38 -38
- package/dist/features/help/index.mjs +4 -4
- package/dist/features/tokenusage/index.d.mts +1 -3
- package/dist/features/tokenusage/index.d.ts +1 -3
- package/dist/features/tokenusage/index.js +127 -135
- package/dist/features/tokenusage/index.js.map +1 -1
- package/dist/features/tokenusage/index.mjs +10 -18
- package/dist/features/tokenusage/index.mjs.map +1 -1
- package/dist/{stripe-promotion-code.interface-C3qqh3mi.d.ts → format-options-BtX3o0Yu.d.ts} +104 -1
- package/dist/{stripe-promotion-code.interface-ClZ7DxS9.d.mts → format-options-D6c7FNDi.d.mts} +104 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +37 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +36 -4
- package/dist/server/index.js +4 -4
- package/dist/server/index.mjs +3 -3
- package/messages/en.json +125 -3
- package/messages/it.json +125 -3
- package/package.json +9 -1
- package/scripts/validate-messages.mjs +1 -0
- package/src/billing/index.ts +1 -0
- package/src/client/index.ts +4 -0
- package/src/components/editors/server/BlockNoteServerContent.tsx +28 -0
- package/src/components/editors/server/__tests__/renderBlockNoteHTML.spec.ts +44 -0
- package/src/components/editors/server/renderBlockNoteHTML.ts +26 -0
- package/src/components/editors/server-entry.ts +7 -0
- package/src/components/index.ts +1 -0
- package/src/components/viewers/BlockNoteStaticViewer.tsx +44 -0
- package/src/core/index.ts +1 -0
- package/src/core/registry/ModuleRegistry.ts +3 -0
- package/src/features/administration/components/AdminIndexContainer.tsx +61 -33
- package/src/features/administration/components/__tests__/AdminIndexContainer.spec.tsx +61 -1
- package/src/features/administration/components/index.ts +2 -1
- package/src/features/administration/contexts/AdministrationContext.tsx +11 -3
- package/src/features/administration/contexts/__tests__/AdministrationContext.spec.tsx +40 -0
- package/src/features/ai-connection/ai-connection.module.ts +2 -0
- package/src/features/ai-connection/contexts/AiConnectionsContext.tsx +3 -16
- package/src/features/ai-connection/contexts/__tests__/AiConnectionsContext.spec.tsx +33 -0
- package/src/features/analytics/__tests__/server-entry.spec.tsx +2 -1
- package/src/features/analytics/analytics.modules.ts +22 -5
- package/src/features/analytics/components/AnalyticsAdminContainer.tsx +4 -3
- package/src/features/analytics/components/AnalyticsAdminPageContainer.tsx +6 -3
- package/src/features/analytics/components/AnalyticsJourneyContainer.tsx +4 -3
- package/src/features/analytics/components/__tests__/AnalyticsJourneyContainer.spec.tsx +11 -0
- package/src/features/analytics/components/__tests__/AnalyticsPageContainers.spec.tsx +12 -1
- package/src/features/analytics/components/server/AnalyticsAdminPage.tsx +6 -3
- package/src/features/analytics/contexts/AnalyticsAdminContext.tsx +8 -17
- package/src/features/analytics/contexts/__tests__/AnalyticsAdminContext.spec.tsx +17 -0
- package/src/features/billing/components/cards/InvoicesSummaryCard.tsx +3 -2
- package/src/features/billing/components/cards/SubscriptionSummaryCard.tsx +4 -3
- package/src/features/billing/components/containers/BillingDashboardContainer.tsx +22 -5
- package/src/features/billing/components/utils/__tests__/discount.spec.ts +59 -0
- package/src/features/billing/components/utils/__tests__/url.spec.ts +32 -0
- package/src/features/billing/components/utils/discount.ts +38 -0
- package/src/features/billing/components/utils/index.ts +2 -0
- package/src/features/billing/components/utils/url.ts +12 -0
- package/src/features/billing/contexts/CouponContext.tsx +211 -0
- package/src/features/billing/contexts/index.ts +1 -0
- package/src/features/billing/i18n-keys.ts +79 -0
- package/src/features/billing/stripe-coupon/components/containers/CouponContainer.tsx +38 -0
- package/src/features/billing/stripe-coupon/components/containers/index.ts +1 -0
- package/src/features/billing/stripe-coupon/components/details/CouponDetails.tsx +69 -0
- package/src/features/billing/stripe-coupon/components/details/index.ts +1 -0
- package/src/features/billing/stripe-coupon/components/forms/CouponCodeEditor.tsx +79 -0
- package/src/features/billing/stripe-coupon/components/forms/CouponCodeFields.tsx +192 -0
- package/src/features/billing/stripe-coupon/components/forms/CouponEditor.tsx +333 -0
- package/src/features/billing/stripe-coupon/components/forms/CouponRefresher.tsx +45 -0
- package/src/features/billing/stripe-coupon/components/forms/index.ts +6 -0
- package/src/features/billing/stripe-coupon/components/index.ts +5 -0
- package/src/features/billing/stripe-coupon/components/lists/CouponCodesList.tsx +61 -0
- package/src/features/billing/stripe-coupon/components/lists/CouponsList.tsx +67 -0
- package/src/features/billing/stripe-coupon/components/lists/index.ts +2 -0
- package/src/features/billing/stripe-coupon/components/widgets/CouponCodeActions.tsx +80 -0
- package/src/features/billing/stripe-coupon/components/widgets/index.ts +1 -0
- package/src/features/billing/stripe-coupon/data/__tests__/stripe-coupon-models.spec.ts +114 -0
- package/src/features/billing/stripe-coupon/data/index.ts +10 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon-code.fields.ts +8 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon-code.interface.ts +44 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon-code.service.ts +92 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon-code.ts +132 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon-offer.interface.ts +17 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon-offer.ts +70 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon.fields.ts +7 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon.interface.ts +51 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon.service.ts +93 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon.ts +165 -0
- package/src/features/billing/stripe-coupon/hooks/useStripeCouponCodeTableStructure.tsx +154 -0
- package/src/features/billing/stripe-coupon/hooks/useStripeCouponTableStructure.tsx +113 -0
- package/src/features/billing/stripe-coupon/index.ts +5 -0
- package/src/features/billing/stripe-coupon/stripe-coupon-code.module.ts +14 -0
- package/src/features/billing/stripe-coupon/stripe-coupon-offer.module.ts +10 -0
- package/src/features/billing/stripe-coupon/stripe-coupon.module.ts +18 -0
- package/src/features/billing/stripe-coupon/utils/__tests__/coupon-places.spec.ts +19 -0
- package/src/features/billing/stripe-coupon/utils/__tests__/to-promotion-code-result.spec.ts +41 -0
- package/src/features/billing/stripe-coupon/utils/coupon-places.ts +23 -0
- package/src/features/billing/stripe-coupon/utils/format-coupon-discount.ts +36 -0
- package/src/features/billing/stripe-coupon/utils/index.ts +3 -0
- package/src/features/billing/stripe-coupon/utils/to-promotion-code-result.ts +23 -0
- package/src/features/billing/stripe-customer/components/forms/PaymentMethodForm.tsx +24 -8
- package/src/features/billing/stripe-price/stripe-price.module.ts +2 -0
- package/src/features/billing/stripe-product/components/containers/ProductsListContainer.tsx +21 -6
- package/src/features/billing/stripe-product/stripe-product.module.ts +2 -0
- package/src/features/billing/stripe-promotion-code/components/PromoCodeInput.tsx +60 -19
- package/src/features/billing/stripe-promotion-code/data/stripe-promotion-code.interface.ts +2 -0
- package/src/features/billing/stripe-subscription/components/containers/SubscriptionsContainer.tsx +24 -5
- package/src/features/billing/stripe-subscription/components/lists/SubscriptionsList.tsx +21 -2
- package/src/features/billing/stripe-subscription/components/wizards/SubscriptionWizard.tsx +1 -1
- package/src/features/billing/stripe-subscription/components/wizards/WizardStepPaymentMethod.tsx +1 -1
- package/src/features/billing/stripe-subscription/components/wizards/WizardStepReview.tsx +60 -49
- package/src/features/billing/stripe-subscription/components/wizards/__tests__/review-card.spec.ts +34 -0
- package/src/features/billing/stripe-subscription/components/wizards/review-card.ts +24 -0
- package/src/features/billing/stripe-subscription/data/__tests__/stripe-subscription.discount.spec.ts +67 -0
- package/src/features/billing/stripe-subscription/data/stripe-subscription.interface.ts +14 -0
- package/src/features/billing/stripe-subscription/data/stripe-subscription.service.ts +39 -0
- package/src/features/billing/stripe-subscription/data/stripe-subscription.ts +21 -0
- package/src/features/billing/stripe-subscription/hooks/__tests__/wizard-payment.spec.ts +48 -0
- package/src/features/billing/stripe-subscription/hooks/__tests__/wizard-promotion.spec.ts +132 -0
- package/src/features/billing/stripe-subscription/hooks/useSubscriptionWizard.ts +138 -12
- package/src/features/billing/stripe-subscription/hooks/wizard-payment.ts +30 -0
- package/src/features/billing/stripe-subscription/hooks/wizard-promotion.ts +54 -0
- package/src/features/blog/components/admin/BlogAdminContainer.tsx +13 -6
- package/src/features/blog/components/admin/BlogCategoriesList.tsx +1 -1
- package/src/features/blog/components/admin/BlogCoverImageField.tsx +1 -1
- package/src/features/blog/components/admin/BlogPostContainer.tsx +0 -1
- package/src/features/blog/components/admin/BlogPostEditor.tsx +42 -33
- package/src/features/blog/components/admin/BlogPostTranslationEditor.tsx +12 -4
- package/src/features/blog/components/admin/BlogPostsList.tsx +1 -1
- package/src/features/blog/components/public/BlogPostCard.tsx +2 -9
- package/src/features/blog/components/public/BlogPostMeta.tsx +2 -2
- package/src/features/blog/components/public/BlogPostView.tsx +9 -3
- package/src/features/blog/components/public/__tests__/BlogList.spec.tsx +2 -0
- package/src/features/blog/components/public/__tests__/BlogPostView.spec.tsx +17 -11
- package/src/features/blog/server/fetchers.ts +57 -8
- package/src/features/email-automation/components/containers/EmailAutomationsListContainer.tsx +12 -3
- package/src/features/rbac/contexts/RbacContext.tsx +4 -7
- package/src/features/rbac/contexts/__tests__/RbacContext.spec.tsx +42 -0
- package/src/features/tokenusage/components/TokenUsageAdminContainer.tsx +4 -3
- package/src/features/tokenusage/contexts/TokenUsageAdminContext.tsx +3 -18
- package/src/features/tokenusage/contexts/__tests__/TokenUsageAdminContext.spec.tsx +17 -0
- package/src/features/tokenusage/tokenusage-admin.module.ts +4 -3
- package/src/features/tokenusage/tokenusage.modules.ts +12 -2
- package/src/features/user/contexts/CurrentUserContext.tsx +25 -0
- package/src/features/user/contexts/UserContext.tsx +18 -3
- package/src/features/user/contexts/__tests__/CurrentUserContext.spec.tsx +121 -2
- package/src/features/user/contexts/__tests__/UserContext.spec.tsx +95 -0
- package/dist/chunk-3NLAXVIN.js.map +0 -1
- package/dist/chunk-EUKTBOI2.mjs.map +0 -1
- package/dist/chunk-NFBOSPNJ.js.map +0 -1
- package/dist/chunk-WC63PJSC.mjs.map +0 -1
- /package/dist/{BlockNoteEditor-YQL2SGFR.mjs.map → BlockNoteEditor-NEYG5L6W.mjs.map} +0 -0
- /package/dist/{BlockNoteViewer-AEJKJ27Q.mjs.map → BlockNoteViewer-3OUT5UFY.mjs.map} +0 -0
- /package/dist/{chunk-XCHSX3VC.mjs.map → chunk-BTPUYUQ3.mjs.map} +0 -0
- /package/dist/{chunk-6DC2WVVB.mjs.map → chunk-KQHBONL4.mjs.map} +0 -0
- /package/dist/{chunk-QMYQRPQC.mjs.map → chunk-Y7RR57EW.mjs.map} +0 -0
|
@@ -12,7 +12,7 @@ import "../../hooks/useBlogCategoryTableStructure";
|
|
|
12
12
|
import { BlogCategoryEditor } from "./BlogCategoryEditor";
|
|
13
13
|
|
|
14
14
|
type BlogCategoriesListProps = {
|
|
15
|
-
/** Pass when the list is the page's
|
|
15
|
+
/** Pass only when the list is the page's whole content inside a `RoundPageContainer fullWidth`; omit it in a tab so the list keeps its bordered card. */
|
|
16
16
|
fullWidth?: boolean;
|
|
17
17
|
};
|
|
18
18
|
|
|
@@ -105,7 +105,7 @@ export function BlogCoverImageField({
|
|
|
105
105
|
alt={alt ?? label}
|
|
106
106
|
width={320}
|
|
107
107
|
height={180}
|
|
108
|
-
className="h-auto max-h-48 w-auto rounded-md object-cover"
|
|
108
|
+
className="h-auto max-h-48 w-auto max-w-full rounded-md object-cover"
|
|
109
109
|
unoptimized={isRemoteImageSrc(previewUrl ? undefined : currentUrl)}
|
|
110
110
|
/>
|
|
111
111
|
) : (
|
|
@@ -149,7 +149,7 @@ export function BlogPostEditor({ post, trigger, onSuccess }: BlogPostEditorProps
|
|
|
149
149
|
onNavigate={(url) => router.push(url)}
|
|
150
150
|
trigger={trigger}
|
|
151
151
|
>
|
|
152
|
-
<div className="flex w-full flex-col gap-y-4">
|
|
152
|
+
<div className="flex h-full min-h-0 w-full flex-col gap-y-4">
|
|
153
153
|
{!post && (
|
|
154
154
|
<FormSelect
|
|
155
155
|
form={form}
|
|
@@ -160,38 +160,45 @@ export function BlogPostEditor({ post, trigger, onSuccess }: BlogPostEditorProps
|
|
|
160
160
|
/>
|
|
161
161
|
)}
|
|
162
162
|
<FormInput form={form} id="title" name={t("blog.fields.title")} isRequired />
|
|
163
|
-
|
|
164
|
-
<
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
163
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
164
|
+
<div className="flex flex-col gap-y-4">
|
|
165
|
+
{post && (
|
|
166
|
+
<FormInput form={form} id="slug" name={t("blog.fields.slug")} description={t("blog.fields.slugHelp")} />
|
|
167
|
+
)}
|
|
168
|
+
<EntityMultiSelector<BlogCategoryInterface>
|
|
169
|
+
id="categories"
|
|
170
|
+
form={form}
|
|
171
|
+
label={t("blog.fields.categories")}
|
|
172
|
+
retriever={(params) => BlogCategoryService.findMany(params)}
|
|
173
|
+
module={Modules.BlogCategory}
|
|
174
|
+
getLabel={blogCategoryAdminName}
|
|
175
|
+
// A post's included categories carry no translations, so the stored chip
|
|
176
|
+
// names come from the category list.
|
|
177
|
+
getFormValueLabel={(value: { id: string; name?: string }) => value.name || categoryName(value.id)}
|
|
178
|
+
/>
|
|
179
|
+
<FormTextarea
|
|
180
|
+
form={form}
|
|
181
|
+
id="summary"
|
|
182
|
+
name={t("blog.fields.summary")}
|
|
183
|
+
description={t("blog.fields.summaryHelp")}
|
|
184
|
+
className="min-h-24"
|
|
185
|
+
/>
|
|
186
|
+
</div>
|
|
187
|
+
<div className="flex flex-col gap-y-4">
|
|
188
|
+
<BlogCoverImageField
|
|
189
|
+
form={form}
|
|
190
|
+
id="coverImage"
|
|
191
|
+
label={t("blog.fields.coverImage")}
|
|
192
|
+
keyPrefix={BLOG_POST_UPLOAD_PREFIX}
|
|
193
|
+
entityId={form.getValues("id")}
|
|
194
|
+
currentUrl={post?.coverImage}
|
|
195
|
+
alt={original?.coverImageAlt}
|
|
196
|
+
selection={cover}
|
|
197
|
+
onSelectionChange={setCover}
|
|
198
|
+
/>
|
|
199
|
+
<FormInput form={form} id="coverImageAlt" name={t("blog.fields.coverImageAlt")} />
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
195
202
|
<FormBlockNote
|
|
196
203
|
uploadKeyPrefix={BLOG_POST_UPLOAD_PREFIX}
|
|
197
204
|
form={form}
|
|
@@ -199,6 +206,8 @@ export function BlogPostEditor({ post, trigger, onSuccess }: BlogPostEditorProps
|
|
|
199
206
|
name={t("blog.fields.body")}
|
|
200
207
|
type="blog-post"
|
|
201
208
|
isRequired
|
|
209
|
+
stretch
|
|
210
|
+
className="min-h-96"
|
|
202
211
|
onEmptyChange={setIsBodyEmptyState}
|
|
203
212
|
/>
|
|
204
213
|
</div>
|
|
@@ -90,11 +90,17 @@ export function BlogPostTranslationEditor({ translation, trigger, onSuccess }: B
|
|
|
90
90
|
onSuccess={onSuccess ?? (() => {})}
|
|
91
91
|
trigger={trigger}
|
|
92
92
|
>
|
|
93
|
-
<div className="flex w-full flex-col gap-y-4">
|
|
93
|
+
<div className="flex h-full min-h-0 w-full flex-col gap-y-4">
|
|
94
94
|
<FormInput form={form} id="title" name={t("blog.fields.title")} isRequired />
|
|
95
|
-
<
|
|
96
|
-
|
|
97
|
-
|
|
95
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
96
|
+
<div className="flex flex-col gap-y-4">
|
|
97
|
+
<FormInput form={form} id="slug" name={t("blog.fields.slug")} description={t("blog.fields.slugHelp")} />
|
|
98
|
+
<FormTextarea form={form} id="summary" name={t("blog.fields.summary")} className="min-h-24" />
|
|
99
|
+
</div>
|
|
100
|
+
<div className="flex flex-col gap-y-4">
|
|
101
|
+
<FormInput form={form} id="coverImageAlt" name={t("blog.fields.coverImageAlt")} />
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
98
104
|
<FormBlockNote
|
|
99
105
|
uploadKeyPrefix={BLOG_POST_UPLOAD_PREFIX}
|
|
100
106
|
form={form}
|
|
@@ -102,6 +108,8 @@ export function BlogPostTranslationEditor({ translation, trigger, onSuccess }: B
|
|
|
102
108
|
name={t("blog.fields.body")}
|
|
103
109
|
type="blog-post"
|
|
104
110
|
isRequired
|
|
111
|
+
stretch
|
|
112
|
+
className="min-h-96"
|
|
105
113
|
onEmptyChange={setIsBodyEmptyState}
|
|
106
114
|
/>
|
|
107
115
|
</div>
|
|
@@ -12,7 +12,7 @@ import "../../hooks/useBlogPostTableStructure";
|
|
|
12
12
|
import { BlogPostEditor } from "./BlogPostEditor";
|
|
13
13
|
|
|
14
14
|
type BlogPostsListProps = {
|
|
15
|
-
/** Pass when the list is the page's
|
|
15
|
+
/** Pass only when the list is the page's whole content inside a `RoundPageContainer fullWidth`; omit it in a tab so the list keeps its bordered card. */
|
|
16
16
|
fullWidth?: boolean;
|
|
17
17
|
};
|
|
18
18
|
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useFormatter } from "next-intl";
|
|
4
3
|
import Image from "next/image";
|
|
5
4
|
import { MicroLabel, SectionHeader } from "../../../../components/typography";
|
|
6
5
|
import { Link } from "../../../../shadcnui";
|
|
@@ -59,16 +58,10 @@ export function BlogPostCard(props: {
|
|
|
59
58
|
headingLevel?: "h2" | "h3";
|
|
60
59
|
}) {
|
|
61
60
|
const { post, categories, basePath, variant = "card", priority, headingLevel = "h2" } = props;
|
|
62
|
-
const format = useFormatter();
|
|
63
61
|
const category = resolvePostCategory(post, categories);
|
|
64
62
|
const isProminent = variant === "hero" || variant === "large";
|
|
65
63
|
|
|
66
|
-
const eyebrow =
|
|
67
|
-
variant === "row" && post.publishedAt
|
|
68
|
-
? [category?.name, format.dateTime(new Date(post.publishedAt), { day: "numeric", month: "short" })]
|
|
69
|
-
.filter(Boolean)
|
|
70
|
-
.join(" · ")
|
|
71
|
-
: category?.name;
|
|
64
|
+
const eyebrow = category?.name;
|
|
72
65
|
|
|
73
66
|
const text = (
|
|
74
67
|
<div className="flex min-w-0 flex-col">
|
|
@@ -79,7 +72,7 @@ export function BlogPostCard(props: {
|
|
|
79
72
|
</Link>
|
|
80
73
|
</SectionHeader>
|
|
81
74
|
{post.summary ? <p className="text-muted-foreground mt-2 text-sm leading-relaxed">{post.summary}</p> : null}
|
|
82
|
-
<BlogPostMeta post={post} showAuthor={isProminent}
|
|
75
|
+
<BlogPostMeta post={post} showAuthor={isProminent} className="mt-3" />
|
|
83
76
|
</div>
|
|
84
77
|
);
|
|
85
78
|
|
|
@@ -7,8 +7,8 @@ import type { PublicBlogPost } from "../../types/blog-public.types";
|
|
|
7
7
|
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* "author · date · updated · reading time". Each part
|
|
11
|
-
*
|
|
10
|
+
* "author · date · updated · reading time". Each part except reading time is
|
|
11
|
+
* opt-in. The public blog shows no dates, so its callers pass only showAuthor.
|
|
12
12
|
*/
|
|
13
13
|
export function BlogPostMeta(props: {
|
|
14
14
|
post: PublicBlogPost;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { ArrowLeftIcon } from "lucide-react";
|
|
4
4
|
import { useTranslations } from "next-intl";
|
|
5
|
+
import type { ReactNode } from "react";
|
|
5
6
|
import { BlockNoteEditorContainer } from "../../../../components/editors/BlockNoteEditorContainer";
|
|
6
7
|
import { ContentTitle } from "../../../../components/navigations/ContentTitle";
|
|
7
8
|
import { SectionHeader } from "../../../../components/typography";
|
|
@@ -14,14 +15,19 @@ import { BlogPostMeta } from "./BlogPostMeta";
|
|
|
14
15
|
/**
|
|
15
16
|
* Public post page: back link, category, title, summary, meta, cover, body,
|
|
16
17
|
* then up to three posts from the same category.
|
|
18
|
+
*
|
|
19
|
+
* `body` replaces the client-only editor with server-rendered content (the app
|
|
20
|
+
* passes BlockNoteServerContent from `@carlonicora/nextjs-jsonapi/blocknote/server`),
|
|
21
|
+
* so the post text is in the HTML crawlers read.
|
|
17
22
|
*/
|
|
18
23
|
export function BlogPostView(props: {
|
|
19
24
|
post: PublicBlogPost;
|
|
20
25
|
categories: PublicBlogCategory[];
|
|
21
26
|
related: PublicBlogPost[];
|
|
22
27
|
basePath: string;
|
|
28
|
+
body?: ReactNode;
|
|
23
29
|
}) {
|
|
24
|
-
const { post, categories, related, basePath } = props;
|
|
30
|
+
const { post, categories, related, basePath, body } = props;
|
|
25
31
|
const t = useTranslations();
|
|
26
32
|
const category = resolvePostCategory(post, categories);
|
|
27
33
|
const more = related.slice(0, 3);
|
|
@@ -36,7 +42,7 @@ export function BlogPostView(props: {
|
|
|
36
42
|
<header className="mt-8">
|
|
37
43
|
<ContentTitle type={category?.name} element={post.title} headingLevel={1} className="mb-0" />
|
|
38
44
|
{post.summary ? <p className="text-muted-foreground mt-3 text-sm leading-relaxed">{post.summary}</p> : null}
|
|
39
|
-
<BlogPostMeta post={post} showAuthor
|
|
45
|
+
<BlogPostMeta post={post} showAuthor className="mt-4" />
|
|
40
46
|
</header>
|
|
41
47
|
{post.coverImage ? (
|
|
42
48
|
<BlogPostCover
|
|
@@ -48,7 +54,7 @@ export function BlogPostView(props: {
|
|
|
48
54
|
/>
|
|
49
55
|
) : null}
|
|
50
56
|
<div className="mt-8">
|
|
51
|
-
<BlockNoteEditorContainer id={post.id} type="blog-post" initialContent={post.body} />
|
|
57
|
+
{body ?? <BlockNoteEditorContainer id={post.id} type="blog-post" initialContent={post.body} />}
|
|
52
58
|
</div>
|
|
53
59
|
</div>
|
|
54
60
|
{more.length > 0 ? (
|
|
@@ -100,6 +100,8 @@ describe("BlogList", () => {
|
|
|
100
100
|
expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1);
|
|
101
101
|
expect(screen.getByRole("heading", { level: 1, name: "blog.public.title" })).toBeInTheDocument();
|
|
102
102
|
expect(screen.getByText("blog.public.eyebrow")).toBeInTheDocument();
|
|
103
|
+
// The public blog shows no dates in any layout.
|
|
104
|
+
expect(screen.queryByText(/2026-10-12/)).not.toBeInTheDocument();
|
|
103
105
|
});
|
|
104
106
|
|
|
105
107
|
it("active category chip is marked and links back to all", () => {
|
|
@@ -100,24 +100,30 @@ describe("BlogPostView", () => {
|
|
|
100
100
|
expect(screen.getByRole("link", { name: "Three-way scenes" })).toHaveAttribute("href", "/blog/three-way");
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
-
it("shows
|
|
104
|
-
|
|
105
|
-
expect(screen.
|
|
103
|
+
it("shows neither the publish date nor the updated date", () => {
|
|
104
|
+
render(<BlogPostView post={main} categories={cats} related={[]} basePath="/blog" />);
|
|
105
|
+
expect(screen.queryByText(/2026-10-12/)).not.toBeInTheDocument();
|
|
106
|
+
expect(screen.queryByText(/2026-10-15/)).not.toBeInTheDocument();
|
|
107
|
+
expect(screen.queryByText(/blog\.public\.updated/)).not.toBeInTheDocument();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("omits the more-in section when there are no related posts", () => {
|
|
111
|
+
render(<BlogPostView post={main} categories={cats} related={[]} basePath="/blog" />);
|
|
112
|
+
expect(screen.queryByText(/blog\.public\.moreIn/)).not.toBeInTheDocument();
|
|
113
|
+
});
|
|
106
114
|
|
|
107
|
-
|
|
115
|
+
it("renders the body prop in place of the editor when given", () => {
|
|
116
|
+
render(
|
|
108
117
|
<BlogPostView
|
|
109
|
-
post={
|
|
118
|
+
post={main}
|
|
110
119
|
categories={cats}
|
|
111
120
|
related={[]}
|
|
112
121
|
basePath="/blog"
|
|
122
|
+
body={<div data-testid="server-body">Server-rendered text</div>}
|
|
113
123
|
/>,
|
|
114
124
|
);
|
|
115
|
-
expect(screen.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
it("omits the more-in section when there are no related posts", () => {
|
|
119
|
-
render(<BlogPostView post={main} categories={cats} related={[]} basePath="/blog" />);
|
|
120
|
-
expect(screen.queryByText(/blog\.public\.moreIn/)).not.toBeInTheDocument();
|
|
125
|
+
expect(screen.getByTestId("server-body")).toHaveTextContent("Server-rendered text");
|
|
126
|
+
expect(screen.queryByTestId("post-body")).not.toBeInTheDocument();
|
|
121
127
|
});
|
|
122
128
|
|
|
123
129
|
it("caps related posts at three", () => {
|
|
@@ -28,21 +28,20 @@ export async function fetchPublicBlogPosts(params: {
|
|
|
28
28
|
language: string;
|
|
29
29
|
categorySlug?: string;
|
|
30
30
|
search?: string;
|
|
31
|
-
page
|
|
31
|
+
/** 1-based list page; ignored with `all`. */
|
|
32
|
+
page?: number;
|
|
33
|
+
/** Every matching post, read in API-sized pages (a sitemap). `hasMore` is then false. */
|
|
34
|
+
all?: boolean;
|
|
32
35
|
}): Promise<PublicBlogList> {
|
|
36
|
+
if (params.all) return { posts: await fetchEveryPublicBlogPost(params), hasMore: false };
|
|
37
|
+
|
|
33
38
|
// Matches the API cap (120 = 10 pages of 12); a larger ?page= is served as the last allowed one.
|
|
34
39
|
const page = Math.min(10, Math.max(1, Math.floor(params.page || 1)));
|
|
35
40
|
const size = BLOG_PAGE_SIZE * page;
|
|
36
41
|
|
|
37
|
-
const endpoint = new EndpointCreator({ endpoint: publicPostsEndpoint() });
|
|
38
|
-
endpoint.addAdditionalParam("language", params.language);
|
|
39
|
-
if (params.categorySlug) endpoint.addAdditionalParam("category", encodeURIComponent(params.categorySlug));
|
|
40
|
-
if (params.search) endpoint.addAdditionalParam("search", encodeURIComponent(params.search));
|
|
41
|
-
endpoint.addAdditionalParam("page[size]", String(size));
|
|
42
|
-
|
|
43
42
|
const res = await ServerJsonApiGet({
|
|
44
43
|
classKey: { ...Modules.BlogPost, cache: "minutes" },
|
|
45
|
-
endpoint:
|
|
44
|
+
endpoint: publicPostsListEndpoint({ ...params, size }),
|
|
46
45
|
language: params.language,
|
|
47
46
|
});
|
|
48
47
|
if (!res.ok) return { posts: [], hasMore: false };
|
|
@@ -54,6 +53,56 @@ export async function fetchPublicBlogPosts(params: {
|
|
|
54
53
|
return { posts, hasMore };
|
|
55
54
|
}
|
|
56
55
|
|
|
56
|
+
function publicPostsListEndpoint(params: {
|
|
57
|
+
language: string;
|
|
58
|
+
categorySlug?: string;
|
|
59
|
+
search?: string;
|
|
60
|
+
size: number;
|
|
61
|
+
offset?: number;
|
|
62
|
+
}): string {
|
|
63
|
+
const endpoint = new EndpointCreator({ endpoint: publicPostsEndpoint() });
|
|
64
|
+
endpoint.addAdditionalParam("language", params.language);
|
|
65
|
+
if (params.categorySlug) endpoint.addAdditionalParam("category", encodeURIComponent(params.categorySlug));
|
|
66
|
+
if (params.search) endpoint.addAdditionalParam("search", encodeURIComponent(params.search));
|
|
67
|
+
endpoint.addAdditionalParam("page[size]", String(params.size));
|
|
68
|
+
if (params.offset) endpoint.addAdditionalParam("page[offset]", String(params.offset));
|
|
69
|
+
return endpoint.generate();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The API serves at most PUBLIC_MAX_PAGE_SIZE posts per request, so "all" walks
|
|
73
|
+
// page[offset] until a short page comes back. MAX_ALL_PAGES only stops a runaway loop.
|
|
74
|
+
const PUBLIC_MAX_PAGE_SIZE = 120;
|
|
75
|
+
const MAX_ALL_PAGES = 100;
|
|
76
|
+
|
|
77
|
+
async function fetchEveryPublicBlogPost(params: {
|
|
78
|
+
language: string;
|
|
79
|
+
categorySlug?: string;
|
|
80
|
+
search?: string;
|
|
81
|
+
}): Promise<PublicBlogPost[]> {
|
|
82
|
+
const posts: PublicBlogPost[] = [];
|
|
83
|
+
|
|
84
|
+
// In sequence: a failing cached call throws, and parallel failing cached calls
|
|
85
|
+
// never close the response.
|
|
86
|
+
for (let index = 0; index < MAX_ALL_PAGES; index++) {
|
|
87
|
+
const res = await ServerJsonApiGet({
|
|
88
|
+
classKey: { ...Modules.BlogPost, cache: "minutes" },
|
|
89
|
+
endpoint: publicPostsListEndpoint({
|
|
90
|
+
...params,
|
|
91
|
+
size: PUBLIC_MAX_PAGE_SIZE,
|
|
92
|
+
offset: index * PUBLIC_MAX_PAGE_SIZE,
|
|
93
|
+
}),
|
|
94
|
+
language: params.language,
|
|
95
|
+
});
|
|
96
|
+
if (!res.ok) break;
|
|
97
|
+
|
|
98
|
+
const batch = asArray<BlogPostInterface>(res.data);
|
|
99
|
+
posts.push(...batch.map(toPublicBlogPost));
|
|
100
|
+
if (batch.length < PUBLIC_MAX_PAGE_SIZE) break;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return posts;
|
|
104
|
+
}
|
|
105
|
+
|
|
57
106
|
export async function fetchPublicBlogPost(params: { language: string; slug: string }): Promise<PublicBlogPost | null> {
|
|
58
107
|
const endpoint = new EndpointCreator({ endpoint: publicPostsEndpoint(), id: encodeURIComponent(params.slug) });
|
|
59
108
|
endpoint.addAdditionalParam("language", params.language);
|
package/src/features/email-automation/components/containers/EmailAutomationsListContainer.tsx
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
3
4
|
import { RoundPageContainer } from "../../../../components";
|
|
5
|
+
import { SharedProvider } from "../../../../contexts/SharedContext";
|
|
4
6
|
import { Modules } from "../../../../core";
|
|
5
|
-
import {
|
|
7
|
+
import { BreadcrumbItemData } from "../../../../interfaces";
|
|
6
8
|
import { EmailAutomationsList } from "../lists/EmailAutomationsList";
|
|
7
9
|
|
|
8
10
|
/**
|
|
@@ -15,13 +17,20 @@ import { EmailAutomationsList } from "../lists/EmailAutomationsList";
|
|
|
15
17
|
*
|
|
16
18
|
* No `forceHeader`: ContentListTable draws its own header row with the title
|
|
17
19
|
* and the create action, exactly as the company and user list pages do.
|
|
20
|
+
*
|
|
21
|
+
* Publishes its own breadcrumb, as CompanyProvider does for the companies list:
|
|
22
|
+
* the list root comes first and has no href because it is the current page.
|
|
18
23
|
*/
|
|
19
24
|
export function EmailAutomationsListContainer() {
|
|
25
|
+
const t = useTranslations();
|
|
26
|
+
|
|
27
|
+
const breadcrumbs: BreadcrumbItemData[] = [{ name: t("emailAutomations.admin.title") }];
|
|
28
|
+
|
|
20
29
|
return (
|
|
21
|
-
<
|
|
30
|
+
<SharedProvider value={{ breadcrumbs, title: { type: t("emailAutomations.admin.title") } }}>
|
|
22
31
|
<RoundPageContainer module={Modules.EmailAutomation} fullWidth>
|
|
23
32
|
<EmailAutomationsList fullWidth />
|
|
24
33
|
</RoundPageContainer>
|
|
25
|
-
</
|
|
34
|
+
</SharedProvider>
|
|
26
35
|
);
|
|
27
36
|
}
|
|
@@ -4,8 +4,6 @@ import { DownloadIcon, Loader2Icon } from "lucide-react";
|
|
|
4
4
|
import { useTranslations } from "next-intl";
|
|
5
5
|
import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
6
6
|
import { SharedProvider } from "../../../contexts";
|
|
7
|
-
import { Modules } from "../../../core";
|
|
8
|
-
import { usePageUrlGenerator } from "../../../hooks";
|
|
9
7
|
import { BreadcrumbItemData } from "../../../interfaces";
|
|
10
8
|
import { Button } from "../../../shadcnui";
|
|
11
9
|
import { showError, showToast } from "../../../utils/toast";
|
|
@@ -70,7 +68,6 @@ export const RbacProvider = ({
|
|
|
70
68
|
moduleNames,
|
|
71
69
|
outputPath = DEFAULT_OUTPUT_PATH,
|
|
72
70
|
}: RbacProviderProps) => {
|
|
73
|
-
const generateUrl = usePageUrlGenerator();
|
|
74
71
|
const t = useTranslations();
|
|
75
72
|
|
|
76
73
|
const [matrix, setMatrix] = useState<RbacMatrix | null>(null);
|
|
@@ -149,10 +146,10 @@ export const RbacProvider = ({
|
|
|
149
146
|
|
|
150
147
|
const breadcrumb = (): BreadcrumbItemData[] => {
|
|
151
148
|
const response: BreadcrumbItemData[] = [];
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
});
|
|
149
|
+
// Unlinked: the provider only serves the RBAC page, so the root is always
|
|
150
|
+
// the current page. The dev matrix module has no pageUrl either, so a link
|
|
151
|
+
// built from it pointed at "undefined".
|
|
152
|
+
response.push({ name: t(`entities.rbac`, { count: 2 }) });
|
|
156
153
|
return response;
|
|
157
154
|
};
|
|
158
155
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { useSharedContext } from "../../../../contexts/SharedContext";
|
|
4
|
+
import { RbacService } from "../../data/RbacService";
|
|
5
|
+
import { RbacProvider } from "../RbacContext";
|
|
6
|
+
|
|
7
|
+
vi.mock("../../data/RbacService", () => ({
|
|
8
|
+
RbacService: {
|
|
9
|
+
fetchMatrix: vi.fn(async () => ({ matrix: {}, modulePaths: {} })),
|
|
10
|
+
saveMatrix: vi.fn(async () => undefined),
|
|
11
|
+
},
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
// The provider refetches whenever `t` changes identity, and the global next-intl
|
|
15
|
+
// mock in vitest.setup.ts builds a new `t` per call. A stable one keeps the fetch
|
|
16
|
+
// to a single run. Labels still render as their own key.
|
|
17
|
+
const { stableT } = vi.hoisted(() => ({ stableT: (key: string) => key }));
|
|
18
|
+
vi.mock("next-intl", () => ({
|
|
19
|
+
useTranslations: () => stableT,
|
|
20
|
+
useLocale: () => "en",
|
|
21
|
+
useNow: () => new Date(),
|
|
22
|
+
useTimeZone: () => "UTC",
|
|
23
|
+
useFormatter: () => ({ dateTime: vi.fn(), number: vi.fn(), relativeTime: vi.fn() }),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
function BreadcrumbProbe() {
|
|
27
|
+
const { breadcrumbs } = useSharedContext();
|
|
28
|
+
return <span data-testid="breadcrumbs">{JSON.stringify(breadcrumbs)}</span>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("RbacContext", () => {
|
|
32
|
+
it("publishes the page as an unlinked breadcrumb, since it is the current page", async () => {
|
|
33
|
+
render(
|
|
34
|
+
<RbacProvider>
|
|
35
|
+
<BreadcrumbProbe />
|
|
36
|
+
</RbacProvider>,
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
await waitFor(() => expect(RbacService.fetchMatrix).toHaveBeenCalled());
|
|
40
|
+
expect(screen.getByTestId("breadcrumbs")).toHaveTextContent(JSON.stringify([{ name: "entities.rbac" }]));
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -4,6 +4,7 @@ import { cn } from "../../../lib/utils";
|
|
|
4
4
|
import { useTranslations } from "next-intl";
|
|
5
5
|
import { useMemo } from "react";
|
|
6
6
|
import { RoundPageContainer } from "../../../components";
|
|
7
|
+
import { Modules } from "../../../core";
|
|
7
8
|
import {
|
|
8
9
|
Card,
|
|
9
10
|
CardAction,
|
|
@@ -75,7 +76,7 @@ export function TokenUsageAdminContainer() {
|
|
|
75
76
|
|
|
76
77
|
if (error) {
|
|
77
78
|
return (
|
|
78
|
-
<RoundPageContainer fullWidth forceHeader>
|
|
79
|
+
<RoundPageContainer module={Modules.TokenUsageAdminSummary} fullWidth forceHeader>
|
|
79
80
|
<div className="p-4">
|
|
80
81
|
<Card>
|
|
81
82
|
<CardContent>
|
|
@@ -89,12 +90,12 @@ export function TokenUsageAdminContainer() {
|
|
|
89
90
|
|
|
90
91
|
// Loading renders nothing in the body: the title bar (with the filter bar) is
|
|
91
92
|
// already mounted, so a spinner would only make the controls jump on arrival.
|
|
92
|
-
if (isLoading) return <RoundPageContainer fullWidth forceHeader />;
|
|
93
|
+
if (isLoading) return <RoundPageContainer module={Modules.TokenUsageAdminSummary} fullWidth forceHeader />;
|
|
93
94
|
|
|
94
95
|
const emptyLabel = t("token_usage.admin.no_data");
|
|
95
96
|
|
|
96
97
|
return (
|
|
97
|
-
<RoundPageContainer fullWidth forceHeader>
|
|
98
|
+
<RoundPageContainer module={Modules.TokenUsageAdminSummary} fullWidth forceHeader>
|
|
98
99
|
<div className="flex w-full flex-col gap-4 p-4">
|
|
99
100
|
<TokenUsageAdminTiles summary={summary} metric={filters.metric} singleCustomerMode={hidePlatform} />
|
|
100
101
|
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
import { useTranslations } from "next-intl";
|
|
4
4
|
import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
5
5
|
import { SharedProvider } from "../../../contexts";
|
|
6
|
-
import { usePageUrlGenerator } from "../../../hooks";
|
|
7
6
|
import { BreadcrumbItemData } from "../../../interfaces";
|
|
8
7
|
import { TokenUsageAdminFilterBar } from "../components/TokenUsageAdminFilterBar";
|
|
9
8
|
import type { TokenUsageAdminBreakdownInterface } from "../data/tokenusage-admin-breakdown.interface";
|
|
@@ -12,13 +11,6 @@ import type { TokenUsageAdminTimelineInterface } from "../data/tokenusage-admin-
|
|
|
12
11
|
import type { Granularity, Metric, StackBy } from "../data/tokenusage-admin.types";
|
|
13
12
|
import { TokenUsageAdminService } from "../data/TokenUsageAdminService";
|
|
14
13
|
|
|
15
|
-
/**
|
|
16
|
-
* Default route the breadcrumb links back to. A host app that mounts the page
|
|
17
|
-
* elsewhere overrides it with the `pageUrl` prop — the constant stays here
|
|
18
|
-
* because the breadcrumb is built by this provider, not by the app.
|
|
19
|
-
*/
|
|
20
|
-
const TOKEN_USAGE_ADMIN_PAGE_URL = "/administration/token-usage";
|
|
21
|
-
|
|
22
14
|
/** Rows kept per ranked panel before the repository folds the tail into "other". */
|
|
23
15
|
const DEFAULT_TOP_N = 10;
|
|
24
16
|
|
|
@@ -72,8 +64,6 @@ type TokenUsageAdminProviderProps = {
|
|
|
72
64
|
initialTo?: string;
|
|
73
65
|
/** Rows per ranked panel. Defaults to 10. */
|
|
74
66
|
topN?: number;
|
|
75
|
-
/** Route the breadcrumb links back to. */
|
|
76
|
-
pageUrl?: string;
|
|
77
67
|
};
|
|
78
68
|
|
|
79
69
|
/**
|
|
@@ -91,10 +81,8 @@ export const TokenUsageAdminProvider = ({
|
|
|
91
81
|
initialFrom,
|
|
92
82
|
initialTo,
|
|
93
83
|
topN = DEFAULT_TOP_N,
|
|
94
|
-
pageUrl = TOKEN_USAGE_ADMIN_PAGE_URL,
|
|
95
84
|
}: TokenUsageAdminProviderProps) => {
|
|
96
85
|
const t = useTranslations();
|
|
97
|
-
const generateUrl = usePageUrlGenerator();
|
|
98
86
|
|
|
99
87
|
const [filters, setFilterState] = useState<TokenUsageAdminFilterState>(() => {
|
|
100
88
|
const range = defaultRange();
|
|
@@ -185,12 +173,9 @@ export const TokenUsageAdminProvider = ({
|
|
|
185
173
|
[byCompany],
|
|
186
174
|
);
|
|
187
175
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
href: generateUrl({ page: pageUrl }),
|
|
192
|
-
},
|
|
193
|
-
];
|
|
176
|
+
// Unlinked: this provider only serves the token-usage page, so the root is
|
|
177
|
+
// always the current page.
|
|
178
|
+
const breadcrumb = (): BreadcrumbItemData[] => [{ name: t("token_usage.admin.title") }];
|
|
194
179
|
|
|
195
180
|
const title = () => ({
|
|
196
181
|
type: t("token_usage.admin.title"),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { render, screen, waitFor } from "@testing-library/react";
|
|
2
2
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { useSharedContext } from "../../../../contexts/SharedContext";
|
|
3
4
|
import { TokenUsageAdminService } from "../../data/TokenUsageAdminService";
|
|
4
5
|
import { TokenUsageAdminProvider, useTokenUsageAdmin } from "../TokenUsageAdminContext";
|
|
5
6
|
|
|
@@ -16,6 +17,11 @@ function Probe() {
|
|
|
16
17
|
return <span data-testid="mode">{String(ctx.singleCustomerMode)}</span>;
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
function BreadcrumbProbe() {
|
|
21
|
+
const { breadcrumbs } = useSharedContext();
|
|
22
|
+
return <span data-testid="breadcrumbs">{JSON.stringify(breadcrumbs)}</span>;
|
|
23
|
+
}
|
|
24
|
+
|
|
19
25
|
describe("TokenUsageAdminContext", () => {
|
|
20
26
|
// The mocked service is module-level, so its call counts survive across tests.
|
|
21
27
|
// Without this reset the per-test `toHaveBeenCalledTimes` assertions would
|
|
@@ -77,4 +83,15 @@ describe("TokenUsageAdminContext", () => {
|
|
|
77
83
|
expect(calls).toHaveLength(3);
|
|
78
84
|
expect(screen.getByTestId("mode")).toHaveTextContent("true");
|
|
79
85
|
});
|
|
86
|
+
|
|
87
|
+
it("publishes the page as an unlinked breadcrumb, since it is the current page", async () => {
|
|
88
|
+
render(
|
|
89
|
+
<TokenUsageAdminProvider>
|
|
90
|
+
<BreadcrumbProbe />
|
|
91
|
+
</TokenUsageAdminProvider>,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
await waitFor(() => expect(TokenUsageAdminService.getSummary).toHaveBeenCalled());
|
|
95
|
+
expect(screen.getByTestId("breadcrumbs")).toHaveTextContent(JSON.stringify([{ name: "token_usage.admin.title" }]));
|
|
96
|
+
});
|
|
80
97
|
});
|