@gogitcms/editor 0.23.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/app/index.html +38 -0
- package/app/src/App.tsx +2665 -0
- package/app/src/apollo.ts +119 -0
- package/app/src/auth.ts +248 -0
- package/app/src/config.ts +88 -0
- package/app/src/main.tsx +18 -0
- package/app/src/media.ts +151 -0
- package/app/src/navigation.tsx +321 -0
- package/app/src/plugins.ts +11 -0
- package/app/src/previewHost.tsx +263 -0
- package/app/src/previewToken.ts +68 -0
- package/app/src/queries.ts +591 -0
- package/app/src/virtual-cms-plugins.d.ts +6 -0
- package/app/vendor/analytics/src/__tests__/config.test.ts +91 -0
- package/app/vendor/analytics/src/config.ts +94 -0
- package/app/vendor/analytics/src/events.ts +56 -0
- package/app/vendor/analytics/src/index.ts +23 -0
- package/app/vendor/analytics/src/provider.tsx +196 -0
- package/app/vendor/design-system/src/ThemeProvider.tsx +86 -0
- package/app/vendor/design-system/src/__tests__/ApplyChangesModal.test.tsx +148 -0
- package/app/vendor/design-system/src/__tests__/BranchImport.test.tsx +46 -0
- package/app/vendor/design-system/src/__tests__/Button.test.tsx +45 -0
- package/app/vendor/design-system/src/__tests__/ChangeRequestSummary.test.tsx +57 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.changes.test.tsx +611 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.collab.test.tsx +322 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.collabsync.test.tsx +264 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.contentslot.test.tsx +53 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.discriminator.test.tsx +142 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.drafts.test.tsx +271 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.fields.test.tsx +117 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.media.test.tsx +140 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.mixedcollab.test.tsx +63 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.mixedvalues.test.tsx +38 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.pagination.test.tsx +62 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.previewtab.test.tsx +212 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.reorder.test.tsx +45 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.search.test.tsx +135 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.selectvalue.test.tsx +185 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.staged.test.tsx +132 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.usermenu.test.tsx +56 -0
- package/app/vendor/design-system/src/__tests__/MediaBrowser.test.tsx +353 -0
- package/app/vendor/design-system/src/__tests__/MediaField.test.tsx +185 -0
- package/app/vendor/design-system/src/__tests__/Notifications.test.tsx +69 -0
- package/app/vendor/design-system/src/__tests__/Onboarding.test.tsx +287 -0
- package/app/vendor/design-system/src/__tests__/cssTokens.test.ts +201 -0
- package/app/vendor/design-system/src/__tests__/fieldComponents.test.ts +43 -0
- package/app/vendor/design-system/src/__tests__/reorder.test.ts +58 -0
- package/app/vendor/design-system/src/components/ApplyChangesModal.tsx +348 -0
- package/app/vendor/design-system/src/components/BranchImport.tsx +157 -0
- package/app/vendor/design-system/src/components/BranchMenu.tsx +192 -0
- package/app/vendor/design-system/src/components/Button.tsx +131 -0
- package/app/vendor/design-system/src/components/ChangeDetail.tsx +472 -0
- package/app/vendor/design-system/src/components/ChangeRequestSummary.tsx +173 -0
- package/app/vendor/design-system/src/components/CollabField.tsx +388 -0
- package/app/vendor/design-system/src/components/ContentBrowser.tsx +5073 -0
- package/app/vendor/design-system/src/components/Icon.tsx +28 -0
- package/app/vendor/design-system/src/components/Icon.web.tsx +31 -0
- package/app/vendor/design-system/src/components/Input.tsx +106 -0
- package/app/vendor/design-system/src/components/MediaBrowser.tsx +766 -0
- package/app/vendor/design-system/src/components/MediaField.tsx +670 -0
- package/app/vendor/design-system/src/components/MediaPreview.tsx +91 -0
- package/app/vendor/design-system/src/components/MediaPreview.web.tsx +169 -0
- package/app/vendor/design-system/src/components/NavRow.tsx +105 -0
- package/app/vendor/design-system/src/components/Notifications.tsx +301 -0
- package/app/vendor/design-system/src/components/Onboarding.tsx +751 -0
- package/app/vendor/design-system/src/components/ProjectMenu.tsx +124 -0
- package/app/vendor/design-system/src/components/Segment.tsx +87 -0
- package/app/vendor/design-system/src/components/Skeleton.tsx +216 -0
- package/app/vendor/design-system/src/components/Spinner.tsx +44 -0
- package/app/vendor/design-system/src/components/Text.tsx +85 -0
- package/app/vendor/design-system/src/components/documentDrafts.ts +213 -0
- package/app/vendor/design-system/src/components/layout.tsx +284 -0
- package/app/vendor/design-system/src/components/primitives.tsx +143 -0
- package/app/vendor/design-system/src/components/reorder.ts +40 -0
- package/app/vendor/design-system/src/fieldComponents.ts +63 -0
- package/app/vendor/design-system/src/icons.ts +102 -0
- package/app/vendor/design-system/src/index.ts +198 -0
- package/app/vendor/design-system/src/media.ts +229 -0
- package/app/vendor/design-system/src/theme.ts +116 -0
- package/app/vendor/design-system/src/web/Button.tsx +110 -0
- package/app/vendor/design-system/src/web/Icon.tsx +52 -0
- package/app/vendor/design-system/src/web/Input.tsx +39 -0
- package/app/vendor/design-system/src/web/index.ts +43 -0
- package/app/vendor/design-system/src/web/primitives.tsx +119 -0
- package/app/vendor/markdown-editor/src/MarkdownEditor.tsx +248 -0
- package/app/vendor/markdown-editor/src/Toolbar.tsx +157 -0
- package/app/vendor/markdown-editor/src/field.tsx +67 -0
- package/app/vendor/markdown-editor/src/flavors/commonmark/index.ts +27 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/index.ts +30 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/parser.ts +73 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/schema.ts +70 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/serializer.ts +82 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/taskList.ts +39 -0
- package/app/vendor/markdown-editor/src/flavors/registry.ts +13 -0
- package/app/vendor/markdown-editor/src/flavors/shared/inputrules.ts +79 -0
- package/app/vendor/markdown-editor/src/flavors/shared/keymap.ts +89 -0
- package/app/vendor/markdown-editor/src/flavors/shared/placeholder.ts +24 -0
- package/app/vendor/markdown-editor/src/flavors/shared/plugins.ts +58 -0
- package/app/vendor/markdown-editor/src/flavors/types.ts +22 -0
- package/app/vendor/markdown-editor/src/formats/registry.ts +45 -0
- package/app/vendor/markdown-editor/src/icons.tsx +120 -0
- package/app/vendor/markdown-editor/src/index.ts +23 -0
- package/app/vendor/markdown-editor/src/theme.ts +249 -0
- package/app/vendor/markdown-editor/src/types.ts +58 -0
- package/app/vendor/plugin-sdk/src/__tests__/documentActions.test.ts +108 -0
- package/app/vendor/plugin-sdk/src/__tests__/loader.test.ts +75 -0
- package/app/vendor/plugin-sdk/src/__tests__/registry.test.ts +148 -0
- package/app/vendor/plugin-sdk/src/index.ts +33 -0
- package/app/vendor/plugin-sdk/src/loader.ts +45 -0
- package/app/vendor/plugin-sdk/src/navkeys.ts +17 -0
- package/app/vendor/plugin-sdk/src/react.tsx +155 -0
- package/app/vendor/plugin-sdk/src/registry.ts +337 -0
- package/app/vendor/plugin-sdk/src/types.ts +230 -0
- package/app/vendor/realtime/src/__tests__/pure.test.ts +58 -0
- package/app/vendor/realtime/src/__tests__/seeded-room.base64 +1 -0
- package/app/vendor/realtime/src/__tests__/seeding.test.ts +70 -0
- package/app/vendor/realtime/src/collab.ts +308 -0
- package/app/vendor/realtime/src/hooks.ts +57 -0
- package/app/vendor/realtime/src/index.ts +12 -0
- package/app/vendor/realtime/src/pure.ts +43 -0
- package/app/vite.config.mjs +59 -0
- package/bin/gogitcms-editor.mjs +168 -0
- package/npm-shrinkwrap.json +5696 -0
- package/package.json +74 -0
- package/src/commands/build.mjs +100 -0
- package/src/commands/dev.mjs +186 -0
- package/src/commands/init.mjs +144 -0
- package/src/commands/login.mjs +116 -0
- package/src/commands/status.mjs +68 -0
- package/src/lib/api.mjs +122 -0
- package/src/lib/appRoot.mjs +51 -0
- package/src/lib/baked.json +3 -0
- package/src/lib/config.mjs +108 -0
- package/src/lib/credentials.mjs +91 -0
- package/src/lib/defaults.mjs +26 -0
- package/src/lib/graphql.mjs +28 -0
- package/src/lib/localServer.mjs +206 -0
- package/src/lib/open.mjs +23 -0
- package/src/lib/plugins.mjs +164 -0
- package/src/lib/tokenBroker.mjs +85 -0
- package/src/tui/prompts.mjs +92 -0
- package/src/tui/screen.mjs +282 -0
- package/src/tui/theme.mjs +40 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Build-time PostHog configuration, shared by every CMS front end.
|
|
2
|
+
//
|
|
3
|
+
// Each app resolves the raw values from wherever it gets configured — the
|
|
4
|
+
// frontend from cms.config.js or the server's window globals, the dashboard and
|
|
5
|
+
// desktop app from Vite env vars — and hands them here to be normalized. The
|
|
6
|
+
// key names are deliberately the same everywhere so operators only learn one
|
|
7
|
+
// vocabulary; only the transport differs.
|
|
8
|
+
//
|
|
9
|
+
// Analytics is off unless POSTHOG_API_KEY is present, matching the Go server's
|
|
10
|
+
// CMS_POSTHOG_API_KEY switch: a self-hosted CMS phones home to nobody.
|
|
11
|
+
|
|
12
|
+
/** Raw string values as they arrive from a config file or env vars. */
|
|
13
|
+
export type RawAnalyticsConfig = {
|
|
14
|
+
POSTHOG_API_KEY?: string;
|
|
15
|
+
POSTHOG_API_HOST?: string;
|
|
16
|
+
POSTHOG_UI_HOST?: string;
|
|
17
|
+
POSTHOG_ENVIRONMENT?: string;
|
|
18
|
+
/** "true" opts this build into session replay. Off unless set — see below. */
|
|
19
|
+
POSTHOG_SESSION_RECORDING?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type AnalyticsConfig = {
|
|
23
|
+
apiKey: string;
|
|
24
|
+
apiHost: string;
|
|
25
|
+
uiHost?: string;
|
|
26
|
+
environment?: string;
|
|
27
|
+
sessionRecording: boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_API_HOST = "https://us.i.posthog.com";
|
|
31
|
+
|
|
32
|
+
const clean = (v: string | undefined | null): string | undefined => {
|
|
33
|
+
const s = typeof v === "string" ? v.trim() : "";
|
|
34
|
+
return s === "" ? undefined : s;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* resolveAnalyticsConfig normalizes raw values into an AnalyticsConfig. An
|
|
39
|
+
* absent or blank POSTHOG_API_KEY yields an empty apiKey, which is what
|
|
40
|
+
* isAnalyticsEnabled keys off.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveAnalyticsConfig(raw: RawAnalyticsConfig | undefined | null): AnalyticsConfig {
|
|
43
|
+
const r = raw ?? {};
|
|
44
|
+
return {
|
|
45
|
+
apiKey: clean(r.POSTHOG_API_KEY) ?? "",
|
|
46
|
+
apiHost: clean(r.POSTHOG_API_HOST) ?? DEFAULT_API_HOST,
|
|
47
|
+
uiHost: clean(r.POSTHOG_UI_HOST),
|
|
48
|
+
environment: clean(r.POSTHOG_ENVIRONMENT),
|
|
49
|
+
// Opt-in, never opt-out. The CMS renders customer content — draft posts,
|
|
50
|
+
// unpublished copy, private repositories — and a session replay records all
|
|
51
|
+
// of it into a third party. That is a decision for whoever runs the
|
|
52
|
+
// deployment to make deliberately, not a default they inherit.
|
|
53
|
+
sessionRecording: clean(r.POSTHOG_SESSION_RECORDING) === "true",
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const isAnalyticsEnabled = (cfg: AnalyticsConfig): boolean => cfg.apiKey !== "";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* posthogOptions maps an AnalyticsConfig onto posthog-js init options.
|
|
61
|
+
*
|
|
62
|
+
* The privacy-relevant choices, all of which matter for a CMS:
|
|
63
|
+
*
|
|
64
|
+
* person_profiles identified_only — anonymous visitors don't create person
|
|
65
|
+
* records; only signed-in users do, once identify() runs.
|
|
66
|
+
* autocapture on, but it records which element was clicked, never the
|
|
67
|
+
* text typed into it.
|
|
68
|
+
* mask_all_text on for session replay, so if a deployment does opt in, the
|
|
69
|
+
* recording still won't contain the content being edited.
|
|
70
|
+
*
|
|
71
|
+
* capture_exceptions is set explicitly rather than left to default: undefined
|
|
72
|
+
* means "whatever the project's remote config says", so error tracking silently
|
|
73
|
+
* depended on a PostHog UI toggle and was off for every deployment here. `true`
|
|
74
|
+
* hooks window.onerror and unhandledrejection, which is what turns a crashed
|
|
75
|
+
* render or a rejected API promise into an $exception. Console errors stay off
|
|
76
|
+
* (their default) — the CMS logs plenty that aren't faults.
|
|
77
|
+
*/
|
|
78
|
+
export function posthogOptions(cfg: AnalyticsConfig): Record<string, unknown> {
|
|
79
|
+
return {
|
|
80
|
+
capture_exceptions: true,
|
|
81
|
+
api_host: cfg.apiHost,
|
|
82
|
+
...(cfg.uiHost ? { ui_host: cfg.uiHost } : {}),
|
|
83
|
+
// Pins the behaviour of every option we don't set explicitly, so a
|
|
84
|
+
// posthog-js upgrade can't silently change what gets captured.
|
|
85
|
+
defaults: "2025-05-24",
|
|
86
|
+
person_profiles: "identified_only",
|
|
87
|
+
// Both SPAs drive real URLs, so history-based pageviews line up with the
|
|
88
|
+
// routes users actually see.
|
|
89
|
+
capture_pageview: "history_change",
|
|
90
|
+
disable_session_recording: !cfg.sessionRecording,
|
|
91
|
+
mask_all_text: true,
|
|
92
|
+
session_recording: { maskAllInputs: true },
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// The CMS's product-analytics taxonomy.
|
|
2
|
+
//
|
|
3
|
+
// Every event name lives here rather than as a string literal at the call site,
|
|
4
|
+
// because a typo in an event name is invisible: nothing errors, the event just
|
|
5
|
+
// lands under a slightly different name and quietly splits a funnel in two.
|
|
6
|
+
// capture() only accepts these names, so a new event has to be added here first.
|
|
7
|
+
//
|
|
8
|
+
// Naming: snake_case, object then past-tense verb (workspace_created, not
|
|
9
|
+
// create_workspace), so related events sort together in PostHog's event list.
|
|
10
|
+
//
|
|
11
|
+
// PROPERTIES — what must never go in one:
|
|
12
|
+
// * Content. Document bodies, field values, and search query text are the
|
|
13
|
+
// customer's material, not ours to ship to a third party.
|
|
14
|
+
// * Personal data. Emails and names; identify() already ties events to a user
|
|
15
|
+
// ID, which is the join key for anything else.
|
|
16
|
+
// * Free-text a user typed. It reliably contains the two things above.
|
|
17
|
+
// Prefer opaque ids (joinable against the product database), schema metadata
|
|
18
|
+
// (collection and field names), counts, and enumerations.
|
|
19
|
+
|
|
20
|
+
export const AnalyticsEvent = {
|
|
21
|
+
// --- Dashboard: signup and onboarding ---
|
|
22
|
+
MagicLinkRequested: "magic_link_requested",
|
|
23
|
+
GitHubOAuthStarted: "github_oauth_started",
|
|
24
|
+
RegistrationCompleted: "registration_completed",
|
|
25
|
+
WorkspaceCreated: "workspace_created",
|
|
26
|
+
|
|
27
|
+
// --- Dashboard: connecting a repository ---
|
|
28
|
+
GitHubOrganizationConnected: "github_organization_connected",
|
|
29
|
+
GitHubRepositorySelected: "github_repository_selected",
|
|
30
|
+
RepositoryImportSucceeded: "repository_import_succeeded",
|
|
31
|
+
RepositoryImportFailed: "repository_import_failed",
|
|
32
|
+
|
|
33
|
+
// --- Dashboard: self-hosted editors ---
|
|
34
|
+
SelfHostedEditorModalOpened: "self_hosted_editor_modal_opened",
|
|
35
|
+
SelfHostedEditorAdded: "self_hosted_editor_added",
|
|
36
|
+
|
|
37
|
+
// --- Dashboard: opening the editor ---
|
|
38
|
+
// Properties: workspace_id, target ("cloud" | "self_hosted"), source (the
|
|
39
|
+
// surface the launch started from). Which editor was picked is an id, never
|
|
40
|
+
// the customer's own hostname.
|
|
41
|
+
EditorLaunched: "editor_launched",
|
|
42
|
+
|
|
43
|
+
// --- Editor: finding content ---
|
|
44
|
+
BasicSearchExecuted: "basic_search_executed",
|
|
45
|
+
AdvancedSearchExecuted: "advanced_search_executed",
|
|
46
|
+
|
|
47
|
+
// --- Editor: navigating content ---
|
|
48
|
+
TabOpened: "tab_opened",
|
|
49
|
+
TabClosed: "tab_closed",
|
|
50
|
+
CollectionViewed: "collection_viewed",
|
|
51
|
+
CollectionItemViewed: "collection_item_viewed",
|
|
52
|
+
ConfigurationItemViewed: "configuration_item_viewed",
|
|
53
|
+
} as const;
|
|
54
|
+
|
|
55
|
+
/** The only event names capture() accepts. */
|
|
56
|
+
export type AnalyticsEventName = (typeof AnalyticsEvent)[keyof typeof AnalyticsEvent];
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// @gogitcms/analytics — PostHog for the CMS front ends.
|
|
2
|
+
//
|
|
3
|
+
// Configuration-driven and off by default, mirroring the Go server's
|
|
4
|
+
// packages/telemetry: with no POSTHOG_API_KEY baked into the build, the SDK is
|
|
5
|
+
// never loaded and nothing is sent. See config.ts for how each app supplies it.
|
|
6
|
+
export {
|
|
7
|
+
DEFAULT_API_HOST,
|
|
8
|
+
isAnalyticsEnabled,
|
|
9
|
+
posthogOptions,
|
|
10
|
+
resolveAnalyticsConfig,
|
|
11
|
+
type AnalyticsConfig,
|
|
12
|
+
type RawAnalyticsConfig,
|
|
13
|
+
} from "./config";
|
|
14
|
+
export { AnalyticsEvent, type AnalyticsEventName } from "./events";
|
|
15
|
+
export {
|
|
16
|
+
AnalyticsProvider,
|
|
17
|
+
captureException,
|
|
18
|
+
useAnalytics,
|
|
19
|
+
useIdentify,
|
|
20
|
+
type Analytics,
|
|
21
|
+
type AnalyticsProperties,
|
|
22
|
+
type AnalyticsProviderProps,
|
|
23
|
+
} from "./provider";
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { type AnalyticsConfig, isAnalyticsEnabled, posthogOptions } from "./config";
|
|
3
|
+
import { type AnalyticsEventName } from "./events";
|
|
4
|
+
|
|
5
|
+
// The bit of posthog-js this package uses. Declared structurally rather than
|
|
6
|
+
// imported as a type from posthog-js, so nothing in the module graph reaches the
|
|
7
|
+
// SDK — a type-only import would be erased, but it is easy to turn one into a
|
|
8
|
+
// value import by accident and silently undo the code splitting below.
|
|
9
|
+
type PostHogClient = {
|
|
10
|
+
init(apiKey: string, options: Record<string, unknown>): void;
|
|
11
|
+
/** Only names from the AnalyticsEvent taxonomy — see events.ts. */
|
|
12
|
+
capture(event: AnalyticsEventName, properties?: AnalyticsProperties): void;
|
|
13
|
+
captureException(error: unknown, properties?: Record<string, unknown>): void;
|
|
14
|
+
identify(id: string, properties?: Record<string, unknown>): void;
|
|
15
|
+
register(properties: Record<string, unknown>): void;
|
|
16
|
+
reset(): void;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const ClientContext = createContext<PostHogClient | null>(null);
|
|
20
|
+
|
|
21
|
+
// The same client, reachable outside React. Errors are raised by plain modules
|
|
22
|
+
// too — the REST client is the main one — and those callers have no hook to
|
|
23
|
+
// call. Assigned by AnalyticsProvider, null whenever PostHog is unconfigured or
|
|
24
|
+
// its chunk is still loading.
|
|
25
|
+
let liveClient: PostHogClient | null = null;
|
|
26
|
+
|
|
27
|
+
// Exceptions raised before the SDK chunk arrives, held so they aren't lost. The
|
|
28
|
+
// dashboard fires its first API requests on mount, well before a ~230 kB import
|
|
29
|
+
// resolves, so the failures most worth seeing — the ones on initial load — are
|
|
30
|
+
// exactly the ones that would otherwise fall in that gap. Bounded because an
|
|
31
|
+
// app erroring in a loop must not grow this without limit.
|
|
32
|
+
const PENDING_LIMIT = 20;
|
|
33
|
+
let pending: { error: unknown; properties?: Record<string, unknown> }[] = [];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* captureException reports a handled or unhandled error to PostHog's error
|
|
37
|
+
* tracking. Safe to call from anywhere, at any time: it no-ops when analytics is
|
|
38
|
+
* disabled and buffers when the SDK has not finished loading.
|
|
39
|
+
*
|
|
40
|
+
* Properties follow the same rule as event properties (see AnalyticsProperties):
|
|
41
|
+
* ids, statuses and paths, never content or personal data.
|
|
42
|
+
*/
|
|
43
|
+
export function captureException(error: unknown, properties?: Record<string, unknown>): void {
|
|
44
|
+
if (liveClient) {
|
|
45
|
+
liveClient.captureException(error, properties);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (pending.length < PENDING_LIMIT) pending.push({ error, properties });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type AnalyticsProviderProps = {
|
|
52
|
+
config: AnalyticsConfig;
|
|
53
|
+
children?: React.ReactNode;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* AnalyticsProvider loads and initializes PostHog when config carries an API
|
|
58
|
+
* key, and does nothing at all when it doesn't.
|
|
59
|
+
*
|
|
60
|
+
* posthog-js is pulled in with a dynamic import() so the bundler splits it into
|
|
61
|
+
* its own chunk: a self-hosted build with no key never downloads the ~230 kB
|
|
62
|
+
* SDK, which is the whole point of analytics being opt-in. The rendered tree is
|
|
63
|
+
* the same either way — the client arrives through context once it has loaded —
|
|
64
|
+
* so children mount exactly once and never remount when it does.
|
|
65
|
+
*
|
|
66
|
+
* config must be a module-level constant (it is, in every app here): a new
|
|
67
|
+
* object each render would re-run the effect and re-initialize the SDK.
|
|
68
|
+
*/
|
|
69
|
+
export function AnalyticsProvider({ config, children }: AnalyticsProviderProps) {
|
|
70
|
+
const [client, setClient] = useState<PostHogClient | null>(null);
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!isAnalyticsEnabled(config)) return;
|
|
74
|
+
let cancelled = false;
|
|
75
|
+
void import("posthog-js").then(({ default: posthog }) => {
|
|
76
|
+
// The app may have unmounted (or config changed) while the chunk was in
|
|
77
|
+
// flight; initializing then would leave an orphaned client capturing.
|
|
78
|
+
if (cancelled) return;
|
|
79
|
+
posthog.init(config.apiKey, posthogOptions(config));
|
|
80
|
+
// Tags every event with the deployment it came from, so production and
|
|
81
|
+
// staging can be told apart. Registered once rather than passed at each
|
|
82
|
+
// capture call site.
|
|
83
|
+
if (config.environment) posthog.register({ environment: config.environment });
|
|
84
|
+
const c = posthog as unknown as PostHogClient;
|
|
85
|
+
liveClient = c;
|
|
86
|
+
// Drain anything that failed while the chunk was in flight.
|
|
87
|
+
const buffered = pending;
|
|
88
|
+
pending = [];
|
|
89
|
+
for (const { error, properties } of buffered) c.captureException(error, properties);
|
|
90
|
+
setClient(c);
|
|
91
|
+
});
|
|
92
|
+
return () => {
|
|
93
|
+
cancelled = true;
|
|
94
|
+
};
|
|
95
|
+
}, [config]);
|
|
96
|
+
|
|
97
|
+
return <ClientContext.Provider value={client}>{children}</ClientContext.Provider>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Event properties. Scalars only: nested objects arrive in PostHog as
|
|
102
|
+
* "[object Object]", and the flat shape discourages attaching whole records
|
|
103
|
+
* (which is how content and personal data leak into analytics).
|
|
104
|
+
*/
|
|
105
|
+
export type AnalyticsProperties = Record<string, string | number | boolean | null | undefined>;
|
|
106
|
+
|
|
107
|
+
/** The subset of PostHog the CMS apps use, safe to call when disabled. */
|
|
108
|
+
export type Analytics = {
|
|
109
|
+
/** False when PostHog is unconfigured or its chunk has not loaded yet. */
|
|
110
|
+
enabled: boolean;
|
|
111
|
+
/** Only names from the AnalyticsEvent taxonomy — see events.ts. */
|
|
112
|
+
capture(event: AnalyticsEventName, properties?: AnalyticsProperties): void;
|
|
113
|
+
/**
|
|
114
|
+
* captureException reports an error the UI caught and handled — a failed save,
|
|
115
|
+
* a rejected request — which autocapture never sees precisely because it was
|
|
116
|
+
* caught. Unlike capture(), it buffers rather than dropping while the SDK
|
|
117
|
+
* loads, so it stays useful before `enabled` flips true.
|
|
118
|
+
*/
|
|
119
|
+
captureException(error: unknown, properties?: Record<string, unknown>): void;
|
|
120
|
+
/**
|
|
121
|
+
* identify ties subsequent events to a signed-in user. The first argument is
|
|
122
|
+
* the user's ID — the same identifier the API uses, so PostHog persons join
|
|
123
|
+
* against the product database.
|
|
124
|
+
*
|
|
125
|
+
* properties become *person* properties, and this is the one place an email
|
|
126
|
+
* belongs: it makes a person record recognizable in PostHog without putting
|
|
127
|
+
* the address on every event (see the property rules in events.ts, which still
|
|
128
|
+
* hold). Pass it as `email`, the key PostHog's person UI reads.
|
|
129
|
+
*/
|
|
130
|
+
identify(userId: string, properties?: Record<string, unknown>): void;
|
|
131
|
+
/** reset unlinks the session at sign-out, so the next user is not merged in. */
|
|
132
|
+
reset(): void;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const NOOP: Analytics = {
|
|
136
|
+
enabled: false,
|
|
137
|
+
capture: () => {},
|
|
138
|
+
// Not a no-op: buffered by the module-level captureException above, so an
|
|
139
|
+
// error during the SDK's load window still lands once it arrives.
|
|
140
|
+
captureException,
|
|
141
|
+
identify: () => {},
|
|
142
|
+
reset: () => {},
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* useAnalytics returns an Analytics facade. Every method is a no-op until (and
|
|
147
|
+
* unless) PostHog is configured and loaded, so callers never branch on it.
|
|
148
|
+
*/
|
|
149
|
+
export function useAnalytics(): Analytics {
|
|
150
|
+
const client = useContext(ClientContext);
|
|
151
|
+
return useMemo(() => {
|
|
152
|
+
if (!client) return NOOP;
|
|
153
|
+
return {
|
|
154
|
+
enabled: true,
|
|
155
|
+
capture: (event, properties) => client.capture(event, properties),
|
|
156
|
+
captureException: (error, properties) => client.captureException(error, properties),
|
|
157
|
+
identify: (userId, properties) => client.identify(userId, properties),
|
|
158
|
+
reset: () => client.reset(),
|
|
159
|
+
};
|
|
160
|
+
}, [client]);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* useIdentify keeps PostHog's identity in step with the signed-in user: it
|
|
165
|
+
* identifies on sign-in and resets on sign-out, including the sign-out that
|
|
166
|
+
* happens when a token expires. Pass undefined or null while signed out.
|
|
167
|
+
*
|
|
168
|
+
* Because it depends on the analytics facade, it also re-runs once the SDK
|
|
169
|
+
* finishes loading — so a user who was already signed in when the page opened
|
|
170
|
+
* still gets identified, rather than being missed by the race.
|
|
171
|
+
*
|
|
172
|
+
* Placing this in one hook means each app has a single line to add rather than
|
|
173
|
+
* its own identify/reset bookkeeping to get subtly wrong.
|
|
174
|
+
*/
|
|
175
|
+
export function useIdentify(userId: string | undefined | null, properties?: Record<string, unknown>) {
|
|
176
|
+
const analytics = useAnalytics();
|
|
177
|
+
// Serialized so a fresh object literal at the call site doesn't re-identify
|
|
178
|
+
// on every render.
|
|
179
|
+
const propsKey = properties ? JSON.stringify(properties) : "";
|
|
180
|
+
// Whether we have identified anyone yet. Reset is only correct as a
|
|
181
|
+
// *transition* out of a signed-in session — calling it on first load would
|
|
182
|
+
// throw away the anonymous ID that ties a visitor's pre-login events to the
|
|
183
|
+
// account they then sign into.
|
|
184
|
+
const identified = useRef(false);
|
|
185
|
+
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
if (!analytics.enabled) return;
|
|
188
|
+
if (userId) {
|
|
189
|
+
identified.current = true;
|
|
190
|
+
analytics.identify(userId, propsKey ? JSON.parse(propsKey) : undefined);
|
|
191
|
+
} else if (identified.current) {
|
|
192
|
+
identified.current = false;
|
|
193
|
+
analytics.reset();
|
|
194
|
+
}
|
|
195
|
+
}, [analytics, userId, propsKey]);
|
|
196
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import React, { createContext, useCallback, useContext, useMemo, useState } from "react";
|
|
2
|
+
import { useColorScheme, useWindowDimensions } from "react-native";
|
|
3
|
+
import { makeTheme, lightTheme, Theme, ThemeMode, DESKTOP_BREAKPOINT } from "./theme";
|
|
4
|
+
|
|
5
|
+
type ThemeContextValue = {
|
|
6
|
+
theme: Theme;
|
|
7
|
+
mode: ThemeMode;
|
|
8
|
+
setMode: (mode: ThemeMode) => void;
|
|
9
|
+
toggleMode: () => void;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
|
13
|
+
|
|
14
|
+
export type ThemeProviderProps = {
|
|
15
|
+
children: React.ReactNode;
|
|
16
|
+
/** Force a mode; omit to follow the system color scheme. */
|
|
17
|
+
initialMode?: ThemeMode;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function ThemeProvider({ children, initialMode }: ThemeProviderProps) {
|
|
21
|
+
const system = useColorScheme();
|
|
22
|
+
const [override, setOverride] = useState<ThemeMode | undefined>(initialMode);
|
|
23
|
+
const mode: ThemeMode = override ?? (system === "dark" ? "dark" : "light");
|
|
24
|
+
|
|
25
|
+
const theme = useMemo(() => makeTheme(mode), [mode]);
|
|
26
|
+
const setMode = useCallback((m: ThemeMode) => setOverride(m), []);
|
|
27
|
+
const toggleMode = useCallback(
|
|
28
|
+
() => setOverride((m) => ((m ?? mode) === "dark" ? "light" : "dark")),
|
|
29
|
+
[mode]
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const value = useMemo(
|
|
33
|
+
() => ({ theme, mode, setMode, toggleMode }),
|
|
34
|
+
[theme, mode, setMode, toggleMode]
|
|
35
|
+
);
|
|
36
|
+
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Overrides the theme palette for a subtree while keeping the app's real
|
|
41
|
+
* mode/setMode/toggleMode intact — so a nested control (e.g. the top bar's
|
|
42
|
+
* theme toggle) still drives the whole app, not just the scoped subtree.
|
|
43
|
+
* Used to render the dark-chrome top bar's inline contents against a dark
|
|
44
|
+
* palette regardless of the active app theme.
|
|
45
|
+
*/
|
|
46
|
+
export function ThemeScope({ theme, children }: { theme: Theme; children: React.ReactNode }) {
|
|
47
|
+
const ctx = useContext(ThemeContext);
|
|
48
|
+
const value = useMemo<ThemeContextValue>(
|
|
49
|
+
() => ({
|
|
50
|
+
theme,
|
|
51
|
+
mode: ctx?.mode ?? "light",
|
|
52
|
+
setMode: ctx?.setMode ?? (() => {}),
|
|
53
|
+
toggleMode: ctx?.toggleMode ?? (() => {}),
|
|
54
|
+
}),
|
|
55
|
+
[theme, ctx]
|
|
56
|
+
);
|
|
57
|
+
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Both hooks fall back to the light theme when no provider is mounted, so
|
|
61
|
+
// components never crash in isolation (tests, Storybook-style previews).
|
|
62
|
+
export function useTheme(): Theme {
|
|
63
|
+
const ctx = useContext(ThemeContext);
|
|
64
|
+
return ctx ? ctx.theme : lightTheme;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function useThemeMode() {
|
|
68
|
+
const ctx = useContext(ThemeContext);
|
|
69
|
+
if (ctx) return { mode: ctx.mode, setMode: ctx.setMode, toggleMode: ctx.toggleMode };
|
|
70
|
+
return { mode: "light" as ThemeMode, setMode: () => {}, toggleMode: () => {} };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Responsive layout switch. Works on native and web (react-native-web) via
|
|
75
|
+
* useWindowDimensions, so the same components pick desktop vs mobile layout
|
|
76
|
+
* from the viewport width — no CSS media queries needed.
|
|
77
|
+
*/
|
|
78
|
+
export function useResponsive() {
|
|
79
|
+
const { width, height } = useWindowDimensions();
|
|
80
|
+
return {
|
|
81
|
+
width,
|
|
82
|
+
height,
|
|
83
|
+
isDesktop: width >= DESKTOP_BREAKPOINT,
|
|
84
|
+
isMobile: width < DESKTOP_BREAKPOINT,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
3
|
+
import { ThemeProvider } from "../ThemeProvider";
|
|
4
|
+
import { ApplyChangesModal, type MergeProgress } from "../components/ApplyChangesModal";
|
|
5
|
+
|
|
6
|
+
const wrap = (ui: React.ReactElement) => <ThemeProvider>{ui}</ThemeProvider>;
|
|
7
|
+
|
|
8
|
+
const base: MergeProgress = {
|
|
9
|
+
status: "running",
|
|
10
|
+
step: "comparing",
|
|
11
|
+
sourceBranch: "draft/spring",
|
|
12
|
+
targetBranch: "main",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const branches = { sourceBranch: "draft/spring", targetBranch: "main" };
|
|
16
|
+
|
|
17
|
+
test("without a run it shows a confirmation step explaining the effects", () => {
|
|
18
|
+
const onConfirm = jest.fn();
|
|
19
|
+
render(wrap(<ApplyChangesModal {...branches} summary="3 collections changed" onConfirm={onConfirm} onClose={() => {}} />));
|
|
20
|
+
|
|
21
|
+
const confirm = screen.getByTestId("apply-confirm");
|
|
22
|
+
// The irreversible, outward-facing effects are spelled out.
|
|
23
|
+
expect(confirm).toHaveTextContent("merges draft/spring into main");
|
|
24
|
+
expect(confirm).toHaveTextContent("pushes a merge commit to origin/main");
|
|
25
|
+
expect(confirm).toHaveTextContent("draft/spring is then deleted");
|
|
26
|
+
expect(confirm).toHaveTextContent("can’t be undone");
|
|
27
|
+
expect(confirm).toHaveTextContent("3 collections changed");
|
|
28
|
+
|
|
29
|
+
// The checklist is not shown until the merge actually starts.
|
|
30
|
+
expect(screen.queryByTestId("merge-step-active")).not.toBeInTheDocument();
|
|
31
|
+
|
|
32
|
+
fireEvent.click(screen.getByTestId("apply-confirm-submit"));
|
|
33
|
+
expect(onConfirm).toHaveBeenCalledTimes(1);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("the checklist advances: earlier steps done, current active, later pending", () => {
|
|
37
|
+
render(wrap(<ApplyChangesModal {...branches} progress={{ ...base, step: "merging" }} onClose={() => {}} />));
|
|
38
|
+
|
|
39
|
+
// comparing is behind us → done; merging is current → active; pushing → pending.
|
|
40
|
+
expect(screen.getAllByTestId("merge-step-done")).toHaveLength(1);
|
|
41
|
+
expect(screen.getAllByTestId("merge-step-active")).toHaveLength(1);
|
|
42
|
+
expect(screen.getAllByTestId("merge-step-pending")).toHaveLength(1);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("the header shows source → target", () => {
|
|
46
|
+
render(wrap(<ApplyChangesModal {...branches} progress={base} onClose={() => {}} />));
|
|
47
|
+
expect(screen.getByText("draft/spring")).toBeInTheDocument();
|
|
48
|
+
expect(screen.getByText("main")).toBeInTheDocument();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("success shows the merge panel with commit and stats, and a Done button", () => {
|
|
52
|
+
const onDone = jest.fn();
|
|
53
|
+
render(
|
|
54
|
+
wrap(
|
|
55
|
+
<ApplyChangesModal
|
|
56
|
+
{...branches}
|
|
57
|
+
progress={{
|
|
58
|
+
...base,
|
|
59
|
+
status: "succeeded",
|
|
60
|
+
step: "pushing",
|
|
61
|
+
commit: "b7e1a2c9deadbeef",
|
|
62
|
+
filesChanged: 3,
|
|
63
|
+
added: 64,
|
|
64
|
+
removed: 6,
|
|
65
|
+
}}
|
|
66
|
+
onClose={() => {}}
|
|
67
|
+
onDone={onDone}
|
|
68
|
+
/>,
|
|
69
|
+
),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const panel = screen.getByTestId("merge-success");
|
|
73
|
+
expect(panel).toHaveTextContent("Merged draft/spring into main");
|
|
74
|
+
expect(panel).toHaveTextContent("b7e1a2c"); // short sha
|
|
75
|
+
expect(panel).toHaveTextContent("3 files");
|
|
76
|
+
expect(panel).toHaveTextContent("+64 −6");
|
|
77
|
+
|
|
78
|
+
// Every step reads as done on success.
|
|
79
|
+
expect(screen.getAllByTestId("merge-step-done")).toHaveLength(3);
|
|
80
|
+
expect(screen.getByTestId("apply-done")).toBeInTheDocument();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("needs_review lists the conflicting files and escalates with an explanation", () => {
|
|
84
|
+
const onCreateChangeRequest = jest.fn();
|
|
85
|
+
render(
|
|
86
|
+
wrap(
|
|
87
|
+
<ApplyChangesModal
|
|
88
|
+
{...branches}
|
|
89
|
+
progress={{ ...base, status: "needs_review", step: "merging", escalationFiles: ["src/app.go", "package.json"] }}
|
|
90
|
+
onCreateChangeRequest={onCreateChangeRequest}
|
|
91
|
+
onClose={() => {}}
|
|
92
|
+
/>,
|
|
93
|
+
),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const panel = screen.getByTestId("merge-needs-review");
|
|
97
|
+
expect(panel).toHaveTextContent("Code conflicts need a developer");
|
|
98
|
+
expect(screen.getByTestId("escalation-file-src/app.go")).toBeInTheDocument();
|
|
99
|
+
expect(screen.getByTestId("escalation-file-package.json")).toBeInTheDocument();
|
|
100
|
+
|
|
101
|
+
// The submit is disabled until an explanation is entered.
|
|
102
|
+
const submit = screen.getByTestId("create-change-request-submit");
|
|
103
|
+
fireEvent.click(submit);
|
|
104
|
+
expect(onCreateChangeRequest).not.toHaveBeenCalled();
|
|
105
|
+
|
|
106
|
+
fireEvent.change(screen.getByTestId("change-request-explanation"), { target: { value: "Updated pricing copy" } });
|
|
107
|
+
fireEvent.click(submit);
|
|
108
|
+
expect(onCreateChangeRequest).toHaveBeenCalledWith("Updated pricing copy");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("an open change request replaces the confirm step with a shareable link and view button", () => {
|
|
112
|
+
const onViewChangeRequest = jest.fn();
|
|
113
|
+
render(
|
|
114
|
+
wrap(
|
|
115
|
+
<ApplyChangesModal
|
|
116
|
+
{...branches}
|
|
117
|
+
changeRequestOpen
|
|
118
|
+
changeRequestUrl="https://github.com/acme/site/pull/42"
|
|
119
|
+
onViewChangeRequest={onViewChangeRequest}
|
|
120
|
+
onClose={() => {}}
|
|
121
|
+
/>,
|
|
122
|
+
),
|
|
123
|
+
);
|
|
124
|
+
const panel = screen.getByTestId("apply-cr-open");
|
|
125
|
+
expect(panel).toHaveTextContent("A change request is already open");
|
|
126
|
+
expect(panel).toHaveTextContent("Send them this link");
|
|
127
|
+
// The normal apply confirmation is not offered.
|
|
128
|
+
expect(screen.queryByTestId("apply-confirm-submit")).not.toBeInTheDocument();
|
|
129
|
+
// The PR link is shown read-only with a copy button.
|
|
130
|
+
expect(screen.getByTestId("change-request-link")).toHaveValue("https://github.com/acme/site/pull/42");
|
|
131
|
+
expect(screen.getByTestId("copy-change-request-link")).toBeInTheDocument();
|
|
132
|
+
fireEvent.click(screen.getByTestId("apply-view-change-request"));
|
|
133
|
+
expect(onViewChangeRequest).toHaveBeenCalledTimes(1);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("failure surfaces the error and a dismiss button", () => {
|
|
137
|
+
render(
|
|
138
|
+
wrap(
|
|
139
|
+
<ApplyChangesModal
|
|
140
|
+
{...branches}
|
|
141
|
+
progress={{ ...base, status: "failed", step: "pushing", error: "main moved while merging" }}
|
|
142
|
+
onClose={() => {}}
|
|
143
|
+
/>,
|
|
144
|
+
),
|
|
145
|
+
);
|
|
146
|
+
expect(screen.getByTestId("merge-error")).toHaveTextContent("main moved while merging");
|
|
147
|
+
expect(screen.getByTestId("apply-dismiss")).toBeInTheDocument();
|
|
148
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, fireEvent, screen } from "@testing-library/react";
|
|
3
|
+
import { ThemeProvider } from "../ThemeProvider";
|
|
4
|
+
import { BranchImportingScreen, BranchImportFailedScreen } from "../components/BranchImport";
|
|
5
|
+
|
|
6
|
+
const wrap = (ui: React.ReactElement) => <ThemeProvider>{ui}</ThemeProvider>;
|
|
7
|
+
|
|
8
|
+
test("the importing screen names the branch it is waiting on", () => {
|
|
9
|
+
render(wrap(<BranchImportingScreen branch="release/2.0" repository="acme/site" />));
|
|
10
|
+
expect(screen.getByTestId("branch-importing")).toBeInTheDocument();
|
|
11
|
+
expect(screen.getByText("Importing release/2.0")).toBeInTheDocument();
|
|
12
|
+
expect(screen.getByText(/acme\/site/)).toBeInTheDocument();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("a missing config reads as a setup step, not an error", () => {
|
|
16
|
+
render(
|
|
17
|
+
wrap(
|
|
18
|
+
<BranchImportFailedScreen
|
|
19
|
+
branch="main"
|
|
20
|
+
repository="acme/site"
|
|
21
|
+
error="no go-git-cms config found"
|
|
22
|
+
errorCode="config_not_found"
|
|
23
|
+
/>,
|
|
24
|
+
),
|
|
25
|
+
);
|
|
26
|
+
expect(screen.getByText("main has no CMS config yet")).toBeInTheDocument();
|
|
27
|
+
expect(screen.getByText(/go-git-cms\.yml/)).toBeInTheDocument();
|
|
28
|
+
// The raw message is redundant once the screen has said what to commit.
|
|
29
|
+
expect(screen.queryByTestId("branch-import-error")).not.toBeInTheDocument();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("any other failure shows the run's own message", () => {
|
|
33
|
+
render(wrap(<BranchImportFailedScreen branch="main" error="clone: permission denied" />));
|
|
34
|
+
expect(screen.getByText("Couldn’t import main")).toBeInTheDocument();
|
|
35
|
+
expect(screen.getByTestId("branch-import-error")).toHaveTextContent("clone: permission denied");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("check again is offered only when the host can re-read the state", () => {
|
|
39
|
+
const onRecheck = jest.fn();
|
|
40
|
+
const { rerender } = render(wrap(<BranchImportFailedScreen branch="main" errorCode="config_not_found" />));
|
|
41
|
+
expect(screen.queryByTestId("branch-import-recheck")).not.toBeInTheDocument();
|
|
42
|
+
|
|
43
|
+
rerender(wrap(<BranchImportFailedScreen branch="main" errorCode="config_not_found" onRecheck={onRecheck} />));
|
|
44
|
+
fireEvent.click(screen.getByTestId("branch-import-recheck"));
|
|
45
|
+
expect(onRecheck).toHaveBeenCalled();
|
|
46
|
+
});
|