diff --git a/frontend/src/lib/components/AddToHome/AddToHomeButton.tsx b/frontend/src/lib/components/AddToHome/AddToHomeButton.tsx new file mode 100644 index 000000000000..fe8d5d55e600 --- /dev/null +++ b/frontend/src/lib/components/AddToHome/AddToHomeButton.tsx @@ -0,0 +1,22 @@ +import { useActions, useValues } from 'kea' + +import { LemonButton } from 'lib/lemon-ui/LemonButton' + +import { HomePinInsight, homePinsLogic } from './homePinsLogic' + +export function AddToHomeButton({ insight }: { insight: HomePinInsight }): JSX.Element { + const { homePinState } = useValues(homePinsLogic) + const { toggleHomePin } = useActions(homePinsLogic) + const { pinned, disabledReason } = homePinState(insight) + + return ( + toggleHomePin(insight, 'dashboard_card')} + disabledReason={disabledReason} + fullWidth + data-attr="insight-card-add-to-home" + > + {pinned ? 'Remove from home' : 'Add to home'} + + ) +} diff --git a/frontend/src/lib/components/AddToHome/homePinsLogic.test.ts b/frontend/src/lib/components/AddToHome/homePinsLogic.test.ts new file mode 100644 index 000000000000..e1c213d90a54 --- /dev/null +++ b/frontend/src/lib/components/AddToHome/homePinsLogic.test.ts @@ -0,0 +1,83 @@ +import { expectLogic } from 'kea-test-utils' +import posthog from 'posthog-js' + +import { useMocks } from '~/mocks/jest' +import { initKeaTests } from '~/test/init' +import { InsightShortId } from '~/types' + +import type { PulsePinApi } from 'products/today/frontend/generated/api.schemas' + +import { homePinsLogic } from './homePinsLogic' + +const pin = (shortId: string): PulsePinApi => ({ key: `insight:${shortId}`, name: shortId, dashboard_id: null }) +const insight = { short_id: 'new' as InsightShortId, name: 'Signups' } + +describe('homePinsLogic', () => { + let storedPins: PulsePinApi[] + let savedPins: PulsePinApi[][] + + beforeEach(() => { + savedPins = [] + useMocks({ + get: { '/api/projects/:team_id/today/pulse_pins/': () => [200, { pins: storedPins }] }, + put: { + '/api/projects/:team_id/today/pulse_pins/': async ({ request }) => { + const { pins } = (await request.json()) as { pins: PulsePinApi[] } + savedPins.push(pins) + return [200, { pins }] + }, + }, + }) + initKeaTests() + jest.spyOn(posthog, 'capture').mockImplementation(() => undefined) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it.each([ + { + name: 'adds the insight after the existing pins', + stored: [pin('a')], + label: { pinned: false, disabledReason: undefined }, + saved: [[pin('a'), { key: 'insight:new', name: 'Signups', dashboard_id: null }]], + event: 'today pulse number pinned', + }, + { + name: 'removes a pinned insight even when the home is full', + stored: [pin('a'), pin('new'), pin('c'), pin('d')], + label: { pinned: true, disabledReason: undefined }, + saved: [[pin('a'), pin('c'), pin('d')]], + event: 'today pulse number unpinned', + }, + { + name: 'does not add a fifth number', + stored: [pin('a'), pin('b'), pin('c'), pin('d')], + label: { pinned: false, disabledReason: 'Your home already shows 4 numbers. Remove one first.' }, + saved: [], + event: null, + }, + ])('$name', async ({ stored, label, saved, event }) => { + storedPins = stored + const logic = homePinsLogic() + logic.mount() + await expectLogic(logic).toFinishAllListeners() + + expect(logic.values.homePinState(insight)).toEqual(label) + + logic.actions.toggleHomePin(insight, 'dashboard_card') + await expectLogic(logic).toFinishAllListeners() + + expect(savedPins).toEqual(saved) + if (event) { + expect(posthog.capture).toHaveBeenCalledWith(event, { + kind: 'insight', + source: 'dashboard_card', + pinned_count_before: stored.length, + }) + } else { + expect(posthog.capture).not.toHaveBeenCalled() + } + }) +}) diff --git a/frontend/src/lib/components/AddToHome/homePinsLogic.ts b/frontend/src/lib/components/AddToHome/homePinsLogic.ts new file mode 100644 index 000000000000..be9e38dcc24b --- /dev/null +++ b/frontend/src/lib/components/AddToHome/homePinsLogic.ts @@ -0,0 +1,178 @@ +import { MakeLogicType, actions, afterMount, connect, kea, listeners, path, reducers, selectors } from 'kea' +import { loaders } from 'kea-loaders' +import posthog from 'posthog-js' + +import { toast } from '@posthog/quill' + +import { ApiError } from 'lib/api' +import { teamLogic } from 'scenes/teamLogic' + +import { isInsightVizNode, isTrendsQuery } from '~/queries/schema/node-guards' +import { ChartDisplayType, type InsightModel } from '~/types' + +import { todayPulsePinsRetrieve, todayPulsePinsUpdate } from 'products/today/frontend/generated/api' +import type { PulsePinApi } from 'products/today/frontend/generated/api.schemas' + +export const HOME_PIN_LIMIT = 4 + +const HOME_PIN_DISPLAYS: ChartDisplayType[] = [ + ChartDisplayType.ActionsLineGraph, + ChartDisplayType.ActionsBar, + ChartDisplayType.ActionsStackedBar, + ChartDisplayType.ActionsAreaGraph, +] + +export type HomePinSource = 'dashboard_card' | 'insight_menu' + +export type HomePinInsight = Pick & Partial> + +export interface HomePinState { + pinned: boolean + disabledReason: string | undefined +} + +export function isHomePinnable(insight: Partial): insight is Partial & HomePinInsight { + const query = insight.query + return ( + !!insight.short_id && + isInsightVizNode(query) && + isTrendsQuery(query.source) && + HOME_PIN_DISPLAYS.includes(query.source.trendsFilter?.display ?? ChartDisplayType.ActionsLineGraph) + ) +} + +function homePinKey(insight: HomePinInsight): string { + return `insight:${insight.short_id}` +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface homePinsLogicValues { + currentProjectId: number | null + homePinState: (insight: HomePinInsight) => HomePinState + homePins: PulsePinApi[] | null + homePinsLoading: boolean + homePinsSaving: boolean +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface homePinsLogicActions { + loadHomePins: () => any + loadHomePinsFailure: ( + error: string, + errorObject?: any + ) => { + error: string + errorObject?: any + } + loadHomePinsSuccess: ( + homePins: PulsePinApi[] | null, + payload?: any + ) => { + homePins: PulsePinApi[] | null + payload?: any + } + setHomePinsSaving: (saving: boolean) => { + saving: boolean + } + toggleHomePin: ( + insight: HomePinInsight, + source: HomePinSource + ) => { + insight: HomePinInsight + source: HomePinSource + } +} + +export type homePinsLogicType = MakeLogicType + +function nextHomePins(pins: PulsePinApi[], insight: HomePinInsight, pinned: boolean): PulsePinApi[] { + const key = homePinKey(insight) + if (pinned) { + return pins.filter((pin) => pin.key !== key) + } + return [...pins, { key, name: insight.name || insight.derived_name || 'Untitled insight', dashboard_id: null }] +} + +export const homePinsLogic = kea([ + path(['lib', 'components', 'AddToHome', 'homePinsLogic']), + connect(() => ({ + values: [teamLogic, ['currentProjectId']], + })), + actions({ + toggleHomePin: (insight: HomePinInsight, source: HomePinSource) => ({ insight, source }), + setHomePinsSaving: (saving: boolean) => ({ saving }), + }), + loaders(({ values }) => ({ + homePins: [ + null as PulsePinApi[] | null, + { + loadHomePins: async (): Promise => { + try { + const { pins } = await todayPulsePinsRetrieve(String(values.currentProjectId)) + return pins + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + return [] + } + throw error + } + }, + }, + ], + })), + reducers({ + homePinsSaving: [false, { setHomePinsSaving: (_, { saving }) => saving }], + }), + selectors({ + homePinState: [ + (s) => [s.homePins, s.homePinsLoading, s.homePinsSaving], + (homePins: PulsePinApi[] | null, homePinsLoading: boolean, homePinsSaving: boolean) => + (insight: HomePinInsight): HomePinState => { + const pinned = !!homePins?.some((pin) => pin.key === homePinKey(insight)) + const disabledReason = + homePinsLoading || homePinsSaving + ? 'Loading your home numbers' + : homePins === null + ? "Couldn't load your home numbers. Open this menu again to retry." + : !pinned && homePins.length >= HOME_PIN_LIMIT + ? `Your home already shows ${HOME_PIN_LIMIT} numbers. Remove one first.` + : undefined + return { pinned, disabledReason } + }, + ], + }), + listeners(({ actions, values, cache }) => ({ + toggleHomePin: async ({ insight, source }) => { + const pins = values.homePins + const { pinned, disabledReason } = values.homePinState(insight) + if (!pins || disabledReason) { + return + } + const next = nextHomePins(pins, insight, pinned) + actions.setHomePinsSaving(true) + let saved: PulsePinApi[] + try { + saved = (await todayPulsePinsUpdate(String(values.currentProjectId), { pins: next })).pins + } catch { + toast.error({ title: "Couldn't update your home. Try again." }) + if (!cache.disposables.isDisposed) { + actions.setHomePinsSaving(false) + } + return + } + posthog.capture(pinned ? 'today pulse number unpinned' : 'today pulse number pinned', { + kind: 'insight', + source, + pinned_count_before: pins.length, + }) + toast.success({ title: pinned ? 'Removed from home' : 'Added to home' }) + if (!cache.disposables.isDisposed) { + actions.loadHomePinsSuccess(saved) + actions.setHomePinsSaving(false) + } + }, + })), + afterMount(({ actions }) => { + actions.loadHomePins() + }), +]) diff --git a/frontend/src/lib/components/Cards/InsightCard/InsightMeta.tsx b/frontend/src/lib/components/Cards/InsightCard/InsightMeta.tsx index f95486f57e6d..ce40046e461d 100644 --- a/frontend/src/lib/components/Cards/InsightCard/InsightMeta.tsx +++ b/frontend/src/lib/components/Cards/InsightCard/InsightMeta.tsx @@ -5,6 +5,8 @@ import React, { useMemo, useState } from 'react' import { IconChevronRight, IconClock, IconPulse, IconThumbsDown, IconThumbsUp, IconWarning } from '@posthog/icons' import { lemonToast } from '@posthog/lemon-ui' +import { AddToHomeButton } from 'lib/components/AddToHome/AddToHomeButton' +import { isHomePinnable } from 'lib/components/AddToHome/homePinsLogic' import { CardMeta } from 'lib/components/Cards/CardMeta' import { CardMetaRefreshButton } from 'lib/components/Cards/CardMetaRefreshButton' import { CompactInfoPopover } from 'lib/components/Cards/InsightCard/CompactInfoPopover' @@ -623,6 +625,9 @@ function InsightMetaBody({ Duplicate )} + {featureFlags[FEATURE_FLAGS.TODAY_RAIL_NAV] && isHomePinnable(insight) && ( + + )} {showDashboardAlertsMenuItem && insight.id ? ( { diff --git a/products/product_analytics/frontend/insights/insightScene/AddToHomeMenuItem.tsx b/products/product_analytics/frontend/insights/insightScene/AddToHomeMenuItem.tsx new file mode 100644 index 000000000000..f96f73d09d2b --- /dev/null +++ b/products/product_analytics/frontend/insights/insightScene/AddToHomeMenuItem.tsx @@ -0,0 +1,23 @@ +import { useActions, useValues } from 'kea' + +import { IconHome } from '@posthog/icons' +import { DropdownMenuItem } from '@posthog/quill' + +import { HomePinInsight, homePinsLogic } from 'lib/components/AddToHome/homePinsLogic' + +export function AddToHomeMenuItem({ insight }: { insight: HomePinInsight }): JSX.Element { + const { homePinState } = useValues(homePinsLogic) + const { toggleHomePin } = useActions(homePinsLogic) + const { pinned, disabledReason } = homePinState(insight) + + return ( + toggleHomePin(insight, 'insight_menu')} + disabledReason={disabledReason} + > + + {pinned ? 'Remove from home' : 'Add to home'} + + ) +} diff --git a/products/product_analytics/frontend/insights/insightScene/QuillInsightPageHeaderActions.tsx b/products/product_analytics/frontend/insights/insightScene/QuillInsightPageHeaderActions.tsx index de57cb726754..9e27d83f25ea 100644 --- a/products/product_analytics/frontend/insights/insightScene/QuillInsightPageHeaderActions.tsx +++ b/products/product_analytics/frontend/insights/insightScene/QuillInsightPageHeaderActions.tsx @@ -11,6 +11,7 @@ import { } from '@posthog/quill' import { AccessControlAction } from 'lib/components/AccessControlAction' +import { isHomePinnable } from 'lib/components/AddToHome/homePinsLogic' import { InsightSubscribeProminentButton } from 'lib/components/Scenes/InsightSubscribeProminentButton' import { useFeatureFlag } from 'lib/hooks/useFeatureFlag' import { insightDataLogic } from 'scenes/insights/insightDataLogic' @@ -26,6 +27,7 @@ import { AccessControlLevel, AccessControlResourceType, InsightLogicProps, ItemM import { insightModalsLogic } from 'products/product_analytics/frontend/insights/insightScene/insightModalsLogic' import { urlForSubscriptions } from 'products/subscriptions/frontend/components/Subscriptions/utils' +import { AddToHomeMenuItem } from './AddToHomeMenuItem' import { InsightShareMenu, useInsightShareItems } from './InsightShareMenu' import { openInsightEditor } from './openInsightEditor' import { QuillInsightSaveButton } from './QuillInsightSaveButton' @@ -112,6 +114,7 @@ export function QuillInsightPageHeaderActions({ Add to dashboard + {isHomePinnable(insight) && } {canCreateAlertForInsight && (