diff --git a/cypress/e2e/billingUpgradeCTA.cy.ts b/cypress/e2e/billingUpgradeCTA.cy.ts new file mode 100644 index 0000000000000..be4a0a9a03e44 --- /dev/null +++ b/cypress/e2e/billingUpgradeCTA.cy.ts @@ -0,0 +1,57 @@ +import { decideResponse } from '../fixtures/api/decide' +import * as fflate from 'fflate' + +// Mainly testing to make sure events are fired as expected + +describe('Billing Upgrade CTA', () => { + beforeEach(() => { + cy.intercept('**/decide/*', (req) => + req.reply( + decideResponse({ + 'billing-upgrade-language': 'credit_card', + }) + ) + ) + + cy.intercept('/api/billing-v2/', { fixture: 'api/billing-v2/billing-v2-unsubscribed.json' }) + }) + + it('Check that events are being sent on each page visit', () => { + cy.visit('/organization/billing') + cy.get('[data-attr=product_analytics-upgrade-cta] .LemonButton__content').should('have.text', 'Add credit card') + cy.window().then((win) => { + const events = (win as any)._cypress_posthog_captures + + const matchingEvents = events.filter((event) => event.event === 'billing CTA shown') + // One for each product card + expect(matchingEvents.length).to.equal(4) + }) + + // Mock billing response with subscription + cy.intercept('/api/billing-v2/', { fixture: 'api/billing-v2/billing-v2.json' }) + cy.reload() + + cy.get('[data-attr=session_replay-upgrade-cta] .LemonButton__content').should('have.text', 'Add paid plan') + cy.intercept('POST', '**/e/?compression=gzip-js*').as('capture3') + cy.window().then((win) => { + const events = (win as any)._cypress_posthog_captures + + const matchingEvents = events.filter((event) => event.event === 'billing CTA shown') + expect(matchingEvents.length).to.equal(4) + }) + + cy.intercept('/api/billing-v2/', { fixture: 'api/billing-v2/billing-v2-unsubscribed.json' }) + // Navigate to the onboarding billing step + cy.visit('/products') + cy.get('[data-attr=product_analytics-onboarding-card]').click() + cy.get('[data-attr=onboarding-breadcrumbs] > :nth-child(5)').click() + + cy.intercept('POST', '**/e/?compression=gzip-js*').as('capture4') + cy.window().then((win) => { + const events = (win as any)._cypress_posthog_captures + + const matchingEvents = events.filter((event) => event.event === 'billing CTA shown') + expect(matchingEvents.length).to.equal(3) + }) + }) +}) diff --git a/cypress/e2e/events.cy.ts b/cypress/e2e/events.cy.ts index d8fea2656987c..fab6627b467d0 100644 --- a/cypress/e2e/events.cy.ts +++ b/cypress/e2e/events.cy.ts @@ -72,7 +72,7 @@ describe('Events', () => { cy.get('[data-attr="new-prop-filter-EventPropertyFilters.0"]').click() cy.get('[data-attr=taxonomic-filter-searchfield]').click() cy.get('[data-attr=prop-filter-event_properties-0]').click() - cy.get('[data-attr=prop-val] .ant-select-selector').click({ force: true }) + cy.get('[data-attr=prop-val] .LemonInput').click({ force: true }) cy.wait('@getBrowserValues').then(() => { cy.get('[data-attr=prop-val-0]').click() cy.get('.DataTable').should('exist') diff --git a/cypress/e2e/surveys.cy.ts b/cypress/e2e/surveys.cy.ts index 7d72c850c7ac4..e3efcd4c48a0f 100644 --- a/cypress/e2e/surveys.cy.ts +++ b/cypress/e2e/surveys.cy.ts @@ -94,7 +94,7 @@ describe('Surveys', () => { // select the first property cy.get('[data-attr="property-select-toggle-0"]').click() cy.get('[data-attr="prop-filter-person_properties-0"]').click() - cy.get('[data-attr=prop-val] .ant-select-selector').click({ force: true }) + cy.get('[data-attr=prop-val] .LemonInput').click({ force: true }) cy.get('[data-attr=prop-val-0]').click({ force: true }) cy.get('[data-attr="rollout-percentage"]').type('100') diff --git a/cypress/fixtures/api/billing-v2/billing-v2-unsubscribed.json b/cypress/fixtures/api/billing-v2/billing-v2-unsubscribed.json index 60aed5c9693c4..0aec292a9d38d 100644 --- a/cypress/fixtures/api/billing-v2/billing-v2-unsubscribed.json +++ b/cypress/fixtures/api/billing-v2/billing-v2-unsubscribed.json @@ -47,17 +47,17 @@ "api_access", "social_sso", "community_support", - "terms_and_conditions" + "2fa" ], "license": { - "plan": "cloud" + "plan": "dev" }, - "customer_id": null, + "customer_id": "cus_Pg7PIL8MsKi6bx", "deactivated": false, "has_active_subscription": false, "billing_period": { - "current_period_start": "2024-02-06T19:37:14.843Z", - "current_period_end": "2024-03-07T19:37:14.843Z", + "current_period_start": "2024-03-04T23:43:35.772Z", + "current_period_end": "2024-04-03T23:43:35.772Z", "interval": "month" }, "available_product_features": [ @@ -438,12 +438,12 @@ "note": null }, { - "key": "terms_and_conditions", - "name": "Terms and conditions", - "description": "Terms and conditions", + "key": "2fa", + "name": "2FA", + "description": "Secure your PostHog account with two-factor authentication.", "unit": null, "limit": null, - "note": "Standard" + "note": null } ], "current_total_amount_usd": null, @@ -464,7 +464,7 @@ { "plan_key": "free-20230117", "product_key": "product_analytics", - "name": "Product analytics", + "name": "Free", "description": "A comprehensive product analytics platform built to natively work with session replay, feature flags, A/B testing, and surveys.", "image_url": "https://posthog.com/images/products/product-analytics/product-analytics.png", "docs_url": "https://posthog.com/docs/product-analytics", @@ -515,12 +515,14 @@ ], "tiers": null, "current_plan": true, - "included_if": null + "included_if": null, + "contact_support": null, + "unit_amount_usd": null }, { "plan_key": "paid-20240111", "product_key": "product_analytics", - "name": "Product analytics", + "name": "Paid", "description": "A comprehensive product analytics platform built to natively work with session replay, feature flags, A/B testing, and surveys.", "image_url": "https://posthog.com/images/products/product-analytics/product-analytics.png", "docs_url": "https://posthog.com/docs/product-analytics", @@ -576,30 +578,6 @@ "limit": null, "note": null }, - { - "key": "dashboard_permissioning", - "name": "Dashboard permissions", - "description": "Restrict access to dashboards within the organization to only those who need it.", - "unit": null, - "limit": null, - "note": null - }, - { - "key": "dashboard_collaboration", - "name": "Tags & text cards", - "description": "Keep organized by adding tags to your dashboards, cohorts and more. Add text cards and descriptions to your dashboards to provide context to your team.", - "unit": null, - "limit": null, - "note": null - }, - { - "key": "ingestion_taxonomy", - "name": "Ingestion taxonomy", - "description": "Ingestion taxonomy", - "unit": null, - "limit": null, - "note": null - }, { "key": "correlation_analysis", "name": "Correlation analysis", @@ -608,14 +586,6 @@ "limit": null, "note": null }, - { - "key": "tagging", - "name": "Dashboard tags", - "description": "Organize dashboards with tags.", - "unit": null, - "limit": null, - "note": null - }, { "key": "behavioral_cohort_filtering", "name": "Lifecycle", @@ -645,7 +615,7 @@ }, { "flat_amount_usd": "0", - "unit_amount_usd": "0.000248", + "unit_amount_usd": "0.00031", "up_to": 2000000, "current_amount_usd": "0.00", "current_usage": 0, @@ -654,7 +624,7 @@ }, { "flat_amount_usd": "0", - "unit_amount_usd": "0.000104", + "unit_amount_usd": "0.00013", "up_to": 15000000, "current_amount_usd": "0.00", "current_usage": 0, @@ -663,7 +633,7 @@ }, { "flat_amount_usd": "0", - "unit_amount_usd": "0.0000655", + "unit_amount_usd": "0.0000819", "up_to": 50000000, "current_amount_usd": "0.00", "current_usage": 0, @@ -672,7 +642,7 @@ }, { "flat_amount_usd": "0", - "unit_amount_usd": "0.0000364", + "unit_amount_usd": "0.0000455", "up_to": 100000000, "current_amount_usd": "0.00", "current_usage": 0, @@ -681,7 +651,7 @@ }, { "flat_amount_usd": "0", - "unit_amount_usd": "0.0000187", + "unit_amount_usd": "0.0000234", "up_to": 250000000, "current_amount_usd": "0.00", "current_usage": 0, @@ -690,7 +660,7 @@ }, { "flat_amount_usd": "0", - "unit_amount_usd": "0.0000042", + "unit_amount_usd": "0.0000052", "up_to": null, "current_amount_usd": "0.00", "current_usage": 0, @@ -699,7 +669,9 @@ } ], "current_plan": false, - "included_if": null + "included_if": null, + "contact_support": null, + "unit_amount_usd": null } ], "type": "product_analytics", @@ -737,7 +709,7 @@ }, { "flat_amount_usd": "0", - "unit_amount_usd": "0.000071", + "unit_amount_usd": "0.0000708", "up_to": 2000000, "current_amount_usd": "0.00", "current_usage": 0, @@ -803,7 +775,7 @@ { "plan_key": "addon-20230509", "product_key": "group_analytics", - "name": "Group analytics", + "name": "Addon", "description": "Associate events with a group or entity - such as a company, community, or project. Analyze these events as if they were sent by that entity itself. Great for B2B, marketplaces, and more.", "image_url": "https://posthog.com/images/product/product-icons/group-analytics.svg", "docs_url": "https://posthog.com/docs/product-analytics/group-analytics", @@ -832,7 +804,7 @@ }, { "flat_amount_usd": "0", - "unit_amount_usd": "0.000071", + "unit_amount_usd": "0.0000708", "up_to": 2000000, "current_amount_usd": "0.00", "current_usage": 0, @@ -886,10 +858,12 @@ } ], "current_plan": false, - "included_if": null + "included_if": null, + "contact_support": null, + "unit_amount_usd": null } ], - "contact_support": false + "contact_support": null }, { "name": "Data pipelines", @@ -911,7 +885,7 @@ }, { "flat_amount_usd": "0", - "unit_amount_usd": "0.000062", + "unit_amount_usd": "0.000248", "up_to": 2000000, "current_amount_usd": "0.00", "current_usage": 0, @@ -920,7 +894,7 @@ }, { "flat_amount_usd": "0", - "unit_amount_usd": "0.000026", + "unit_amount_usd": "0.000104", "up_to": 15000000, "current_amount_usd": "0.00", "current_usage": 0, @@ -929,7 +903,7 @@ }, { "flat_amount_usd": "0", - "unit_amount_usd": "0.0000164", + "unit_amount_usd": "0.0000655", "up_to": 50000000, "current_amount_usd": "0.00", "current_usage": 0, @@ -938,7 +912,7 @@ }, { "flat_amount_usd": "0", - "unit_amount_usd": "0.0000091", + "unit_amount_usd": "0.0000364", "up_to": 100000000, "current_amount_usd": "0.00", "current_usage": 0, @@ -947,7 +921,7 @@ }, { "flat_amount_usd": "0", - "unit_amount_usd": "0.0000047", + "unit_amount_usd": "0.0000187", "up_to": 250000000, "current_amount_usd": "0.00", "current_usage": 0, @@ -956,7 +930,7 @@ }, { "flat_amount_usd": "0", - "unit_amount_usd": "0.000001", + "unit_amount_usd": "0.0000042", "up_to": null, "current_amount_usd": "0.00", "current_usage": 0, @@ -977,7 +951,7 @@ { "plan_key": "addon-20240111", "product_key": "data_pipelines", - "name": "Data pipelines", + "name": "Addon", "description": "Get your PostHog data into your data warehouse or other tools like BigQuery, Redshift, Customer.io, and more.", "image_url": null, "docs_url": "https://posthog.com/docs/cdp/batch-exports", @@ -1006,7 +980,7 @@ }, { "flat_amount_usd": "0", - "unit_amount_usd": "0.000062", + "unit_amount_usd": "0.000248", "up_to": 2000000, "current_amount_usd": "0.00", "current_usage": 0, @@ -1015,7 +989,7 @@ }, { "flat_amount_usd": "0", - "unit_amount_usd": "0.000026", + "unit_amount_usd": "0.000104", "up_to": 15000000, "current_amount_usd": "0.00", "current_usage": 0, @@ -1024,7 +998,7 @@ }, { "flat_amount_usd": "0", - "unit_amount_usd": "0.0000164", + "unit_amount_usd": "0.0000655", "up_to": 50000000, "current_amount_usd": "0.00", "current_usage": 0, @@ -1033,7 +1007,7 @@ }, { "flat_amount_usd": "0", - "unit_amount_usd": "0.0000091", + "unit_amount_usd": "0.0000364", "up_to": 100000000, "current_amount_usd": "0.00", "current_usage": 0, @@ -1042,7 +1016,7 @@ }, { "flat_amount_usd": "0", - "unit_amount_usd": "0.0000047", + "unit_amount_usd": "0.0000187", "up_to": 250000000, "current_amount_usd": "0.00", "current_usage": 0, @@ -1051,7 +1025,7 @@ }, { "flat_amount_usd": "0", - "unit_amount_usd": "0.000001", + "unit_amount_usd": "0.0000042", "up_to": null, "current_amount_usd": "0.00", "current_usage": 0, @@ -1060,10 +1034,12 @@ } ], "current_plan": false, - "included_if": null + "included_if": null, + "contact_support": null, + "unit_amount_usd": null } ], - "contact_support": false + "contact_support": null } ], "contact_support": false, @@ -1131,30 +1107,6 @@ "icon_key": "IconNotification", "type": "secondary" }, - { - "key": "dashboard_collaboration", - "name": "Tags & text cards", - "description": "Keep organized by adding tags to your dashboards, cohorts and more. Add text cards and descriptions to your dashboards to provide context to your team.", - "images": null, - "icon_key": null, - "type": null - }, - { - "key": "dashboard_permissioning", - "name": "Dashboard permissions", - "description": "Restrict access to dashboards within the organization to only those who need it.", - "images": null, - "icon_key": null, - "type": null - }, - { - "key": "ingestion_taxonomy", - "name": "Ingestion taxonomy", - "description": "Ingestion taxonomy", - "images": null, - "icon_key": null, - "type": null - }, { "key": "paths_advanced", "name": "Advanced paths", @@ -1174,14 +1126,6 @@ "icon_key": null, "type": "primary" }, - { - "key": "tagging", - "name": "Dashboard tags", - "description": "Organize dashboards with tags.", - "images": null, - "icon_key": null, - "type": null - }, { "key": "behavioral_cohort_filtering", "name": "Lifecycle", @@ -1256,7 +1200,7 @@ { "plan_key": "free-20231218", "product_key": "session_replay", - "name": "Session replay", + "name": "Free", "description": "Session replay helps you diagnose issues and understand user behavior in your product or website.", "image_url": "https://posthog.com/images/products/session-replay/session-replay.png", "docs_url": "https://posthog.com/docs/session-replay", @@ -1379,12 +1323,14 @@ ], "tiers": null, "current_plan": true, - "included_if": null + "included_if": null, + "contact_support": null, + "unit_amount_usd": null }, { "plan_key": "paid-20231218", "product_key": "session_replay", - "name": "Session replay", + "name": "Paid", "description": "Session replay helps you diagnose issues and understand user behavior in your product or website.", "image_url": "https://posthog.com/images/products/session-replay/session-replay.png", "docs_url": "https://posthog.com/docs/session-replay", @@ -1570,7 +1516,9 @@ } ], "current_plan": false, - "included_if": null + "included_if": null, + "contact_support": null, + "unit_amount_usd": null } ], "type": "session_replay", @@ -1753,7 +1701,7 @@ { "plan_key": "free-20230117", "product_key": "feature_flags", - "name": "Feature flags & A/B testing", + "name": "Free", "description": "Test changes with small groups of users before rolling out wider. Analyze usage with product analytics and session replay.", "image_url": "https://posthog.com/images/products/feature-flags/feature-flags.png", "docs_url": "https://posthog.com/docs/feature-flags", @@ -1836,12 +1784,14 @@ ], "tiers": null, "current_plan": true, - "included_if": null + "included_if": null, + "contact_support": null, + "unit_amount_usd": null }, { "plan_key": "paid-20230623", "product_key": "feature_flags", - "name": "Feature flags & A/B testing", + "name": "Paid", "description": "Test changes with small groups of users before rolling out wider. Analyze usage with product analytics and session replay.", "image_url": "https://posthog.com/images/products/feature-flags/feature-flags.png", "docs_url": "https://posthog.com/docs/feature-flags", @@ -2018,7 +1968,9 @@ } ], "current_plan": false, - "included_if": null + "included_if": null, + "contact_support": null, + "unit_amount_usd": null } ], "type": "feature_flags", @@ -2215,7 +2167,7 @@ { "plan_key": "free-20230928", "product_key": "surveys", - "name": "Surveys", + "name": "Free", "description": "Build in-app popups with freeform text responses, multiple choice, NPS, ratings, and emoji reactions. Or use the API for complete control.", "image_url": "https://posthog.com/images/products/surveys/surveys.png", "docs_url": "https://posthog.com/docs/surveys", @@ -2290,12 +2242,14 @@ ], "tiers": null, "current_plan": true, - "included_if": null + "included_if": null, + "contact_support": null, + "unit_amount_usd": null }, { "plan_key": "paid-20230928", "product_key": "surveys", - "name": "Surveys", + "name": "Paid", "description": "Build in-app popups with freeform text responses, multiple choice, NPS, ratings, and emoji reactions. Or use the API for complete control.", "image_url": "https://posthog.com/images/products/surveys/surveys.png", "docs_url": "https://posthog.com/docs/surveys", @@ -2449,7 +2403,9 @@ } ], "current_plan": false, - "included_if": null + "included_if": null, + "contact_support": null, + "unit_amount_usd": null } ], "type": "surveys", @@ -2617,7 +2573,7 @@ { "plan_key": "free-20230117", "product_key": "integrations", - "name": "Integrations", + "name": "Free", "description": "Connect PostHog to your favorite tools.", "image_url": "https://posthog.com/images/product/product-icons/integrations.svg", "docs_url": "https://posthog.com/docs/apps", @@ -2668,12 +2624,14 @@ ], "tiers": null, "current_plan": true, - "included_if": "no_active_subscription" + "included_if": "no_active_subscription", + "contact_support": null, + "unit_amount_usd": null }, { "plan_key": "paid-20230117", "product_key": "integrations", - "name": "Integrations", + "name": "Paid", "description": "Connect PostHog to your favorite tools.", "image_url": "https://posthog.com/images/product/product-icons/integrations.svg", "docs_url": "https://posthog.com/docs/apps", @@ -2732,7 +2690,9 @@ ], "tiers": null, "current_plan": false, - "included_if": "has_subscription" + "included_if": "has_subscription", + "contact_support": null, + "unit_amount_usd": null } ], "type": "integrations", @@ -2818,7 +2778,7 @@ { "plan_key": "free-20230117", "product_key": "platform_and_support", - "name": "Platform and support", + "name": "Totally free", "description": "SSO, permission management, and support.", "image_url": "https://posthog.com/images/product/product-icons/platform.svg", "docs_url": "https://posthog.com/docs", @@ -2875,22 +2835,114 @@ "note": null }, { - "key": "terms_and_conditions", - "name": "Terms and conditions", - "description": "Terms and conditions", + "key": "2fa", + "name": "2FA", + "description": "Secure your PostHog account with two-factor authentication.", "unit": null, "limit": null, - "note": "Standard" + "note": null } ], "tiers": null, "current_plan": true, - "included_if": "no_active_subscription" + "included_if": "no_active_subscription", + "contact_support": null, + "unit_amount_usd": null }, { - "plan_key": "paid-20230926", + "plan_key": "paid-20240208", "product_key": "platform_and_support", - "name": "Platform and support", + "name": "With subscription", + "description": "SSO, permission management, and support.", + "image_url": "https://posthog.com/images/product/product-icons/platform.svg", + "docs_url": "https://posthog.com/docs", + "note": null, + "unit": null, + "free_allocation": null, + "features": [ + { + "key": "tracked_users", + "name": "Tracked users", + "description": "Track users across devices and sessions.", + "unit": null, + "limit": null, + "note": "Unlimited" + }, + { + "key": "team_members", + "name": "Team members", + "description": "PostHog doesn't charge per seat add your entire team!", + "unit": null, + "limit": null, + "note": "Unlimited" + }, + { + "key": "organizations_projects", + "name": "Projects", + "description": "Create silos of data within PostHog. All data belongs to a single project and all queries are project-specific.", + "unit": "projects", + "limit": 2, + "note": null + }, + { + "key": "api_access", + "name": "API access", + "description": "Access your data via our developer-friendly API.", + "unit": null, + "limit": null, + "note": null + }, + { + "key": "social_sso", + "name": "SSO via Google, Github, or Gitlab", + "description": "Log in to PostHog with your Google, Github, or Gitlab account.", + "unit": null, + "limit": null, + "note": null + }, + { + "key": "community_support", + "name": "Community support", + "description": "Get help from other users and PostHog team members in our Community forums.", + "unit": null, + "limit": null, + "note": null + }, + { + "key": "dedicated_support", + "name": "Dedicated account manager", + "description": "Work with a dedicated account manager via Slack or email to help you get the most out of PostHog.", + "unit": null, + "limit": null, + "note": "$2k+/month spend" + }, + { + "key": "email_support", + "name": "Email support", + "description": "Get help directly from our product engineers via email. No wading through multiple support people before you get help.", + "unit": null, + "limit": null, + "note": null + }, + { + "key": "2fa", + "name": "2FA", + "description": "Secure your PostHog account with two-factor authentication.", + "unit": null, + "limit": null, + "note": null + } + ], + "tiers": null, + "current_plan": false, + "included_if": "has_subscription", + "contact_support": null, + "unit_amount_usd": null + }, + { + "plan_key": "teams-20240208", + "product_key": "platform_and_support", + "name": "Teams", "description": "SSO, permission management, and support.", "image_url": "https://posthog.com/images/product/product-icons/platform.svg", "docs_url": "https://posthog.com/docs", @@ -2938,6 +2990,70 @@ "limit": null, "note": null }, + { + "key": "sso_enforcement", + "name": "Enforce SSO login", + "description": "Users can only sign up and log in to your PostHog organization with your specified SSO provider.", + "unit": null, + "limit": null, + "note": null + }, + { + "key": "2fa", + "name": "2FA", + "description": "Secure your PostHog account with two-factor authentication.", + "unit": null, + "limit": null, + "note": null + }, + { + "key": "2fa_enforcement", + "name": "Enforce 2FA", + "description": "Require all users in your organization to enable two-factor authentication.", + "unit": null, + "limit": null, + "note": null + }, + { + "key": "community_support", + "name": "Community support", + "description": "Get help from other users and PostHog team members in our Community forums.", + "unit": null, + "limit": null, + "note": null + }, + { + "key": "email_support", + "name": "Email support", + "description": "Get help directly from our product engineers via email. No wading through multiple support people before you get help.", + "unit": null, + "limit": null, + "note": null + }, + { + "key": "dedicated_support", + "name": "Dedicated account manager", + "description": "Work with a dedicated account manager via Slack or email to help you get the most out of PostHog.", + "unit": null, + "limit": null, + "note": "$2k+/month spend" + }, + { + "key": "priority_support", + "name": "Priority support", + "description": "Get help from our team faster than other customers.", + "unit": null, + "limit": null, + "note": null + }, + { + "key": "white_labelling", + "name": "White labeling", + "description": "Use your own branding on surveys, shared dashboards, shared insights, and more.", + "unit": null, + "limit": null, + "note": null + }, { "key": "project_based_permissioning", "name": "Project permissions", @@ -2946,10 +3062,228 @@ "limit": null, "note": null }, + { + "key": "advanced_permissions", + "name": "Advanced permissions", + "description": "Control who can access and modify data and features within your organization.", + "unit": null, + "limit": null, + "note": "Project-based only" + }, + { + "key": "audit_logs", + "name": "Audit logs", + "description": "See who in your organization has accessed or modified entities within PostHog.", + "unit": null, + "limit": null, + "note": "Basic" + }, + { + "key": "security_assessment", + "name": "Security assessment", + "description": "Security assessment", + "unit": null, + "limit": null, + "note": null + }, + { + "key": "hipaa_baa", + "name": "HIPAA BAA", + "description": "Get a signed HIPAA Business Associate Agreement (BAA) to use PostHog in a HIPAA-compliant manner.", + "unit": null, + "limit": null, + "note": null + }, + { + "key": "team_collaboration", + "name": "Team collaboration features", + "description": "Work together better with tags on dashboards and insights; descriptions on insights, events, & properties; verified events; comments on almost anything.", + "unit": null, + "limit": null, + "note": null + }, + { + "key": "ingestion_taxonomy", + "name": "Ingestion taxonomy", + "description": "Mark events as verified or unverified to help you understand the quality of your data.", + "unit": null, + "limit": null, + "note": null + }, + { + "key": "tagging", + "name": "Dashboard tags", + "description": "Organize dashboards with tags.", + "unit": null, + "limit": null, + "note": null + } + ], + "tiers": [], + "current_plan": false, + "included_if": null, + "contact_support": null, + "unit_amount_usd": "450.00" + }, + { + "plan_key": "enterprise-20240208", + "product_key": "platform_and_support", + "name": "Enterprise", + "description": "SSO, permission management, and support.", + "image_url": "https://posthog.com/images/product/product-icons/platform.svg", + "docs_url": "https://posthog.com/docs", + "note": null, + "unit": null, + "free_allocation": null, + "features": [ + { + "key": "team_members", + "name": "Team members", + "description": "PostHog doesn't charge per seat add your entire team!", + "unit": null, + "limit": null, + "note": "Unlimited" + }, + { + "key": "organizations_projects", + "name": "Projects", + "description": "Create silos of data within PostHog. All data belongs to a single project and all queries are project-specific.", + "unit": null, + "limit": null, + "note": "Unlimited" + }, + { + "key": "tracked_users", + "name": "Tracked users", + "description": "Track users across devices and sessions.", + "unit": null, + "limit": null, + "note": "Unlimited" + }, + { + "key": "api_access", + "name": "API access", + "description": "Access your data via our developer-friendly API.", + "unit": null, + "limit": null, + "note": null + }, { "key": "white_labelling", "name": "White labeling", - "description": "Use your own branding in your PostHog organization.", + "description": "Use your own branding on surveys, shared dashboards, shared insights, and more.", + "unit": null, + "limit": null, + "note": null + }, + { + "key": "team_collaboration", + "name": "Team collaboration features", + "description": "Work together better with tags on dashboards and insights; descriptions on insights, events, & properties; verified events; comments on almost anything.", + "unit": null, + "limit": null, + "note": null + }, + { + "key": "ingestion_taxonomy", + "name": "Ingestion taxonomy", + "description": "Mark events as verified or unverified to help you understand the quality of your data.", + "unit": null, + "limit": null, + "note": null + }, + { + "key": "tagging", + "name": "Dashboard tags", + "description": "Organize dashboards with tags.", + "unit": null, + "limit": null, + "note": null + }, + { + "key": "social_sso", + "name": "SSO via Google, Github, or Gitlab", + "description": "Log in to PostHog with your Google, Github, or Gitlab account.", + "unit": null, + "limit": null, + "note": null + }, + { + "key": "sso_enforcement", + "name": "Enforce SSO login", + "description": "Users can only sign up and log in to your PostHog organization with your specified SSO provider.", + "unit": null, + "limit": null, + "note": null + }, + { + "key": "saml", + "name": "SAML SSO", + "description": "Allow your organization's users to log in with SAML.", + "unit": null, + "limit": null, + "note": null + }, + { + "key": "2fa", + "name": "2FA", + "description": "Secure your PostHog account with two-factor authentication.", + "unit": null, + "limit": null, + "note": null + }, + { + "key": "2fa_enforcement", + "name": "Enforce 2FA", + "description": "Require all users in your organization to enable two-factor authentication.", + "unit": null, + "limit": null, + "note": null + }, + { + "key": "project_based_permissioning", + "name": "Project permissions", + "description": "Restrict access to data within the organization to only those who need it.", + "unit": null, + "limit": null, + "note": null + }, + { + "key": "role_based_access", + "name": "Role-based access", + "description": "Control access to features like experiments, session recordings, and feature flags with custom roles.", + "unit": null, + "limit": null, + "note": null + }, + { + "key": "advanced_permissions", + "name": "Advanced permissions", + "description": "Control who can access and modify data and features within your organization.", + "unit": null, + "limit": null, + "note": null + }, + { + "key": "audit_logs", + "name": "Audit logs", + "description": "See who in your organization has accessed or modified entities within PostHog.", + "unit": null, + "limit": null, + "note": "Advanced" + }, + { + "key": "hipaa_baa", + "name": "HIPAA BAA", + "description": "Get a signed HIPAA Business Associate Agreement (BAA) to use PostHog in a HIPAA-compliant manner.", + "unit": null, + "limit": null, + "note": null + }, + { + "key": "custom_msa", + "name": "Custom MSA", + "description": "Get a custom Master Services Agreement (MSA) to use PostHog in a way that fits your company's needs.", "unit": null, "limit": null, "note": null @@ -2963,28 +3297,28 @@ "note": null }, { - "key": "dedicated_support", - "name": "Slack (dedicated channel)", - "description": "Get help directly from our support team in a dedicated Slack channel shared between you and the PostHog team.", + "key": "email_support", + "name": "Email support", + "description": "Get help directly from our product engineers via email. No wading through multiple support people before you get help.", "unit": null, "limit": null, - "note": "$2k/month spend or above" + "note": null }, { - "key": "email_support", - "name": "Direct access to engineers", - "description": "Get help directly from our product engineers via email. No wading through multiple support people before you get help.", + "key": "dedicated_support", + "name": "Dedicated account manager", + "description": "Work with a dedicated account manager via Slack or email to help you get the most out of PostHog.", "unit": null, "limit": null, "note": null }, { - "key": "terms_and_conditions", - "name": "Terms and conditions", - "description": "Terms and conditions", + "key": "priority_support", + "name": "Priority support", + "description": "Get help from our team faster than other customers.", "unit": null, "limit": null, - "note": "Standard" + "note": null }, { "key": "security_assessment", @@ -2993,11 +3327,29 @@ "unit": null, "limit": null, "note": null + }, + { + "key": "training", + "name": "Ongoing training", + "description": "Get training from our team to help you quickly get up and running with PostHog.", + "unit": null, + "limit": null, + "note": null + }, + { + "key": "configuration_support", + "name": "Personalized onboarding", + "description": "Get help from our team to create dashboards that will help you understand your data and your business.", + "unit": null, + "limit": null, + "note": null } ], "tiers": null, "current_plan": false, - "included_if": "has_subscription" + "included_if": null, + "contact_support": true, + "unit_amount_usd": null } ], "type": "platform_and_support", @@ -3015,7 +3367,7 @@ "projected_amount_usd": null, "unit": null, "addons": [], - "contact_support": true, + "contact_support": false, "inclusion_only": true, "features": [ { @@ -3051,17 +3403,17 @@ "type": null }, { - "key": "role_based_access", - "name": "Role-based access", - "description": "Control access to features like experiments, session recordings, and feature flags with custom roles.", + "key": "social_sso", + "name": "SSO via Google, Github, or Gitlab", + "description": "Log in to PostHog with your Google, Github, or Gitlab account.", "images": null, "icon_key": null, "type": null }, { - "key": "social_sso", - "name": "SSO via Google, Github, or Gitlab", - "description": "Log in to PostHog with your Google, Github, or Gitlab account.", + "key": "role_based_access", + "name": "Role-based access", + "description": "Control access to features like experiments, session recordings, and feature flags with custom roles.", "images": null, "icon_key": null, "type": null @@ -3074,6 +3426,14 @@ "icon_key": null, "type": null }, + { + "key": "advanced_permissions", + "name": "Advanced permissions", + "description": "Control who can access and modify data and features within your organization.", + "images": null, + "icon_key": null, + "type": null + }, { "key": "saml", "name": "SAML SSO", @@ -3090,10 +3450,26 @@ "icon_key": null, "type": null }, + { + "key": "2fa", + "name": "2FA", + "description": "Secure your PostHog account with two-factor authentication.", + "images": null, + "icon_key": null, + "type": null + }, + { + "key": "2fa_enforcement", + "name": "Enforce 2FA", + "description": "Require all users in your organization to enable two-factor authentication.", + "images": null, + "icon_key": null, + "type": null + }, { "key": "white_labelling", "name": "White labeling", - "description": "Use your own branding in your PostHog organization.", + "description": "Use your own branding on surveys, shared dashboards, shared insights, and more.", "images": null, "icon_key": null, "type": null @@ -3108,31 +3484,31 @@ }, { "key": "dedicated_support", - "name": "Slack (dedicated channel)", - "description": "Get help directly from our support team in a dedicated Slack channel shared between you and the PostHog team.", + "name": "Dedicated account manager", + "description": "Work with a dedicated account manager via Slack or email to help you get the most out of PostHog.", "images": null, "icon_key": null, "type": null }, { "key": "email_support", - "name": "Direct access to engineers", + "name": "Email support", "description": "Get help directly from our product engineers via email. No wading through multiple support people before you get help.", "images": null, "icon_key": null, "type": null }, { - "key": "account_manager", - "name": "Account manager", - "description": "Work with a dedicated account manager to help you get the most out of PostHog.", + "key": "priority_support", + "name": "Priority support", + "description": "Get help from our team faster than other customers.", "images": null, "icon_key": null, "type": null }, { "key": "training", - "name": "Training sessions", + "name": "Ongoing training", "description": "Get training from our team to help you quickly get up and running with PostHog.", "images": null, "icon_key": null, @@ -3140,7 +3516,7 @@ }, { "key": "configuration_support", - "name": "Dashboard configuration support", + "name": "Personalized onboarding", "description": "Get help from our team to create dashboards that will help you understand your data and your business.", "images": null, "icon_key": null, @@ -3185,6 +3561,54 @@ "images": null, "icon_key": null, "type": null + }, + { + "key": "audit_logs", + "name": "Audit logs", + "description": "See who in your organization has accessed or modified entities within PostHog.", + "images": null, + "icon_key": null, + "type": null + }, + { + "key": "hipaa_baa", + "name": "HIPAA BAA", + "description": "Get a signed HIPAA Business Associate Agreement (BAA) to use PostHog in a HIPAA-compliant manner.", + "images": null, + "icon_key": null, + "type": null + }, + { + "key": "custom_msa", + "name": "Custom MSA", + "description": "Get a custom Master Services Agreement (MSA) to use PostHog in a way that fits your company's needs.", + "images": null, + "icon_key": null, + "type": null + }, + { + "key": "team_collaboration", + "name": "Team collaboration features", + "description": "Work together better with tags on dashboards and insights; descriptions on insights, events, & properties; verified events; comments on almost anything.", + "images": null, + "icon_key": null, + "type": null + }, + { + "key": "ingestion_taxonomy", + "name": "Ingestion taxonomy", + "description": "Mark events as verified or unverified to help you understand the quality of your data.", + "images": null, + "icon_key": null, + "type": null + }, + { + "key": "tagging", + "name": "Dashboard tags", + "description": "Organize dashboards with tags.", + "images": null, + "icon_key": null, + "type": null } ] } @@ -3213,5 +3637,5 @@ "discount_amount_usd": null, "amount_off_expires_at": null, "never_drop_data": null, - "stripe_portal_url": null + "stripe_portal_url": "https://billing.stripe.com/p/session/test_YWNjdF8xSElNRERFdUlhdFJYU2R6LF9QZzdwUjRPMXBobnRsdHdKaDVpbEVzbkREcE9RQnFT0100S0djyn8G" } diff --git a/cypress/productAnalytics/index.ts b/cypress/productAnalytics/index.ts index 00b9279410e1b..6fd8a5c521de5 100644 --- a/cypress/productAnalytics/index.ts +++ b/cypress/productAnalytics/index.ts @@ -215,14 +215,6 @@ export const dashboard = { cy.get('[data-attr="prop-val-0"]').click({ force: true }) cy.get('.PropertyFilterButton').should('have.length', 1) }, - addPropertyFilter(type: string = 'Browser', value: string = 'Chrome'): void { - cy.get('.PropertyFilterButton').should('have.length', 0) - cy.get('[data-attr="property-filter-0"]').click() - cy.get('[data-attr="taxonomic-filter-searchfield"]').click().type('Browser').wait(1000) - cy.get('[data-attr="prop-filter-event_properties-0"]').click({ force: true }) - cy.get('.ant-select-selector').type(value) - cy.get('.ant-select-item-option-content').click({ force: true }) - }, } export function createInsight(insightName: string): void { diff --git a/docker-compose.base.yml b/docker-compose.base.yml index 2e5ce0c2bdb9a..073af7c1d04bb 100644 --- a/docker-compose.base.yml +++ b/docker-compose.base.yml @@ -103,7 +103,6 @@ services: KAFKA_HOSTS: 'kafka:9092' REDIS_URL: 'redis://redis:6379/' - plugins: command: ./bin/plugin-server --no-restart-loop restart: on-failure @@ -152,8 +151,6 @@ services: volumes: - /var/lib/elasticsearch/data temporal: - - environment: - DB=postgresql - DB_PORT=5432 @@ -190,4 +187,3 @@ services: restart: on-failure environment: TEMPORAL_HOST: temporal - diff --git a/docker-compose.dev-full.yml b/docker-compose.dev-full.yml index ba940322fb3dd..b8dbe9ebd3c7e 100644 --- a/docker-compose.dev-full.yml +++ b/docker-compose.dev-full.yml @@ -182,4 +182,4 @@ services: - clickhouse - kafka - object_storage - - temporal \ No newline at end of file + - temporal diff --git a/frontend/@posthog/lemon-ui/src/index.ts b/frontend/@posthog/lemon-ui/src/index.ts index bd2d23a5b74ea..dd674ab2c4e30 100644 --- a/frontend/@posthog/lemon-ui/src/index.ts +++ b/frontend/@posthog/lemon-ui/src/index.ts @@ -21,7 +21,7 @@ export * from 'lib/lemon-ui/LemonModal' export * from 'lib/lemon-ui/LemonRow' export * from 'lib/lemon-ui/LemonSegmentedButton' export * from 'lib/lemon-ui/LemonSelect' -export * from 'lib/lemon-ui/LemonSelectMultiple' +export * from 'lib/lemon-ui/LemonInputSelect' export * from 'lib/lemon-ui/LemonSkeleton' export * from 'lib/lemon-ui/LemonSnack' export * from 'lib/lemon-ui/LemonSwitch' diff --git a/frontend/__snapshots__/filters-cohort-filters-fields-person-properties--basic--dark.png b/frontend/__snapshots__/filters-cohort-filters-fields-person-properties--basic--dark.png index f10afc853072e..f97d12fe86644 100644 Binary files a/frontend/__snapshots__/filters-cohort-filters-fields-person-properties--basic--dark.png and b/frontend/__snapshots__/filters-cohort-filters-fields-person-properties--basic--dark.png differ diff --git a/frontend/__snapshots__/filters-cohort-filters-fields-person-properties--basic--light.png b/frontend/__snapshots__/filters-cohort-filters-fields-person-properties--basic--light.png index 61b90277049e8..418f247258351 100644 Binary files a/frontend/__snapshots__/filters-cohort-filters-fields-person-properties--basic--light.png and b/frontend/__snapshots__/filters-cohort-filters-fields-person-properties--basic--light.png differ diff --git a/frontend/__snapshots__/filters-propertyfilters--comparing-property-filters--dark.png b/frontend/__snapshots__/filters-propertyfilters--comparing-property-filters--dark.png index 6be95c84a2b94..78d03d3f2878f 100644 Binary files a/frontend/__snapshots__/filters-propertyfilters--comparing-property-filters--dark.png and b/frontend/__snapshots__/filters-propertyfilters--comparing-property-filters--dark.png differ diff --git a/frontend/__snapshots__/filters-propertyfilters--comparing-property-filters--light.png b/frontend/__snapshots__/filters-propertyfilters--comparing-property-filters--light.png index d56fbb0049291..c85b6df7b3923 100644 Binary files a/frontend/__snapshots__/filters-propertyfilters--comparing-property-filters--light.png and b/frontend/__snapshots__/filters-propertyfilters--comparing-property-filters--light.png differ diff --git a/frontend/__snapshots__/lemon-ui-colors--color-palette--dark.png b/frontend/__snapshots__/lemon-ui-colors--color-palette--dark.png index 9b77208d77294..74150138e8bd3 100644 Binary files a/frontend/__snapshots__/lemon-ui-colors--color-palette--dark.png and b/frontend/__snapshots__/lemon-ui-colors--color-palette--dark.png differ diff --git a/frontend/__snapshots__/lemon-ui-colors--color-palette--light.png b/frontend/__snapshots__/lemon-ui-colors--color-palette--light.png index 48775cfc8bad6..62cf1c2370dad 100644 Binary files a/frontend/__snapshots__/lemon-ui-colors--color-palette--light.png and b/frontend/__snapshots__/lemon-ui-colors--color-palette--light.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-input-select--default--dark.png b/frontend/__snapshots__/lemon-ui-lemon-input-select--default--dark.png new file mode 100644 index 0000000000000..96cc9e2c65fb9 Binary files /dev/null and b/frontend/__snapshots__/lemon-ui-lemon-input-select--default--dark.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-input-select--default--light.png b/frontend/__snapshots__/lemon-ui-lemon-input-select--default--light.png new file mode 100644 index 0000000000000..7ff297a125ea3 Binary files /dev/null and b/frontend/__snapshots__/lemon-ui-lemon-input-select--default--light.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-input-select--disabled--dark.png b/frontend/__snapshots__/lemon-ui-lemon-input-select--disabled--dark.png new file mode 100644 index 0000000000000..109cf0f8db9be Binary files /dev/null and b/frontend/__snapshots__/lemon-ui-lemon-input-select--disabled--dark.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-input-select--disabled--light.png b/frontend/__snapshots__/lemon-ui-lemon-input-select--disabled--light.png new file mode 100644 index 0000000000000..9baed9618a6aa Binary files /dev/null and b/frontend/__snapshots__/lemon-ui-lemon-input-select--disabled--light.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-input-select--loading--dark.png b/frontend/__snapshots__/lemon-ui-lemon-input-select--loading--dark.png new file mode 100644 index 0000000000000..004699f0e3761 Binary files /dev/null and b/frontend/__snapshots__/lemon-ui-lemon-input-select--loading--dark.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-input-select--loading--light.png b/frontend/__snapshots__/lemon-ui-lemon-input-select--loading--light.png new file mode 100644 index 0000000000000..5bf9eb654b451 Binary files /dev/null and b/frontend/__snapshots__/lemon-ui-lemon-input-select--loading--light.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-input-select--multiple-select--dark.png b/frontend/__snapshots__/lemon-ui-lemon-input-select--multiple-select--dark.png new file mode 100644 index 0000000000000..dfcba89cef491 Binary files /dev/null and b/frontend/__snapshots__/lemon-ui-lemon-input-select--multiple-select--dark.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-input-select--multiple-select--light.png b/frontend/__snapshots__/lemon-ui-lemon-input-select--multiple-select--light.png new file mode 100644 index 0000000000000..6f9828363b7d4 Binary files /dev/null and b/frontend/__snapshots__/lemon-ui-lemon-input-select--multiple-select--light.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-input-select--multiple-select-with-custom--dark.png b/frontend/__snapshots__/lemon-ui-lemon-input-select--multiple-select-with-custom--dark.png new file mode 100644 index 0000000000000..aa1ba92d1702f Binary files /dev/null and b/frontend/__snapshots__/lemon-ui-lemon-input-select--multiple-select-with-custom--dark.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-input-select--multiple-select-with-custom--light.png b/frontend/__snapshots__/lemon-ui-lemon-input-select--multiple-select-with-custom--light.png new file mode 100644 index 0000000000000..bce77b83e7476 Binary files /dev/null and b/frontend/__snapshots__/lemon-ui-lemon-input-select--multiple-select-with-custom--light.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-input-select--no-options--dark.png b/frontend/__snapshots__/lemon-ui-lemon-input-select--no-options--dark.png new file mode 100644 index 0000000000000..19881c003fc27 Binary files /dev/null and b/frontend/__snapshots__/lemon-ui-lemon-input-select--no-options--dark.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-input-select--no-options--light.png b/frontend/__snapshots__/lemon-ui-lemon-input-select--no-options--light.png new file mode 100644 index 0000000000000..a128f92384aa1 Binary files /dev/null and b/frontend/__snapshots__/lemon-ui-lemon-input-select--no-options--light.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-input-select--prefilled-many-values--dark.png b/frontend/__snapshots__/lemon-ui-lemon-input-select--prefilled-many-values--dark.png new file mode 100644 index 0000000000000..52f7374e18bb6 Binary files /dev/null and b/frontend/__snapshots__/lemon-ui-lemon-input-select--prefilled-many-values--dark.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-input-select--prefilled-many-values--light.png b/frontend/__snapshots__/lemon-ui-lemon-input-select--prefilled-many-values--light.png new file mode 100644 index 0000000000000..9014b3540f2f4 Binary files /dev/null and b/frontend/__snapshots__/lemon-ui-lemon-input-select--prefilled-many-values--light.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-input-select--single-option-with-custom--dark.png b/frontend/__snapshots__/lemon-ui-lemon-input-select--single-option-with-custom--dark.png new file mode 100644 index 0000000000000..349c97229174d Binary files /dev/null and b/frontend/__snapshots__/lemon-ui-lemon-input-select--single-option-with-custom--dark.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-input-select--single-option-with-custom--light.png b/frontend/__snapshots__/lemon-ui-lemon-input-select--single-option-with-custom--light.png new file mode 100644 index 0000000000000..9ab0f7424fad8 Binary files /dev/null and b/frontend/__snapshots__/lemon-ui-lemon-input-select--single-option-with-custom--light.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-slider--basic--dark.png b/frontend/__snapshots__/lemon-ui-lemon-slider--basic--dark.png index 58a3777e5b1bc..b1200cf4c527d 100644 Binary files a/frontend/__snapshots__/lemon-ui-lemon-slider--basic--dark.png and b/frontend/__snapshots__/lemon-ui-lemon-slider--basic--dark.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-slider--basic--light.png b/frontend/__snapshots__/lemon-ui-lemon-slider--basic--light.png index 828e365954235..787e8259d52c3 100644 Binary files a/frontend/__snapshots__/lemon-ui-lemon-slider--basic--light.png and b/frontend/__snapshots__/lemon-ui-lemon-slider--basic--light.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-tag--breakdown-tag--dark.png b/frontend/__snapshots__/lemon-ui-lemon-tag--breakdown-tag--dark.png index 3b4b55227faac..fcfe5de2715ed 100644 Binary files a/frontend/__snapshots__/lemon-ui-lemon-tag--breakdown-tag--dark.png and b/frontend/__snapshots__/lemon-ui-lemon-tag--breakdown-tag--dark.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-tag--breakdown-tag--light.png b/frontend/__snapshots__/lemon-ui-lemon-tag--breakdown-tag--light.png index 44c5df9cd7035..2ebc9ce11d8dc 100644 Binary files a/frontend/__snapshots__/lemon-ui-lemon-tag--breakdown-tag--light.png and b/frontend/__snapshots__/lemon-ui-lemon-tag--breakdown-tag--light.png differ diff --git a/frontend/__snapshots__/lemon-ui-scrollable-shadows--horizontal--dark.png b/frontend/__snapshots__/lemon-ui-scrollable-shadows--horizontal--dark.png index 80fd7103af84e..a513619924cf3 100644 Binary files a/frontend/__snapshots__/lemon-ui-scrollable-shadows--horizontal--dark.png and b/frontend/__snapshots__/lemon-ui-scrollable-shadows--horizontal--dark.png differ diff --git a/frontend/__snapshots__/lemon-ui-scrollable-shadows--horizontal--light.png b/frontend/__snapshots__/lemon-ui-scrollable-shadows--horizontal--light.png index ca420ed8bdc54..f90ea838ab39e 100644 Binary files a/frontend/__snapshots__/lemon-ui-scrollable-shadows--horizontal--light.png and b/frontend/__snapshots__/lemon-ui-scrollable-shadows--horizontal--light.png differ diff --git a/frontend/__snapshots__/lemon-ui-scrollable-shadows--vertical--dark.png b/frontend/__snapshots__/lemon-ui-scrollable-shadows--vertical--dark.png index ea2b6db0a77ea..b7de7c83d8f1f 100644 Binary files a/frontend/__snapshots__/lemon-ui-scrollable-shadows--vertical--dark.png and b/frontend/__snapshots__/lemon-ui-scrollable-shadows--vertical--dark.png differ diff --git a/frontend/__snapshots__/lemon-ui-scrollable-shadows--vertical--light.png b/frontend/__snapshots__/lemon-ui-scrollable-shadows--vertical--light.png index da9075401372e..8725eaaeaea92 100644 Binary files a/frontend/__snapshots__/lemon-ui-scrollable-shadows--vertical--light.png and b/frontend/__snapshots__/lemon-ui-scrollable-shadows--vertical--light.png differ diff --git a/frontend/__snapshots__/posthog-3000-navigation--navigation-base--dark.png b/frontend/__snapshots__/posthog-3000-navigation--navigation-base--dark.png index 5ab6b69cf8edc..06adea217229e 100644 Binary files a/frontend/__snapshots__/posthog-3000-navigation--navigation-base--dark.png and b/frontend/__snapshots__/posthog-3000-navigation--navigation-base--dark.png differ diff --git a/frontend/__snapshots__/posthog-3000-navigation--navigation-base--light.png b/frontend/__snapshots__/posthog-3000-navigation--navigation-base--light.png index 8252b5fdf1bb7..d1bd6fb608eaa 100644 Binary files a/frontend/__snapshots__/posthog-3000-navigation--navigation-base--light.png and b/frontend/__snapshots__/posthog-3000-navigation--navigation-base--light.png differ diff --git a/frontend/__snapshots__/scenes-app-batchexports--view-export--dark.png b/frontend/__snapshots__/scenes-app-batchexports--view-export--dark.png index 0f8b113af43cb..5090a2518e09f 100644 Binary files a/frontend/__snapshots__/scenes-app-batchexports--view-export--dark.png and b/frontend/__snapshots__/scenes-app-batchexports--view-export--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-notebooks--notebooks-list--dark.png b/frontend/__snapshots__/scenes-app-notebooks--notebooks-list--dark.png index 06adf374409a7..5c5c43c394a5d 100644 Binary files a/frontend/__snapshots__/scenes-app-notebooks--notebooks-list--dark.png and b/frontend/__snapshots__/scenes-app-notebooks--notebooks-list--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-notebooks--notebooks-list--light.png b/frontend/__snapshots__/scenes-app-notebooks--notebooks-list--light.png index c6ae09e9748b5..ee6f207261e02 100644 Binary files a/frontend/__snapshots__/scenes-app-notebooks--notebooks-list--light.png and b/frontend/__snapshots__/scenes-app-notebooks--notebooks-list--light.png differ diff --git a/frontend/__snapshots__/scenes-app-sidepanels--side-panel-support-no-email--dark.png b/frontend/__snapshots__/scenes-app-sidepanels--side-panel-support-no-email--dark.png new file mode 100644 index 0000000000000..c17dad0b17e61 Binary files /dev/null and b/frontend/__snapshots__/scenes-app-sidepanels--side-panel-support-no-email--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-sidepanels--side-panel-support-no-email--light.png b/frontend/__snapshots__/scenes-app-sidepanels--side-panel-support-no-email--light.png new file mode 100644 index 0000000000000..474c85c22515e Binary files /dev/null and b/frontend/__snapshots__/scenes-app-sidepanels--side-panel-support-no-email--light.png differ diff --git a/frontend/__snapshots__/scenes-app-sidepanels--side-panel-support-with-email--dark.png b/frontend/__snapshots__/scenes-app-sidepanels--side-panel-support-with-email--dark.png new file mode 100644 index 0000000000000..4f968d7f68b6f Binary files /dev/null and b/frontend/__snapshots__/scenes-app-sidepanels--side-panel-support-with-email--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-sidepanels--side-panel-support-with-email--light.png b/frontend/__snapshots__/scenes-app-sidepanels--side-panel-support-with-email--light.png new file mode 100644 index 0000000000000..d92a0899a7bd2 Binary files /dev/null and b/frontend/__snapshots__/scenes-app-sidepanels--side-panel-support-with-email--light.png differ diff --git a/frontend/__snapshots__/scenes-app-surveys--new-survey-targeting-section--dark.png b/frontend/__snapshots__/scenes-app-surveys--new-survey-targeting-section--dark.png index cfb811a0a8e4d..db9932e1f2320 100644 Binary files a/frontend/__snapshots__/scenes-app-surveys--new-survey-targeting-section--dark.png and b/frontend/__snapshots__/scenes-app-surveys--new-survey-targeting-section--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-surveys--new-survey-targeting-section--light.png b/frontend/__snapshots__/scenes-app-surveys--new-survey-targeting-section--light.png index cb81bc3c5ac25..bbbe76e720788 100644 Binary files a/frontend/__snapshots__/scenes-app-surveys--new-survey-targeting-section--light.png and b/frontend/__snapshots__/scenes-app-surveys--new-survey-targeting-section--light.png differ diff --git a/frontend/__snapshots__/scenes-other-billing-v2--billing-v-2--dark.png b/frontend/__snapshots__/scenes-other-billing-v2--billing-v-2--dark.png index f3521aa3d9ba9..d58d47379698a 100644 Binary files a/frontend/__snapshots__/scenes-other-billing-v2--billing-v-2--dark.png and b/frontend/__snapshots__/scenes-other-billing-v2--billing-v-2--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-billing-v2--billing-v-2--light.png b/frontend/__snapshots__/scenes-other-billing-v2--billing-v-2--light.png index a0bf0be976004..d452707e2f2c4 100644 Binary files a/frontend/__snapshots__/scenes-other-billing-v2--billing-v-2--light.png and b/frontend/__snapshots__/scenes-other-billing-v2--billing-v-2--light.png differ diff --git a/frontend/__snapshots__/scenes-other-onboarding--onboarding-billing--dark.png b/frontend/__snapshots__/scenes-other-onboarding--onboarding-billing--dark.png index 243cee06e1f0d..593c08ed4bcec 100644 Binary files a/frontend/__snapshots__/scenes-other-onboarding--onboarding-billing--dark.png and b/frontend/__snapshots__/scenes-other-onboarding--onboarding-billing--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-onboarding--onboarding-billing--light.png b/frontend/__snapshots__/scenes-other-onboarding--onboarding-billing--light.png index dfc8dab84102a..a4a4045de7b70 100644 Binary files a/frontend/__snapshots__/scenes-other-onboarding--onboarding-billing--light.png and b/frontend/__snapshots__/scenes-other-onboarding--onboarding-billing--light.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-project--dark.png b/frontend/__snapshots__/scenes-other-settings--settings-project--dark.png index aafe7723ef5bc..cb28d08f5b090 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-project--dark.png and b/frontend/__snapshots__/scenes-other-settings--settings-project--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-project--light.png b/frontend/__snapshots__/scenes-other-settings--settings-project--light.png index 0b458d548f41e..31dbd5b34cfcf 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-project--light.png and b/frontend/__snapshots__/scenes-other-settings--settings-project--light.png differ diff --git a/frontend/src/layout/navigation-3000/components/AlgoliaSearch.tsx b/frontend/src/layout/navigation-3000/components/AlgoliaSearch.tsx new file mode 100644 index 0000000000000..b6c52e3018133 --- /dev/null +++ b/frontend/src/layout/navigation-3000/components/AlgoliaSearch.tsx @@ -0,0 +1,268 @@ +import { IconCheckCircle } from '@posthog/icons' +import { LemonButton, LemonInput, LemonTag } from '@posthog/lemon-ui' +import algoliasearch from 'algoliasearch/lite' +import { useActions } from 'kea' +import { useEffect, useRef, useState } from 'react' +import { InstantSearch, useHits, useRefinementList, useSearchBox } from 'react-instantsearch' +import { AutoSizer } from 'react-virtualized/dist/es/AutoSizer' +import { List } from 'react-virtualized/dist/es/List' + +import { sidePanelStateLogic } from '~/layout/navigation-3000/sidepanel/sidePanelStateLogic' +import { SidePanelTab } from '~/types' + +const searchClient = algoliasearch('7VNQB5W0TX', '37f41fd37095bc85af76ed4edc85eb5a') + +const rowRenderer = ({ key, index, style, hits, activeOption }: any): JSX.Element => { + const { slug, title, type, resolved } = hits[index] + return ( + // eslint-disable-next-line react/forbid-dom-props +
  • + + + +

    {title}

    + {type === 'question' && resolved && ( + + )} +
    +

    /{slug}

    +
    +
    +
  • + ) +} + +const Hits = ({ activeOption }: { activeOption?: number }): JSX.Element => { + const { hits } = useHits() + return ( +
      + + {({ height, width }: { height: number; width: number }) => ( + rowRenderer({ ...options, hits, activeOption })} + /> + )} + +
    + ) +} + +const SearchInput = ({ + value, + setValue, +}: { + value: string + setValue: React.Dispatch> +}): JSX.Element => { + const { refine } = useSearchBox() + + const handleChange = (value: string): void => { + setValue(value) + refine(value) + } + + return +} + +type Tag = { + type: string + label: string +} + +const tags: Tag[] = [ + { + type: 'all', + label: 'All', + }, + { + type: 'docs', + label: 'Docs', + }, + { + type: 'question', + label: 'Questions', + }, + { + type: 'tutorial', + label: 'Tutorials', + }, +] + +type SearchTagProps = Tag & { + active?: boolean + onClick: (type: string) => void +} + +const SearchTag = ({ type, label, active, onClick }: SearchTagProps): JSX.Element => { + const { refine, items } = useRefinementList({ attribute: 'type' }) + const itemCount = type !== 'all' && items.find(({ value }) => value === type)?.count + + const handleClick = (e: React.MouseEvent): void => { + e.stopPropagation() + onClick(type) + } + + useEffect(() => { + refine(type) + }, []) + + return ( + + ) +} + +const Tags = ({ + activeTag, + setActiveTag, +}: { + activeTag: string + setActiveTag: React.Dispatch> +}): JSX.Element => { + const handleClick = (type: string): void => { + setActiveTag(type) + } + + return ( +
      + {tags.map((tag) => { + const { type } = tag + return ( +
    • + +
    • + ) + })} +
    + ) +} + +const Search = (): JSX.Element => { + const { openSidePanel } = useActions(sidePanelStateLogic) + const { hits } = useHits() + const { items, refine } = useRefinementList({ attribute: 'type' }) + + const ref = useRef(null) + const [searchValue, setSearchValue] = useState('') + const [activeOption, setActiveOption] = useState() + const [activeTag, setActiveTag] = useState('all') + const [searchOpen, setSearchOpen] = useState(false) + + const handleKeyDown = (e: React.KeyboardEvent): void => { + switch (e.key) { + case 'Enter': { + if (activeOption !== undefined) { + openSidePanel(SidePanelTab.Docs, `https://posthog.com/${hits[activeOption].slug}`) + } + break + } + + case 'Escape': { + setSearchOpen(false) + break + } + case 'ArrowDown': { + e.preventDefault() + setActiveOption((currOption) => { + if (currOption === undefined || currOption >= hits.length - 1) { + return 0 + } + return currOption + 1 + }) + break + } + case 'ArrowUp': { + e.preventDefault() + setActiveOption((currOption) => { + if (currOption !== undefined) { + return currOption <= 0 ? hits.length - 1 : currOption - 1 + } + }) + break + } + case 'Tab': + case 'ArrowRight': { + e.preventDefault() + const currTagIndex = tags.findIndex(({ type }) => type === activeTag) + setActiveTag(tags[currTagIndex >= tags.length - 1 ? 0 : currTagIndex + 1].type) + break + } + case 'ArrowLeft': { + e.preventDefault() + const currTagIndex = tags.findIndex(({ type }) => type === activeTag) + setActiveTag(tags[currTagIndex <= 0 ? tags.length - 1 : currTagIndex - 1].type) + } + } + } + + useEffect(() => { + setSearchOpen(!!searchValue) + setActiveOption(0) + }, [searchValue]) + + useEffect(() => { + setActiveOption(0) + if (activeTag === 'all') { + const filteredItems = items.filter(({ value }) => tags.some(({ type }) => type === value)) + filteredItems.forEach(({ value, isRefined }) => { + if (!isRefined) { + refine(value) + } + }) + } else { + items.forEach(({ value, isRefined }) => { + if (isRefined) { + refine(value) + } + }) + refine(activeTag) + } + }, [activeTag]) + + useEffect(() => { + const handleClick = (e: any): void => { + if (!ref?.current?.contains(e.target)) { + setSearchOpen(false) + } + } + + window.addEventListener('click', handleClick) + + return () => { + window.removeEventListener('click', handleClick) + } + }, []) + + return ( +
    + + {searchOpen && ( +
    + + +
    + )} +
    + ) +} + +export default function AlgoliaSearch(): JSX.Element { + return ( + + + + ) +} diff --git a/frontend/src/layout/navigation-3000/sidepanel/SidePanel.stories.tsx b/frontend/src/layout/navigation-3000/sidepanel/SidePanel.stories.tsx index abad5ff308743..2cbd7574fa1fb 100644 --- a/frontend/src/layout/navigation-3000/sidepanel/SidePanel.stories.tsx +++ b/frontend/src/layout/navigation-3000/sidepanel/SidePanel.stories.tsx @@ -1,11 +1,13 @@ import { Meta, StoryFn } from '@storybook/react' import { useActions } from 'kea' import { router } from 'kea-router' +import { supportLogic } from 'lib/components/Support/supportLogic' import { useEffect } from 'react' import { App } from 'scenes/App' import { urls } from 'scenes/urls' -import { mswDecorator } from '~/mocks/browser' +import { mswDecorator, useStorybookMocks } from '~/mocks/browser' +import organizationCurrent from '~/mocks/fixtures/api/organizations/@current/@current.json' import { SidePanelTab } from '~/types' import { sidePanelStateLogic } from './sidePanelStateLogic' @@ -59,3 +61,36 @@ export const SidePanelActivation: StoryFn = () => { export const SidePanelNotebooks: StoryFn = () => { return } + +export const SidePanelSupportNoEmail: StoryFn = () => { + return +} + +export const SidePanelSupportWithEmail: StoryFn = () => { + const { openEmailForm } = useActions(supportLogic) + useStorybookMocks({ + get: { + // TODO: setting available featues should be a decorator to make this easy + '/api/users/@me': () => [ + 200, + { + email: 'test@posthog.com', + first_name: 'Test Hedgehog', + organization: { + ...organizationCurrent, + available_product_features: [ + { + key: 'email_support', + name: 'Email support', + }, + ], + }, + }, + ], + }, + }) + useEffect(() => { + openEmailForm() + }, []) + return +} diff --git a/frontend/src/layout/navigation-3000/sidepanel/SidePanel.tsx b/frontend/src/layout/navigation-3000/sidepanel/SidePanel.tsx index 8b6b61e55faa2..bcad53bdc9dfb 100644 --- a/frontend/src/layout/navigation-3000/sidepanel/SidePanel.tsx +++ b/frontend/src/layout/navigation-3000/sidepanel/SidePanel.tsx @@ -37,7 +37,7 @@ export const SIDE_PANEL_TABS: Record< noModalSupport: true, }, [SidePanelTab.Support]: { - label: 'Support', + label: 'Help', Icon: IconSupport, Content: SidePanelSupport, }, diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/SidePanelSupport.tsx b/frontend/src/layout/navigation-3000/sidepanel/panels/SidePanelSupport.tsx index 381cd59181267..022b8d18dfbbb 100644 --- a/frontend/src/layout/navigation-3000/sidepanel/panels/SidePanelSupport.tsx +++ b/frontend/src/layout/navigation-3000/sidepanel/panels/SidePanelSupport.tsx @@ -1,51 +1,305 @@ -import { LemonButton } from '@posthog/lemon-ui' +import { + IconBug, + IconChevronDown, + IconFeatures, + IconFlask, + IconHelmet, + IconMap, + IconMessage, + IconRewindPlay, + IconStack, + IconToggle, + IconTrends, +} from '@posthog/icons' +import { LemonButton, Link } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' import { SupportForm } from 'lib/components/Support/SupportForm' import { supportLogic } from 'lib/components/Support/supportLogic' +import React from 'react' +import { billingLogic } from 'scenes/billing/billingLogic' +import { urls } from 'scenes/urls' +import { userLogic } from 'scenes/userLogic' -import { SidePanelTab } from '~/types' +import { AvailableFeature, ProductKey, SidePanelTab } from '~/types' +import AlgoliaSearch from '../../components/AlgoliaSearch' import { SidePanelPaneHeader } from '../components/SidePanelPaneHeader' +import { SIDE_PANEL_TABS } from '../SidePanel' import { sidePanelStateLogic } from '../sidePanelStateLogic' +const PRODUCTS = [ + { + name: 'Product OS', + slug: 'product-os', + icon: , + }, + { + name: 'Product analytics', + slug: 'product-analytics', + icon: , + }, + { + name: 'Session replay', + slug: 'session-replay', + icon: , + }, + { + name: 'Feature flags', + slug: 'feature-flags', + icon: , + }, + { + name: 'A/B testing', + slug: 'ab-testing', + icon: , + }, + { + name: 'Surveys', + slug: 'surveys', + icon: , + }, +] + +const Section = ({ title, children }: { title: string; children: React.ReactNode }): React.ReactElement => { + return ( +
    +

    {title}

    + {children} +
    + ) +} + +const SupportFormBlock = ({ onCancel }: { onCancel: () => void }): JSX.Element => { + const { billing } = useValues(billingLogic) + const supportResponseTimes = { + [AvailableFeature.EMAIL_SUPPORT]: '2-3 days', + [AvailableFeature.PRIORITY_SUPPORT]: '4-6 hours', + } + + return ( +
    +
    +
    +
    + Avg support response times +
    +
    + Explore options +
    +
    + {billing?.products + ?.find((product) => product.type == ProductKey.PLATFORM_AND_SUPPORT) + ?.plans?.map((plan, i) => ( + +
    + {i == 1 ? 'Pay-per-use' : plan.name} + {plan.current_plan && ( + <> + {' '} + (your plan) + + )} +
    +
    + {plan.features.some((f) => f.key == AvailableFeature.PRIORITY_SUPPORT) + ? supportResponseTimes[AvailableFeature.PRIORITY_SUPPORT] + : plan.features.some((f) => f.key == AvailableFeature.EMAIL_SUPPORT) + ? supportResponseTimes[AvailableFeature.EMAIL_SUPPORT] + : 'Community support only'} +
    +
    + ))} +
    + + + Submit + + + Cancel + +
    + ) +} + export const SidePanelSupport = (): JSX.Element => { const { closeSidePanel } = useActions(sidePanelStateLogic) + const { hasAvailableFeature } = useValues(userLogic) + const { openEmailForm, closeEmailForm } = useActions(supportLogic) + const { isEmailFormOpen } = useValues(supportLogic) const theLogic = supportLogic({ onClose: () => closeSidePanel(SidePanelTab.Support) }) const { title } = useValues(theLogic) - const { closeSupportForm } = useActions(theLogic) return ( <> - +
    - +
    + +
    + +
    +
      + {PRODUCTS.map((product, index) => ( +
    • + +
      + {product.icon} + + {product.name} + +
      +
      + +
      + +
    • + ))} +
    +
    -
    +
    +

    + Questions about features, how to's, or use cases? There are thousands of discussions in our + community forums. +

    - Submit - - - Cancel + Ask a question -
    + + +
    +
      +
    • + } + targetBlank + > + Report a bug + +
    • +
    • + } + targetBlank + > + See what we're building + +
    • +
    • + } + targetBlank + > + Vote on our roadmap + +
    • +
    • + } + targetBlank + > + Request a feature + +
    • +
    +
    + + {hasAvailableFeature(AvailableFeature.EMAIL_SUPPORT) ? ( +
    + {isEmailFormOpen ? ( + closeEmailForm()} /> + ) : ( +

    + Can't find what you need in the docs?{' '} + openEmailForm()}>Email an engineer +

    + )} +
    + ) : ( +
    +

    + Due to our large userbase, we're unable to offer email support to organizations on the + free plan. But we still want to help! +

    + +
      +
    1. + Search our docs +

      + We're constantly updating our docs and tutorials to provide the latest + information about installing, using, and troubleshooting. +

      +
    2. +
    3. + Ask a community question +

      + Many common (and niche) questions have already been resolved by users just like + you. (Our own engineers also keep an eye on the questions as they have time!){' '} + + Search community questions or ask your own. + +

      +
    4. +
    5. + + Explore PostHog partners + +

      + Third-party providers can help with installation and debugging of data issues. +

      +
    6. +
    7. + Upgrade to a paid plan +

      + Our paid plans offer email support.{' '} + + Explore options. + +

      +
    8. +
    +
    + )}
    diff --git a/frontend/src/layout/navigation/ProjectSwitcher.tsx b/frontend/src/layout/navigation/ProjectSwitcher.tsx index 1fda0cb22f47d..3cd08496f447c 100644 --- a/frontend/src/layout/navigation/ProjectSwitcher.tsx +++ b/frontend/src/layout/navigation/ProjectSwitcher.tsx @@ -46,6 +46,7 @@ export function ProjectSwitcherOverlay({ onClickInside }: { onClickInside?: () = fullWidth disabled={!!projectCreationForbiddenReason} tooltip={projectCreationForbiddenReason} + data-attr="new-project-button" onClick={() => { onClickInside?.() guardAvailableFeature( diff --git a/frontend/src/lib/components/BillingUpgradeCTA.tsx b/frontend/src/lib/components/BillingUpgradeCTA.tsx new file mode 100644 index 0000000000000..cf9278e909af3 --- /dev/null +++ b/frontend/src/lib/components/BillingUpgradeCTA.tsx @@ -0,0 +1,13 @@ +import { useActions } from 'kea' +import { LemonButton, LemonButtonProps } from 'lib/lemon-ui/LemonButton' +import { eventUsageLogic } from 'lib/utils/eventUsageLogic' +import { useEffect } from 'react' + +export function BillingUpgradeCTA({ children, ...props }: LemonButtonProps): JSX.Element { + const { reportBillingCTAShown } = useActions(eventUsageLogic) + useEffect(() => { + reportBillingCTAShown() + }, []) + + return {children} +} diff --git a/frontend/src/lib/components/PropertyFilters/components/PropertyValue.scss b/frontend/src/lib/components/PropertyFilters/components/PropertyValue.scss deleted file mode 100644 index 6bb87aa7f7ec7..0000000000000 --- a/frontend/src/lib/components/PropertyFilters/components/PropertyValue.scss +++ /dev/null @@ -1,40 +0,0 @@ -.property-filters-property-value { - min-width: 150px; - min-height: 40px; - background-color: var(--bg-light); - border: 1px solid var(--border); - border-radius: var(--radius); - - .ant-select-selection-search, - .ant-select-selection-placeholder { - display: flex; - align-items: center; - padding: 0 4px !important; - } - - &.ant-select-single { - .ant-select-selector { - height: unset; - min-height: 38px !important; - background-color: inherit; - border: none !important; - } - } - - &.ant-select-multiple { - .ant-select-selector { - height: 100% !important; - padding: 5px 40px 5px 11px; - background-color: inherit; - border: none !important; - - .ant-select-selection-search { - padding-left: 0 !important; - } - - .ant-select-selection-placeholder { - padding-left: 6px !important; - } - } - } -} diff --git a/frontend/src/lib/components/PropertyFilters/components/PropertyValue.tsx b/frontend/src/lib/components/PropertyFilters/components/PropertyValue.tsx index fae22ccf8a00e..8f6cb1e96a68b 100644 --- a/frontend/src/lib/components/PropertyFilters/components/PropertyValue.tsx +++ b/frontend/src/lib/components/PropertyFilters/components/PropertyValue.tsx @@ -1,16 +1,12 @@ -import './PropertyValue.scss' - -import { AutoComplete } from 'antd' -import clsx from 'clsx' import { useActions, useValues } from 'kea' import { DateFilter } from 'lib/components/DateFilter/DateFilter' import { DurationPicker } from 'lib/components/DurationPicker/DurationPicker' import { PropertyFilterDatePicker } from 'lib/components/PropertyFilters/components/PropertyFilterDatePicker' import { propertyFilterTypeToPropertyDefinitionType } from 'lib/components/PropertyFilters/utils' import { dayjs } from 'lib/dayjs' -import { LemonSelectMultiple } from 'lib/lemon-ui/LemonSelectMultiple/LemonSelectMultiple' +import { LemonInputSelect } from 'lib/lemon-ui/LemonInputSelect/LemonInputSelect' import { formatDate, isOperatorDate, isOperatorFlag, isOperatorMulti, toString } from 'lib/utils' -import { useEffect, useRef, useState } from 'react' +import { useEffect } from 'react' import { propertyDefinitionsModel } from '~/models/propertyDefinitionsModel' import { PropertyFilterType, PropertyOperator, PropertyType } from '~/types' @@ -20,43 +16,26 @@ export interface PropertyValueProps { type: PropertyFilterType endpoint?: string // Endpoint to fetch options from placeholder?: string - className?: string onSet: CallableFunction value?: string | number | Array | null operator: PropertyOperator autoFocus?: boolean - allowCustom?: boolean eventNames?: string[] addRelativeDateTimeOptions?: boolean } -function matchesLowerCase(needle?: string, haystack?: string): boolean { - if (typeof haystack !== 'string' || typeof needle !== 'string') { - return false - } - return haystack.toLowerCase().indexOf(needle.toLowerCase()) > -1 -} - export function PropertyValue({ propertyKey, type, endpoint = undefined, placeholder = undefined, - className, onSet, value, operator, autoFocus = false, - allowCustom = true, eventNames = [], addRelativeDateTimeOptions = false, }: PropertyValueProps): JSX.Element { - // what the human has typed into the box - const [input, setInput] = useState(Array.isArray(value) ? '' : toString(value) ?? '') - - const [shouldBlur, setShouldBlur] = useState(false) - const autoCompleteRef = useRef(null) - const { formatPropertyValueForDisplay, describeProperty, options } = useValues(propertyDefinitionsModel) const { loadPropertyValues } = useActions(propertyDefinitionsModel) @@ -67,20 +46,6 @@ export function PropertyValue({ const isDurationProperty = propertyKey && describeProperty(propertyKey, propertyDefinitionType) === PropertyType.Duration - // update the input field if passed a new `value` prop - useEffect(() => { - if (value == null) { - setInput('') - } else if (!Array.isArray(value) && toString(value) !== input) { - const valueObject = options[propertyKey]?.values?.find((v) => v.id === value) - if (valueObject) { - setInput(toString(valueObject.name)) - } else { - setInput(toString(value)) - } - } - }, [value]) - const load = (newInput: string | undefined): void => { loadPropertyValues({ endpoint, @@ -91,114 +56,26 @@ export function PropertyValue({ }) } - function setValue(newValue: PropertyValueProps['value']): void { - onSet(newValue) - if (isMultiSelect) { - setInput('') - } - } + const setValue = (newValue: PropertyValueProps['value']): void => onSet(newValue) useEffect(() => { load('') }, [propertyKey]) - useEffect(() => { - if (input === '' && shouldBlur) { - ;(document.activeElement as HTMLElement)?.blur() - setShouldBlur(false) - } - }, [input, shouldBlur]) - - const displayOptions = (options[propertyKey]?.values || []).filter( - (option) => input === '' || matchesLowerCase(input, toString(option?.name)) - ) + const displayOptions = options[propertyKey]?.values || [] - const commonInputProps = { - onSearch: (newInput: string) => { - setInput(newInput) - if (!Object.keys(options).includes(newInput) && !(operator && isOperatorFlag(operator))) { - load(newInput.trim()) - } - }, - ['data-attr']: 'prop-val', - dropdownMatchSelectWidth: 350, - placeholder, - allowClear: Boolean(value), - onKeyDown: (e: React.KeyboardEvent) => { - if (e.key === 'Escape') { - setInput('') - setShouldBlur(true) - return - } - if (!isMultiSelect && e.key === 'Enter') { - // We have not explicitly selected a dropdown item by pressing the up/down keys; or the ref is unavailable - if ( - !autoCompleteRef.current || - autoCompleteRef.current?.querySelectorAll?.('.ant-select-item-option-active')?.length === 0 - ) { - setValue(input) - } - } - }, - handleBlur: () => { - if (input != '') { - if (Array.isArray(value) && !value.includes(input)) { - setValue([...value, ...[input]]) - } else if (!Array.isArray(value)) { - setValue(input) - } - setInput('') - } - }, + const onSearchTextChange = (newInput: string): void => { + if (!Object.keys(options).includes(newInput) && !(operator && isOperatorFlag(operator))) { + load(newInput.trim()) + } } - if (isMultiSelect) { - const formattedValues = ( - value === null || value === undefined ? [] : Array.isArray(value) ? value : [value] - ).map((label) => String(formatPropertyValueForDisplay(propertyKey, label))) - return ( - { - setValue(nextVal) - }} - onBlur={commonInputProps.handleBlur} - // TODO: When LemonSelectMultiple is free of AntD, add footnote that pressing comma applies the value - options={Object.fromEntries([ - ...displayOptions.map(({ name: _name }, index) => { - const name = toString(_name) - return [ - name, - { - label: name, - labelComponent: ( - - {name === '' ? ( - (empty string) - ) : ( - formatPropertyValueForDisplay(propertyKey, name) - )} - - ), - }, - ] - }), - ])} - /> - ) + if (isDurationProperty) { + return } - if (isDateTimeProperty && addRelativeDateTimeOptions) { - if (operator === PropertyOperator.IsDateExact) { + if (isDateTimeProperty) { + if (!addRelativeDateTimeOptions || operator === PropertyOperator.IsDateExact) { return ( ) @@ -241,52 +118,32 @@ export function PropertyValue({ ) } - return isDateTimeProperty ? ( - - ) : isDurationProperty ? ( - - ) : ( - { - setInput('') - setValue('') - }} - onChange={(val) => { - setInput(toString(val)) - }} - onSelect={(val, option) => { - setInput(option.title) - setValue(toString(val).trim()) - }} - ref={autoCompleteRef} - > - {[ - ...(input && allowCustom && !displayOptions.some(({ name }) => input === toString(name)) - ? [ - - Specify: {input} - , - ] - : []), - ...displayOptions.map(({ name: _name, id }, index) => { - const name = toString(_name) - return ( - - {name} - - ) - }), - ]} - + const formattedValues = (value === null || value === undefined ? [] : Array.isArray(value) ? value : [value]).map( + (label) => String(formatPropertyValueForDisplay(propertyKey, label)) + ) + + return ( + setValue(nextVal)} + onInputChange={onSearchTextChange} + placeholder={placeholder} + options={displayOptions.map(({ name: _name }, index) => { + const name = toString(_name) + return { + key: name, + label: name, + labelComponent: ( + + {name === '' ? (empty string) : formatPropertyValueForDisplay(propertyKey, name)} + + ), + } + })} + /> ) } diff --git a/frontend/src/lib/components/Subscriptions/subscriptionLogic.ts b/frontend/src/lib/components/Subscriptions/subscriptionLogic.ts index cf673b42dbe4c..ab5108074621b 100644 --- a/frontend/src/lib/components/Subscriptions/subscriptionLogic.ts +++ b/frontend/src/lib/components/Subscriptions/subscriptionLogic.ts @@ -78,7 +78,7 @@ export const subscriptionLogic = kea([ : undefined, memberOfSlackChannel: target_type == 'slack' - ? !values.isMemberOfSlackChannel(target_value) + ? target_value && !values.isMemberOfSlackChannel(target_value) ? 'Please add the PostHog Slack App to the selected channel' : undefined : undefined, diff --git a/frontend/src/lib/components/Subscriptions/utils.tsx b/frontend/src/lib/components/Subscriptions/utils.tsx index 136548cfdfcf1..370c6855d6d1f 100644 --- a/frontend/src/lib/components/Subscriptions/utils.tsx +++ b/frontend/src/lib/components/Subscriptions/utils.tsx @@ -1,7 +1,7 @@ import { IconLetter } from '@posthog/icons' import { LemonSelectOptions } from '@posthog/lemon-ui' import { IconSlack, IconSlackExternal } from 'lib/lemon-ui/icons' -import { LemonSelectMultipleOptionItem } from 'lib/lemon-ui/LemonSelectMultiple/LemonSelectMultiple' +import { LemonInputSelectOption } from 'lib/lemon-ui/LemonInputSelect/LemonInputSelect' import { range } from 'lib/utils' import { urls } from 'scenes/urls' @@ -84,7 +84,7 @@ export const timeOptions: LemonSelectOptions = range(0, 24).map((x) => ( export const getSlackChannelOptions = ( value: string, slackChannels?: SlackChannelType[] | null -): LemonSelectMultipleOptionItem[] => { +): LemonInputSelectOption[] => { return slackChannels ? slackChannels.map((x) => ({ key: `${x.id}|#${x.name}`, diff --git a/frontend/src/lib/components/Subscriptions/views/EditSubscription.tsx b/frontend/src/lib/components/Subscriptions/views/EditSubscription.tsx index ed34a261c6103..d6fc5af539bcb 100644 --- a/frontend/src/lib/components/Subscriptions/views/EditSubscription.tsx +++ b/frontend/src/lib/components/Subscriptions/views/EditSubscription.tsx @@ -8,13 +8,10 @@ import { IconChevronLeft } from 'lib/lemon-ui/icons' import { LemonBanner } from 'lib/lemon-ui/LemonBanner' import { LemonButton } from 'lib/lemon-ui/LemonButton' import { LemonField } from 'lib/lemon-ui/LemonField' +import { LemonInputSelect, LemonInputSelectOption } from 'lib/lemon-ui/LemonInputSelect/LemonInputSelect' import { LemonLabel } from 'lib/lemon-ui/LemonLabel/LemonLabel' import { LemonModal } from 'lib/lemon-ui/LemonModal' import { LemonSelect } from 'lib/lemon-ui/LemonSelect' -import { - LemonSelectMultiple, - LemonSelectMultipleOptionItem, -} from 'lib/lemon-ui/LemonSelectMultiple/LemonSelectMultiple' import { LemonSkeleton } from 'lib/lemon-ui/LemonSkeleton' import { useEffect, useMemo } from 'react' import { membersLogic } from 'scenes/organization/membersLogic' @@ -86,7 +83,7 @@ export function EditSubscription({ }, [subscription?.target_type, slackIntegration]) // If slackChannels aren't loaded, make sure we display only the channel name and not the actual underlying value - const slackChannelOptions: LemonSelectMultipleOptionItem[] = useMemo( + const slackChannelOptions: LemonInputSelectOption[] = useMemo( () => getSlackChannelOptions(subscription?.target_value, slackChannels), [slackChannels, subscription?.target_value] ) @@ -199,11 +196,12 @@ export function EditSubscription({ help="Enter the email addresses of the users you want to share with" > {({ value, onChange }) => ( - onChange(val.join(','))} + onChange(val.join(','))} value={value?.split(',').filter(Boolean)} disabled={emailDisabled} - mode="multiple-custom" + mode="multiple" + allowCustomValues data-attr="subscribed-emails" options={usersLemonSelectOptions(meFirstMembers.map((x) => x.user))} loading={membersLoading} @@ -276,12 +274,13 @@ export function EditSubscription({ } > {({ value, onChange }) => ( - onChange(val)} - value={value} + onChange(val[0] ?? null)} + value={value ? [value] : []} disabled={slackDisabled} mode="single" data-attr="select-slack-channel" + placeholder="Select a channel..." options={slackChannelOptions} loading={slackChannelsLoading} /> diff --git a/frontend/src/lib/components/Support/SupportForm.tsx b/frontend/src/lib/components/Support/SupportForm.tsx index 7f92d89f1445a..b23fdd83a3501 100644 --- a/frontend/src/lib/components/Support/SupportForm.tsx +++ b/frontend/src/lib/components/Support/SupportForm.tsx @@ -1,4 +1,4 @@ -import { IconBug, IconQuestion } from '@posthog/icons' +import { IconBug, IconInfo, IconQuestion } from '@posthog/icons' import { LemonBanner, LemonInput, @@ -6,6 +6,7 @@ import { LemonSegmentedButtonOption, lemonToast, Link, + Tooltip, } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' import { Form } from 'kea-forms' @@ -90,9 +91,12 @@ export function SupportForm(): JSX.Element | null { )} - + + + + {posthog.getFeatureFlag('show-troubleshooting-docs-in-support-form') === 'test-replay-banner' && sendSupportRequest.target_area === 'session_replay' && ( @@ -127,18 +131,6 @@ export function SupportForm(): JSX.Element | null { )} - - - - - ({ - label: value, - value: key, - }))} - /> - )} + + <> +
    + + + Definitions + +
    + ({ + label: value, + value: key, + }))} + /> + +
    ) } diff --git a/frontend/src/lib/components/Support/supportLogic.ts b/frontend/src/lib/components/Support/supportLogic.ts index 003ad6a4a9e6e..9c4cbd02712b5 100644 --- a/frontend/src/lib/components/Support/supportLogic.ts +++ b/frontend/src/lib/components/Support/supportLogic.ts @@ -45,7 +45,7 @@ function getSentryLink(user: UserType | null, cloudRegion: Region | null | undef } const SUPPORT_TICKET_KIND_TO_TITLE: Record = { - support: 'Ask a question', + support: 'Contact support', feedback: 'Give feedback', bug: 'Report a bug', } @@ -237,6 +237,8 @@ export const supportLogic = kea([ openSupportForm: (values: Partial) => values, submitZendeskTicket: (form: SupportFormFields) => form, updateUrlParams: true, + openEmailForm: true, + closeEmailForm: true, })), reducers(() => ({ isSupportFormOpen: [ @@ -246,6 +248,13 @@ export const supportLogic = kea([ closeSupportForm: () => false, }, ], + isEmailFormOpen: [ + false, + { + openEmailForm: () => true, + closeEmailForm: () => false, + }, + ], })), forms(({ actions, values }) => ({ sendSupportRequest: { diff --git a/frontend/src/lib/components/UserSelectItem.tsx b/frontend/src/lib/components/UserSelectItem.tsx index 7990f4c8a7301..903440db365e4 100644 --- a/frontend/src/lib/components/UserSelectItem.tsx +++ b/frontend/src/lib/components/UserSelectItem.tsx @@ -1,4 +1,4 @@ -import { LemonSelectMultipleOptionItem } from 'lib/lemon-ui/LemonSelectMultiple' +import { LemonInputSelectOption } from 'lib/lemon-ui/LemonInputSelect' import { ProfilePicture } from 'lib/lemon-ui/ProfilePicture' import { UserBasicType, UserType } from '~/types' @@ -21,7 +21,7 @@ export function UserSelectItem({ user }: UserSelectItemProps): JSX.Element { export function usersLemonSelectOptions( users: (UserBasicType | UserType)[], key: 'email' | 'uuid' = 'email' -): LemonSelectMultipleOptionItem[] { +): LemonInputSelectOption[] { return users.map((user) => ({ key: user[key], label: `${user.first_name} ${user.email}`, diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index 77d5f9c37eb0f..522d393f0f9a6 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -202,6 +202,7 @@ export const FEATURE_FLAGS = { REPLAY_SIMILAR_RECORDINGS: 'session-replay-similar-recordings', // owner: #team-replay SAVED_NOT_PINNED: 'saved-not-pinned', // owner: #team-replay EXPORTS_SIDEPANEL: 'exports-sidepanel', // owner: #team-product-analytics + BILLING_UPGRADE_LANGUAGE: 'billing-upgrade-language', // owner: @biancayang NEW_EXPERIMENTS_UI: 'new-experiments-ui', // owner: @jurajmajerik #team-feature-success SESSION_REPLAY_V3_INGESTION_PLAYBACK: 'session-replay-v3-ingestion-playback', // owner: @benjackwhite SESSION_REPLAY_FILTER_ORDERING: 'session-replay-filter-ordering', // owner: #team-replay diff --git a/frontend/src/lib/lemon-ui/LemonInput/LemonInput.scss b/frontend/src/lib/lemon-ui/LemonInput/LemonInput.scss index 28a84357dadeb..934cdaeb36254 100644 --- a/frontend/src/lib/lemon-ui/LemonInput/LemonInput.scss +++ b/frontend/src/lib/lemon-ui/LemonInput/LemonInput.scss @@ -1,9 +1,11 @@ .LemonInput { + --lemon-input-height: calc(2.125rem + 3px); // Medium size button height + button shadow height; + display: flex; gap: 0.25rem; align-items: center; - justify-content: center; - height: calc(2.125rem + 3px); // Medium size button height + button shadow height + justify-content: left; + height: var(--lemon-input-height); padding: 0.25rem 0.5rem; font-size: 0.875rem; line-height: 1.25rem; @@ -33,6 +35,7 @@ } .LemonInput__input { + flex: 1; align-self: stretch; // Improves selectability width: 100%; text-overflow: ellipsis; @@ -56,7 +59,8 @@ } &.LemonInput--small { - height: 2rem; + --lemon-input-height: 2rem; + padding: 0.125rem 0.25rem; .LemonIcon { @@ -85,4 +89,10 @@ width: 100%; max-width: 100%; } + + .LemonInputSelect & { + flex-wrap: wrap; + height: auto; + min-height: var(--lemon-input-height); + } } diff --git a/frontend/src/lib/lemon-ui/LemonInputSelect/LemonInputSelect.stories.tsx b/frontend/src/lib/lemon-ui/LemonInputSelect/LemonInputSelect.stories.tsx new file mode 100644 index 0000000000000..796d1794b4c89 --- /dev/null +++ b/frontend/src/lib/lemon-ui/LemonInputSelect/LemonInputSelect.stories.tsx @@ -0,0 +1,102 @@ +import { Meta, StoryFn, StoryObj } from '@storybook/react' +import { capitalizeFirstLetter } from 'lib/utils' +import { useState } from 'react' + +import { ProfilePicture } from '../ProfilePicture' +import { LemonInputSelect, LemonInputSelectProps } from './LemonInputSelect' + +const names = ['ben', 'marius', 'paul', 'tiina', 'tim', 'james', 'neil', 'tom', 'annika', 'thomas'] + +type Story = StoryObj +const meta: Meta = { + title: 'Lemon UI/Lemon Input Select', + component: LemonInputSelect, + args: { + options: names.map((x, i) => ({ + key: `user-${i}`, + labelComponent: ( + + + + {capitalizeFirstLetter(x)} {`<${x}@posthog.com>`} + + + ), + label: `${x} ${x}@posthog.com>`, + })), + }, + tags: ['autodocs'], +} +export default meta + +const Template: StoryFn = (props: LemonInputSelectProps) => { + const [value, setValue] = useState(props.value || []) + return +} + +export const Default: Story = Template.bind({}) +Default.args = { + placeholder: 'Pick one email', + mode: 'single', +} + +export const MultipleSelect: Story = Template.bind({}) +MultipleSelect.args = { + placeholder: 'Enter emails...', + mode: 'multiple', +} + +export const MultipleSelectWithCustom: Story = Template.bind({}) +MultipleSelectWithCustom.args = { + placeholder: 'Enter any email...', + mode: 'multiple', + allowCustomValues: true, +} + +export const Disabled: Story = Template.bind({}) +Disabled.args = { + mode: 'single', + placeholder: 'Disabled...', + disabled: true, +} + +export const Loading: Story = Template.bind({}) +Loading.args = { + mode: 'single', + placeholder: 'Loading...', + options: [], + loading: true, +} +Loading.parameters = { + testOptions: { + waitForLoadersToDisappear: false, + }, +} + +export const NoOptions: Story = Template.bind({}) +NoOptions.args = { + mode: 'multiple', + allowCustomValues: true, + placeholder: 'No options...', + options: [], +} + +export const SingleOptionWithCustom: Story = Template.bind({}) +SingleOptionWithCustom.args = { + mode: 'single', + allowCustomValues: true, + placeholder: 'Only one option allowed but can be custom', +} + +export const PrefilledManyValues: Story = Template.bind({}) +PrefilledManyValues.args = { + mode: 'multiple', + allowCustomValues: true, + value: names.map((_, i) => `user-${i}`), +} diff --git a/frontend/src/lib/lemon-ui/LemonInputSelect/LemonInputSelect.tsx b/frontend/src/lib/lemon-ui/LemonInputSelect/LemonInputSelect.tsx new file mode 100644 index 0000000000000..f3e39c46f1e11 --- /dev/null +++ b/frontend/src/lib/lemon-ui/LemonInputSelect/LemonInputSelect.tsx @@ -0,0 +1,269 @@ +import { LemonSkeleton } from 'lib/lemon-ui/LemonSkeleton' +import { LemonSnack } from 'lib/lemon-ui/LemonSnack/LemonSnack' +import { range } from 'lib/utils' +import { useEffect, useMemo, useRef, useState } from 'react' + +import { KeyboardShortcut } from '~/layout/navigation-3000/components/KeyboardShortcut' + +import { LemonButton } from '../LemonButton' +import { LemonDropdown } from '../LemonDropdown' +import { LemonInput } from '../LemonInput' +import { PopoverReferenceContext } from '../Popover' + +export interface LemonInputSelectOption { + key: string + label: string + labelComponent?: React.ReactNode +} + +export type LemonInputSelectProps = { + options?: LemonInputSelectOption[] + value?: string[] | null + disabled?: boolean + loading?: boolean + placeholder?: string + disableFiltering?: boolean + mode: 'multiple' | 'single' + allowCustomValues?: boolean + onChange?: (newValue: string[]) => void + onInputChange?: (newValue: string) => void + 'data-attr'?: string +} + +export function LemonInputSelect({ + placeholder, + options = [], + value, + loading, + onChange, + onInputChange, + mode, + disabled, + disableFiltering = false, + allowCustomValues = false, + ...props +}: LemonInputSelectProps): JSX.Element { + const [showPopover, setShowPopover] = useState(false) + const [inputValue, _setInputValue] = useState('') + const popoverFocusRef = useRef(false) + const inputRef = useRef(null) + const [selectedIndex, setSelectedIndex] = useState(0) + const values = value ?? [] + + const visibleOptions = useMemo(() => { + const res: LemonInputSelectOption[] = [] + const customValues = [...values] + + options.forEach((option) => { + // Remove from the custom values list if it's in the options + + if (customValues.includes(option.key)) { + customValues.splice(customValues.indexOf(option.key), 1) + } + + // Check for filtering + if (inputValue && !disableFiltering && !option.label.toLowerCase().includes(inputValue.toLowerCase())) { + return + } + + res.push(option) + }) + + // Custom values are always shown before the list + if (customValues.length) { + customValues.forEach((value) => { + res.unshift({ key: value, label: value }) + }) + } + + // Finally we show the input value if custom values are allowed and it's not in the list + if (allowCustomValues && inputValue && !values.includes(inputValue)) { + res.unshift({ key: inputValue, label: inputValue }) + } + + return res + }, [options, inputValue, value]) + + // Reset the selected index when the visible options change + useEffect(() => { + setSelectedIndex(0) + }, [visibleOptions.length]) + + const setInputValue = (newValue: string): void => { + _setInputValue(newValue) + onInputChange?.(inputValue) + } + + const _onActionItem = (item: string): void => { + let newValues = [...values] + if (values.includes(item)) { + // Remove the item + if (mode === 'single') { + newValues = [] + } else { + newValues.splice(values.indexOf(item), 1) + } + } else { + // Add the item + if (mode === 'single') { + newValues = [item] + } else { + newValues.push(item) + } + + setInputValue('') + } + + onChange?.(newValues) + } + + const _onBlur = (): void => { + // We need to add a delay as a click could be in the popover or the input wrapper which refocuses + setTimeout(() => { + if (popoverFocusRef.current) { + popoverFocusRef.current = false + inputRef.current?.focus() + _onFocus() + return + } + if (allowCustomValues && inputValue.trim() && !values.includes(inputValue)) { + _onActionItem(inputValue.trim()) + } else { + setInputValue('') + } + setShowPopover(false) + }, 100) + } + + const _onFocus = (): void => { + setShowPopover(true) + popoverFocusRef.current = true + } + + const _onKeyDown = (e: React.KeyboardEvent): void => { + if (e.key === 'Enter') { + e.preventDefault() + + const itemToAdd = visibleOptions[selectedIndex]?.key + if (itemToAdd) { + _onActionItem(visibleOptions[selectedIndex]?.key) + } + } else if (e.key === 'Backspace') { + if (!inputValue) { + e.preventDefault() + const newValues = [...values] + newValues.pop() + onChange?.(newValues) + } + } else if (e.key === 'ArrowDown') { + e.preventDefault() + setSelectedIndex(Math.min(selectedIndex + 1, options.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setSelectedIndex(Math.max(selectedIndex - 1, 0)) + } + } + + // TRICKY: We don't want the popover to affect the snack buttons + const prefix = ( + + <> + {values.map((value) => { + const option = options.find((option) => option.key === value) ?? { + label: value, + labelComponent: null, + } + return ( + <> + _onActionItem(value)}> + {option?.labelComponent ?? option?.label} + + + ) + })} + + + ) + + return ( + { + popoverFocusRef.current = false + setShowPopover(false) + }} + onClickInside={(e) => { + popoverFocusRef.current = true + e.stopPropagation() + }} + overlay={ +
    + {visibleOptions.length ? ( + visibleOptions?.map((option, index) => { + const isHighlighted = index === selectedIndex + return ( + _onActionItem(option.key)} + onMouseEnter={() => setSelectedIndex(index)} + > + + {option.labelComponent ?? option.label} + {isHighlighted ? ( + + {' '} + {!values.includes(option.key) + ? mode === 'single' + ? 'select' + : 'add' + : mode === 'single' + ? 'unselect' + : 'remove'} + + ) : undefined} + + + ) + }) + ) : loading ? ( + <> + {range(5).map((x) => ( +
    + + +
    + ))} + + ) : ( +

    + {allowCustomValues + ? 'Start typing and press Enter to add options' + : `No options matching "${inputValue}"`} +

    + )} +
    + } + > + + + +
    + ) +} diff --git a/frontend/src/lib/lemon-ui/LemonInputSelect/index.ts b/frontend/src/lib/lemon-ui/LemonInputSelect/index.ts new file mode 100644 index 0000000000000..4e01ced8508e3 --- /dev/null +++ b/frontend/src/lib/lemon-ui/LemonInputSelect/index.ts @@ -0,0 +1 @@ +export * from './LemonInputSelect' diff --git a/frontend/src/lib/lemon-ui/LemonModal/LemonModal.tsx b/frontend/src/lib/lemon-ui/LemonModal/LemonModal.tsx index a4dd176d9511b..3a50ea89355f8 100644 --- a/frontend/src/lib/lemon-ui/LemonModal/LemonModal.tsx +++ b/frontend/src/lib/lemon-ui/LemonModal/LemonModal.tsx @@ -45,6 +45,7 @@ export interface LemonModalProps { forceAbovePopovers?: boolean contentRef?: React.RefCallback overlayRef?: React.RefCallback + 'data-attr'?: string } export const LemonModalHeader = ({ children, className }: LemonModalInnerProps): JSX.Element => { @@ -82,6 +83,7 @@ export function LemonModal({ contentRef, overlayRef, hideCloseButton = false, + 'data-attr': dataAttr, }: LemonModalProps): JSX.Element { const nodeRef = useRef(null) const [ignoredOverlayClickCount, setIgnoredOverlayClickCount] = useState(0) @@ -89,7 +91,7 @@ export function LemonModal({ useEffect(() => setIgnoredOverlayClickCount(0), [hasUnsavedInput]) // Reset when there no longer is unsaved input const modalContent = ( -
    +
    {closable && !hideCloseButton && ( // The key causes the div to be re-rendered, which restarts the animation, // providing immediate visual feedback on click diff --git a/frontend/src/lib/lemon-ui/LemonSelectMultiple/LemonSelectMultiple.scss b/frontend/src/lib/lemon-ui/LemonSelectMultiple/LemonSelectMultiple.scss deleted file mode 100644 index 67200ac17bdf3..0000000000000 --- a/frontend/src/lib/lemon-ui/LemonSelectMultiple/LemonSelectMultiple.scss +++ /dev/null @@ -1,118 +0,0 @@ -.LemonSelectMultiple { - .ant-select { - width: 100%; - - .ant-select-selector, - &.ant-select-single .ant-select-selector { - min-height: 2.125rem; - padding: 0.25rem; - font-size: 0.875rem; - line-height: 1.25rem; - text-align: left; - background: var(--bg-light); - border: 1px solid var(--border); - border-radius: var(--radius); - - .ant-select-selection-overflow { - gap: 0.25rem; - } - } - - &:not(.ant-select-disabled):hover, - &.ant-select-focused:not(.ant-select-disabled), - &:not(.ant-select-disabled):active { - .ant-select-selector { - background: var(--bg-light); - border-color: var(--border-bold); - box-shadow: none; - } - } - - &:not(.ant-select-disabled):active { - .ant-select-selector { - color: var(--primary-active); - } - } - - .ant-select-selection-placeholder { - color: var(--muted); - } - - &.ant-select-single { - .ant-select-selector { - box-sizing: border-box; - height: 40px; - - .ant-select-selection-search-input { - height: 38px; - } - - .ant-select-selection-placeholder { - padding-left: 0.4rem; - } - - .ant-select-selection-item { - padding-left: 0.4rem; - } - } - } - - .ant-select-arrow { - display: none; - } - } -} - -.LemonSelectMultipleDropdown { - padding: 0.5rem; - margin: -4px 0; // Counteract antd wrapper - background: var(--bg-light); - border: 1px solid var(--primary-3000); - border-radius: var(--radius); - - .ant-select-item { - padding: 0; - padding-bottom: 0.2rem; - background: none; - - .ant-select-item-option-content { - height: 40px; - padding: 0.25rem 0.5rem; - cursor: pointer; - border-radius: var(--radius); - } - - &.ant-select-item-option-active { - .ant-select-item-option-content { - background: var(--primary-bg-hover); - } - } - - &.ant-select-item-option-selected { - .ant-select-item-option-content { - background: var(--primary-bg-active); - } - } - - .ant-select-item-option-state { - display: none; - } - } - - .ant-select-item-empty { - padding: 0; - } - - .ant-select-item-option-content { - display: flex; - align-items: center; - } - - .LemonSelectMultipleDropdown__skeleton { - display: flex; - gap: 0.5rem; - align-items: center; - height: 40px; - padding: 0 0.25rem; - } -} diff --git a/frontend/src/lib/lemon-ui/LemonSelectMultiple/LemonSelectMultiple.stories.tsx b/frontend/src/lib/lemon-ui/LemonSelectMultiple/LemonSelectMultiple.stories.tsx deleted file mode 100644 index baa2f805f48e4..0000000000000 --- a/frontend/src/lib/lemon-ui/LemonSelectMultiple/LemonSelectMultiple.stories.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { Meta, StoryFn, StoryObj } from '@storybook/react' -import { capitalizeFirstLetter } from 'lib/utils' -import { useState } from 'react' - -import { ProfilePicture } from '../ProfilePicture' -import { LemonSelectMultiple, LemonSelectMultipleProps } from './LemonSelectMultiple' - -type Story = StoryObj -const meta: Meta = { - title: 'Lemon UI/Lemon SelectMultiple', - component: LemonSelectMultiple, - args: { - options: ['ben', 'marius', 'paul', 'tiina', 'li'].reduce( - (acc, x, i) => ({ - ...acc, - [`user-${i}`]: { - labelComponent: ( - - - - {capitalizeFirstLetter(x)} {`<${x}@posthog.com>`} - - - ), - label: `${x} ${x}@posthog.com>`, - }, - }), - {} - ), - }, - tags: ['autodocs'], -} -export default meta - -const Template: StoryFn = (props: LemonSelectMultipleProps) => { - const [value, setValue] = useState(props.value || []) - return -} - -export const Default: Story = Template.bind({}) -Default.args = { - placeholder: 'Pick one email', -} - -export const MultipleSelect: Story = Template.bind({}) -MultipleSelect.args = { - placeholder: 'Enter emails...', - mode: 'multiple', -} - -export const MultipleSelectWithCustom: Story = Template.bind({}) -MultipleSelectWithCustom.args = { - placeholder: 'Enter any email...', - mode: 'multiple-custom', -} - -export const Disabled: Story = Template.bind({}) -Disabled.args = { - placeholder: 'Disabled...', - disabled: true, -} - -export const Loading: Story = Template.bind({}) -Loading.args = { - placeholder: 'Loading...', - options: [], - loading: true, -} -Loading.parameters = { - testOptions: { - waitForLoadersToDisappear: false, - }, -} - -export const NoOptions: Story = Template.bind({}) -NoOptions.args = { - mode: 'multiple-custom', - placeholder: 'No options...', - options: [], -} diff --git a/frontend/src/lib/lemon-ui/LemonSelectMultiple/LemonSelectMultiple.tsx b/frontend/src/lib/lemon-ui/LemonSelectMultiple/LemonSelectMultiple.tsx deleted file mode 100644 index 9c085799bb855..0000000000000 --- a/frontend/src/lib/lemon-ui/LemonSelectMultiple/LemonSelectMultiple.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import './LemonSelectMultiple.scss' - -import { Select } from 'antd' -import { LemonSkeleton } from 'lib/lemon-ui/LemonSkeleton' -import { LemonSnack } from 'lib/lemon-ui/LemonSnack/LemonSnack' -import { range } from 'lib/utils' -import { ReactNode } from 'react' - -export interface LemonSelectMultipleOption { - label: string - disabled?: boolean - 'data-attr'?: string - labelComponent?: React.ReactNode -} - -export interface LemonSelectMultipleOptionItem extends LemonSelectMultipleOption { - key: string -} - -export type LemonSelectMultipleOptions = Record - -export type LemonSelectMultipleProps = { - selectClassName?: string - options?: LemonSelectMultipleOptions | LemonSelectMultipleOptionItem[] - value?: string | string[] | null - disabled?: boolean - loading?: boolean - placeholder?: string - labelInValue?: boolean - onSearch?: (value: string) => void - onFocus?: () => void - onBlur?: () => void - filterOption?: boolean - mode?: 'single' | 'multiple' | 'multiple-custom' - onChange?: ((newValue: string) => void) | ((newValue: string[]) => void) - 'data-attr'?: string -} - -export type LabelInValue = { value: string; label: ReactNode } - -export function LemonSelectMultiple({ - value, - options, - disabled, - loading, - placeholder, - labelInValue, - onChange, - onSearch, - onFocus, - onBlur, - filterOption = true, - mode = 'single', - selectClassName, - ...props -}: LemonSelectMultipleProps): JSX.Element { - const optionsAsList: LemonSelectMultipleOptionItem[] = Array.isArray(options) - ? options - : Object.entries(options || {}).map(([key, option]) => ({ - key: key, - ...option, - })) - - const antOptions = optionsAsList.map((option) => ({ - key: option.key, - value: option.key, - label: option.labelComponent || option.label, - labelString: option.label, - })) - - return ( -
    -