mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
feat(analytics): report the Share tab publish flow (publish_file / artifact_publish_result / copy_publish_link) (#6651)
* feat(analytics): report the Share tab publish flow
The 0.18.0 unified Share panel made 'Publish this file for everyone' the
primary sharing path, but the whole chain was untracked: no click event,
no outcome event, no copy-link event. This wires the three points:
- ui_click element 'publish_file' when the publish button is clicked
- artifact_publish_result (new event, mirroring the artifact_deploy_result
split) when a publish/unpublish attempt settles, with action/result/
error_code/publish_duration_ms
- ui_click element 'copy_publish_link' on the post-publish copy button
Both viewer copies (ReactComponentViewer, HtmlViewer) report the same
shape; ReactComponentViewer has no projectKind prop so it reports
project_kind null. Failure codes are stable analytics values
(workspace_identity_required | publish_failed), not i18n keys.
* fix(analytics): gate HtmlViewer publish tracking on workspaceActive and close the error_code type
- Wrap the new publish/copy-link/result emissions in HtmlViewer behind
fire*-style helpers that early-return on !workspaceActive, matching every
neighboring emission site; the publish/unpublish network calls stay
unconditional. ReactComponentViewer has no workspaceActive prop and is
unchanged.
- Declare TrackingPublishErrorCode as a closed two-value union in
contracts shared-enums, use it for ArtifactPublishResultProps.error_code,
and import it from publish-error-code.ts so the helper and contract
cannot drift.
- Extend the publish-tracking suite with unpublish success/failed cases and
a ReactComponentViewer case asserting project_kind: null.
Generated-By: looper 0.11.8 (runner=fixer, agent=claude-code)
* fix(analytics): report the real project kind from ReactComponentViewer publish events
ReactComponentViewer's publishTrackingIdentity() hard-coded
project_kind: null, so every publish_file, copy_publish_link, and
artifact_publish_result emitted for a React artifact lost project-kind
attribution — even though FileViewer requires Props.projectKind and had
the value in scope at the render site.
Thread projectKind into ReactComponentViewer and return it from
publishTrackingIdentity(), matching the HtmlViewer copy so both viewers
really do report the same shape.
The React-path test now asserts project_kind 'prototype' on both the
publish click and the publish result, and additionally covers the
copy_publish_link click that previously had no React-path assertion.
Generated-By: looper 0.11.8 (runner=fixer, agent=claude-code)
* fix(analytics): read the live workspaceActive ref when a publish settles
firePublishResult closed over the workspaceActive prop captured by the
render that created the click handler. A publish/unpublish request can
start while the HtmlViewer is active and settle after the user switches
tabs; the in-flight continuation still saw the render-time `true`, so a
retained (inert) viewer emitted artifact_publish_result anyway — exactly
what the gate added in ff77c14 promised it would not do.
Read workspaceActiveRef.current, the live value the component already
maintains, instead of the captured prop. firePublishFlowClick keeps the
prop: it only runs inside a synchronous click handler, where the two
values cannot diverge.
Regression test drives a gated publish request, rerenders the viewer with
workspaceActive: false, then settles the request and asserts no
artifact_publish_result is recorded. Verified red on the prop-reading
version and green on this one.
Generated-By: looper 0.11.8 (runner=fixer, agent=claude-code)
* fix(analytics): derive publish artifact_kind from the renderer id
Both publishTrackingIdentity() helpers passed only file.kind to
artifactKindToTracking. That mapper recognises HTML solely through the
renderer id — file.kind 'html' (HtmlViewer) and 'code'
(ReactComponentViewer) both fall through to 'unknown'. Every new
publish_file, copy_publish_link, and artifact_publish_result therefore
shipped artifact_kind: 'unknown', so the funnel this PR exists to create
could not be segmented by artifact kind at all. QA confirmed the same
'unknown' payloads reaching the test ingest sink.
Supply the renderer identity each helper already knows statically:
ReactComponentViewer is only rendered on the 'react-component' renderer
match, and HtmlViewer is only rendered on the 'html' / 'deck-html'
matches — which is exactly what its isDeck prop is derived from. Both now
map to 'html'.
Scoped to the two helpers this PR added; the pre-existing fileKind-only
call sites elsewhere in FileViewer are left untouched.
Assertions for artifact_kind: 'html' added to the HTML and React paths in
FileViewer.publish-tracking.test.tsx. Verified red without the source
change (both cases report 'unknown') and green with it; full web suite
6309 passed, pnpm guard and pnpm typecheck clean.
Generated-By: looper 0.11.8 (runner=fixer, agent=claude-code)
* test(analytics): follow the Share panel's publish row after the header split
#6654 removed the Share popover's tab strip and relabelled the publish
entry: the row is now a `role="menuitem"` labelled by
`fileViewer.publishSingleFileTitle` ("Get a share link", was "Publish this
file for everyone"), and the unpublish control reads "Stop sharing" (was
"Unpublish file"). The publish-tracking suite still drove the pre-split
labels, so every case failed to find its control after the rebase.
Only the queries move — which control fires which event is unchanged, and
the payload assertions are untouched.
* fix(analytics): gate ReactComponentViewer publish results on the live active view
`FileWorkspace` keeps a non-active viewer mounted and re-renders it with
`workspaceActive={false}`, but `FileViewer` never forwarded that prop to
`ReactComponentViewer`. A React publish/unpublish started while the tab was
active and settling after the user switched away therefore still reported
`artifact_publish_result` from the hidden viewer, attributing the outcome to
whatever tab the user had moved to.
Thread `workspaceActive` into `ReactComponentViewer` and route both click and
result emissions through the same helper pair HtmlViewer already uses: clicks
read the render-time prop, results read a live ref so the in-flight
continuation sees the current value rather than the one it captured.
Red spec: the new `(ReactComponentViewer)` case in
`FileViewer.publish-tracking.test.tsx` — it fails on the previous source
(`expected [ { page_name: 'artifact', … } ] to deeply equal []`) and passes
with this change.
* test(analytics): wait for the publish panel to settle instead of timer turns
The inactive-viewer regression released `publishGate` and then assumed two
`setTimeout(0)` turns were enough for the handler continuation to run before
the negative assertion — turns that are not tied to the operation, so a
different scheduler could let the assertion win the race and pass falsely.
Wait for the operation's own completion signal instead. `publishingPublicFile`
is cleared in the handler's `finally`, strictly after the point where the
result event would have been emitted, so the panel leaving its "Creating link…"
state proves the continuation ran past the emission site. A retained viewer
renders no chrome, so the assertion switches back to active first — the
inactive window has already closed by then, and an event emitted during it
would still have been recorded.
Settled looks different per chrome, and both shapes are accepted: HtmlViewer
discards the published URL at the request-identity guard that follows the
emission site and returns to the idle publish row, while ReactComponentViewer
commits it and swaps in the copy-link control.
Still red without the source gate (`ReactComponentViewer` case fails in 45ms
with `expected [ { page_name: 'artifact', … } ] to deeply equal []`), green
with it.
---------
Co-authored-by: elifive555555 <296440099+elifive555555@users.noreply.github.com>
This commit is contained in:
co-authored by
elifive555555
parent
bf71452807
commit
abbc7cbe3b
@@ -137,6 +137,7 @@ import type {
|
||||
SpeakerNotesSaveResultProps,
|
||||
ArtifactExportResultProps,
|
||||
ArtifactDeployResultProps,
|
||||
ArtifactPublishResultProps,
|
||||
SketchSaveResultProps,
|
||||
SketchExportResultProps,
|
||||
FeedbackSubmitResultProps,
|
||||
@@ -1175,6 +1176,13 @@ export function trackArtifactDeployResult(
|
||||
send(track, 'artifact_deploy_result', props, options);
|
||||
}
|
||||
|
||||
export function trackArtifactPublishResult(
|
||||
track: Track,
|
||||
props: ArtifactPublishResultProps,
|
||||
): void {
|
||||
send(track, 'artifact_publish_result', props);
|
||||
}
|
||||
|
||||
export function trackFileVersionRestoreResult(
|
||||
track: Track,
|
||||
props: FileVersionRestoreResultProps,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Classify a failed publish/unpublish error into a stable, queryable analytics
|
||||
* code for `artifact_publish_result.error_code`.
|
||||
*
|
||||
* The publish flow already routes user-facing copy through
|
||||
* `publicFilePublishFailureKey` (apps/web/src/collab/public-file-publish.ts),
|
||||
* but that returns an i18n MESSAGE KEY — screen copy, not a metric value.
|
||||
* Analytics wants a code that stays stable when the copy key is renamed, so
|
||||
* this maps the one actionable failure (workspace identity could not be
|
||||
* confirmed) to its own code and buckets everything else. Only FIXED codes are
|
||||
* emitted — no message text can leak into analytics.
|
||||
*
|
||||
* Mirrors apps/web/src/analytics/deploy-error-code.ts (issue-#5220 pattern).
|
||||
*/
|
||||
|
||||
import type { TrackingPublishErrorCode } from '@open-design/contracts/analytics';
|
||||
|
||||
import { publicFilePublishFailureKey } from '../collab/public-file-publish';
|
||||
|
||||
export function publishErrorCode(err: unknown): TrackingPublishErrorCode {
|
||||
return publicFilePublishFailureKey(err) === 'fileViewer.publishFileRequiresWorkspace'
|
||||
? 'workspace_identity_required'
|
||||
: 'publish_failed';
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import {
|
||||
anonymizeArtifactId,
|
||||
artifactKindToTracking,
|
||||
type ArtifactPublishResultProps,
|
||||
type TrackingFileVersionSource,
|
||||
type TrackingArtifactKind,
|
||||
type TrackingProjectKind,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
import { useAnalytics } from '../analytics/provider';
|
||||
import { exportErrorCode } from '../analytics/export-error-code';
|
||||
import { deployErrorCode } from '../analytics/deploy-error-code';
|
||||
import { publishErrorCode } from '../analytics/publish-error-code';
|
||||
import {
|
||||
reportPreviewIframeMessage,
|
||||
subscribePreviewIframeMessages,
|
||||
@@ -44,6 +46,7 @@ import {
|
||||
import {
|
||||
trackArtifactExportResult,
|
||||
trackArtifactDeployResult,
|
||||
trackArtifactPublishResult,
|
||||
trackArtifactHeaderClick,
|
||||
trackArtifactToolbarClick,
|
||||
trackCommentPopoverClick,
|
||||
@@ -1897,6 +1900,7 @@ export const FileViewer = memo(function FileViewer({
|
||||
return (
|
||||
<ReactComponentViewer
|
||||
projectId={projectId}
|
||||
projectKind={projectKind}
|
||||
file={file}
|
||||
onOpenFileReplacing={onOpenFileReplacing}
|
||||
projectName={projectName}
|
||||
@@ -1907,6 +1911,7 @@ export const FileViewer = memo(function FileViewer({
|
||||
metricsConsent={metricsConsent}
|
||||
installationId={installationId}
|
||||
viewerOnly={viewerOnly}
|
||||
workspaceActive={workspaceActive}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -6298,6 +6303,7 @@ function ReactModulePointer({
|
||||
|
||||
function ReactComponentViewer({
|
||||
projectId,
|
||||
projectKind,
|
||||
file,
|
||||
onOpenFileReplacing,
|
||||
projectName,
|
||||
@@ -6308,8 +6314,10 @@ function ReactComponentViewer({
|
||||
metricsConsent = false,
|
||||
installationId,
|
||||
viewerOnly = false,
|
||||
workspaceActive = true,
|
||||
}: {
|
||||
projectId: string;
|
||||
projectKind: TrackingProjectKind;
|
||||
file: ProjectFile;
|
||||
onOpenFileReplacing?: (openName: string, closeName: string) => void;
|
||||
projectName?: string;
|
||||
@@ -6320,8 +6328,16 @@ function ReactComponentViewer({
|
||||
metricsConsent?: boolean;
|
||||
installationId?: string | null;
|
||||
viewerOnly?: boolean;
|
||||
workspaceActive?: boolean;
|
||||
}) {
|
||||
const t = useT();
|
||||
const analytics = useAnalytics();
|
||||
// `FileWorkspace` keeps a non-active viewer mounted, so an in-flight publish
|
||||
// can settle after the user has switched away. The ref carries the LIVE value
|
||||
// into those continuations; the captured prop would still read the
|
||||
// render-time `true`.
|
||||
const workspaceActiveRef = useRef(workspaceActive);
|
||||
workspaceActiveRef.current = workspaceActive;
|
||||
const { workspaceContext } = useProjectCollabContext();
|
||||
const [mode, setMode] = useState<'preview' | 'source'>('preview');
|
||||
const [source, setSource] = useState<string | null>(null);
|
||||
@@ -6490,16 +6506,70 @@ function ReactComponentViewer({
|
||||
// render as unpublished.
|
||||
}, [projectId, file.name, canPublishPublic, viewerOnly]);
|
||||
|
||||
// Shared identity fields for the publish-flow events (ReactComponentViewer copy).
|
||||
// `artifactKindToTracking` only recognises HTML through the renderer id — a React
|
||||
// component's `file.kind` is `code`, which would degrade to `unknown` — and this
|
||||
// viewer is reached only through the `react-component` renderer match, so its
|
||||
// renderer identity is a constant.
|
||||
function publishTrackingIdentity() {
|
||||
return {
|
||||
artifact_id: anonymizeArtifactId({ projectId, fileName: file.name }),
|
||||
artifact_kind: artifactKindToTracking({
|
||||
rendererId: 'react-component',
|
||||
fileKind: file.kind ?? null,
|
||||
}),
|
||||
project_id: projectId,
|
||||
project_kind: projectKind,
|
||||
} as const;
|
||||
}
|
||||
|
||||
// Retained (inert) viewers must never report analytics — same rule the
|
||||
// HtmlViewer copy of this flow follows. Only the tracking is gated; the
|
||||
// publish/unpublish calls themselves stay unconditional.
|
||||
const firePublishFlowClick = (element: 'publish_file' | 'copy_publish_link') => {
|
||||
if (!workspaceActive) return;
|
||||
trackShareOptionPopoverClick(analytics.track, {
|
||||
page_name: 'artifact',
|
||||
area: 'share_option_popover',
|
||||
element,
|
||||
...publishTrackingIdentity(),
|
||||
});
|
||||
};
|
||||
|
||||
const firePublishResult = (
|
||||
outcome: Pick<
|
||||
ArtifactPublishResultProps,
|
||||
'action' | 'result' | 'error_code' | 'publish_duration_ms'
|
||||
>,
|
||||
) => {
|
||||
// Read the live ref, not the captured prop: a request can start while this
|
||||
// viewer is active and settle after the user switches tabs.
|
||||
if (!workspaceActiveRef.current) return;
|
||||
trackArtifactPublishResult(analytics.track, {
|
||||
page_name: 'artifact',
|
||||
area: 'share_option_popover',
|
||||
...outcome,
|
||||
...publishTrackingIdentity(),
|
||||
});
|
||||
};
|
||||
|
||||
async function publishCurrentFilePublic() {
|
||||
if (viewerOnly || publishingPublicFile) return;
|
||||
const requestProjectId = projectId;
|
||||
const requestFileName = file.name;
|
||||
const requestSeq = ++publicFileRequestSeqRef.current;
|
||||
firePublishFlowClick('publish_file');
|
||||
const publishStarted = performance.now();
|
||||
setPublishingPublicFile(true);
|
||||
setPublishLinkFeedback(null);
|
||||
setPublishFailureKey(null);
|
||||
try {
|
||||
const response = await publishProjectFilePublic(requestProjectId, requestFileName, workspaceContext);
|
||||
firePublishResult({
|
||||
action: 'publish',
|
||||
result: 'success',
|
||||
publish_duration_ms: Math.round(performance.now() - publishStarted),
|
||||
});
|
||||
const current = publicFileIdentityRef.current;
|
||||
if (
|
||||
publicFileRequestSeqRef.current !== requestSeq ||
|
||||
@@ -6512,6 +6582,12 @@ function ReactComponentViewer({
|
||||
setPublishedFileSlug(response.slug);
|
||||
} catch (error) {
|
||||
console.warn('[FileViewer] failed to publish public file', error);
|
||||
firePublishResult({
|
||||
action: 'publish',
|
||||
result: 'failed',
|
||||
error_code: publishErrorCode(error),
|
||||
publish_duration_ms: Math.round(performance.now() - publishStarted),
|
||||
});
|
||||
if (publicFileRequestSeqRef.current === requestSeq) {
|
||||
setPublishLinkFeedback('failed');
|
||||
setPublishFailureKey(publicFilePublishFailureKey(error));
|
||||
@@ -6527,11 +6603,17 @@ function ReactComponentViewer({
|
||||
const requestFileName = file.name;
|
||||
const requestSlug = publishedFileSlug;
|
||||
const requestSeq = ++publicFileRequestSeqRef.current;
|
||||
const unpublishStarted = performance.now();
|
||||
setPublishingPublicFile(true);
|
||||
setPublishLinkFeedback(null);
|
||||
setPublishFailureKey(null);
|
||||
try {
|
||||
await unpublishProjectFilePublic(requestProjectId, requestFileName, requestSlug, workspaceContext);
|
||||
firePublishResult({
|
||||
action: 'unpublish',
|
||||
result: 'success',
|
||||
publish_duration_ms: Math.round(performance.now() - unpublishStarted),
|
||||
});
|
||||
const current = publicFileIdentityRef.current;
|
||||
if (
|
||||
publicFileRequestSeqRef.current !== requestSeq ||
|
||||
@@ -6544,6 +6626,12 @@ function ReactComponentViewer({
|
||||
setPublishedFileSlug('');
|
||||
} catch (error) {
|
||||
console.warn('[FileViewer] failed to unpublish public file', error);
|
||||
firePublishResult({
|
||||
action: 'unpublish',
|
||||
result: 'failed',
|
||||
error_code: publishErrorCode(error),
|
||||
publish_duration_ms: Math.round(performance.now() - unpublishStarted),
|
||||
});
|
||||
if (publicFileRequestSeqRef.current === requestSeq) {
|
||||
setPublishLinkFeedback('failed');
|
||||
setPublishFailureKey(publicFilePublishFailureKey(error));
|
||||
@@ -6554,6 +6642,7 @@ function ReactComponentViewer({
|
||||
}
|
||||
|
||||
async function copyPublishedFileLink() {
|
||||
firePublishFlowClick('copy_publish_link');
|
||||
let ok = false;
|
||||
try {
|
||||
if (publishedFileUrl && typeof navigator !== 'undefined' && navigator.clipboard) {
|
||||
@@ -7843,16 +7932,71 @@ function HtmlViewer({
|
||||
sourceAuthorizationScopeKey,
|
||||
]);
|
||||
|
||||
// Shared identity fields for the publish-flow events (HtmlViewer copy).
|
||||
// `artifactKindToTracking` only recognises HTML through the renderer id — an HTML
|
||||
// artifact's `file.kind` is `html`, which would degrade to `unknown` — and this
|
||||
// viewer is reached only through the `html` / `deck-html` renderer matches, which
|
||||
// is exactly what the `isDeck` prop is derived from.
|
||||
function publishTrackingIdentity() {
|
||||
return {
|
||||
artifact_id: anonymizeArtifactId({ projectId, fileName: file.name }),
|
||||
artifact_kind: artifactKindToTracking({
|
||||
rendererId: isDeck ? 'deck-html' : 'html',
|
||||
fileKind: file.kind ?? null,
|
||||
}),
|
||||
project_id: projectId,
|
||||
project_kind: projectKind,
|
||||
} as const;
|
||||
}
|
||||
|
||||
// Background (inert) HtmlViewer instances must never report analytics, same
|
||||
// as every other emission site in this component. Only the tracking is
|
||||
// gated — the publish/unpublish calls themselves stay unconditional.
|
||||
const firePublishFlowClick = (element: 'publish_file' | 'copy_publish_link') => {
|
||||
if (!workspaceActive) return;
|
||||
trackShareOptionPopoverClick(analytics.track, {
|
||||
page_name: 'artifact',
|
||||
area: 'share_option_popover',
|
||||
element,
|
||||
...publishTrackingIdentity(),
|
||||
});
|
||||
};
|
||||
|
||||
const firePublishResult = (
|
||||
outcome: Pick<
|
||||
ArtifactPublishResultProps,
|
||||
'action' | 'result' | 'error_code' | 'publish_duration_ms'
|
||||
>,
|
||||
) => {
|
||||
// Read the live ref, not the captured prop: a publish/unpublish request can
|
||||
// start while this viewer is active and settle after the user switches tabs,
|
||||
// and the in-flight continuation still holds the render-time `true`.
|
||||
if (!workspaceActiveRef.current) return;
|
||||
trackArtifactPublishResult(analytics.track, {
|
||||
page_name: 'artifact',
|
||||
area: 'share_option_popover',
|
||||
...outcome,
|
||||
...publishTrackingIdentity(),
|
||||
});
|
||||
};
|
||||
|
||||
async function publishCurrentFilePublic() {
|
||||
if (viewerOnly || publishingPublicFile) return;
|
||||
const requestProjectId = projectId;
|
||||
const requestFileName = file.name;
|
||||
const requestSeq = ++publicFileRequestSeqRef.current;
|
||||
firePublishFlowClick('publish_file');
|
||||
const publishStarted = performance.now();
|
||||
setPublishingPublicFile(true);
|
||||
setPublishLinkFeedback(null);
|
||||
setPublishFailureKey(null);
|
||||
try {
|
||||
const response = await publishProjectFilePublic(requestProjectId, requestFileName, workspaceContext);
|
||||
firePublishResult({
|
||||
action: 'publish',
|
||||
result: 'success',
|
||||
publish_duration_ms: Math.round(performance.now() - publishStarted),
|
||||
});
|
||||
const current = publicFileIdentityRef.current;
|
||||
if (
|
||||
publicFileRequestSeqRef.current !== requestSeq ||
|
||||
@@ -7865,6 +8009,12 @@ function HtmlViewer({
|
||||
setPublishedFileSlug(response.slug);
|
||||
} catch (error) {
|
||||
console.warn('[FileViewer] failed to publish public file', error);
|
||||
firePublishResult({
|
||||
action: 'publish',
|
||||
result: 'failed',
|
||||
error_code: publishErrorCode(error),
|
||||
publish_duration_ms: Math.round(performance.now() - publishStarted),
|
||||
});
|
||||
if (publicFileRequestSeqRef.current === requestSeq) {
|
||||
setPublishLinkFeedback('failed');
|
||||
setPublishFailureKey(publicFilePublishFailureKey(error));
|
||||
@@ -7880,11 +8030,17 @@ function HtmlViewer({
|
||||
const requestFileName = file.name;
|
||||
const requestSlug = publishedFileSlug;
|
||||
const requestSeq = ++publicFileRequestSeqRef.current;
|
||||
const unpublishStarted = performance.now();
|
||||
setPublishingPublicFile(true);
|
||||
setPublishLinkFeedback(null);
|
||||
setPublishFailureKey(null);
|
||||
try {
|
||||
await unpublishProjectFilePublic(requestProjectId, requestFileName, requestSlug, workspaceContext);
|
||||
firePublishResult({
|
||||
action: 'unpublish',
|
||||
result: 'success',
|
||||
publish_duration_ms: Math.round(performance.now() - unpublishStarted),
|
||||
});
|
||||
const current = publicFileIdentityRef.current;
|
||||
if (
|
||||
publicFileRequestSeqRef.current !== requestSeq ||
|
||||
@@ -7897,6 +8053,12 @@ function HtmlViewer({
|
||||
setPublishedFileSlug('');
|
||||
} catch (error) {
|
||||
console.warn('[FileViewer] failed to unpublish public file', error);
|
||||
firePublishResult({
|
||||
action: 'unpublish',
|
||||
result: 'failed',
|
||||
error_code: publishErrorCode(error),
|
||||
publish_duration_ms: Math.round(performance.now() - unpublishStarted),
|
||||
});
|
||||
if (publicFileRequestSeqRef.current === requestSeq) {
|
||||
setPublishLinkFeedback('failed');
|
||||
setPublishFailureKey(publicFilePublishFailureKey(error));
|
||||
@@ -7907,6 +8069,7 @@ function HtmlViewer({
|
||||
}
|
||||
|
||||
async function copyPublishedFileLink() {
|
||||
firePublishFlowClick('copy_publish_link');
|
||||
let ok = false;
|
||||
try {
|
||||
if (publishedFileUrl && typeof navigator !== 'undefined' && navigator.clipboard) {
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
// The Share tab's "Publish this file for everyone" flow reports three
|
||||
// analytics points: the publish button click (`ui_click` element
|
||||
// `publish_file`), the settled outcome (`artifact_publish_result` with
|
||||
// action/result/error_code), and the post-publish copy-link click (`ui_click`
|
||||
// element `copy_publish_link`). These are the numerator inputs for the 产物导出率
|
||||
// metric once publish success joins it, so a silent regression here would make
|
||||
// the metric under-count real sharing again.
|
||||
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ComponentProps } from 'react';
|
||||
import {
|
||||
buildWorkspacePermissions,
|
||||
buildWorkspaceSeatSummary,
|
||||
type WorkspaceCollabContext,
|
||||
} from '@open-design/contracts';
|
||||
|
||||
import { FileViewer } from '../../src/components/FileViewer';
|
||||
import {
|
||||
CollabProvider,
|
||||
type CollabContextValue,
|
||||
} from '../../src/collab/collab-context';
|
||||
import type { ProjectFile } from '../../src/types';
|
||||
|
||||
const analytics = vi.hoisted(() => ({
|
||||
track: vi.fn(),
|
||||
newRequestId: vi.fn(() => 'request-publish-1'),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/analytics/provider', () => ({
|
||||
useAnalytics: () => ({
|
||||
track: analytics.track,
|
||||
newRequestId: analytics.newRequestId,
|
||||
}),
|
||||
}));
|
||||
|
||||
function teamWorkspaceContext(): WorkspaceCollabContext {
|
||||
return {
|
||||
workspaceId: 'ws-1',
|
||||
workspaceType: 'team',
|
||||
teamId: 'team-1',
|
||||
workspaceMemberId: 'wm-1',
|
||||
role: 'member',
|
||||
memberStatus: 'active',
|
||||
lifecycleState: 'active',
|
||||
billingState: 'active',
|
||||
planId: null,
|
||||
providerMode: 'platform_credits',
|
||||
seatSummary: buildWorkspaceSeatSummary({ seatLimit: 5, usedSeats: 1 }),
|
||||
permissions: buildWorkspacePermissions({ role: 'member', lifecycleState: 'active' }),
|
||||
};
|
||||
}
|
||||
|
||||
function htmlFile(): ProjectFile {
|
||||
return {
|
||||
name: 'index.html',
|
||||
path: 'index.html',
|
||||
type: 'file',
|
||||
size: 1024,
|
||||
mtime: 1710000000,
|
||||
kind: 'html',
|
||||
mime: 'text/html',
|
||||
artifactManifest: {
|
||||
version: 1,
|
||||
kind: 'html',
|
||||
title: 'Page',
|
||||
entry: 'index.html',
|
||||
renderer: 'html',
|
||||
exports: ['html'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function renderProjectFileViewer(
|
||||
context: WorkspaceCollabContext,
|
||||
props: ComponentProps<typeof FileViewer>,
|
||||
) {
|
||||
const collab: CollabContextValue = {
|
||||
workspaceContext: context,
|
||||
workspaceContextLoading: false,
|
||||
enabled: true,
|
||||
member: null,
|
||||
present: [],
|
||||
publishedVersion: null,
|
||||
syncState: null,
|
||||
viewerOnly: false,
|
||||
writerAuthority: 'allowed',
|
||||
isOwner: true,
|
||||
isEffectiveOwner: true,
|
||||
isSharedNonOwner: false,
|
||||
ownerDisplayName: null,
|
||||
ownerRole: null,
|
||||
downloadPending: false,
|
||||
reportChange: () => {},
|
||||
requestPublish: () => {},
|
||||
refreshPresence: () => {},
|
||||
checkStatusNow: () => {},
|
||||
};
|
||||
const tree = (next: ComponentProps<typeof FileViewer>) => (
|
||||
<CollabProvider value={collab}>
|
||||
<FileViewer {...next} />
|
||||
</CollabProvider>
|
||||
);
|
||||
const result = render(tree(props));
|
||||
return {
|
||||
...result,
|
||||
rerenderWith: (next: ComponentProps<typeof FileViewer>) => result.rerender(tree(next)),
|
||||
};
|
||||
}
|
||||
|
||||
function stubFetch(
|
||||
options: { publishStatus?: number; publishBody?: unknown; unpublishStatus?: number } = {},
|
||||
) {
|
||||
const { publishStatus = 200, publishBody, unpublishStatus = 200 } = options;
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input.toString();
|
||||
if (url.includes('/api/workspace/context')) {
|
||||
return new Response(JSON.stringify({ context: teamWorkspaceContext() }), { status: 200 });
|
||||
}
|
||||
if (url.includes('publish-public')) {
|
||||
if (init?.method === 'POST') {
|
||||
const body =
|
||||
publishBody ??
|
||||
(publishStatus === 200
|
||||
? { url: 'https://open-design.ai/p/slug-1', slug: 'slug-1', fileName: 'index.html' }
|
||||
: { error: { message: 'WORKSPACE_IDENTITY_REQUIRED' } });
|
||||
return new Response(JSON.stringify(body), { status: publishStatus });
|
||||
}
|
||||
if (init?.method === 'DELETE') {
|
||||
const body =
|
||||
unpublishStatus === 200
|
||||
? { ok: true, slug: 'slug-1', fileName: 'index.html' }
|
||||
: { error: { message: 'WORKSPACE_IDENTITY_REQUIRED' } };
|
||||
return new Response(JSON.stringify(body), { status: unpublishStatus });
|
||||
}
|
||||
return new Response(JSON.stringify({ publication: null }), { status: 200 });
|
||||
}
|
||||
return new Response(JSON.stringify({ deployments: [] }), { status: 200 });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
function trackedEvents(name: string): Record<string, unknown>[] {
|
||||
return analytics.track.mock.calls
|
||||
.filter(([event]) => event === name)
|
||||
.map(([, props]) => props as Record<string, unknown>);
|
||||
}
|
||||
|
||||
function reactComponentFile(): ProjectFile {
|
||||
return {
|
||||
name: 'App.jsx',
|
||||
path: 'App.jsx',
|
||||
type: 'file',
|
||||
size: 512,
|
||||
mtime: 1710000000,
|
||||
kind: 'code',
|
||||
mime: 'text/javascript',
|
||||
artifactManifest: {
|
||||
version: 1,
|
||||
kind: 'react-component',
|
||||
title: 'App',
|
||||
entry: 'App.jsx',
|
||||
renderer: 'react-component',
|
||||
exports: ['jsx'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// The publish trigger is the Share panel's `role="menuitem"` row labelled by
|
||||
// `fileViewer.publishSingleFileTitle`; once published it is replaced by the
|
||||
// copy-link / `fileViewer.unpublishFile` pair.
|
||||
const PUBLISH_ROW = /get a share link/i;
|
||||
const UNPUBLISH_ROW = /stop sharing/i;
|
||||
// `fileViewer.publishingFile` — the row's in-flight label, and therefore the
|
||||
// state the publish handler leaves behind only once its `finally` has run.
|
||||
const BUSY_PUBLISH_ROW = /creating link/i;
|
||||
// Either settled shape of the panel: the idle publish row, or the copy-link
|
||||
// control that replaces it once a published URL is committed.
|
||||
const SETTLED_PUBLISH_PANEL = /get a share link|copy share link/i;
|
||||
|
||||
async function openPublishPanel() {
|
||||
renderProjectFileViewer(teamWorkspaceContext(), {
|
||||
projectId: 'project-pub',
|
||||
projectKind: 'prototype',
|
||||
file: htmlFile(),
|
||||
liveHtml: '<html><body><h1>Hello</h1></body></html>',
|
||||
});
|
||||
const shareButton = await screen.findByRole('button', { name: /^share$/i });
|
||||
fireEvent.click(shareButton);
|
||||
return await screen.findByRole('menuitem', { name: PUBLISH_ROW });
|
||||
}
|
||||
|
||||
// Same flow through the ReactComponentViewer copy of the publish card, which
|
||||
// is hand-duplicated from HtmlViewer and can regress independently.
|
||||
async function openReactComponentPublishPanel() {
|
||||
renderProjectFileViewer(teamWorkspaceContext(), {
|
||||
projectId: 'project-pub',
|
||||
projectKind: 'prototype',
|
||||
file: reactComponentFile(),
|
||||
});
|
||||
const shareButton = await screen.findByRole('button', { name: /^share$/i });
|
||||
fireEvent.click(shareButton);
|
||||
return await screen.findByRole('menuitem', { name: PUBLISH_ROW });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
analytics.track.mockReset();
|
||||
analytics.newRequestId.mockClear();
|
||||
});
|
||||
|
||||
describe('publish flow analytics', () => {
|
||||
it('reports the publish click, the success result, and the copy-link click', async () => {
|
||||
stubFetch();
|
||||
const publishButton = await openPublishPanel();
|
||||
fireEvent.click(publishButton);
|
||||
|
||||
expect(trackedEvents('ui_click')).toContainEqual(
|
||||
expect.objectContaining({
|
||||
area: 'share_option_popover',
|
||||
element: 'publish_file',
|
||||
project_id: 'project-pub',
|
||||
// Derived from the `html` renderer, not from `file.kind` (which maps to `unknown`).
|
||||
artifact_kind: 'html',
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(trackedEvents('artifact_publish_result')).toContainEqual(
|
||||
expect.objectContaining({
|
||||
action: 'publish',
|
||||
result: 'success',
|
||||
publish_duration_ms: expect.any(Number),
|
||||
artifact_kind: 'html',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const copyButton = await screen.findByRole('button', { name: /copy share link/i });
|
||||
fireEvent.click(copyButton);
|
||||
expect(trackedEvents('ui_click')).toContainEqual(
|
||||
expect.objectContaining({
|
||||
area: 'share_option_popover',
|
||||
element: 'copy_publish_link',
|
||||
artifact_kind: 'html',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a failed publish with the workspace-identity error code', async () => {
|
||||
stubFetch({ publishStatus: 403 });
|
||||
const publishButton = await openPublishPanel();
|
||||
fireEvent.click(publishButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(trackedEvents('artifact_publish_result')).toContainEqual(
|
||||
expect.objectContaining({
|
||||
action: 'publish',
|
||||
result: 'failed',
|
||||
error_code: 'workspace_identity_required',
|
||||
}),
|
||||
);
|
||||
});
|
||||
// A failed publish must not report a success or render the copy-link state.
|
||||
expect(
|
||||
trackedEvents('artifact_publish_result').some((p) => p.result === 'success'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('reports a successful unpublish as action unpublish', async () => {
|
||||
stubFetch();
|
||||
const publishButton = await openPublishPanel();
|
||||
fireEvent.click(publishButton);
|
||||
|
||||
const unpublishButton = await screen.findByRole('button', { name: UNPUBLISH_ROW });
|
||||
fireEvent.click(unpublishButton);
|
||||
await waitFor(() => {
|
||||
expect(trackedEvents('artifact_publish_result')).toContainEqual(
|
||||
expect.objectContaining({
|
||||
action: 'unpublish',
|
||||
result: 'success',
|
||||
publish_duration_ms: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a failed unpublish with the workspace-identity error code', async () => {
|
||||
stubFetch({ unpublishStatus: 403 });
|
||||
const publishButton = await openPublishPanel();
|
||||
fireEvent.click(publishButton);
|
||||
|
||||
const unpublishButton = await screen.findByRole('button', { name: UNPUBLISH_ROW });
|
||||
fireEvent.click(unpublishButton);
|
||||
await waitFor(() => {
|
||||
expect(trackedEvents('artifact_publish_result')).toContainEqual(
|
||||
expect.objectContaining({
|
||||
action: 'unpublish',
|
||||
result: 'failed',
|
||||
error_code: 'workspace_identity_required',
|
||||
}),
|
||||
);
|
||||
});
|
||||
// The failed unpublish must not clear the published state's success record.
|
||||
expect(
|
||||
trackedEvents('artifact_publish_result').filter((p) => p.action === 'unpublish' && p.result === 'success'),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
// A publish request can start while this viewer is active and settle after the
|
||||
// user has switched to another tab. The retained (inert) viewer must stay
|
||||
// silent, so the guard has to read the live `workspaceActive` ref rather than
|
||||
// the value the in-flight closure captured at click time. `FileWorkspace`
|
||||
// retains BOTH viewer chromes, so both copies of the flow need the guard.
|
||||
function inactiveViewerCase(props: ComponentProps<typeof FileViewer>, label: string) {
|
||||
it(`stays silent when the viewer goes inactive before the request settles (${label})`, async () => {
|
||||
let settlePublish: (() => void) | undefined;
|
||||
const publishGate = new Promise<void>((resolve) => {
|
||||
settlePublish = resolve;
|
||||
});
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input.toString();
|
||||
if (url.includes('/api/workspace/context')) {
|
||||
return new Response(JSON.stringify({ context: teamWorkspaceContext() }), { status: 200 });
|
||||
}
|
||||
if (url.includes('publish-public')) {
|
||||
if (init?.method === 'POST') {
|
||||
await publishGate;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
url: 'https://open-design.ai/p/slug-1',
|
||||
slug: 'slug-1',
|
||||
fileName: 'index.html',
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
return new Response(JSON.stringify({ publication: null }), { status: 200 });
|
||||
}
|
||||
return new Response(JSON.stringify({ deployments: [] }), { status: 200 });
|
||||
}),
|
||||
);
|
||||
|
||||
const { rerenderWith } = renderProjectFileViewer(teamWorkspaceContext(), props);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /^share$/i }));
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: PUBLISH_ROW }));
|
||||
expect(trackedEvents('ui_click')).toContainEqual(
|
||||
expect.objectContaining({ element: 'publish_file' }),
|
||||
);
|
||||
|
||||
// The user switches tabs: the viewer stays mounted but retained/inert.
|
||||
rerenderWith({ ...props, workspaceActive: false });
|
||||
|
||||
await act(async () => {
|
||||
settlePublish?.();
|
||||
await publishGate;
|
||||
});
|
||||
|
||||
// Wait for the operation's own completion signal rather than timer turns.
|
||||
// The handler clears `publishingPublicFile` in its `finally`, strictly
|
||||
// after the point where the result event would have been emitted, so the
|
||||
// panel leaving its "Creating link…" state proves the continuation ran
|
||||
// past the emission site. A retained viewer renders no chrome at all, so
|
||||
// switch back first to observe it — the inactive window has already
|
||||
// closed, and an event emitted during it would still be recorded.
|
||||
rerenderWith({ ...props, workspaceActive: true });
|
||||
const shareButton = await screen.findByRole('button', { name: /^share$/i });
|
||||
// Only the HtmlViewer chrome drops its open popover while retained;
|
||||
// re-opening the React one would toggle it shut.
|
||||
if (shareButton.getAttribute('aria-expanded') !== 'true') fireEvent.click(shareButton);
|
||||
// Settled looks different per chrome: HtmlViewer discards the published
|
||||
// URL at the request-identity guard that follows the emission site and
|
||||
// returns to the idle publish row, while ReactComponentViewer commits it
|
||||
// and swaps in the copy-link control. Either one means "no longer busy".
|
||||
await screen.findByText(SETTLED_PUBLISH_PANEL);
|
||||
expect(screen.queryByText(BUSY_PUBLISH_ROW)).toBeNull();
|
||||
expect(trackedEvents('artifact_publish_result')).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
inactiveViewerCase(
|
||||
{
|
||||
projectId: 'project-pub',
|
||||
projectKind: 'prototype',
|
||||
file: htmlFile(),
|
||||
liveHtml: '<html><body><h1>Hello</h1></body></html>',
|
||||
},
|
||||
'HtmlViewer',
|
||||
);
|
||||
inactiveViewerCase(
|
||||
{ projectId: 'project-pub', projectKind: 'prototype', file: reactComponentFile() },
|
||||
'ReactComponentViewer',
|
||||
);
|
||||
|
||||
it('reports the project kind and artifact kind from the ReactComponentViewer copy of the flow', async () => {
|
||||
stubFetch();
|
||||
const publishButton = await openReactComponentPublishPanel();
|
||||
fireEvent.click(publishButton);
|
||||
|
||||
expect(trackedEvents('ui_click')).toContainEqual(
|
||||
expect.objectContaining({
|
||||
area: 'share_option_popover',
|
||||
element: 'publish_file',
|
||||
project_kind: 'prototype',
|
||||
// Derived from the `react-component` renderer; `file.kind` is `code`,
|
||||
// which on its own maps to `unknown`.
|
||||
artifact_kind: 'html',
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(trackedEvents('artifact_publish_result')).toContainEqual(
|
||||
expect.objectContaining({
|
||||
action: 'publish',
|
||||
result: 'success',
|
||||
project_kind: 'prototype',
|
||||
artifact_kind: 'html',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const copyButton = await screen.findByRole('button', { name: /copy share link/i });
|
||||
fireEvent.click(copyButton);
|
||||
expect(trackedEvents('ui_click')).toContainEqual(
|
||||
expect.objectContaining({
|
||||
area: 'share_option_popover',
|
||||
element: 'copy_publish_link',
|
||||
project_kind: 'prototype',
|
||||
artifact_kind: 'html',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,7 @@ export type AnalyticsEventName =
|
||||
// Artifact
|
||||
| 'artifact_export_result'
|
||||
| 'artifact_deploy_result'
|
||||
| 'artifact_publish_result'
|
||||
| 'file_version_restore_result'
|
||||
// Workspace redesign: authoritative outcome events. Clicks and impressions
|
||||
// continue to use the core ui_click/surface_view catalogue.
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { AmrAuthStageProps } from './amr-auth.js';
|
||||
import type { DesignSystemApplyResultProps, DesignSystemCreateResultProps, DesignSystemEnrichResultProps, DesignSystemReviewResultProps, DesignSystemSourceIngestResultProps, DesignSystemStatusResultProps } from './design-systems.js';
|
||||
import type { OnboardingCompletedProps, OnboardingCompleteResultProps, OnboardingFirstGenerationCompletedProps, OnboardingFirstPromptSentProps, OnboardingPromptPrefilledProps, OnboardingRuntimeScanResultProps } from './onboarding.js';
|
||||
import type { PageViewProps } from './page-view.js';
|
||||
import type { ArtifactDeployResultProps, ArtifactExportResultProps, AssistantFeedbackClickProps, AssistantFeedbackReasonClickProps, AssistantFeedbackReasonSubmitProps, AssistantFeedbackReasonViewProps, ByokPreflightBlockedProps, ContextLinkResultProps, ConversationForkResultProps, FeedbackSubmitResultProps, FileUploadResultProps, FileVersionRestoreResultProps, LangfuseReportResultProps, MediaGenerationResultProps, PackagedRuntimeFailedProps, PluginImportResultProps, PluginReplacementResultProps, ProjectCreateResultProps, RunCreatedProps, RunFinishedProps, RunRetryAttemptedProps, RunRetryFinishedProps, SettingsByokModelsFetchResultProps, SettingsByokTestResultProps, SettingsCliTestResultProps, SettingsConnectorAuthResultProps, SettingsViewProps, SketchExportResultProps, SketchSaveResultProps, SpeakerNotesSaveResultProps, UpdateApplyObservedProps, UpdateCheckResultProps, UpdateInstallResultProps } from './result-events.js';
|
||||
import type { ArtifactDeployResultProps, ArtifactExportResultProps, ArtifactPublishResultProps, AssistantFeedbackClickProps, AssistantFeedbackReasonClickProps, AssistantFeedbackReasonSubmitProps, AssistantFeedbackReasonViewProps, ByokPreflightBlockedProps, ContextLinkResultProps, ConversationForkResultProps, FeedbackSubmitResultProps, FileUploadResultProps, FileVersionRestoreResultProps, LangfuseReportResultProps, MediaGenerationResultProps, PackagedRuntimeFailedProps, PluginImportResultProps, PluginReplacementResultProps, ProjectCreateResultProps, RunCreatedProps, RunFinishedProps, RunRetryAttemptedProps, RunRetryFinishedProps, SettingsByokModelsFetchResultProps, SettingsByokTestResultProps, SettingsCliTestResultProps, SettingsConnectorAuthResultProps, SettingsViewProps, SketchExportResultProps, SketchSaveResultProps, SpeakerNotesSaveResultProps, UpdateApplyObservedProps, UpdateCheckResultProps, UpdateInstallResultProps } from './result-events.js';
|
||||
import type { SurfaceViewProps } from './surface-view.js';
|
||||
import type { AmrAuthResultProps, UiClickProps } from './ui-click.js';
|
||||
import type {
|
||||
@@ -43,6 +43,7 @@ export type AnalyticsEventPayload =
|
||||
| { event: 'speaker_notes_save_result'; props: SpeakerNotesSaveResultProps }
|
||||
| { event: 'artifact_export_result'; props: ArtifactExportResultProps }
|
||||
| { event: 'artifact_deploy_result'; props: ArtifactDeployResultProps }
|
||||
| { event: 'artifact_publish_result'; props: ArtifactPublishResultProps }
|
||||
| { event: 'sketch_save_result'; props: SketchSaveResultProps }
|
||||
| { event: 'sketch_export_result'; props: SketchExportResultProps }
|
||||
| { event: 'file_version_restore_result'; props: FileVersionRestoreResultProps }
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { ReleaseChannel } from '@open-design/release';
|
||||
import type { ArtifactOriginEntrySurface, ArtifactOriginStatus } from '../../api/files.js';
|
||||
import type { TrackingDesignSystemEditSurface, TrackingDesignSystemKind, TrackingDesignSystemLengthBucket, TrackingDesignSystemOrigin, TrackingDesignSystemRunEntryFrom } from './design-systems.js';
|
||||
import type { TrackingSettingsPage } from './event-names.js';
|
||||
import type { TrackingAmrOpenCodeErrorPhase, TrackingAmrOpenCodeLastEventType, TrackingAmrOpenCodeLastToolKind, TrackingAmrOpenCodeLastToolStatus, TrackingArtifactKind, TrackingArtifactWriteSource, TrackingArtifactWriteStatus, TrackingByokPreflightBlockReason, TrackingByokProviderId, TrackingCliProviderId, TrackingDesignSystemSource, TrackingExecutionMode, TrackingExportFormat, TrackingExportResult, TrackingFeedbackAction, TrackingFeedbackProviderId, TrackingFeedbackRating, TrackingFeedbackRatingWithNone, TrackingFeedbackReasonCode, TrackingFidelity, TrackingFileSizeBucket, TrackingFileType, TrackingFirstModelEventType, TrackingLangfuseDeliveryStatus, TrackingLangfuseDropReason, TrackingLangfuseReportResult, TrackingLangfuseReportSkipReason, TrackingProjectKind, TrackingProjectSource, TrackingResult, TrackingRunCancelOrigin, TrackingRunCloseReason, TrackingRunDiagnosticSource, TrackingRunFailureCategory, TrackingRunFailureDetail, TrackingRunFailureStage, TrackingRunFailureUserAction, TrackingRunLifecyclePhase, TrackingRunPhaseTimingStatus, TrackingRunResult, TrackingRunRetryFinalResult, TrackingRunRetryStrategy, TrackingRunRetrySuppressedReason, TrackingRunTerminalTrigger, TrackingStderrLineCountBucket, TrackingTestResult, TrackingTokenCountSource } from './shared-enums.js';
|
||||
import type { TrackingAmrOpenCodeErrorPhase, TrackingAmrOpenCodeLastEventType, TrackingAmrOpenCodeLastToolKind, TrackingAmrOpenCodeLastToolStatus, TrackingArtifactKind, TrackingArtifactWriteSource, TrackingArtifactWriteStatus, TrackingByokPreflightBlockReason, TrackingByokProviderId, TrackingCliProviderId, TrackingDesignSystemSource, TrackingExecutionMode, TrackingExportFormat, TrackingExportResult, TrackingFeedbackAction, TrackingFeedbackProviderId, TrackingFeedbackRating, TrackingFeedbackRatingWithNone, TrackingFeedbackReasonCode, TrackingFidelity, TrackingFileSizeBucket, TrackingFileType, TrackingFirstModelEventType, TrackingLangfuseDeliveryStatus, TrackingLangfuseDropReason, TrackingLangfuseReportResult, TrackingLangfuseReportSkipReason, TrackingProjectKind, TrackingProjectSource, TrackingPublishErrorCode, TrackingResult, TrackingRunCancelOrigin, TrackingRunCloseReason, TrackingRunDiagnosticSource, TrackingRunFailureCategory, TrackingRunFailureDetail, TrackingRunFailureStage, TrackingRunFailureUserAction, TrackingRunLifecyclePhase, TrackingRunPhaseTimingStatus, TrackingRunResult, TrackingRunRetryFinalResult, TrackingRunRetryStrategy, TrackingRunRetrySuppressedReason, TrackingRunTerminalTrigger, TrackingStderrLineCountBucket, TrackingTestResult, TrackingTokenCountSource } from './shared-enums.js';
|
||||
import type { ConversationForkAnalyticsContext, TrackingFileVersionSource, TrackingPluginImportSource, TrackingSessionMode, TrackingSettingsArea } from './ui-click.js';
|
||||
// ---- Result events -------------------------------------------------------
|
||||
|
||||
@@ -792,6 +792,27 @@ export interface ArtifactDeployResultProps {
|
||||
project_kind: TrackingProjectKind | null;
|
||||
}
|
||||
|
||||
// Fired when a "Publish this file for everyone" attempt from the Share tab
|
||||
// resolves — publishing and unpublishing share the event, split by `action`.
|
||||
// Fires when the daemon call settles (success once the public URL is returned
|
||||
// for publish, or removal is confirmed for unpublish), regardless of whether a
|
||||
// newer request superseded this one in the UI. Clicking the publish button
|
||||
// reports separately as ui_click element 'publish_file'.
|
||||
export interface ArtifactPublishResultProps {
|
||||
page_name: 'artifact';
|
||||
area: 'share_option_popover';
|
||||
artifact_id: string;
|
||||
artifact_kind: TrackingArtifactKind;
|
||||
action: 'publish' | 'unpublish';
|
||||
result: TrackingExportResult;
|
||||
// 'workspace_identity_required' when the workspace context could not be
|
||||
// confirmed (the one actionable failure), 'publish_failed' otherwise.
|
||||
error_code?: TrackingPublishErrorCode;
|
||||
publish_duration_ms: number;
|
||||
project_id: string;
|
||||
project_kind: TrackingProjectKind | null;
|
||||
}
|
||||
|
||||
// Outcome of an HTML file version restore from the version history modal.
|
||||
// Fires once per confirmed restore attempt (after the restore API settles) —
|
||||
// opening the confirm popover or cancelling it only reports ui_click.
|
||||
|
||||
@@ -225,6 +225,10 @@ export type TrackingRunTerminalTrigger =
|
||||
| 'inactivity_watchdog'
|
||||
| 'daemon_restart';
|
||||
export type TrackingExportResult = 'success' | 'failed' | 'cancelled';
|
||||
// Stable codes for artifact_publish_result.error_code. Deliberately a CLOSED
|
||||
// set — unlike artifact_deploy_result's open-ended provider/HTTP codes — so no
|
||||
// free-form message text can ever be passed as an analytics error code.
|
||||
export type TrackingPublishErrorCode = 'workspace_identity_required' | 'publish_failed';
|
||||
export type TrackingTestResult = 'success' | 'failed' | 'timeout';
|
||||
export type TrackingRunFailureCategory =
|
||||
| 'auth'
|
||||
|
||||
@@ -1337,7 +1337,14 @@ export interface ShareOptionPopoverClickProps {
|
||||
// Export/share formats, plus 'publish_required_guide' for the share-intent
|
||||
// signal: the user opened Share wanting a link but the artifact isn't
|
||||
// deployed yet, so only the "publish online first" guide row is shown.
|
||||
element: TrackingExportFormat | 'publish_required_guide';
|
||||
// 'publish_file' is the Share tab's "Publish this file for everyone" button
|
||||
// (the outcome reports separately via artifact_publish_result);
|
||||
// 'copy_publish_link' is the copy-link button shown once a file is published.
|
||||
element:
|
||||
| TrackingExportFormat
|
||||
| 'publish_required_guide'
|
||||
| 'publish_file'
|
||||
| 'copy_publish_link';
|
||||
artifact_id: string;
|
||||
artifact_kind: TrackingArtifactKind;
|
||||
project_id: string;
|
||||
|
||||
Reference in New Issue
Block a user