@graph8/sdk 0.361.0 → 0.370.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/README.md +14 -10
- package/blocks/_foundation/README.md +172 -1
- package/blocks/_foundation/block.json +234 -13
- package/blocks/_foundation/components/api-error.tsx +71 -25
- package/blocks/_foundation/components/brand-provider.tsx +23 -0
- package/blocks/_foundation/components/confirmed-action.tsx +90 -0
- package/blocks/_foundation/components/connect-graph8.tsx +280 -0
- package/blocks/_foundation/components/empty-state.tsx +39 -7
- package/blocks/_foundation/components/screen/bar-list.tsx +71 -0
- package/blocks/_foundation/components/screen/detail-panel.tsx +114 -0
- package/blocks/_foundation/components/screen/entity-avatar.tsx +56 -0
- package/blocks/_foundation/components/screen/form-sheet.tsx +73 -0
- package/blocks/_foundation/components/screen/index.ts +22 -0
- package/blocks/_foundation/components/screen/key-value-list.tsx +65 -0
- package/blocks/_foundation/components/screen/record-link.tsx +76 -0
- package/blocks/_foundation/components/screen/screen-layout.tsx +124 -0
- package/blocks/_foundation/components/screen/section-card.tsx +47 -0
- package/blocks/_foundation/components/screen/sparkline.tsx +77 -0
- package/blocks/_foundation/components/screen/split-view.tsx +125 -0
- package/blocks/_foundation/components/screen/stat-strip.tsx +121 -0
- package/blocks/_foundation/components/screen/status-pill.tsx +104 -0
- package/blocks/_foundation/components/screen/time-series-chart-recharts.tsx +65 -0
- package/blocks/_foundation/components/screen/time-series-chart.tsx +32 -0
- package/blocks/_foundation/components/screen/time-series-format.ts +57 -0
- package/blocks/_foundation/components/screen/toolbar.tsx +144 -0
- package/blocks/_foundation/hooks/use-brand.ts +52 -0
- package/blocks/_foundation/hooks/use-g8-mutation.ts +23 -0
- package/blocks/_foundation/hooks/use-g8-query.ts +127 -1
- package/blocks/_foundation/hooks/use-paginated.ts +120 -0
- package/blocks/_foundation/hooks/use-row-activation.ts +57 -0
- package/blocks/_foundation/hooks/use-url-state.ts +74 -0
- package/blocks/_foundation/lib/g8-cache.ts +454 -0
- package/blocks/_foundation/lib/g8-client.ts +1 -1
- package/blocks/_foundation/lib/plural.ts +23 -0
- package/blocks/_foundation/server/connect-route.ts +15 -0
- package/blocks/_foundation/server/connect.ts +127 -0
- package/blocks/_foundation/server/local-key.ts +70 -0
- package/blocks/_foundation/server/proxy.ts +103 -3
- package/blocks/_foundation/server/route.ts +16 -4
- package/blocks/agents/README.md +14 -0
- package/blocks/agents/block.json +33 -1
- package/blocks/agents/components/agent-builder.tsx +13 -8
- package/blocks/agents/components/agent-knowledge-panel.tsx +2 -1
- package/blocks/agents/components/agent-run-detail.tsx +102 -0
- package/blocks/agents/components/agent-runs.tsx +16 -7
- package/blocks/agents/components/agents-screen.tsx +184 -0
- package/blocks/analytics/README.md +13 -0
- package/blocks/analytics/block.json +45 -1
- package/blocks/analytics/components/analytics-screen.tsx +209 -0
- package/blocks/analytics/components/leaderboard.tsx +14 -7
- package/blocks/analytics/components/report-builder.tsx +23 -9
- package/blocks/analytics/components/report-detail.tsx +84 -0
- package/blocks/analytics/components/trend-chart.tsx +62 -0
- package/blocks/appointments/README.md +21 -1
- package/blocks/appointments/block.json +43 -2
- package/blocks/appointments/components/availability-editor.tsx +17 -5
- package/blocks/appointments/components/event-type-editor.tsx +2 -1
- package/blocks/appointments/components/event-type-related.tsx +77 -0
- package/blocks/appointments/components/meeting-list.tsx +14 -6
- package/blocks/appointments/components/meetings-screen.tsx +157 -0
- package/blocks/appointments/components/scheduling-screen.tsx +122 -0
- package/blocks/appointments/hooks/use-event-types.ts +17 -0
- package/blocks/block.schema.json +18 -0
- package/blocks/block.types.ts +26 -0
- package/blocks/campaigns/README.md +41 -11
- package/blocks/campaigns/block.json +105 -42
- package/blocks/campaigns/components/campaign-assets.tsx +16 -5
- package/blocks/campaigns/components/campaign-launch-step.tsx +102 -19
- package/blocks/campaigns/components/campaign-list.tsx +7 -3
- package/blocks/campaigns/components/campaign-wizard.tsx +43 -10
- package/blocks/campaigns/components/campaigns-screen.tsx +267 -0
- package/blocks/campaigns/components/icp-detail.tsx +154 -0
- package/blocks/campaigns/components/icp-editor.tsx +15 -15
- package/blocks/campaigns/components/persona-detail.tsx +172 -0
- package/blocks/campaigns/components/persona-editor.tsx +14 -11
- package/blocks/campaigns/lib/campaigns-utils.ts +134 -1
- package/blocks/campaigns/recipes/attach-audience-and-launch.ts +16 -3
- package/blocks/campaigns/recipes/generate-ideas-and-create-campaign.ts +8 -4
- package/blocks/coach/README.md +10 -0
- package/blocks/coach/block.json +16 -1
- package/blocks/coach/components/coach-screen.tsx +222 -0
- package/blocks/copilot/README.md +13 -0
- package/blocks/copilot/block.json +14 -1
- package/blocks/copilot/components/copilot-panel.tsx +14 -5
- package/blocks/copilot/components/copilot-screen.tsx +148 -0
- package/blocks/copilot/components/copilot-team-chat.tsx +7 -5
- package/blocks/crm/README.md +28 -0
- package/blocks/crm/block.json +58 -2
- package/blocks/crm/components/companies-screen.tsx +345 -0
- package/blocks/crm/components/company-detail.tsx +8 -6
- package/blocks/crm/components/company-table.tsx +11 -4
- package/blocks/crm/components/contact-detail.tsx +6 -3
- package/blocks/crm/components/contact-table.tsx +10 -3
- package/blocks/crm/components/contacts-screen.tsx +359 -0
- package/blocks/crm/components/dedupe-review.tsx +7 -5
- package/blocks/crm/components/import-wizard.tsx +4 -2
- package/blocks/crm/components/notes-panel.tsx +4 -4
- package/blocks/crm/components/record-form.tsx +5 -3
- package/blocks/crm/components/task-list.tsx +4 -4
- package/blocks/crm/components/tasks-screen.tsx +379 -0
- package/blocks/crm/hooks/use-custom-fields.ts +6 -1
- package/blocks/crm/hooks/use-page-query.ts +7 -1
- package/blocks/crm/lib/crm-types.ts +12 -2
- package/blocks/crm/lib/screen-parts.tsx +105 -0
- package/blocks/deals/README.md +34 -3
- package/blocks/deals/block.json +45 -4
- package/blocks/deals/components/deal-detail.tsx +27 -13
- package/blocks/deals/components/deal-related.tsx +87 -0
- package/blocks/deals/components/deals-screen.tsx +298 -0
- package/blocks/deals/components/pipeline-board.tsx +4 -2
- package/blocks/deals/components/stage-editor.tsx +4 -4
- package/blocks/deals/hooks/use-default-owner.ts +60 -0
- package/blocks/deals/hooks/use-pipeline-deals.ts +5 -1
- package/blocks/deals/hooks/use-pipelines.ts +1 -1
- package/blocks/deals/lib/deals-types.ts +14 -0
- package/blocks/dialer/README.md +34 -2
- package/blocks/dialer/block.json +67 -1
- package/blocks/dialer/components/call-contact-button.tsx +252 -0
- package/blocks/dialer/components/call-log-screen.tsx +205 -0
- package/blocks/dialer/components/call-log.tsx +7 -3
- package/blocks/dialer/components/call-queue.tsx +1 -1
- package/blocks/dialer/components/dialer-screen.tsx +319 -0
- package/blocks/dialer/components/dialer-session-detail.tsx +153 -0
- package/blocks/dialer/components/live-calls-table.tsx +7 -3
- package/blocks/dialer/components/session-start-form.tsx +4 -2
- package/blocks/dialer/components/softphone.tsx +3 -1
- package/blocks/enrichment/README.md +14 -2
- package/blocks/enrichment/block.json +32 -4
- package/blocks/enrichment/components/bulk-enrich-panel.tsx +10 -5
- package/blocks/enrichment/components/email-verify-badge.tsx +28 -8
- package/blocks/enrichment/components/enrich-button.tsx +32 -9
- package/blocks/enrichment/components/enrichment-job-detail.tsx +78 -0
- package/blocks/enrichment/components/enrichment-screen.tsx +288 -0
- package/blocks/enrichment/components/waterfall-config.tsx +39 -10
- package/blocks/enrichment/recipes/waterfall-pipeline.ts +3 -2
- package/blocks/forms/README.md +12 -0
- package/blocks/forms/block.json +39 -17
- package/blocks/forms/components/form-builder.tsx +3 -1
- package/blocks/forms/components/form-embed.tsx +3 -1
- package/blocks/forms/components/forms-screen.tsx +353 -0
- package/blocks/forms/components/submissions-table.tsx +16 -6
- package/blocks/inbox/README.md +27 -3
- package/blocks/inbox/block.json +14 -2
- package/blocks/inbox/components/inbox-list.tsx +7 -3
- package/blocks/inbox/components/inbox-screen.tsx +294 -27
- package/blocks/inbox/components/reply-composer.tsx +65 -9
- package/blocks/inbox/components/tag-and-assign.tsx +4 -2
- package/blocks/inbox/hooks/use-inbox-thread.ts +1 -1
- package/blocks/inbox/hooks/use-inbox-threads.ts +1 -0
- package/blocks/inbox/lib/inbox-types.ts +47 -1
- package/blocks/inbox/recipes/01-triage-and-reply.ts +12 -2
- package/blocks/integrations/README.md +9 -0
- package/blocks/integrations/block.json +46 -1
- package/blocks/integrations/components/crm-detail.tsx +100 -0
- package/blocks/integrations/components/integration-list.tsx +9 -4
- package/blocks/integrations/components/integrations-screen.tsx +135 -0
- package/blocks/integrations/components/sync-status.tsx +5 -3
- package/blocks/integrations/components/webhook-detail.tsx +160 -0
- package/blocks/integrations/components/webhook-manager.tsx +13 -8
- package/blocks/integrations/lib/integrations-utils.ts +6 -1
- package/blocks/intent/README.md +14 -0
- package/blocks/intent/block.json +27 -1
- package/blocks/intent/components/intent-feed.tsx +5 -2
- package/blocks/intent/components/intent-keyword-manager.tsx +8 -4
- package/blocks/intent/components/intent-screen.tsx +265 -0
- package/blocks/intent/components/keyword-detail.tsx +124 -0
- package/blocks/lifecycle/README.md +16 -0
- package/blocks/lifecycle/block.json +34 -7
- package/blocks/lifecycle/components/account-related.tsx +66 -0
- package/blocks/lifecycle/components/lifecycle-board.tsx +3 -1
- package/blocks/lifecycle/components/lifecycle-screen.tsx +149 -0
- package/blocks/lifecycle/lib/lifecycle.ts +1 -1
- package/blocks/lists/README.md +13 -0
- package/blocks/lists/block.json +39 -1
- package/blocks/lists/components/add-to-list-button.tsx +3 -2
- package/blocks/lists/components/list-picker.tsx +5 -1
- package/blocks/lists/components/list-table.tsx +11 -5
- package/blocks/lists/components/lists-screen.tsx +344 -0
- package/blocks/lists/components/suppression-manager.tsx +6 -2
- package/blocks/lists/components/suppressions-screen.tsx +255 -0
- package/blocks/mailboxes/README.md +17 -0
- package/blocks/mailboxes/block.json +65 -17
- package/blocks/mailboxes/components/connect-mailbox.tsx +23 -13
- package/blocks/mailboxes/components/domain-detail.tsx +142 -0
- package/blocks/mailboxes/components/domain-purchase.tsx +6 -3
- package/blocks/mailboxes/components/mailbox-detail.tsx +156 -0
- package/blocks/mailboxes/components/mailboxes-screen.tsx +223 -0
- package/blocks/mailboxes/components/warmup-health.tsx +4 -2
- package/blocks/mailboxes/lib/mailboxes-utils.ts +22 -1
- package/blocks/newsletter/README.md +13 -0
- package/blocks/newsletter/block.json +47 -23
- package/blocks/newsletter/components/issue-scheduler.tsx +2 -1
- package/blocks/newsletter/components/newsletter-screen.tsx +404 -0
- package/blocks/newsletter/components/subscriber-table.tsx +12 -6
- package/blocks/nurture/README.md +12 -0
- package/blocks/nurture/block.json +35 -13
- package/blocks/nurture/components/nurture-builder.tsx +3 -1
- package/blocks/nurture/components/nurture-enroll.tsx +6 -3
- package/blocks/nurture/components/nurture-form.tsx +23 -3
- package/blocks/nurture/components/nurture-screen.tsx +283 -0
- package/blocks/nurture/lib/nurture-types.ts +1 -1
- package/blocks/objects/README.md +17 -0
- package/blocks/objects/block.json +29 -16
- package/blocks/objects/components/object-not-found.tsx +3 -1
- package/blocks/objects/components/object-record-detail.tsx +2 -1
- package/blocks/objects/components/object-schema-editor.tsx +3 -1
- package/blocks/objects/components/object-table.tsx +13 -3
- package/blocks/objects/components/objects-screen.tsx +147 -0
- package/blocks/objects/lib/object-types.ts +1 -1
- package/blocks/pages/README.md +12 -0
- package/blocks/pages/block.json +35 -22
- package/blocks/pages/components/brand-kit-editor.tsx +5 -2
- package/blocks/pages/components/content-library.tsx +46 -11
- package/blocks/pages/components/page-list.tsx +6 -3
- package/blocks/pages/components/pages-screen.tsx +154 -0
- package/blocks/quotes/README.md +17 -0
- package/blocks/quotes/block.json +38 -9
- package/blocks/quotes/components/product-picker.tsx +3 -1
- package/blocks/quotes/components/quote-list.tsx +7 -3
- package/blocks/quotes/components/quote-related.tsx +123 -0
- package/blocks/quotes/components/quotes-screen.tsx +169 -0
- package/blocks/radar/README.md +10 -0
- package/blocks/radar/block.json +41 -1
- package/blocks/radar/components/competitor-detail.tsx +117 -0
- package/blocks/radar/components/competitor-tracker.tsx +3 -1
- package/blocks/radar/components/radar-report.tsx +4 -2
- package/blocks/radar/components/radar-screen.tsx +214 -0
- package/blocks/radar/components/report-detail.tsx +104 -0
- package/blocks/search/README.md +10 -0
- package/blocks/search/block.json +16 -1
- package/blocks/search/components/filter-builder.tsx +3 -3
- package/blocks/search/components/prospect-results.tsx +5 -3
- package/blocks/search/components/prospect-search-screen.tsx +235 -0
- package/blocks/search/components/saved-searches.tsx +4 -1
- package/blocks/search/hooks/use-filter-options.ts +20 -6
- package/blocks/search/lib/search-types.ts +2 -0
- package/blocks/sequencer/README.md +49 -9
- package/blocks/sequencer/block.json +99 -19
- package/blocks/sequencer/components/enroll-dialog.tsx +9 -3
- package/blocks/sequencer/components/sequence-builder.tsx +211 -46
- package/blocks/sequencer/components/sequence-detail.tsx +259 -0
- package/blocks/sequencer/components/sequence-enrollments.tsx +114 -0
- package/blocks/sequencer/components/sequence-list.tsx +33 -8
- package/blocks/sequencer/components/sequence-stats.tsx +3 -2
- package/blocks/sequencer/components/sequence-steps-panel.tsx +103 -0
- package/blocks/sequencer/components/sequences-screen.tsx +159 -0
- package/blocks/sequencer/components/step-editor.tsx +3 -1
- package/blocks/sequencer/lib/sequencer-utils.ts +128 -5
- package/blocks/sequencer/recipes/create-multichannel-sequence.ts +33 -5
- package/blocks/sequencer/recipes/launch-and-enroll.ts +19 -2
- package/blocks/social-ads/README.md +11 -0
- package/blocks/social-ads/block.json +101 -2
- package/blocks/social-ads/components/ad-campaign-list.tsx +9 -5
- package/blocks/social-ads/components/ads-screen.tsx +152 -0
- package/blocks/social-ads/components/audience-sync-detail.tsx +188 -0
- package/blocks/social-ads/components/audience-sync.tsx +11 -7
- package/blocks/social-ads/components/campaign-detail.tsx +133 -0
- package/blocks/social-ads/components/creative-detail.tsx +150 -0
- package/blocks/social-ads/components/creative-gallery.tsx +15 -5
- package/blocks/social-ads/components/post-composer.tsx +3 -1
- package/blocks/social-ads/components/post-detail.tsx +160 -0
- package/blocks/social-ads/components/social-post-list.tsx +11 -3
- package/blocks/social-ads/components/social-screen.tsx +160 -0
- package/blocks/visitors/README.md +10 -0
- package/blocks/visitors/block.json +25 -1
- package/blocks/visitors/components/snippet-install.tsx +3 -1
- package/blocks/visitors/components/visitor-company-card.tsx +2 -2
- package/blocks/visitors/components/visitor-detail.tsx +105 -0
- package/blocks/visitors/components/visitor-feed.tsx +11 -2
- package/blocks/visitors/components/visitors-screen.tsx +166 -0
- package/blocks/webchat/README.md +10 -0
- package/blocks/webchat/block.json +21 -7
- package/blocks/webchat/components/chat-widget-config.tsx +7 -4
- package/blocks/webchat/components/conversation-list.tsx +7 -3
- package/blocks/webchat/components/webchat-screen.tsx +245 -0
- package/blocks/workbench/README.md +11 -0
- package/blocks/workbench/block.json +48 -1
- package/blocks/workbench/components/column-recipe-editor.tsx +4 -2
- package/blocks/workbench/components/workbench-grid.tsx +9 -3
- package/blocks/workbench/components/workbench-row-detail.tsx +104 -0
- package/blocks/workbench/components/workbench-screen.tsx +192 -0
- package/blocks/workbench/components/workbench-sources.tsx +194 -0
- package/blocks/workflows/README.md +13 -0
- package/blocks/workflows/block.json +28 -9
- package/blocks/workflows/components/run-history.tsx +31 -9
- package/blocks/workflows/components/trigger-config.tsx +3 -1
- package/blocks/workflows/components/workflow-editor.tsx +3 -1
- package/blocks/workflows/components/workflow-list.tsx +13 -6
- package/blocks/workflows/components/workflows-screen.tsx +193 -0
- package/dist/cli.js +2785 -1637
- package/dist/errors.d.mts +34 -0
- package/dist/errors.d.ts +34 -0
- package/dist/errors.js +46 -0
- package/dist/errors.js.map +1 -0
- package/dist/errors.mjs +21 -0
- package/dist/errors.mjs.map +1 -0
- package/dist/index.browser.js +11 -5
- package/dist/index.browser.js.map +1 -1
- package/dist/index.browser.mjs +11 -5
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.mts +164 -25
- package/dist/index.d.ts +164 -25
- package/dist/index.js +11 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +11 -5
- package/dist/index.mjs.map +1 -1
- package/dist/react.browser.js +10 -4
- package/dist/react.browser.js.map +1 -1
- package/dist/react.browser.mjs +10 -4
- package/dist/react.browser.mjs.map +1 -1
- package/dist/react.d.mts +142 -5
- package/dist/react.d.ts +142 -5
- package/dist/react.js +10 -4
- package/dist/react.js.map +1 -1
- package/dist/react.mjs +10 -4
- package/dist/react.mjs.map +1 -1
- package/dist/types/blocks/add.d.ts +11 -4
- package/dist/types/blocks/brand.d.ts +113 -0
- package/dist/types/blocks/compose.d.ts +53 -0
- package/dist/types/blocks/create.d.ts +25 -8
- package/dist/types/blocks/doctor.d.ts +6 -1
- package/dist/types/blocks/flows.d.ts +20 -0
- package/dist/types/blocks/navfile.d.ts +35 -0
- package/dist/types/blocks/paths.d.ts +4 -0
- package/dist/types/blocks/prefixes.d.ts +37 -0
- package/dist/types/blocks/registry.d.ts +32 -0
- package/dist/types/blocks/screens.d.ts +53 -0
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/errors.d.ts +33 -0
- package/dist/types/http.d.ts +2 -18
- package/dist/types/version.d.ts +2 -2
- package/docs/agents.md +15 -4
- package/docs/api/README.md +2 -2
- package/docs/api/by-path.md +1 -0
- package/docs/api/desk.d.ts +98 -2
- package/docs/api/desk.md +123 -1
- package/docs/api/inbox.d.ts +10 -2
- package/docs/api/inbox.md +10 -2
- package/docs/api/seo.d.ts +19 -1
- package/docs/api/seo.md +24 -1
- package/package.json +16 -2
- package/stacks/6sense.json +41 -16
- package/stacks/apollo/pages/companies.tsx.tmpl +50 -0
- package/stacks/apollo/pages/contacts.tsx.tmpl +117 -0
- package/stacks/apollo.json +118 -36
- package/stacks/calendly.json +60 -0
- package/stacks/clay/pages/companies.tsx.tmpl +15 -0
- package/stacks/clay/pages/contacts.tsx.tmpl +19 -0
- package/stacks/clay.json +59 -26
- package/stacks/gong.json +81 -35
- package/stacks/hootsuite.json +99 -0
- package/stacks/hubspot/pages/companies.tsx.tmpl +47 -0
- package/stacks/hubspot/pages/contacts.tsx.tmpl +111 -0
- package/stacks/hubspot/pages/deals.tsx.tmpl +11 -0
- package/stacks/hubspot/pages/inbox.tsx.tmpl +92 -0
- package/stacks/hubspot/pages/quotes.tsx.tmpl +11 -0
- package/stacks/hubspot.json +167 -66
- package/stacks/intercom.json +48 -20
- package/stacks/mailchimp.json +52 -33
- package/stacks/outreach/pages/contacts.tsx.tmpl +85 -0
- package/stacks/outreach.json +88 -25
- package/stacks/pipedrive/pages/companies.tsx.tmpl +47 -0
- package/stacks/pipedrive/pages/contacts.tsx.tmpl +47 -0
- package/stacks/pipedrive/pages/deals.tsx.tmpl +11 -0
- package/stacks/pipedrive/pages/inbox.tsx.tmpl +92 -0
- package/stacks/pipedrive/pages/quotes.tsx.tmpl +11 -0
- package/stacks/pipedrive.json +115 -0
- package/stacks/salesforce/pages/companies.tsx.tmpl +47 -0
- package/stacks/salesforce/pages/contacts.tsx.tmpl +47 -0
- package/stacks/salesforce/pages/deals.tsx.tmpl +11 -0
- package/stacks/salesforce/pages/quotes.tsx.tmpl +11 -0
- package/stacks/salesforce.json +81 -31
- package/stacks/stack.schema.json +6 -1
- package/stacks/zoominfo/pages/companies.tsx.tmpl +15 -0
- package/stacks/zoominfo/pages/contacts.tsx.tmpl +19 -0
- package/stacks/zoominfo.json +102 -0
- package/styles/styles.css +1 -1
- package/templates/next-app/README.md.tmpl +55 -8
- package/templates/next-app/app/globals.css.tmpl +105 -0
- package/templates/next-app/app/layout.tsx.tmpl +18 -4
- package/templates/next-app/components/app-shell.tsx.tmpl +248 -23
- package/templates/next-app/components/shell/connection-status.tsx.tmpl +109 -0
- package/templates/next-app/components/shell/nav-model.ts.tmpl +65 -0
- package/templates/next-app/components/shell/quick-jump.tsx.tmpl +131 -0
- package/templates/next-app/components/shell/theme-toggle.tsx.tmpl +40 -0
- package/templates/next-app/dot-gitignore.tmpl +2 -0
- package/templates/next-app/lib/nav.ts.tmpl +1 -1
- package/templates/next-app/package.json.tmpl +1 -1
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* White-label: who the app is and what the platform behind it is called. Every block reads the platform's name
|
|
5
|
+
* and link-outs from here ("Connect in graph8", "Open in graph8", the settings pages they open), so an agency or
|
|
6
|
+
* reseller can show its own name and its own app URL everywhere, from one config (`g8.config.ts` in a created app,
|
|
7
|
+
* passed to <BrandProvider>).
|
|
8
|
+
*
|
|
9
|
+
* const brand = useBrand(); // { name, appUrl, linkLabel, logo? }
|
|
10
|
+
* brandUrl(brand, "/studio/settings?tab=integrations")
|
|
11
|
+
*
|
|
12
|
+
* With no provider, or for any field left out, the default is graph8 itself (app.graph8.com), so nothing changes
|
|
13
|
+
* for an app that does not brand.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createContext, useContext } from "react";
|
|
17
|
+
|
|
18
|
+
export interface BrandConfig {
|
|
19
|
+
/** The app's own name (sidebar, page title). */
|
|
20
|
+
name: string;
|
|
21
|
+
/** Base URL of the platform's web app; link-outs append their path. No trailing slash needed. */
|
|
22
|
+
appUrl: string;
|
|
23
|
+
/** What the platform is called in copy and links ("Connect in <linkLabel>"). */
|
|
24
|
+
linkLabel: string;
|
|
25
|
+
/** Public path or URL of the logo (`/brand/logo.svg`). */
|
|
26
|
+
logo?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_BRAND: BrandConfig = { name: "graph8 app", appUrl: "https://app.graph8.com", linkLabel: "graph8" };
|
|
30
|
+
|
|
31
|
+
/** Fill the gaps of a partial config with the defaults; an appUrl that is not http(s) falls back to the default. */
|
|
32
|
+
export function resolveBrand(partial?: Partial<BrandConfig> | null): BrandConfig {
|
|
33
|
+
const p = partial ?? {};
|
|
34
|
+
const text = (v: unknown, d: string) => (typeof v === "string" && v.trim() ? v.trim() : d);
|
|
35
|
+
let appUrl = DEFAULT_BRAND.appUrl;
|
|
36
|
+
if (typeof p.appUrl === "string" && /^https?:\/\/[^\s/]+/i.test(p.appUrl.trim())) appUrl = p.appUrl.trim().replace(/\/+$/, "");
|
|
37
|
+
const logo = typeof p.logo === "string" && p.logo.trim() ? p.logo.trim() : undefined;
|
|
38
|
+
return { name: text(p.name, DEFAULT_BRAND.name), appUrl, linkLabel: text(p.linkLabel, DEFAULT_BRAND.linkLabel), ...(logo ? { logo } : {}) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const BrandContext = createContext<BrandConfig>(DEFAULT_BRAND);
|
|
42
|
+
|
|
43
|
+
/** The current brand: the nearest <BrandProvider>'s, else graph8's defaults. */
|
|
44
|
+
export function useBrand(): BrandConfig {
|
|
45
|
+
return useContext(BrandContext);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `appUrl` + a path on the platform (`"/lists"`, `"/studio/settings?tab=api"`); no path gives the app's home. */
|
|
49
|
+
export function brandUrl(brand: Pick<BrandConfig, "appUrl">, path = ""): string {
|
|
50
|
+
if (!path) return brand.appUrl;
|
|
51
|
+
return `${brand.appUrl}${path.startsWith("/") || path.startsWith("?") ? "" : "/"}${path}`;
|
|
52
|
+
}
|
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { g8QueryCache } from "../lib/g8-cache";
|
|
5
|
+
import type { G8QueryKey } from "../lib/g8-cache";
|
|
4
6
|
|
|
5
7
|
export interface UseG8MutationOptions<TVars, TResult> {
|
|
6
8
|
onSuccess?: (result: TResult, vars: TVars) => void;
|
|
7
9
|
onError?: (error: unknown, vars: TVars) => void;
|
|
10
|
+
/**
|
|
11
|
+
* Cache keys (prefixes) to mark stale after a successful write, so every list and detail showing them
|
|
12
|
+
* refreshes: `invalidates: ["contacts"]`, or a function of the result: `(r, v) => [["contacts", "detail", v.id]]`.
|
|
13
|
+
*/
|
|
14
|
+
invalidates?: readonly G8QueryKey[] | ((result: TResult, vars: TVars) => readonly G8QueryKey[]);
|
|
15
|
+
/**
|
|
16
|
+
* Optimistic update: change cached data under `prefix` before the request; on failure it is put back.
|
|
17
|
+
* Either way the prefix is invalidated afterwards so the server's answer wins.
|
|
18
|
+
*/
|
|
19
|
+
optimistic?: { prefix: G8QueryKey; update: (old: any, vars: TVars, key: readonly unknown[]) => any }; // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
8
20
|
}
|
|
9
21
|
|
|
10
22
|
export interface UseG8MutationResult<TVars, TResult> {
|
|
@@ -54,15 +66,26 @@ export function useG8Mutation<TVars, TResult>(
|
|
|
54
66
|
const id = ++latest.current;
|
|
55
67
|
setLoading(true);
|
|
56
68
|
setError(undefined);
|
|
69
|
+
const { optimistic } = optionsRef.current;
|
|
70
|
+
const snapshot = optimistic
|
|
71
|
+
? g8QueryCache.setQueriesData(optimistic.prefix, (old, key) => optimistic.update(old, vars, key))
|
|
72
|
+
: null;
|
|
57
73
|
try {
|
|
58
74
|
const result = await mutationRef.current(vars);
|
|
59
75
|
if (id === latest.current && mounted.current) {
|
|
60
76
|
setData(result);
|
|
61
77
|
setLoading(false);
|
|
62
78
|
}
|
|
79
|
+
const inv = optionsRef.current.invalidates;
|
|
80
|
+
const prefixes = typeof inv === "function" ? inv(result, vars) : inv ?? [];
|
|
81
|
+
for (const prefix of optimistic ? [...prefixes, optimistic.prefix] : prefixes) void g8QueryCache.invalidate(prefix);
|
|
63
82
|
optionsRef.current.onSuccess?.(result, vars);
|
|
64
83
|
return result;
|
|
65
84
|
} catch (err) {
|
|
85
|
+
if (snapshot && optimistic) {
|
|
86
|
+
g8QueryCache.restore(snapshot);
|
|
87
|
+
void g8QueryCache.invalidate(optimistic.prefix);
|
|
88
|
+
}
|
|
66
89
|
if (id === latest.current && mounted.current) {
|
|
67
90
|
setError(err);
|
|
68
91
|
setLoading(false);
|
|
@@ -1,12 +1,31 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
4
|
+
import { EMPTY_QUERY_STATE, g8QueryCache, hashKey } from "../lib/g8-cache";
|
|
5
|
+
import type { G8QueryKey, G8QueryObserver, G8QueryState } from "../lib/g8-cache";
|
|
4
6
|
|
|
5
7
|
export interface UseG8QueryOptions<T> {
|
|
6
8
|
/** Set false to hold the request (for example until a required id is known). */
|
|
7
9
|
enabled?: boolean;
|
|
8
10
|
/** Shown until the first response lands. */
|
|
9
11
|
initialData?: T;
|
|
12
|
+
/**
|
|
13
|
+
* Opt in to the shared cache (lib/g8-cache.ts). Every component asking for the same key shares one
|
|
14
|
+
* request and one answer; cached data shows instantly and refreshes in the background. The key must
|
|
15
|
+
* name every input the fetcher reads: `["contacts", "detail", id]`. Without a key nothing changes.
|
|
16
|
+
*/
|
|
17
|
+
key?: G8QueryKey;
|
|
18
|
+
/** With `key`: how long (ms) cached data counts as fresh, so a mount does not refetch it. Default 0. */
|
|
19
|
+
staleTime?: number;
|
|
20
|
+
/** With `key`: refresh when the window regains focus or the network returns (throttled). Default true. */
|
|
21
|
+
refetchOnFocus?: boolean;
|
|
22
|
+
/** With `key`: keep showing the previous key's data while a new key loads (like the uncached hook does). Default true. */
|
|
23
|
+
keepPrevious?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* With `key`: shown while the key has no data yet (for example the row a detail was opened from), with
|
|
26
|
+
* `isPlaceholderData: true`. NEVER seed an edit form from it: wait until `isPlaceholderData` is false.
|
|
27
|
+
*/
|
|
28
|
+
placeholderData?: T;
|
|
10
29
|
}
|
|
11
30
|
|
|
12
31
|
export interface UseG8QueryResult<T> {
|
|
@@ -16,6 +35,12 @@ export interface UseG8QueryResult<T> {
|
|
|
16
35
|
loading: boolean;
|
|
17
36
|
/** Re-run the request now. Resolves with the data, or undefined if it failed or was superseded. */
|
|
18
37
|
refetch: () => Promise<T | undefined>;
|
|
38
|
+
/** With `key` only: `data` is `placeholderData` (not the real record yet). */
|
|
39
|
+
isPlaceholderData?: boolean;
|
|
40
|
+
/** With `key` only: `data` belongs to the previous key while the new one loads. */
|
|
41
|
+
isPreviousData?: boolean;
|
|
42
|
+
/** With `key` only: epoch ms when the data was fetched (0 = not yet). */
|
|
43
|
+
updatedAt?: number;
|
|
19
44
|
}
|
|
20
45
|
|
|
21
46
|
/**
|
|
@@ -34,6 +59,19 @@ export function useG8Query<T>(
|
|
|
34
59
|
fetcher: (signal: AbortSignal) => Promise<T>,
|
|
35
60
|
deps: readonly unknown[],
|
|
36
61
|
options: UseG8QueryOptions<T> = {},
|
|
62
|
+
): UseG8QueryResult<T> {
|
|
63
|
+
// Both are always called (rules of hooks); only one is ever active.
|
|
64
|
+
const keyed = options.key !== undefined;
|
|
65
|
+
const cached = useCachedQuery(fetcher, deps, options, keyed);
|
|
66
|
+
const plain = usePlainQuery(fetcher, deps, keyed ? { ...options, enabled: false } : options);
|
|
67
|
+
return keyed ? cached : plain;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The uncached hook, exactly as it was before the cache existed. */
|
|
71
|
+
function usePlainQuery<T>(
|
|
72
|
+
fetcher: (signal: AbortSignal) => Promise<T>,
|
|
73
|
+
deps: readonly unknown[],
|
|
74
|
+
options: UseG8QueryOptions<T>,
|
|
37
75
|
): UseG8QueryResult<T> {
|
|
38
76
|
const enabled = options.enabled ?? true;
|
|
39
77
|
const fetcherRef = useRef(fetcher);
|
|
@@ -85,3 +123,91 @@ export function useG8Query<T>(
|
|
|
85
123
|
|
|
86
124
|
return { data, error, loading, refetch: run };
|
|
87
125
|
}
|
|
126
|
+
|
|
127
|
+
const noopSubscribe = (): (() => void) => () => undefined;
|
|
128
|
+
const emptySnapshot = (): G8QueryState => EMPTY_QUERY_STATE;
|
|
129
|
+
|
|
130
|
+
/** The keyed hook: state lives in the shared cache, this component only subscribes to it. */
|
|
131
|
+
function useCachedQuery<T>(
|
|
132
|
+
fetcher: (signal: AbortSignal) => Promise<T>,
|
|
133
|
+
deps: readonly unknown[],
|
|
134
|
+
options: UseG8QueryOptions<T>,
|
|
135
|
+
active: boolean,
|
|
136
|
+
): UseG8QueryResult<T> {
|
|
137
|
+
const enabled = options.enabled ?? true;
|
|
138
|
+
const key = options.key ?? "";
|
|
139
|
+
const hash = active ? hashKey(key) : "";
|
|
140
|
+
const keyRef = useRef(key);
|
|
141
|
+
keyRef.current = key;
|
|
142
|
+
const fetcherRef = useRef(fetcher);
|
|
143
|
+
fetcherRef.current = fetcher;
|
|
144
|
+
const optionsRef = useRef(options);
|
|
145
|
+
optionsRef.current = options;
|
|
146
|
+
|
|
147
|
+
const observer = useRef<G8QueryObserver>({
|
|
148
|
+
fetcher: () => (signal) => fetcherRef.current(signal),
|
|
149
|
+
enabled: () => optionsRef.current.enabled ?? true,
|
|
150
|
+
staleTime: () => optionsRef.current.staleTime ?? 0,
|
|
151
|
+
refetchOnFocus: () => (optionsRef.current.enabled ?? true) && (optionsRef.current.refetchOnFocus ?? true),
|
|
152
|
+
}).current;
|
|
153
|
+
|
|
154
|
+
const subscribe = useCallback(
|
|
155
|
+
(onChange: () => void) => (active ? g8QueryCache.subscribe(keyRef.current, onChange, observer) : () => undefined),
|
|
156
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
157
|
+
[active, hash],
|
|
158
|
+
);
|
|
159
|
+
const getSnapshot = useCallback(
|
|
160
|
+
() => (active ? g8QueryCache.getState(keyRef.current) : EMPTY_QUERY_STATE),
|
|
161
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
162
|
+
[active, hash],
|
|
163
|
+
);
|
|
164
|
+
const state = useSyncExternalStore(active ? subscribe : noopSubscribe, active ? getSnapshot : emptySnapshot, emptySnapshot) as G8QueryState<T>;
|
|
165
|
+
|
|
166
|
+
// Same key, new deps: the fetcher changed under the key, so the cached answer may be wrong. Refetch.
|
|
167
|
+
const lastRun = useRef<{ hash: string; deps: readonly unknown[] } | null>(null);
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
if (!active || !enabled) return;
|
|
170
|
+
const prev = lastRun.current;
|
|
171
|
+
const depsChanged =
|
|
172
|
+
prev !== null && prev.hash === hash && (prev.deps.length !== deps.length || prev.deps.some((d, i) => !Object.is(d, deps[i])));
|
|
173
|
+
lastRun.current = { hash, deps };
|
|
174
|
+
if (depsChanged) void g8QueryCache.fetch(keyRef.current, observer.fetcher(), { force: true });
|
|
175
|
+
else void g8QueryCache.ensureFresh(keyRef.current, observer.fetcher(), optionsRef.current.staleTime ?? 0);
|
|
176
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
177
|
+
}, [active, enabled, hash, ...deps]);
|
|
178
|
+
|
|
179
|
+
const previous = useRef<{ hash: string; data: T } | null>(null);
|
|
180
|
+
if (active && state.data !== undefined) previous.current = { hash, data: state.data };
|
|
181
|
+
|
|
182
|
+
const refetch = useCallback(
|
|
183
|
+
() =>
|
|
184
|
+
active
|
|
185
|
+
? (g8QueryCache.fetch(keyRef.current, observer.fetcher(), { force: true, share: false }) as Promise<T | undefined>)
|
|
186
|
+
: Promise.resolve(undefined),
|
|
187
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
188
|
+
[active, hash],
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
let data = state.data;
|
|
192
|
+
let isPreviousData = false;
|
|
193
|
+
let isPlaceholderData = false;
|
|
194
|
+
if (data === undefined && (options.keepPrevious ?? true) && previous.current && previous.current.hash !== hash) {
|
|
195
|
+
data = previous.current.data;
|
|
196
|
+
isPreviousData = true;
|
|
197
|
+
}
|
|
198
|
+
if (data === undefined && options.placeholderData !== undefined) {
|
|
199
|
+
data = options.placeholderData;
|
|
200
|
+
isPlaceholderData = true;
|
|
201
|
+
}
|
|
202
|
+
if (data === undefined) data = options.initialData;
|
|
203
|
+
const neverLoaded = state.updatedAt === 0 && state.error === undefined;
|
|
204
|
+
return {
|
|
205
|
+
data,
|
|
206
|
+
error: state.error,
|
|
207
|
+
loading: enabled && (state.fetching || neverLoaded),
|
|
208
|
+
refetch,
|
|
209
|
+
isPlaceholderData,
|
|
210
|
+
isPreviousData,
|
|
211
|
+
updatedAt: state.updatedAt,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -2,9 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
4
|
import type { PaginatedResponse } from "@graph8/sdk";
|
|
5
|
+
import { g8QueryCache } from "../lib/g8-cache";
|
|
6
|
+
import type { G8QueryKey } from "../lib/g8-cache";
|
|
7
|
+
import { useG8Query } from "./use-g8-query";
|
|
5
8
|
|
|
6
9
|
export interface UsePaginatedOptions {
|
|
7
10
|
enabled?: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Opt in to the shared cache: the loaded pages are kept under this key, shown instantly on the next
|
|
13
|
+
* mount and refreshed from the first page in the background; `invalidate(<prefix>)` reloads them.
|
|
14
|
+
* Name every filter in it: `["contacts", "cursor", { q }]`. Without a key nothing changes.
|
|
15
|
+
*/
|
|
16
|
+
key?: G8QueryKey;
|
|
17
|
+
/** With `key`: ms the cached pages count as fresh. Default 0 (refresh on every mount). */
|
|
18
|
+
staleTime?: number;
|
|
19
|
+
/** With `key`: refresh (back to the first page) on window focus. Default false, so a long scroll is not lost. */
|
|
20
|
+
refetchOnFocus?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** What the cache holds for a keyed paginated list. */
|
|
24
|
+
export interface PaginatedCacheData<T> {
|
|
25
|
+
items: T[];
|
|
26
|
+
nextCursor?: string;
|
|
8
27
|
}
|
|
9
28
|
|
|
10
29
|
export interface UsePaginatedResult<T> {
|
|
@@ -35,6 +54,19 @@ export function usePaginated<T>(
|
|
|
35
54
|
fetchPage: (cursor: string | undefined, signal: AbortSignal) => Promise<PaginatedResponse<T>>,
|
|
36
55
|
deps: readonly unknown[],
|
|
37
56
|
options: UsePaginatedOptions = {},
|
|
57
|
+
): UsePaginatedResult<T> {
|
|
58
|
+
// Both are always called (rules of hooks); only one is ever active.
|
|
59
|
+
const keyed = options.key !== undefined;
|
|
60
|
+
const cached = useCachedPages(fetchPage, deps, options, keyed);
|
|
61
|
+
const plain = usePlainPages(fetchPage, deps, keyed ? { enabled: false } : options);
|
|
62
|
+
return keyed ? cached : plain;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The uncached hook, exactly as it was before the cache existed. */
|
|
66
|
+
function usePlainPages<T>(
|
|
67
|
+
fetchPage: (cursor: string | undefined, signal: AbortSignal) => Promise<PaginatedResponse<T>>,
|
|
68
|
+
deps: readonly unknown[],
|
|
69
|
+
options: UsePaginatedOptions,
|
|
38
70
|
): UsePaginatedResult<T> {
|
|
39
71
|
const enabled = options.enabled ?? true;
|
|
40
72
|
const fetchRef = useRef(fetchPage);
|
|
@@ -103,3 +135,91 @@ export function usePaginated<T>(
|
|
|
103
135
|
const reload = useCallback(() => load(true), [load]);
|
|
104
136
|
return { items, loading, loadingMore, error, hasMore, loadMore, reload };
|
|
105
137
|
}
|
|
138
|
+
|
|
139
|
+
/** The keyed hook: the first page goes through the shared cache; "load more" appends to the cached pages. */
|
|
140
|
+
function useCachedPages<T>(
|
|
141
|
+
fetchPage: (cursor: string | undefined, signal: AbortSignal) => Promise<PaginatedResponse<T>>,
|
|
142
|
+
deps: readonly unknown[],
|
|
143
|
+
options: UsePaginatedOptions,
|
|
144
|
+
active: boolean,
|
|
145
|
+
): UsePaginatedResult<T> {
|
|
146
|
+
const key = options.key ?? "";
|
|
147
|
+
const keyRef = useRef(key);
|
|
148
|
+
keyRef.current = key;
|
|
149
|
+
const fetchRef = useRef(fetchPage);
|
|
150
|
+
fetchRef.current = fetchPage;
|
|
151
|
+
const query = useG8Query<PaginatedCacheData<T>>(
|
|
152
|
+
async (signal) => {
|
|
153
|
+
const page = await fetchRef.current(undefined, signal);
|
|
154
|
+
return { items: [...(page.data ?? [])], nextCursor: page.pagination?.next_cursor ?? undefined };
|
|
155
|
+
},
|
|
156
|
+
deps,
|
|
157
|
+
{
|
|
158
|
+
key: active ? key : undefined,
|
|
159
|
+
enabled: active && (options.enabled ?? true),
|
|
160
|
+
staleTime: options.staleTime,
|
|
161
|
+
refetchOnFocus: options.refetchOnFocus ?? false,
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
const [loadingMore, setLoadingMore] = useState(false);
|
|
165
|
+
const [moreError, setMoreError] = useState<unknown>(undefined);
|
|
166
|
+
const more = useRef<AbortController | null>(null);
|
|
167
|
+
|
|
168
|
+
// A new key (filter) or unmount drops a "load more" in flight.
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
return () => {
|
|
171
|
+
more.current?.abort();
|
|
172
|
+
more.current = null;
|
|
173
|
+
setLoadingMore(false);
|
|
174
|
+
};
|
|
175
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
176
|
+
}, [active ? JSON.stringify(key) : ""]);
|
|
177
|
+
|
|
178
|
+
const loadMore = useCallback(async (): Promise<void> => {
|
|
179
|
+
const start = g8QueryCache.getQueryData<PaginatedCacheData<T>>(keyRef.current);
|
|
180
|
+
if (!active || more.current || !start?.nextCursor) return;
|
|
181
|
+
const ctl = new AbortController();
|
|
182
|
+
more.current = ctl;
|
|
183
|
+
setLoadingMore(true);
|
|
184
|
+
try {
|
|
185
|
+
const page = await fetchRef.current(start.nextCursor, ctl.signal);
|
|
186
|
+
// The list was refreshed or replaced meanwhile: this page belongs to an older version.
|
|
187
|
+
if (ctl.signal.aborted || g8QueryCache.getQueryData(keyRef.current) !== start) return;
|
|
188
|
+
g8QueryCache.setQueryData<PaginatedCacheData<T>>(keyRef.current, {
|
|
189
|
+
items: [...start.items, ...(page.data ?? [])],
|
|
190
|
+
nextCursor: page.pagination?.next_cursor ?? undefined,
|
|
191
|
+
});
|
|
192
|
+
setMoreError(undefined);
|
|
193
|
+
} catch (err) {
|
|
194
|
+
if (!ctl.signal.aborted) setMoreError(err);
|
|
195
|
+
} finally {
|
|
196
|
+
if (more.current === ctl) {
|
|
197
|
+
more.current = null;
|
|
198
|
+
setLoadingMore(false);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}, [active]);
|
|
202
|
+
|
|
203
|
+
const reload = useCallback(async (): Promise<void> => {
|
|
204
|
+
more.current?.abort();
|
|
205
|
+
more.current = null;
|
|
206
|
+
setLoadingMore(false);
|
|
207
|
+
setMoreError(undefined);
|
|
208
|
+
await query.refetch();
|
|
209
|
+
}, [query.refetch]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
210
|
+
|
|
211
|
+
const data = query.data;
|
|
212
|
+
return {
|
|
213
|
+
items: data?.items ?? EMPTY_ITEMS,
|
|
214
|
+
// Cached pages stay on screen while they refresh: only a list with nothing of its own yet is "loading".
|
|
215
|
+
loading: query.loading && (data === undefined || query.isPreviousData === true),
|
|
216
|
+
loadingMore,
|
|
217
|
+
error: query.error ?? moreError,
|
|
218
|
+
hasMore: Boolean(data?.nextCursor),
|
|
219
|
+
loadMore,
|
|
220
|
+
reload,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
225
|
+
const EMPTY_ITEMS: any[] = [];
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useRowActivation: make a whole list row open its record from the keyboard as well as the mouse.
|
|
5
|
+
* Spread the returned props on the row (`<TableRow {...rowProps}>` or a `<li>`): it becomes focusable,
|
|
6
|
+
* Enter and Space open it, and a click opens it, but a click or key on something INSIDE the row that
|
|
7
|
+
* has its own job (a checkbox, a button, a link, an input) is left alone.
|
|
8
|
+
*
|
|
9
|
+
* A table row keeps its `row` role (pass `role: undefined`, the default); pass `role: "button"` for a
|
|
10
|
+
* plain `<div>` or `<li>` row that is not part of a table or list.
|
|
11
|
+
*
|
|
12
|
+
* SDK operations: none.
|
|
13
|
+
*/
|
|
14
|
+
import type { KeyboardEvent, MouseEvent } from "react";
|
|
15
|
+
|
|
16
|
+
const INTERACTIVE = "a,button,input,select,textarea,label,[role='button'],[role='checkbox'],[role='link'],[role='menuitem'],[data-row-ignore]";
|
|
17
|
+
|
|
18
|
+
export interface RowActivationProps {
|
|
19
|
+
role?: "button";
|
|
20
|
+
tabIndex: 0;
|
|
21
|
+
onClick: (e: MouseEvent<HTMLElement>) => void;
|
|
22
|
+
onKeyDown: (e: KeyboardEvent<HTMLElement>) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** True when the event started on a nested control that should keep its own behaviour. */
|
|
26
|
+
function onNestedControl(e: { target: EventTarget; currentTarget: EventTarget }): boolean {
|
|
27
|
+
const el = e.target as HTMLElement | null;
|
|
28
|
+
if (!el || el === (e.currentTarget as HTMLElement) || typeof el.closest !== "function") return false;
|
|
29
|
+
const hit = el.closest(INTERACTIVE);
|
|
30
|
+
return hit != null && hit !== (e.currentTarget as HTMLElement) && (e.currentTarget as HTMLElement).contains(hit);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The props, as a plain function: use it inside `.map()` callbacks (`<TableRow {...rowActivationProps(() => open(row.id))}>`),
|
|
35
|
+
* where a hook cannot be called. `useRowActivation` is the same thing for a component's own top level.
|
|
36
|
+
*/
|
|
37
|
+
export function rowActivationProps(onOpen: () => void, options: { role?: "button" } = {}): RowActivationProps {
|
|
38
|
+
return {
|
|
39
|
+
...(options.role ? { role: options.role } : {}),
|
|
40
|
+
tabIndex: 0,
|
|
41
|
+
onClick: (e) => {
|
|
42
|
+
if (onNestedControl(e)) return;
|
|
43
|
+
onOpen();
|
|
44
|
+
},
|
|
45
|
+
onKeyDown: (e) => {
|
|
46
|
+
if (e.target !== e.currentTarget) return; // Enter on an inner button is that button's own
|
|
47
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
48
|
+
e.preventDefault();
|
|
49
|
+
onOpen();
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function useRowActivation(onOpen: () => void, options: { role?: "button" } = {}): RowActivationProps {
|
|
56
|
+
return rowActivationProps(onOpen, options);
|
|
57
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useUrlState: one string in the URL query (`?id=123`, `?tab=activity`, `?q=ada`) as React state.
|
|
5
|
+
* The URL owns it, so the open record, the tab and the search survive reload, Back and sharing.
|
|
6
|
+
* Framework-agnostic: window.history.replaceState (pushState with `{ push: true }`) plus `popstate`,
|
|
7
|
+
* no next/navigation. SSR-safe: the server and the first client render see `defaultValue`.
|
|
8
|
+
*
|
|
9
|
+
* SDK operations: none.
|
|
10
|
+
*/
|
|
11
|
+
import { useCallback, useSyncExternalStore } from "react";
|
|
12
|
+
|
|
13
|
+
const EVENT = "g8:urlstate";
|
|
14
|
+
|
|
15
|
+
type Listener = () => void;
|
|
16
|
+
|
|
17
|
+
function subscribe(listener: Listener): () => void {
|
|
18
|
+
if (typeof window !== "undefined") {
|
|
19
|
+
window.addEventListener("popstate", listener);
|
|
20
|
+
window.addEventListener(EVENT, listener);
|
|
21
|
+
}
|
|
22
|
+
return () => {
|
|
23
|
+
if (typeof window !== "undefined") {
|
|
24
|
+
window.removeEventListener("popstate", listener);
|
|
25
|
+
window.removeEventListener(EVENT, listener);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Current value of `key` in the page URL, or undefined. */
|
|
31
|
+
export function readUrlParam(key: string): string | undefined {
|
|
32
|
+
if (typeof window === "undefined") return undefined;
|
|
33
|
+
const v = new URLSearchParams(window.location.search).get(key);
|
|
34
|
+
return v == null ? undefined : v;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SetUrlStateOptions {
|
|
38
|
+
/** Add a history entry (Back returns to the previous value) instead of replacing. */
|
|
39
|
+
push?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Write `key` into the page URL (null, undefined or "" removes it) and notify every hook. */
|
|
43
|
+
export function writeUrlParam(key: string, value: string | null | undefined, options: SetUrlStateOptions = {}): void {
|
|
44
|
+
if (typeof window === "undefined") return;
|
|
45
|
+
const url = new URL(window.location.href);
|
|
46
|
+
const current = url.searchParams.get(key);
|
|
47
|
+
const next = value == null || value === "" ? null : String(value);
|
|
48
|
+
if (current === next) return;
|
|
49
|
+
if (next == null) url.searchParams.delete(key);
|
|
50
|
+
else url.searchParams.set(key, next);
|
|
51
|
+
const href = `${url.pathname}${url.search}${url.hash}`;
|
|
52
|
+
if (options.push) window.history.pushState(window.history.state, "", href);
|
|
53
|
+
else window.history.replaceState(window.history.state, "", href);
|
|
54
|
+
window.dispatchEvent(new Event(EVENT));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type UrlStateSetter = (value: string | null | undefined, options?: SetUrlStateOptions) => void;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* `const [id, setId] = useUrlState("id")`: `id` is the param or `defaultValue` ("" by default); `setId(null)`
|
|
61
|
+
* or `setId("")` removes it; `setId("42", { push: true })` adds a history entry.
|
|
62
|
+
*/
|
|
63
|
+
export function useUrlState(key: string, defaultValue = ""): [string, UrlStateSetter] {
|
|
64
|
+
const value = useSyncExternalStore(
|
|
65
|
+
subscribe,
|
|
66
|
+
() => readUrlParam(key) ?? defaultValue,
|
|
67
|
+
() => defaultValue,
|
|
68
|
+
);
|
|
69
|
+
const set = useCallback<UrlStateSetter>(
|
|
70
|
+
(next, options) => writeUrlParam(key, next == null || next === defaultValue ? null : next, options),
|
|
71
|
+
[key, defaultValue],
|
|
72
|
+
);
|
|
73
|
+
return [value, set];
|
|
74
|
+
}
|