Skip to content

Commit 2c56e5c

Browse files
author
Esteban Ordano
committed
feat(metrics): creator scene analytics
1 parent d4a352c commit 2c56e5c

38 files changed

Lines changed: 4077 additions & 7 deletions

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { Provider as StoreProvider } from 'react-redux';
2+
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
3+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
4+
import { getKeys } from '/@/modules/store/translation/utils';
5+
import { actions as featureFlagsActions } from '/@/modules/store/featureFlags';
6+
import type { Project } from '/shared/types/projects';
7+
import { createTestStore, type TestStore } from '../../../../tests/utils/testStore';
8+
import { MetricsAction } from './component';
9+
10+
const METRICS_FLAG = 'creatorhub-creator-hub-metrics';
11+
12+
const navigateMock = vi.fn();
13+
14+
vi.mock('react-router-dom', async () => {
15+
const actual = (await vi.importActual('react-router-dom')) as Record<string, unknown>;
16+
return { ...actual, useNavigate: () => navigateMock };
17+
});
18+
19+
vi.mock('/@/lib/metrics', () => ({
20+
isMetricsEnabled: (flags: Record<string, boolean>) => !!flags[METRICS_FLAG],
21+
}));
22+
23+
const project = (overrides: Partial<Project> = {}): Project =>
24+
({
25+
id: 'p',
26+
path: '/tmp/p',
27+
title: 'My Scene',
28+
scene: { base: '0,0', parcels: ['0,0'] },
29+
...overrides,
30+
}) as unknown as Project;
31+
32+
function enableMetrics(store: TestStore) {
33+
store.dispatch(
34+
featureFlagsActions.fetchFeatureFlags.fulfilled(
35+
{ flags: { [METRICS_FLAG]: true }, variants: {} },
36+
'req',
37+
undefined,
38+
),
39+
);
40+
}
41+
42+
function renderAction(proj: Project | undefined, { enabled = true } = {}) {
43+
const store = createTestStore();
44+
if (enabled) enableMetrics(store);
45+
return render(
46+
<StoreProvider store={store}>
47+
<MetricsAction project={proj} />
48+
</StoreProvider>,
49+
);
50+
}
51+
52+
describe('MetricsAction (editor entry)', () => {
53+
beforeAll(() => {
54+
getKeys('en');
55+
});
56+
57+
afterEach(() => {
58+
navigateMock.mockReset();
59+
cleanup();
60+
});
61+
62+
it('renders nothing when the metrics flag is off', () => {
63+
renderAction(project({ worldConfiguration: { name: 'w.dcl.eth' } }), { enabled: false });
64+
expect(screen.queryByText('Analytics')).toBeNull();
65+
});
66+
67+
it('navigates to the world drill-down when the scene is deployed to a World', () => {
68+
renderAction(project({ worldConfiguration: { name: 'Kick-Off.dcl.eth' } }));
69+
fireEvent.click(screen.getByText('Analytics'));
70+
expect(navigateMock).toHaveBeenCalledWith('/metrics', {
71+
state: { sceneType: 'world', sceneId: 'kick-off.dcl.eth', source: 'editor' },
72+
});
73+
});
74+
75+
it('navigates to the genesis drill-down when the scene is deployed to LAND', () => {
76+
renderAction(project({ scene: { base: '10,20', parcels: ['10,20'] } }));
77+
fireEvent.click(screen.getByText('Analytics'));
78+
expect(navigateMock).toHaveBeenCalledWith('/metrics', {
79+
state: { sceneType: 'genesis', sceneId: '10|20', source: 'editor' },
80+
});
81+
});
82+
83+
it('disables the entry and does not navigate for an undeployed scene', () => {
84+
renderAction(project());
85+
const button = screen.getByRole('button', { name: /Analytics/ });
86+
expect(button).toHaveProperty('disabled', true);
87+
fireEvent.click(button);
88+
expect(navigateMock).not.toHaveBeenCalled();
89+
});
90+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { useCallback, useMemo } from 'react';
2+
import { useNavigate } from 'react-router-dom';
3+
import InsightsIcon from '@mui/icons-material/Insights';
4+
import { Tooltip } from 'decentraland-ui2';
5+
import { t } from '/@/modules/store/translation/utils';
6+
import { useFeatureFlags } from '/@/hooks/useFeatureFlags';
7+
import { isMetricsEnabled } from '/@/lib/metrics';
8+
import type { Project } from '/shared/types/projects';
9+
import { Button } from '../../Button';
10+
import { resolveSceneMetricsTarget } from '../utils';
11+
12+
export function MetricsAction({ project }: { project?: Project }) {
13+
const navigate = useNavigate();
14+
const { flags } = useFeatureFlags();
15+
const target = useMemo(() => resolveSceneMetricsTarget(project), [project]);
16+
17+
const handleClick = useCallback(() => {
18+
if (!target) return;
19+
navigate('/metrics', { state: { ...target, source: 'editor' } });
20+
}, [navigate, target]);
21+
22+
if (!isMetricsEnabled(flags)) return null;
23+
24+
const button = (
25+
<span>
26+
<Button
27+
color="secondary"
28+
onClick={handleClick}
29+
disabled={!target}
30+
startIcon={<InsightsIcon />}
31+
>
32+
{t('metrics.actions.analytics')}
33+
</Button>
34+
</span>
35+
);
36+
37+
return target ? button : <Tooltip title={t('metrics.actions.editor_tooltip')}>{button}</Tooltip>;
38+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { MetricsAction } from './component';

packages/creator-hub/renderer/src/components/EditorPage/component.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { ButtonGroup } from '../Button';
3636
import { ConnectionStatusIndicator } from '../ConnectionStatusIndicator';
3737
import { MobileQRCode } from '../Modals/MobileQRCode';
3838
import { DeployModal } from './DeployModal';
39+
import { MetricsAction } from './MetricsAction';
3940
import { PreviewOptions, PublishOptions } from './MenuOptions';
4041
import { getPublishButtonText, getPublishOptions } from './utils';
4142

@@ -376,6 +377,7 @@ export function EditorPage() {
376377
>
377378
{t('editor.header.actions.preview')}
378379
</ButtonGroup>
380+
<MetricsAction project={project} />
379381
{publishOptions.length > 0 ? (
380382
<ButtonGroup
381383
color="primary"
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { describe, expect, it } from 'vitest';
2+
import type { Project } from '/shared/types/projects';
3+
import { resolveSceneMetricsTarget } from './utils';
4+
5+
type Target = Pick<Project, 'worldConfiguration' | 'scene'>;
6+
7+
const withScene = (base: string): Target['scene'] => ({ base, parcels: [base] });
8+
9+
describe('resolveSceneMetricsTarget', () => {
10+
it('resolves a World by its lowercased name', () => {
11+
expect(
12+
resolveSceneMetricsTarget({
13+
worldConfiguration: { name: 'Kick-Off.dcl.eth' },
14+
scene: withScene('0,0'),
15+
}),
16+
).toEqual({ sceneType: 'world', sceneId: 'kick-off.dcl.eth' });
17+
});
18+
19+
it('resolves a Genesis parcel by its base coords with a pipe separator', () => {
20+
expect(resolveSceneMetricsTarget({ scene: withScene('10,20') })).toEqual({
21+
sceneType: 'genesis',
22+
sceneId: '10|20',
23+
});
24+
});
25+
26+
it('prefers the World target when both a world and a land base exist', () => {
27+
expect(
28+
resolveSceneMetricsTarget({
29+
worldConfiguration: { name: 'my-world.dcl.eth' },
30+
scene: withScene('10,20'),
31+
}),
32+
).toEqual({ sceneType: 'world', sceneId: 'my-world.dcl.eth' });
33+
});
34+
35+
it('returns null for a fresh scene at 0,0 with no world', () => {
36+
expect(resolveSceneMetricsTarget({ scene: withScene('0,0') })).toBeNull();
37+
});
38+
39+
it('returns null when there is no project', () => {
40+
expect(resolveSceneMetricsTarget(undefined)).toBeNull();
41+
});
42+
});

packages/creator-hub/renderer/src/components/EditorPage/utils.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,25 @@
11
import type { Project } from '/shared/types/projects';
2+
import type { SceneType } from '/shared/types/metrics';
23
import type { Deployment } from '/@/modules/store/deployment/slice';
34
import { t } from '/@/modules/store/translation/utils';
45
import type { PublishOption } from './MenuOptions';
56

7+
export type SceneMetricsTarget = { sceneType: SceneType; sceneId: string };
8+
9+
export const resolveSceneMetricsTarget = (
10+
project?: Pick<Project, 'worldConfiguration' | 'scene'>,
11+
): SceneMetricsTarget | null => {
12+
const worldName = project?.worldConfiguration?.name;
13+
if (worldName) return { sceneType: 'world', sceneId: worldName.toLowerCase() };
14+
15+
const landBase = project?.scene?.base;
16+
if (landBase && landBase !== '0,0') {
17+
return { sceneType: 'genesis', sceneId: landBase.replace(',', '|') };
18+
}
19+
20+
return null;
21+
};
22+
623
type GetPublishButtonTextParams = {
724
loadingPublish: boolean;
825
deployment?: Deployment;

packages/creator-hub/renderer/src/components/HomePage/component.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ const ScenesCard: React.FC = React.memo(() => {
159159
<div className="CardList">
160160
{projects.slice(0, 6).map(project => (
161161
<CardItem
162-
key={project.id}
162+
key={project.path}
163163
title={project.title}
164164
icon={<LayersOutlinedIcon />}
165165
onClick={() => handleProjectClick(project)}

packages/creator-hub/renderer/src/components/ManagePage/ManagedProjectsList/component.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { useNavigate } from 'react-router-dom';
33
import { Box, Button as DCLButton, Typography } from 'decentraland-ui2';
44
import { useDispatch, useSelector } from '#store';
55
import { useAuth } from '/@/hooks/useAuth';
6+
import { useFeatureFlags } from '/@/hooks/useFeatureFlags';
7+
import { isMetricsEnabled } from '/@/lib/metrics';
68
import { WorldPermissionName, WorldRoleType } from '/@/lib/worlds';
79
import {
810
fetchWorldSettings,
@@ -13,7 +15,7 @@ import {
1315
fetchParcelsPermission,
1416
} from '/@/modules/store/management';
1517
import { t } from '/@/modules/store/translation/utils';
16-
import { WorldSettingsTab, type ManagedProject } from '/shared/types/manage';
18+
import { ManagedProjectType, WorldSettingsTab, type ManagedProject } from '/shared/types/manage';
1719
import { Button } from '../../Button';
1820
import { Loader } from '../../Loader';
1921
import { Modal } from '../../Modals';
@@ -48,6 +50,8 @@ type Props = {
4850
const ManagedProjectsList: React.FC<Props> = React.memo(props => {
4951
const { projects, total, isLoading, onLoadMore } = props;
5052
const { wallet } = useAuth();
53+
const { flags } = useFeatureFlags();
54+
const metricsEnabled = isMetricsEnabled(flags);
5155
const [settingsModal, setSettingsModal] = useState<SettingsModalState>({
5256
isOpen: false,
5357
activeTab: WorldSettingsTab.DETAILS,
@@ -124,6 +128,17 @@ const ManagedProjectsList: React.FC<Props> = React.memo(props => {
124128
navigate('/scenes');
125129
}, [navigate]);
126130

131+
const handleViewMetrics = useCallback(
132+
(project: ManagedProject) => {
133+
const state =
134+
project.type === ManagedProjectType.LAND
135+
? { sceneType: 'genesis', sceneId: project.id.replace(',', '|'), source: 'manage-card' }
136+
: { sceneType: 'world', sceneId: project.id.toLowerCase(), source: 'manage-card' };
137+
navigate('/metrics', { state });
138+
},
139+
[navigate],
140+
);
141+
127142
return (
128143
<div className="ManagedProjectsList">
129144
{projects.map(project => (
@@ -136,6 +151,7 @@ const ManagedProjectsList: React.FC<Props> = React.memo(props => {
136151
onOpenPermissions={() => handleOpenPermissionsModal(project.id)}
137152
onUnpublishWorld={() => handleShowUnpublishWorldConfirmation(project.id)}
138153
onViewScenes={handleViewScenes}
154+
onViewMetrics={metricsEnabled ? () => handleViewMetrics(project) : undefined}
139155
/>
140156
))}
141157
{projects.length < total && (

packages/creator-hub/renderer/src/components/ManagePage/PublishedProjectCard/component.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { useCallback, useMemo } from 'react';
22
import { type ManagedProject, WorldSettingsTab } from '/shared/types/manage';
33
import { ManagedProjectType } from '/shared/types/manage';
44
import WorldSettingsIcon from '@mui/icons-material/SpaceDashboard';
5+
import MetricsIcon from '@mui/icons-material/Insights';
56
import ParcelsIcon from '@mui/icons-material/Layers';
67
import PermissionsIcon from '@mui/icons-material/Lock';
78
import PersonIcon from '@mui/icons-material/Person';
@@ -40,10 +41,18 @@ export type Props = {
4041
onOpenPermissions: () => void;
4142
onViewScenes: () => void;
4243
onUnpublishWorld: () => void;
44+
onViewMetrics?: () => void;
4345
};
4446

4547
const PublishedProjectCard: React.FC<Props> = React.memo(
46-
({ project, onOpenSettings, onOpenPermissions, onViewScenes, onUnpublishWorld }) => {
48+
({
49+
project,
50+
onOpenSettings,
51+
onOpenPermissions,
52+
onViewScenes,
53+
onUnpublishWorld,
54+
onViewMetrics,
55+
}) => {
4756
const { pushGeneric } = useSnackbar();
4857
const { id, displayName, type, role, deployment } = project;
4958
const roleLabel = isCollaboratorRole(role) ? COLLABORATOR_ROLES_LABELS[role] : null;
@@ -112,6 +121,15 @@ const PublishedProjectCard: React.FC<Props> = React.memo(
112121
handler: onOpenPermissions,
113122
active: type === ManagedProjectType.WORLD && role === WorldRoleType.OWNER,
114123
},
124+
{
125+
text: t('metrics.actions.metrics'),
126+
icon: <MetricsIcon />,
127+
handler: () => onViewMetrics?.(),
128+
active:
129+
!!onViewMetrics &&
130+
(type === ManagedProjectType.LAND ||
131+
(type === ManagedProjectType.WORLD && !!deployment)),
132+
},
115133
{
116134
text: t('manage.cards.menu.view_storage'),
117135
icon: <OpenInNew />,
@@ -137,6 +155,7 @@ const PublishedProjectCard: React.FC<Props> = React.memo(
137155
handleViewParcel,
138156
handleViewStorage,
139157
onOpenPermissions,
158+
onViewMetrics,
140159
]);
141160

142161
return (

0 commit comments

Comments
 (0)