diff --git a/ee/api/test/test_organization.py b/ee/api/test/test_organization.py index 2f1b11bb95256..a77361dc579e8 100644 --- a/ee/api/test/test_organization.py +++ b/ee/api/test/test_organization.py @@ -1,6 +1,7 @@ import datetime as dt import random -from unittest.mock import ANY, patch +from unittest import mock +from unittest.mock import ANY, call, patch from freezegun.api import freeze_time from rest_framework import status @@ -104,11 +105,21 @@ def test_delete_last_organization(self, mock_capture): "Did not return a 404 on trying to delete a nonexistent org", ) - mock_capture.assert_called_once_with( - self.user.distinct_id, - "organization deleted", - organization_props, - groups={"instance": ANY, "organization": str(org_id)}, + mock_capture.assert_has_calls( + [ + call( + self.user.distinct_id, + "membership level changed", + properties={"new_level": 15, "previous_level": 1}, + groups=mock.ANY, + ), + call( + self.user.distinct_id, + "organization deleted", + organization_props, + groups={"instance": mock.ANY, "organization": str(org_id)}, + ), + ] ) def test_no_delete_organization_not_owning(self): diff --git a/ee/clickhouse/models/test/__snapshots__/test_property.ambr b/ee/clickhouse/models/test/__snapshots__/test_property.ambr index cc8e77f83a0dc..d27396834cf99 100644 --- a/ee/clickhouse/models/test/__snapshots__/test_property.ambr +++ b/ee/clickhouse/models/test/__snapshots__/test_property.ambr @@ -57,7 +57,7 @@ --- # name: test_parse_groups_persons_edge_case_with_single_filter ( - 'AND ( has(%(vglobalperson_0)s, replaceRegexpAll(JSONExtractRaw(person_props, %(kglobalperson_0)s), \'^"|"$\', \'\')))', + 'AND ( has(%(vglobalperson_0)s, "pmat_email"))', { 'kglobalperson_0': 'email', 'vglobalperson_0': [ diff --git a/ee/clickhouse/views/test/__snapshots__/test_clickhouse_experiment_secondary_results.ambr b/ee/clickhouse/views/test/__snapshots__/test_clickhouse_experiment_secondary_results.ambr index 11cfe7070f2f4..3474ae77b858f 100644 --- a/ee/clickhouse/views/test/__snapshots__/test_clickhouse_experiment_secondary_results.ambr +++ b/ee/clickhouse/views/test/__snapshots__/test_clickhouse_experiment_secondary_results.ambr @@ -1,6 +1,6 @@ # name: ClickhouseTestExperimentSecondaryResults.test_basic_secondary_metric_results ' - /* user_id:133 celery:posthog.celery.sync_insight_caching_state */ + /* user_id:131 celery:posthog.celery.sync_insight_caching_state */ SELECT team_id, date_diff('second', max(timestamp), now()) AS age FROM events diff --git a/frontend/__snapshots__/layout-navigation--app-page-with-side-bar-shown.png b/frontend/__snapshots__/layout-navigation--app-page-with-side-bar-shown.png index b49b6fc4bd341..b2ae49cd91f5b 100644 Binary files a/frontend/__snapshots__/layout-navigation--app-page-with-side-bar-shown.png and b/frontend/__snapshots__/layout-navigation--app-page-with-side-bar-shown.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-banner--closable.png b/frontend/__snapshots__/lemon-ui-lemon-banner--closable.png index a05dd78b3e3e7..a7a8ac55c5061 100644 Binary files a/frontend/__snapshots__/lemon-ui-lemon-banner--closable.png and b/frontend/__snapshots__/lemon-ui-lemon-banner--closable.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-banner--dismissable.png b/frontend/__snapshots__/lemon-ui-lemon-banner--dismissable.png index be2ef2e5a884b..540a8a3ef2c39 100644 Binary files a/frontend/__snapshots__/lemon-ui-lemon-banner--dismissable.png and b/frontend/__snapshots__/lemon-ui-lemon-banner--dismissable.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-banner--error.png b/frontend/__snapshots__/lemon-ui-lemon-banner--error.png index 7db8c557495b9..9389cfa4ea1b2 100644 Binary files a/frontend/__snapshots__/lemon-ui-lemon-banner--error.png and b/frontend/__snapshots__/lemon-ui-lemon-banner--error.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-banner--info.png b/frontend/__snapshots__/lemon-ui-lemon-banner--info.png index 7c6e78d57caf2..6848c05f89a32 100644 Binary files a/frontend/__snapshots__/lemon-ui-lemon-banner--info.png and b/frontend/__snapshots__/lemon-ui-lemon-banner--info.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-banner--success.png b/frontend/__snapshots__/lemon-ui-lemon-banner--success.png index 2053ce5ccc6de..f3b58cb98363a 100644 Binary files a/frontend/__snapshots__/lemon-ui-lemon-banner--success.png and b/frontend/__snapshots__/lemon-ui-lemon-banner--success.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-banner--warning.png b/frontend/__snapshots__/lemon-ui-lemon-banner--warning.png index bf8c975d7385b..3c41933fb5078 100644 Binary files a/frontend/__snapshots__/lemon-ui-lemon-banner--warning.png and b/frontend/__snapshots__/lemon-ui-lemon-banner--warning.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-button--as-links.png b/frontend/__snapshots__/lemon-ui-lemon-button--as-links.png index 24ae6fe59d181..292f9ce7d0a99 100644 Binary files a/frontend/__snapshots__/lemon-ui-lemon-button--as-links.png and b/frontend/__snapshots__/lemon-ui-lemon-button--as-links.png differ diff --git a/frontend/__snapshots__/scenes-app-batchexports--create-export.png b/frontend/__snapshots__/scenes-app-batchexports--create-export.png index 5812443d7cc01..51889a6cdcc34 100644 Binary files a/frontend/__snapshots__/scenes-app-batchexports--create-export.png and b/frontend/__snapshots__/scenes-app-batchexports--create-export.png differ diff --git a/frontend/__snapshots__/scenes-app-experiments--complete-funnel-experiment.png b/frontend/__snapshots__/scenes-app-experiments--complete-funnel-experiment.png index 189dc9741ea0f..0c9084824591a 100644 Binary files a/frontend/__snapshots__/scenes-app-experiments--complete-funnel-experiment.png and b/frontend/__snapshots__/scenes-app-experiments--complete-funnel-experiment.png differ diff --git a/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment.png b/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment.png index 60c850a72b2db..766de1662f8ae 100644 Binary files a/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment.png and b/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment.png differ diff --git a/frontend/__snapshots__/scenes-app-notebooks--notebook-not-found.png b/frontend/__snapshots__/scenes-app-notebooks--notebook-not-found.png index 0df1f64e9ec3c..6286e7ae27078 100644 Binary files a/frontend/__snapshots__/scenes-app-notebooks--notebook-not-found.png and b/frontend/__snapshots__/scenes-app-notebooks--notebook-not-found.png differ diff --git a/frontend/__snapshots__/scenes-app-notebooks--notebooks-list.png b/frontend/__snapshots__/scenes-app-notebooks--notebooks-list.png index c9f29c566c2c6..d6e7ef6b4a71f 100644 Binary files a/frontend/__snapshots__/scenes-app-notebooks--notebooks-list.png and b/frontend/__snapshots__/scenes-app-notebooks--notebooks-list.png differ diff --git a/frontend/__snapshots__/scenes-app-pipeline--pipeline-app-logs.png b/frontend/__snapshots__/scenes-app-pipeline--pipeline-app-logs.png index f7ea3ec3cff02..fc3f054686e53 100644 Binary files a/frontend/__snapshots__/scenes-app-pipeline--pipeline-app-logs.png and b/frontend/__snapshots__/scenes-app-pipeline--pipeline-app-logs.png differ diff --git a/frontend/__snapshots__/scenes-app-surveys--surveys-list.png b/frontend/__snapshots__/scenes-app-surveys--surveys-list.png index 012692ee2758d..80ccaa5e006fd 100644 Binary files a/frontend/__snapshots__/scenes-app-surveys--surveys-list.png and b/frontend/__snapshots__/scenes-app-surveys--surveys-list.png differ diff --git a/frontend/__snapshots__/scenes-other-login--sso-error.png b/frontend/__snapshots__/scenes-other-login--sso-error.png index 4bac52c291407..37309681cfb75 100644 Binary files a/frontend/__snapshots__/scenes-other-login--sso-error.png and b/frontend/__snapshots__/scenes-other-login--sso-error.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-organization.png b/frontend/__snapshots__/scenes-other-settings--settings-organization.png index 240b6b4c957e5..0e5752e576d8b 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-organization.png and b/frontend/__snapshots__/scenes-other-settings--settings-organization.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-project.png b/frontend/__snapshots__/scenes-other-settings--settings-project.png index 71514ddb64f12..7e0fee45a376d 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-project.png and b/frontend/__snapshots__/scenes-other-settings--settings-project.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-user.png b/frontend/__snapshots__/scenes-other-settings--settings-user.png index 5574601d11794..7d18a6db46dd5 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-user.png and b/frontend/__snapshots__/scenes-other-settings--settings-user.png differ diff --git a/frontend/src/layout/navigation-3000/components/Breadcrumbs.scss b/frontend/src/layout/navigation-3000/components/Breadcrumbs.scss index c143a10085e9a..0cf4e5a260384 100644 --- a/frontend/src/layout/navigation-3000/components/Breadcrumbs.scss +++ b/frontend/src/layout/navigation-3000/components/Breadcrumbs.scss @@ -54,6 +54,7 @@ overflow: hidden; height: calc(1.2em * (1 - var(--breadcrumbs-compaction-rate))); box-sizing: content-box; + font-family: var(--font-sans) !important; > * { position: absolute; diff --git a/frontend/src/layout/navigation-3000/sidebars/personsAndGroups.ts b/frontend/src/layout/navigation-3000/sidebars/personsAndGroups.ts index 2cefac31cf5ba..d5f7fc5e2eeae 100644 --- a/frontend/src/layout/navigation-3000/sidebars/personsAndGroups.ts +++ b/frontend/src/layout/navigation-3000/sidebars/personsAndGroups.ts @@ -98,16 +98,17 @@ export const personsAndGroupsSidebarLogic = kea { - const { searchTerm } = values - const displayId = groupDisplayId(group.group_key, group.group_properties) - return { - key: group.group_key, - name: displayId, - url: urls.group(groupType.group_type_index, group.group_key), - searchMatch: findSearchTermInItemName(displayId, searchTerm), - } as BasicListItem - }), + items: + groups[groupType.group_type_index]?.results.map((group) => { + const { searchTerm } = values + const displayId = groupDisplayId(group.group_key, group.group_properties) + return { + key: group.group_key, + name: displayId, + url: urls.group(groupType.group_type_index, group.group_key), + searchMatch: findSearchTermInItemName(displayId, searchTerm), + } as BasicListItem + }) || [], loading: groupsLoading[groupType.group_type_index], // FIXME: Add remote } as SidebarCategory) diff --git a/frontend/src/lib/components/BillingAlertsV2.tsx b/frontend/src/lib/components/BillingAlertsV2.tsx index 21a4023a6262a..6b3a0a7b4c3bb 100644 --- a/frontend/src/lib/components/BillingAlertsV2.tsx +++ b/frontend/src/lib/components/BillingAlertsV2.tsx @@ -12,18 +12,26 @@ export function BillingAlertsV2(): JSX.Element | null { const [alertHidden, setAlertHidden] = useState(false) useEffect(() => { + if (billingAlert?.pathName && currentLocation.pathname !== billingAlert?.pathName) { + setAlertHidden(true) + } else { + setAlertHidden(false) + } if (billingAlert) { reportBillingAlertShown(billingAlert) } - }, [billingAlert]) + }, [billingAlert, currentLocation]) if (!billingAlert || alertHidden) { return null } - const showButton = billingAlert.contactSupport || currentLocation.pathname !== urls.organizationBilling() + const showButton = + billingAlert.action || billingAlert.contactSupport || currentLocation.pathname !== urls.organizationBilling() - const buttonProps = billingAlert.contactSupport + const buttonProps = billingAlert.action + ? billingAlert.action + : billingAlert.contactSupport ? { to: 'mailto:sales@posthog.com', children: billingAlert.buttonCTA || 'Contact support', diff --git a/frontend/src/lib/components/BridgePage/BridgePage.scss b/frontend/src/lib/components/BridgePage/BridgePage.scss index d0c2d285ae248..fb780e9cfe9c4 100644 --- a/frontend/src/lib/components/BridgePage/BridgePage.scss +++ b/frontend/src/lib/components/BridgePage/BridgePage.scss @@ -7,7 +7,8 @@ flex-direction: column; flex: 1; overflow: hidden; - height: 100vh; + min-height: 100vh; + height: 100%; &::-webkit-scrollbar { width: 0 !important; diff --git a/frontend/src/lib/components/CompactList/CompactList.scss b/frontend/src/lib/components/CompactList/CompactList.scss index 673c6863d10df..930ea6f17b1b1 100644 --- a/frontend/src/lib/components/CompactList/CompactList.scss +++ b/frontend/src/lib/components/CompactList/CompactList.scss @@ -32,7 +32,13 @@ padding: 0 0.5rem 0.5rem; } + .LemonButton { + font-family: var(--font-sans) !important; + } + .secondary-text { - color: var(--text-secondary); + .posthog-3000 & { + color: var(--text-secondary); + } } } diff --git a/frontend/src/lib/lemon-ui/LemonBanner/LemonBanner.scss b/frontend/src/lib/lemon-ui/LemonBanner/LemonBanner.scss index e165aaa435d4f..9a948c4f24dd3 100644 --- a/frontend/src/lib/lemon-ui/LemonBanner/LemonBanner.scss +++ b/frontend/src/lib/lemon-ui/LemonBanner/LemonBanner.scss @@ -1,13 +1,14 @@ .LemonBanner { + align-items: center; border-radius: var(--radius); - padding: 0.5rem 0.75rem; + border: solid 1px var(--border-3000); color: var(--primary-alt); - font-weight: 500; display: flex; - align-items: center; - text-align: left; + font-weight: 500; gap: 0.5rem; min-height: 3rem; + padding: 0.5rem 0.75rem; + text-align: left; &.LemonBanner--info { background-color: var(--primary-alt-highlight); diff --git a/frontend/src/lib/lemon-ui/LemonButton/LemonButton.scss b/frontend/src/lib/lemon-ui/LemonButton/LemonButton.scss index 7190ef5c1b0d4..5ed9970665fda 100644 --- a/frontend/src/lib/lemon-ui/LemonButton/LemonButton.scss +++ b/frontend/src/lib/lemon-ui/LemonButton/LemonButton.scss @@ -1,24 +1,33 @@ .LemonButton { - position: relative; - transition: background-color 200ms ease, color 200ms ease, border 200ms ease, opacity 200ms ease, - transform 100ms ease; - display: flex; - flex-direction: row; - flex-shrink: 0; align-items: center; - justify-content: flex-start; - padding: 0.25rem 0.75rem; - gap: 0.5rem; + appearance: none !important; // Important as this gets overridden by Ant styles... background: none; border-radius: var(--radius); border: none; + cursor: pointer; + display: flex; + flex-direction: row; + + .posthog-3000 & { + font-family: var(--font-title); + } + + flex-shrink: 0; font-size: 0.875rem; - text-align: left; - line-height: 1.5rem; font-weight: 500; - cursor: pointer; + gap: 0.5rem; + justify-content: flex-start; + line-height: 1.5rem; + padding: 0.25rem 0.75rem; + position: relative; + text-align: left; + transition: background-color 200ms ease, color 200ms ease, border 200ms ease, opacity 200ms ease, + transform 100ms ease; user-select: none; - appearance: none !important; // Important as this gets overridden by Ant styles... + + .font-normal { + font-family: var(--font-sans); + } > span { display: flex; diff --git a/frontend/src/queries/nodes/InsightViz/PropertyGroupFilters/PropertyGroupFilters.scss b/frontend/src/queries/nodes/InsightViz/PropertyGroupFilters/PropertyGroupFilters.scss index 0c2b080492e42..6637f4f265e04 100644 --- a/frontend/src/queries/nodes/InsightViz/PropertyGroupFilters/PropertyGroupFilters.scss +++ b/frontend/src/queries/nodes/InsightViz/PropertyGroupFilters/PropertyGroupFilters.scss @@ -1,6 +1,11 @@ .PropertyGroupFilters { .property-group { background-color: var(--side); + + .posthog-3000 & { + border-width: 1px; + } + padding: 0.5rem; border-radius: 4px; } diff --git a/frontend/src/scenes/billing/BillingLimitInput.tsx b/frontend/src/scenes/billing/BillingLimitInput.tsx index 6f1dd652a4acb..fac3943214ea7 100644 --- a/frontend/src/scenes/billing/BillingLimitInput.tsx +++ b/frontend/src/scenes/billing/BillingLimitInput.tsx @@ -7,12 +7,14 @@ import { billingProductLogic } from './billingProductLogic' import { LemonButton, LemonInput } from '@posthog/lemon-ui' import { Tooltip } from 'lib/lemon-ui/Tooltip' import clsx from 'clsx' +import { useRef } from 'react' export const BillingLimitInput = ({ product }: { product: BillingProductV2Type }): JSX.Element | null => { + const limitInputRef = useRef(null) const { billing, billingLoading } = useValues(billingLogic) const { updateBillingLimits } = useActions(billingLogic) const { isEditingBillingLimit, showBillingLimitInput, billingLimitInput, customLimitUsd } = useValues( - billingProductLogic({ product }) + billingProductLogic({ product, billingLimitInputRef: limitInputRef }) ) const { setIsEditingBillingLimit, setBillingLimitInput } = useActions(billingProductLogic({ product })) @@ -78,7 +80,7 @@ export const BillingLimitInput = ({ product }: { product: BillingProductV2Type } return null } return ( -
+
{!isEditingBillingLimit ? ( @@ -104,6 +106,7 @@ export const BillingLimitInput = ({ product }: { product: BillingProductV2Type } <>
): BillingV2Type => { @@ -53,6 +56,8 @@ const parseBillingResponse = (data: Partial): BillingV2Type => { export const billingLogic = kea([ path(['scenes', 'billing', 'billingLogic']), actions({ + setProductSpecificAlert: (productSpecificAlert: BillingAlertConfig | null) => ({ productSpecificAlert }), + setScrollToProductKey: (scrollToProductKey: ProductKey | null) => ({ scrollToProductKey }), setShowLicenseDirectInput: (show: boolean) => ({ show }), reportBillingAlertShown: (alertConfig: BillingAlertConfig) => ({ alertConfig }), reportBillingAlertActionClicked: (alertConfig: BillingAlertConfig) => ({ alertConfig }), @@ -66,6 +71,18 @@ export const billingLogic = kea([ actions: [userLogic, ['loadUser'], eventUsageLogic, ['reportProductUnsubscribed']], }), reducers({ + scrollToProductKey: [ + null as ProductKey | null, + { + setScrollToProductKey: (_, { scrollToProductKey }) => scrollToProductKey, + }, + ], + productSpecificAlert: [ + null as BillingAlertConfig | null, + { + setProductSpecificAlert: (_, { productSpecificAlert }) => productSpecificAlert, + }, + ], showLicenseDirectInput: [ false, { @@ -144,8 +161,12 @@ export const billingLogic = kea([ }, ], billingAlert: [ - (s) => [s.billing, s.preflight, s.projectedTotalAmountUsd], - (billing, preflight, projectedTotalAmountUsd): BillingAlertConfig | undefined => { + (s) => [s.billing, s.preflight, s.projectedTotalAmountUsd, s.productSpecificAlert], + (billing, preflight, projectedTotalAmountUsd, productSpecificAlert): BillingAlertConfig | undefined => { + if (productSpecificAlert) { + return productSpecificAlert + } + if (!billing || !preflight?.cloud) { return } @@ -320,6 +341,10 @@ export const billingLogic = kea([ actions.setActivateLicenseValues({ license: hash.license }) actions.submitActivateLicense() } + if (_search.products) { + const products = _search.products.split(',') + actions.setScrollToProductKey(products[0]) + } actions.setRedirectPath() actions.setIsOnboarding() }, diff --git a/frontend/src/scenes/billing/billingProductLogic.ts b/frontend/src/scenes/billing/billingProductLogic.ts index aeb72f177c5be..723e152e9723d 100644 --- a/frontend/src/scenes/billing/billingProductLogic.ts +++ b/frontend/src/scenes/billing/billingProductLogic.ts @@ -1,21 +1,34 @@ -import { actions, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea' +import { actions, connect, events, kea, key, listeners, path, props, reducers, selectors } from 'kea' import { BillingProductV2AddonType, BillingProductV2Type, BillingV2PlanType, BillingV2TierType } from '~/types' import { billingLogic } from './billingLogic' import type { billingProductLogicType } from './billingProductLogicType' import { convertAmountToUsage } from './billing-utils' import posthog from 'posthog-js' +import React from 'react' const DEFAULT_BILLING_LIMIT = 500 +export interface BillingProductLogicProps { + product: BillingProductV2Type | BillingProductV2AddonType + billingLimitInputRef?: React.MutableRefObject +} + export const billingProductLogic = kea([ + props({} as BillingProductLogicProps), key((props) => props.product.type), path(['scenes', 'billing', 'billingProductLogic']), connect({ - values: [billingLogic, ['billing', 'isUnlicensedDebug']], - actions: [billingLogic, ['loadBillingSuccess', 'updateBillingLimitsSuccess', 'deactivateProduct']], - }), - props({ - product: {} as BillingProductV2Type | BillingProductV2AddonType, + values: [billingLogic, ['billing', 'isUnlicensedDebug', 'scrollToProductKey']], + actions: [ + billingLogic, + [ + 'loadBillingSuccess', + 'updateBillingLimitsSuccess', + 'deactivateProduct', + 'setProductSpecificAlert', + 'setScrollToProductKey', + ], + ], }), actions({ setIsEditingBillingLimit: (isEditingBillingLimit: boolean) => ({ isEditingBillingLimit }), @@ -215,5 +228,40 @@ export const billingProductLogic = kea([ }) actions.setSurveyID('') }, + setScrollToProductKey: ({ scrollToProductKey }) => { + if (scrollToProductKey && scrollToProductKey === props.product.type) { + const { currentPlan } = values.currentAndUpgradePlans + + if (currentPlan.initial_billing_limit) { + actions.setProductSpecificAlert({ + status: 'warning', + title: 'Billing Limit Automatically Applied', + pathName: '/organization/billing', + dismissKey: `auto-apply-billing-limit-${props.product.type}`, + message: `To protect your costs and ours, we've automatically applied a $${currentPlan?.initial_billing_limit} billing limit for ${props.product.name}.`, + action: { + onClick: () => { + actions.setIsEditingBillingLimit(true) + setTimeout(() => { + if (props.billingLimitInputRef?.current) { + props.billingLimitInputRef?.current.focus() + props.billingLimitInputRef?.current.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + }) + } + }, 0) + }, + children: 'Update billing limit', + }, + }) + } + } + }, + })), + events(({ actions, values }) => ({ + afterMount: () => { + actions.setScrollToProductKey(values.scrollToProductKey) + }, })), ]) diff --git a/frontend/src/scenes/feature-flags/FeatureFlag.scss b/frontend/src/scenes/feature-flags/FeatureFlag.scss index 0b72506c172cb..319512c5f7670 100644 --- a/frontend/src/scenes/feature-flags/FeatureFlag.scss +++ b/frontend/src/scenes/feature-flags/FeatureFlag.scss @@ -64,6 +64,10 @@ } .FeatureConditionCard { + .posthog-3000 & { + background: var(--bg-light); + } + .FeatureConditionCard--border--highlight { border-color: var(--primary-3000); } diff --git a/frontend/src/scenes/saved-insights/newInsightsMenu.tsx b/frontend/src/scenes/saved-insights/newInsightsMenu.tsx index 3e67827c42907..b4eba27666d1a 100644 --- a/frontend/src/scenes/saved-insights/newInsightsMenu.tsx +++ b/frontend/src/scenes/saved-insights/newInsightsMenu.tsx @@ -33,7 +33,7 @@ export function overlayForNewInsightMenu(dataAttr: string): ReactNode[] { >
{listedInsightTypeMetadata.name} - {listedInsightTypeMetadata.description} + {listedInsightTypeMetadata.description}
) diff --git a/frontend/src/scenes/scenes.ts b/frontend/src/scenes/scenes.ts index b174df0ff581b..5df2ac36f289a 100644 --- a/frontend/src/scenes/scenes.ts +++ b/frontend/src/scenes/scenes.ts @@ -118,7 +118,7 @@ export const sceneConfigurations: Partial> = { }, [Scene.Experiments]: { projectBased: true, - name: 'Experiments', + name: 'A/B testing', }, [Scene.Experiment]: { projectBased: true, diff --git a/frontend/src/scenes/session-recordings/player/inspector/performance-event-utils.ts b/frontend/src/scenes/session-recordings/player/inspector/performance-event-utils.ts index 564b96fb865db..8d432f4b0d6a5 100644 --- a/frontend/src/scenes/session-recordings/player/inspector/performance-event-utils.ts +++ b/frontend/src/scenes/session-recordings/player/inspector/performance-event-utils.ts @@ -134,6 +134,11 @@ export function mapRRWebNetworkRequest( } }) + // KLUDGE: this shouldn't be necessary but let's display correctly while we figure out why it is. + if (!data.name && 'url' in capturedRequest) { + data.name = capturedRequest.url as string | undefined + } + return data as PerformanceEvent } diff --git a/frontend/src/styles/fonts.scss b/frontend/src/styles/fonts.scss index 9633ed07675ee..60443636e9b6b 100644 --- a/frontend/src/styles/fonts.scss +++ b/frontend/src/styles/fonts.scss @@ -29,3 +29,39 @@ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } + +/* Matter; bold (800); latin */ +@font-face { + font-family: MatterSQ; + font-style: normal; + font-weight: 800; + font-display: swap; + + // src: url('../../public/MatterSQ-Bold.woff2') format('woff2'), url('../../public/MatterSQ-Bold.woff') format('woff'); + src: url('https://d1sdjtjk6xzm7.cloudfront.net/MatterSQ-Bold.woff2') format('woff2'), + url('https://d1sdjtjk6xzm7.cloudfront.net/MatterSQ-Bold.woff') format('woff'); +} + +/* Matter; bold (700); latin */ +@font-face { + font-family: MatterSQ; + font-style: normal; + font-weight: 700; + font-display: swap; + + // src: url('../../public/MatterSQ-SemiBold.woff2') format('woff2'), url('../../public/MatterSQ-SemiBold.woff') format('woff'); + src: url('https://d1sdjtjk6xzm7.cloudfront.net/MatterSQ-SemiBold.woff2') format('woff2'), + url('https://d1sdjtjk6xzm7.cloudfront.net/MatterSQ-SemiBold.woff') format('woff'); +} + +/* Matter; medium (500); latin */ +@font-face { + font-family: MatterSQ; + font-style: normal; + font-weight: 500; + font-display: swap; + + // src: url('../../public/MatterSQ-Medium.woff2') format('woff2'), url('../../public/MatterSQ-Medium.woff') format('woff'); + src: url('https://d1sdjtjk6xzm7.cloudfront.net/public/MatterSQ-Medium.woff2') format('woff2'), + url('https://d1sdjtjk6xzm7.cloudfront.net/public/MatterSQ-Medium.woff') format('woff'); +} diff --git a/frontend/src/styles/global.scss b/frontend/src/styles/global.scss index 06991540e0ffa..69d3d66238b01 100644 --- a/frontend/src/styles/global.scss +++ b/frontend/src/styles/global.scss @@ -673,6 +673,13 @@ body { } } } + + h1, + h2, + h3, + h4 { + font-family: var(--font-title); + } } h1, diff --git a/frontend/src/styles/vars.scss b/frontend/src/styles/vars.scss index 4689f47f2ea04..fe7b07bb77ffa 100644 --- a/frontend/src/styles/vars.scss +++ b/frontend/src/styles/vars.scss @@ -125,6 +125,7 @@ $colors: ( 'secondary-3000-hover-light': #cfd1c2, 'accent-3000-light': #eeefe9, 'bg-3000-light': #f3f4ef, + 'bg-hover-3000-light': #f3f4ef, 'border-3000-light': #dadbd2, 'border-bold-3000-light': #c1c2b9, 'glass-bg-3000-light': #e4e5deb3, @@ -157,11 +158,12 @@ $colors: ( 'secondary-3000-dark': #1d1f27, 'secondary-3000-hover-dark': #575d77, - 'accent-3000-dark': #232429, + 'accent-3000-dark': #21242b, 'bg-3000-dark': #1d1f27, - 'border-3000-dark': #4a4c52, + 'bg-hover-3000-dark': #292b36, + 'border-3000-dark': #35373e, 'border-bold-3000-dark': #3f4046, - 'glass-bg-3000-dark': #1d1f27b3, + 'glass-bg-3000-dark': #21242bb3, 'glass-border-3000-dark': var(--border-3000-dark), 'link-3000-dark': #f1a82c, @@ -189,10 +191,11 @@ $colors: ( 'secondary-3000-hover': var(--secondary-3000-hover), 'accent-3000': var(--accent-3000), 'bg-3000': var(--bg-3000), + 'bg-hover-3000': var(--bg-hover-3000), 'border-3000': var(--border-3000), 'border-bold-3000': var(--border-bold-3000), 'glass-bg-3000': var(--glass-bg-3000), - 'glass-border-3000': var(--glass-border-3000), + 'glass-border-3000': var(--border-3000), 'link-3000': var(--link-3000), // 'bg-light': var(--accent-3000), 'primary-3000-frame-bg': var(--primary-3000-frame-bg), @@ -240,6 +243,8 @@ $_lifecycle_dormant: $_danger; --font-semibold: 600; --font-sans: -apple-system, blinkmacsystemfont, 'Inter', 'Segoe UI', 'Roboto', 'Helvetica Neue', helvetica, arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; + --font-title: 'MatterSQ', -apple-system, blinkmacsystemfont, 'Inter', 'Segoe UI', 'Roboto', 'Helvetica Neue', + helvetica, arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; --font-mono: ui-monospace, 'SFMono-Regular', 'SF Mono', 'Menlo', 'Consolas', 'Liberation Mono', monospace; // Dashboard item colors diff --git a/posthog/api/test/batch_exports/test_delete.py b/posthog/api/test/batch_exports/test_delete.py index 20375cecbb768..cc07ed4675151 100644 --- a/posthog/api/test/batch_exports/test_delete.py +++ b/posthog/api/test/batch_exports/test_delete.py @@ -241,3 +241,48 @@ def test_deletes_are_partitioned_by_team_id(client: HttpClient): # Make sure we can still get the export with the right user response = get_batch_export(client, team.pk, batch_export_id) assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db(transaction=True) +def test_delete_batch_export_even_without_underlying_schedule(client: HttpClient): + """Test deleting a BatchExport completes even if underlying Schedule was already deleted.""" + temporal = sync_connect() + + destination_data = { + "type": "S3", + "config": { + "bucket_name": "my-production-s3-bucket", + "region": "us-east-1", + "prefix": "posthog-events/", + "aws_access_key_id": "abc123", + "aws_secret_access_key": "secret", + }, + } + batch_export_data = { + "name": "my-production-s3-bucket-destination", + "destination": destination_data, + "interval": "hour", + } + + organization = create_organization("Test Org") + team = create_team(organization) + user = create_user("test@user.com", "Test User", organization) + client.force_login(user) + + with start_test_worker(temporal): + batch_export = create_batch_export_ok(client, team.pk, batch_export_data) + batch_export_id = batch_export["id"] + + handle = temporal.get_schedule_handle(batch_export_id) + async_to_sync(handle.delete)() + + with pytest.raises(RPCError): + describe_schedule(temporal, batch_export_id) + + delete_batch_export_ok(client, team.pk, batch_export_id) + + response = get_batch_export(client, team.pk, batch_export_id) + assert response.status_code == status.HTTP_404_NOT_FOUND + + with pytest.raises(RPCError): + describe_schedule(temporal, batch_export_id) diff --git a/posthog/api/test/test_team.py b/posthog/api/test/test_team.py index 8924667fbbea8..0431abea04817 100644 --- a/posthog/api/test/test_team.py +++ b/posthog/api/test/test_team.py @@ -1,6 +1,7 @@ import json from typing import List, cast -from unittest.mock import ANY, MagicMock, patch +from unittest import mock +from unittest.mock import MagicMock, call, patch from asgiref.sync import sync_to_async from django.core.cache import cache @@ -219,15 +220,16 @@ def test_delete_team_own_second(self, mock_capture: MagicMock, mock_delete_bulky AsyncDeletion.objects.filter(team_id=team.id, deletion_type=DeletionType.Team, key=str(team.id)).count(), 1, ) - mock_capture.assert_called_once_with( - self.user.distinct_id, - "team deleted", - properties={}, - groups={ - "instance": ANY, - "organization": str(self.organization.id), - "project": str(self.team.uuid), - }, + mock_capture.assert_has_calls( + calls=[ + call( + self.user.distinct_id, + "membership level changed", + properties={"new_level": 8, "previous_level": 1}, + groups=mock.ANY, + ), + call(self.user.distinct_id, "team deleted", properties={}, groups=mock.ANY), + ] ) mock_delete_bulky_postgres_data.assert_called_once_with(team_ids=[team.pk]) diff --git a/posthog/batch_exports/http.py b/posthog/batch_exports/http.py index 8d6005ec663f8..cef17ab628f32 100644 --- a/posthog/batch_exports/http.py +++ b/posthog/batch_exports/http.py @@ -2,6 +2,7 @@ from typing import Any import posthoganalytics +import structlog from django.db import transaction from django.utils.timezone import now from rest_framework import mixins, request, response, serializers, viewsets @@ -27,6 +28,7 @@ BatchExportIdError, BatchExportServiceError, BatchExportServiceRPCError, + BatchExportServiceScheduleNotFound, backfill_export, cancel_running_batch_export_backfill, delete_schedule, @@ -49,6 +51,8 @@ from posthog.temporal.client import sync_connect from posthog.utils import relative_date_parse +logger = structlog.get_logger(__name__) + def validate_date_input(date_input: Any) -> dt.datetime: """Parse any datetime input as a proper dt.datetime. @@ -320,10 +324,22 @@ def unpause(self, request: request.Request, *args, **kwargs) -> response.Respons return response.Response({"paused": False}) def perform_destroy(self, instance: BatchExport): - """Perform a BatchExport destroy by clearing Temporal and Django state.""" - instance.deleted = True + """Perform a BatchExport destroy by clearing Temporal and Django state. + + If the underlying Temporal Schedule doesn't exist, we ignore the error and proceed with the delete anyways. + The Schedule could have been manually deleted causing Django and Temporal to go out of sync. For whatever reason, + since we are deleting, we assume that we can recover from this state by finishing the delete operation by calling + instance.save(). + """ temporal = sync_connect() - delete_schedule(temporal, str(instance.pk)) + + instance.deleted = True + + try: + delete_schedule(temporal, str(instance.pk)) + except BatchExportServiceScheduleNotFound as e: + logger.warning("The Schedule %s could not be deleted as it was not found", e.schedule_id) + instance.save() for backfill in BatchExportBackfill.objects.filter(batch_export=instance): diff --git a/posthog/batch_exports/service.py b/posthog/batch_exports/service.py index fc74d6f51f253..38cecda263aaa 100644 --- a/posthog/batch_exports/service.py +++ b/posthog/batch_exports/service.py @@ -3,6 +3,7 @@ from dataclasses import asdict, dataclass, fields from uuid import UUID +import temporalio from asgiref.sync import async_to_sync from temporalio.client import ( Client, @@ -163,6 +164,14 @@ class BatchExportServiceRPCError(BatchExportServiceError): """Exception raised when the underlying Temporal RPC fails.""" +class BatchExportServiceScheduleNotFound(BatchExportServiceRPCError): + """Exception raised when the underlying Temporal RPC fails because a schedule was not found.""" + + def __init__(self, schedule_id: str): + self.schedule_id = schedule_id + super().__init__(f"The Temporal Schedule {schedule_id} was not found (maybe it was deleted?)") + + def pause_batch_export(temporal: Client, batch_export_id: str, note: str | None = None) -> None: """Pause this BatchExport. @@ -250,7 +259,14 @@ async def unpause_schedule(temporal: Client, schedule_id: str, note: str | None async def delete_schedule(temporal: Client, schedule_id: str) -> None: """Delete a Temporal Schedule.""" handle = temporal.get_schedule_handle(schedule_id) - await handle.delete() + + try: + await handle.delete() + except temporalio.service.RPCError as e: + if e.status == temporalio.service.RPCStatusCode.NOT_FOUND: + raise BatchExportServiceScheduleNotFound(schedule_id) + else: + raise BatchExportServiceRPCError() from e @async_to_sync diff --git a/posthog/event_usage.py b/posthog/event_usage.py index fa69f0c23662b..7cd1945d37df4 100644 --- a/posthog/event_usage.py +++ b/posthog/event_usage.py @@ -196,6 +196,26 @@ def report_bulk_invited( ) +def report_user_organization_membership_level_changed( + user: User, + organization: Organization, + new_level: int, + previous_level: int, +) -> None: + """ + Triggered after a user's membership level in an organization is changed. + """ + posthoganalytics.capture( + user.distinct_id, + "membership level changed", + properties={ + "new_level": new_level, + "previous_level": previous_level, + }, + groups=groups(organization), + ) + + def report_user_action(user: User, event: str, properties: Dict = {}): posthoganalytics.capture( user.distinct_id, diff --git a/posthog/models/organization.py b/posthog/models/organization.py index 869ba9f0f6e75..461c5f777f568 100644 --- a/posthog/models/organization.py +++ b/posthog/models/organization.py @@ -24,12 +24,7 @@ from posthog.cloud_utils import is_cloud from posthog.constants import MAX_SLUG_LENGTH, AvailableFeature from posthog.email import is_email_available -from posthog.models.utils import ( - LowercaseSlugField, - UUIDModel, - create_with_slug, - sane_repr, -) +from posthog.models.utils import LowercaseSlugField, UUIDModel, create_with_slug, sane_repr from posthog.redis import get_client from posthog.utils import absolute_uri @@ -416,3 +411,19 @@ def ensure_organization_membership_consistency(sender, instance: OrganizationMem save_user = True if save_user: instance.user.save() + + +@receiver(models.signals.pre_save, sender=OrganizationMembership) +def organization_membership_saved(sender: Any, instance: OrganizationMembership, **kwargs: Any) -> None: + from posthog.event_usage import report_user_organization_membership_level_changed + + try: + old_instance = OrganizationMembership.objects.get(id=instance.id) + if old_instance.level != instance.level: + # the level has been changed + report_user_organization_membership_level_changed( + instance.user, instance.organization, instance.level, old_instance.level + ) + except OrganizationMembership.DoesNotExist: + # The instance is new, or we are setting up test data + pass diff --git a/posthog/models/test/test_organization_model.py b/posthog/models/test/test_organization_model.py index f140dcc862f26..8c35602a64be5 100644 --- a/posthog/models/test/test_organization_model.py +++ b/posthog/models/test/test_organization_model.py @@ -1,8 +1,10 @@ from unittest import mock +from unittest.mock import patch from django.utils import timezone from posthog.models import Organization, OrganizationInvite, Plugin +from posthog.models.organization import OrganizationMembership from posthog.plugins.test.mock import mocked_plugin_requests_get from posthog.plugins.test.plugin_archives import HELLO_WORLD_PLUGIN_GITHUB_ZIP from posthog.test.base import BaseTest @@ -77,3 +79,28 @@ def test_update_available_features_ignored_if_usage_info_exists(self): new_org.usage = {"events": {"usage": 1000, "limit": None}} new_org.update_available_features() assert new_org.available_features == ["test1", "test2"] + + +class TestOrganizationMembership(BaseTest): + @patch("posthoganalytics.capture") + def test_event_sent_when_membership_level_changed( + self, + mock_capture, + ): + user = self._create_user("user1") + organization = Organization.objects.create(name="Test Org") + membership = OrganizationMembership.objects.create(user=user, organization=organization, level=1) + mock_capture.assert_not_called() + # change the level + membership.level = 15 + membership.save() + # check that the event was sent + mock_capture.assert_called_once_with( + user.distinct_id, + "membership level changed", + properties={ + "new_level": 15, + "previous_level": 1, + }, + groups=mock.ANY, + ) diff --git a/posthog/models/test/test_user_model.py b/posthog/models/test/test_user_model.py index fe26931522eac..9c07f36b16466 100644 --- a/posthog/models/test/test_user_model.py +++ b/posthog/models/test/test_user_model.py @@ -10,6 +10,7 @@ def test_create_user_with_distinct_id(self): self.assertNotEqual(user.distinct_id, None) def test_analytics_metadata(self): + self.maxDiff = None # One org, one team, anonymized organization, team, user = User.objects.bootstrap( organization_name="Test Org", @@ -32,6 +33,7 @@ def test_analytics_metadata(self): "team_member_count_all": 1, "completed_onboarding_once": False, "organization_id": str(organization.id), + "current_organization_membership_level": 15, "project_id": str(team.uuid), "project_setup_complete": False, "has_password_set": True, @@ -67,6 +69,7 @@ def test_analytics_metadata(self): "team_member_count_all": 2, "completed_onboarding_once": True, "organization_id": str(self.organization.id), + "current_organization_membership_level": 1, "project_id": str(self.team.uuid), "project_setup_complete": True, "has_password_set": True, diff --git a/posthog/models/user.py b/posthog/models/user.py index 353d20ae31d9c..b25c12776fb1b 100644 --- a/posthog/models/user.py +++ b/posthog/models/user.py @@ -275,6 +275,10 @@ def get_analytics_metadata(self): .count() ) + current_organization_membership = None + if self.organization: + current_organization_membership = self.organization.memberships.filter(user=self).first() + project_setup_complete = False if self.team and self.team.completed_snippet_onboarding and self.team.ingested_event: project_setup_complete = True @@ -293,6 +297,9 @@ def get_analytics_metadata(self): ).exists(), # has completed the onboarding at least for one project # properties dependent on current project / org below "organization_id": str(self.organization.id) if self.organization else None, + "current_organization_membership_level": current_organization_membership.level + if current_organization_membership + else None, "project_id": str(self.team.uuid) if self.team else None, "project_setup_complete": project_setup_complete, "joined_at": self.date_joined, diff --git a/posthog/test/test_middleware.py b/posthog/test/test_middleware.py index b5efb9c731891..88e2ba6813f6e 100644 --- a/posthog/test/test_middleware.py +++ b/posthog/test/test_middleware.py @@ -116,7 +116,7 @@ class TestAutoProjectMiddleware(APIBaseTest): @classmethod def setUpTestData(cls): super().setUpTestData() - cls.base_app_num_queries = 40 + cls.base_app_num_queries = 41 # Create another team that the user does have access to cls.second_team = Team.objects.create(organization=cls.organization, name="Second Life") diff --git a/posthog/test/test_user_permissions.py b/posthog/test/test_user_permissions.py index da9faef7330ad..b0562dbca57af 100644 --- a/posthog/test/test_user_permissions.py +++ b/posthog/test/test_user_permissions.py @@ -321,7 +321,7 @@ def test_dashboard_efficiency(self): assert user_permissions.insight(insight).effective_privilege_level is not None def test_team_lookup_efficiency(self): - user = User.objects.create(email="test2@posthog.com") + user = User.objects.create(email="test2@posthog.com", distinct_id="test2") models = [] for _ in range(10): organization, membership, team = Organization.objects.bootstrap(