diff --git a/.babelrc b/.babelrc index 7bb5482ce..48f19b72b 100644 --- a/.babelrc +++ b/.babelrc @@ -4,8 +4,8 @@ [ "@babel/transform-react-jsx", { - "pragma": "Preact.h", - "pragmaFrag": "Preact.Fragment" + "runtime": "automatic", + "importSource": "preact" } ] ] diff --git a/cypress/e2e/surveys.cy.ts b/cypress/e2e/surveys.cy.ts index 24bb76955..7544dfb14 100644 --- a/cypress/e2e/surveys.cy.ts +++ b/cypress/e2e/surveys.cy.ts @@ -1,5 +1,6 @@ /// import { getBase64EncodedPayload } from '../support/compression' +import 'cypress-localstorage-commands' function onPageLoad(options = {}) { cy.posthogInit(options) @@ -8,6 +9,41 @@ function onPageLoad(options = {}) { } describe('Surveys', () => { + const openTextQuestion = { + type: 'open', + question: 'What feedback do you have for us?', + description: 'plain text description', + } + const linkQuestion = { + type: 'link', + question: 'Book an interview with us', + link: 'https://posthog.com', + description: '

html description

', + } + const npsRatingQuestion = { type: 'rating', display: 'number', scale: 10, question: 'Would you recommend surveys?' } + const emojiRatingQuestion = { + type: 'rating', + display: 'emoji', + scale: 5, + question: 'How happy are you with your purchase?', + optional: true, + } + const multipleChoiceQuestion = { + type: 'multiple_choice', + question: 'Which types of content would you like to see more of?', + choices: ['Tutorials', 'Product Updates', 'Events', 'Other'], + } + const singleChoiceQuestion = { + type: 'single_choice', + question: 'What is your occupation?', + choices: ['Product Manager', 'Engineer', 'Designer', 'Other'], + } + const appearanceWithThanks = { + displayThankYouMessage: true, + thankyouMessageHeader: 'Thanks!', + thankyouMessageBody: 'We appreciate your feedback.', + } + beforeEach(() => { cy.intercept('POST', '**/decide/*', { config: { enable_collect_everything: false }, @@ -16,131 +52,518 @@ describe('Surveys', () => { isAuthenticated: false, }).as('decide') }) - it('shows and submits a basic survey', () => { - cy.intercept('GET', '**/surveys/*', { - surveys: [ - { - id: '123', - name: 'Test survey', - active: true, - type: 'popover', - start_date: '2021-01-01T00:00:00Z', - questions: [{ type: 'open', question: 'What is your role?', description: 'test description' }], - }, - ], - }).as('surveys') - cy.visit('./playground/cypress') - onPageLoad() - cy.wait(500) - const survey = cy.get('.PostHogSurvey123').shadow() - survey.find('.survey-123-form').should('be.visible') - cy.get('.PostHogSurvey123').shadow().find('.survey-question').should('have.text', 'What is your role?') - cy.get('.PostHogSurvey123').shadow().find('.description').should('have.text', 'test description') - survey.find('.question-textarea-wrapper').type('product engineer') - cy.get('.PostHogSurvey123').shadow().find('.form-submit').click() - cy.phCaptures().should('include', 'survey sent') + + describe('Core display logic', () => { + it('shows the same survey to user if they do not dismiss or respond to it', () => { + cy.intercept('GET', '**/surveys/*', { + surveys: [ + { + id: '123', + name: 'Test survey', + description: 'description', + type: 'popover', + start_date: '2021-01-01T00:00:00Z', + questions: [openTextQuestion], + }, + ], + }).as('surveys') + cy.visit('./playground/cypress') + onPageLoad() + cy.get('.PostHogSurvey123').shadow().find('.survey-form').should('be.visible') + cy.reload() + cy.visit('./playground/cypress') + onPageLoad() + cy.get('.PostHogSurvey123').shadow().find('.survey-form').should('be.visible') + }) + it('does not show the same survey to user if they have dismissed it before', () => { + cy.intercept('GET', '**/surveys/*', { + surveys: [ + { + id: '123', + name: 'Test survey', + description: 'description', + type: 'popover', + start_date: '2021-01-01T00:00:00Z', + questions: [openTextQuestion], + }, + ], + }).as('surveys') + cy.visit('./playground/cypress') + onPageLoad() + cy.get('.PostHogSurvey123').shadow().find('.survey-form').should('be.visible') + cy.get('.PostHogSurvey123').shadow().find('.cancel-btn-wrapper').click() + cy.get('.PostHogSurvey123').should('not.exist') + cy.getLocalStorage('seenSurvey_123').should('equal', 'true') + cy.reload() + cy.visit('./playground/cypress') + onPageLoad() + cy.get('.PostHogSurvey123').should('not.exist') + cy.getLocalStorage('seenSurvey_123').should('equal', 'true') + }) + + it('does not show the same survey to user if they responded to it before', () => { + cy.intercept('GET', '**/surveys/*', { + surveys: [ + { + id: '123', + name: 'Test survey', + description: 'description', + type: 'popover', + start_date: '2021-01-01T00:00:00Z', + questions: [openTextQuestion], + }, + ], + }).as('surveys') + cy.visit('./playground/cypress') + onPageLoad() + cy.get('.PostHogSurvey123').shadow().find('.survey-form').should('be.visible') + cy.get('.PostHogSurvey123').shadow().find('textarea').type('This is great!') + cy.get('.PostHogSurvey123').shadow().find('.form-submit').click() + cy.getLocalStorage('seenSurvey_123').should('equal', 'true') + cy.get('.PostHogSurvey123').shadow().find('.survey-form').should('not.exist') + cy.reload() + cy.visit('./playground/cypress') + onPageLoad() + cy.get('.PostHogSurvey123').should('not.exist') + cy.getLocalStorage('seenSurvey_123').should('equal', 'true') + }) + + it('does not show a survey to user if user has already seen any survey in the wait period', () => { + cy.intercept('GET', '**/surveys/*', { + surveys: [ + { + id: '123', + name: 'Test survey', + description: 'description', + type: 'popover', + start_date: '2021-01-01T00:00:00Z', + questions: [openTextQuestion], + conditions: { seenSurveyWaitPeriodInDays: 10 }, + }, + ], + }).as('surveys') + cy.visit('./playground/cypress') + onPageLoad() + cy.get('.PostHogSurvey123').shadow().find('.survey-form').should('be.visible') + cy.wait(200) + cy.getLocalStorage('lastSeenSurveyDate').then((date) => { + expect(date?.split('T')?.[0]).to.equal(new Date().toISOString().split('T')[0]) + }) + cy.reload() + cy.visit('./playground/cypress') + onPageLoad() + cy.get('.PostHogSurvey123').should('not.exist') + }) }) - it('shows confirmation message after submitting', () => { - cy.intercept('GET', '**/surveys/*', { - surveys: [ - { - id: '1234', - name: 'Test survey 2', - active: true, - type: 'popover', - start_date: '2021-01-01T00:00:00Z', - questions: [ - { type: 'rating', display: 'number', scale: 10, question: 'Would you recommend surveys?' }, - ], - appearance: { - displayThankYouMessage: true, - thankyouMessageHeader: 'Thanks!', - thankyouMessageBody: 'We appreciate your feedback.', - }, - }, - ], - }).as('surveys') - cy.visit('./playground/cypress') - onPageLoad() - cy.get('.PostHogSurvey1234').shadow().find('.ratings-number').should('be.visible') - cy.get('.PostHogSurvey1234').shadow().find('.ratings-number').first().click() - cy.get('.PostHogSurvey1234').shadow().find('.form-submit').click() - expect(cy.get('.PostHogSurvey1234').shadow().find('.thank-you-message').should('be.visible')) + describe('Survey question types', () => { + it('shows and submits a basic survey', () => { + cy.intercept('GET', '**/surveys/*', { + surveys: [ + { + id: '123', + name: 'Test survey', + type: 'popover', + start_date: '2021-01-01T00:00:00Z', + questions: [openTextQuestion], + appearance: appearanceWithThanks, + }, + ], + }).as('surveys') + cy.visit('./playground/cypress') + onPageLoad() + const survey = cy.get('.PostHogSurvey123').shadow() + survey.find('.survey-form').should('be.visible') + cy.get('.PostHogSurvey123') + .shadow() + .find('.survey-question') + .should('have.text', 'What feedback do you have for us?') + cy.get('.PostHogSurvey123').shadow().find('.description').should('have.text', 'plain text description') + survey.find('textarea').type('This is great!') + cy.get('.PostHogSurvey123').shadow().find('.form-submit').click() + cy.phCaptures().should('include', 'survey sent') + }) + + it('rating questions that are on the 10 scale start at 0', () => { + cy.intercept('GET', '**/surveys/*', { + surveys: [ + { + id: '1234', + name: 'Test survey 2', + type: 'popover', + start_date: '2021-01-01T00:00:00Z', + questions: [npsRatingQuestion, { ...npsRatingQuestion, scale: 5 }], + appearance: appearanceWithThanks, + }, + ], + }).as('surveys') + cy.visit('./playground/cypress') + onPageLoad() + cy.get('.PostHogSurvey1234').shadow().find('.ratings-number').should('be.visible') + cy.get('.PostHogSurvey1234').shadow().find('.ratings-number').first().should('have.text', '0') + cy.get('.PostHogSurvey1234').shadow().find('.ratings-number').first().click() + cy.get('.PostHogSurvey1234').shadow().find('.form-submit').click() + cy.get('.PostHogSurvey1234').shadow().find('.ratings-number').first().should('have.text', '1') + }) + + it('multiple question surveys', () => { + cy.intercept('GET', '**/surveys/*', { + surveys: [ + { + id: '12345', + name: 'multiple question survey', + type: 'popover', + start_date: '2021-01-01T00:00:00Z', + questions: [multipleChoiceQuestion, openTextQuestion, { ...npsRatingQuestion, optional: true }], + appearance: appearanceWithThanks, + }, + ], + }).as('surveys') + cy.intercept('POST', '**/e/*').as('capture-assertion') + cy.visit('./playground/cypress') + onPageLoad() + cy.get('.PostHogSurvey12345').shadow().find('.survey-form').should('be.visible') + cy.get('.PostHogSurvey12345').shadow().find('#surveyQuestion0Choice1').click() + cy.get('.PostHogSurvey12345').shadow().find('#surveyQuestion0Choice2').click() + cy.get('.PostHogSurvey12345').shadow().find('.form-submit').eq(0).click() + cy.get('.PostHogSurvey12345') + .shadow() + .find('textarea') + .first() + .type('Because I want to learn more about PostHog') + cy.get('.PostHogSurvey12345').shadow().find('.form-submit').click() + cy.get('.PostHogSurvey12345').shadow().find('.form-submit').click() + cy.wait(200) + cy.get('.PostHogSurvey12345').shadow().find('.thank-you-message').should('be.visible') + cy.get('.PostHogSurvey12345').shadow().find('.form-submit').click() + cy.get('.PostHogSurvey12345').shadow().find('.thank-you-message').should('not.exist') + cy.wait('@capture-assertion') + cy.wait('@capture-assertion').then(async ({ request }) => { + const captures = await getBase64EncodedPayload(request) + expect(captures.map(({ event }) => event)).to.deep.equal(['survey shown', 'survey sent']) + expect(captures[1].properties['$survey_response']).to.deep.equal(['Product Updates', 'Events']) + expect(captures[1].properties).to.contain({ + $survey_id: '12345', + $survey_response_1: 'Because I want to learn more about PostHog', + $survey_response_2: null, + }) + }) + }) + + it('multiple choice questions with open choice', () => { + cy.intercept('GET', '**/surveys/*', { + surveys: [ + { + id: '12345', + name: 'multiple choice survey', + type: 'popover', + start_date: '2021-01-01T00:00:00Z', + questions: [{ ...multipleChoiceQuestion, hasOpenChoice: true }], + }, + ], + }).as('surveys') + cy.visit('./playground/cypress') + cy.intercept('POST', '**/e/*').as('capture-assertion') + onPageLoad() + cy.wait('@capture-assertion') + cy.get('.PostHogSurvey12345').shadow().find('.survey-form').should('be.visible') + cy.get('.PostHogSurvey12345').shadow().find('#surveyQuestion0Choice3').click() + cy.get('.PostHogSurvey12345').shadow().find('#surveyQuestion0Choice0').click() + cy.get('.PostHogSurvey12345').shadow().find('input[type=text]').type('Newsletters') + cy.get('.PostHogSurvey12345').shadow().find('.form-submit').click() + cy.wait('@capture-assertion').then(async ({ request }) => { + const captures = await getBase64EncodedPayload(request) + expect(captures.map(({ event }) => event)).to.deep.equal(['survey shown', 'survey sent']) + expect(captures[1].properties['$survey_response']).to.deep.equal(['Tutorials', 'Newsletters']) + }) + }) + + it('single choice question with open choice', () => { + cy.intercept('GET', '**/surveys/*', { + surveys: [ + { + id: '12345', + name: 'single choice survey', + type: 'popover', + start_date: '2021-01-01T00:00:00Z', + questions: [{ ...singleChoiceQuestion, hasOpenChoice: true }], + appearance: { backgroundColor: 'black', submitButtonColor: 'white' }, + }, + ], + }).as('surveys') + cy.visit('./playground/cypress') + cy.intercept('POST', '**/e/*').as('capture-assertion') + onPageLoad() + cy.wait('@capture-assertion') + cy.get('.PostHogSurvey12345').shadow().find('.survey-form').should('be.visible') + cy.get('.PostHogSurvey12345').shadow().find('#surveyQuestion0Choice3').click() + cy.get('.PostHogSurvey12345').shadow().find('input[type=text]').type('Product engineer') + cy.get('.PostHogSurvey12345').shadow().find('.form-submit').click() + cy.wait('@capture-assertion').then(async ({ request }) => { + const captures = await getBase64EncodedPayload(request) + expect(captures.map(({ event }) => event)).to.deep.equal(['survey shown', 'survey sent']) + expect(captures[1].properties['$survey_response']).to.equal('Product engineer') + }) + }) }) - it('multiple question surveys', () => { - cy.intercept('GET', '**/surveys/*', { - surveys: [ - { - id: '12345', - name: 'multiple question survey', - active: true, - type: 'popover', - start_date: '2021-01-01T00:00:00Z', - questions: [ - { - question: 'Which types of content would you like to see more of?', - description: 'This is a question description', - type: 'multiple_choice', - choices: ['Tutorials', 'Product Updates', 'Events', 'Other'], + describe('Survey customization', () => { + it('automatically sets text color based on background color', () => { + cy.intercept('GET', '**/surveys/*', { + surveys: [ + { + id: '123', + name: 'Test survey', + type: 'popover', + start_date: '2021-01-01T00:00:00Z', + questions: [openTextQuestion], + appearance: { + backgroundColor: '#000000', + submitButtonColor: '#ffffff', }, - { type: 'open', question: 'Why?' }, - { - type: 'rating', - display: 'emoji', - scale: 5, - question: 'How does this survey make you feel?', - optional: true, + }, + ], + }).as('surveys') + const black = 'rgb(0, 0, 0)' + const white = 'rgb(255, 255, 255)' + cy.visit('./playground/cypress') + onPageLoad() + cy.get('.PostHogSurvey123').shadow().find('.survey-form').should('be.visible') + cy.get('.PostHogSurvey123') + .shadow() + .find('.survey-question') + .should('have.text', 'What feedback do you have for us?') + cy.get('.PostHogSurvey123').shadow().find('.description').should('have.text', 'plain text description') + // text should be white on a dark background + cy.get('.PostHogSurvey123').shadow().find('.survey-question').should('have.css', 'background-color', black) + cy.get('.PostHogSurvey123').shadow().find('.survey-question').should('have.css', 'color', white) + // text should be black on a light background + cy.get('.PostHogSurvey123').shadow().find('.form-submit').should('have.css', 'background-color', white) + cy.get('.PostHogSurvey123').shadow().find('.form-submit').should('have.css', 'color', black) + cy.get('.PostHogSurvey123').shadow().find('textarea').type('This is great!') + cy.get('.PostHogSurvey123').shadow().find('.form-submit').click() + cy.phCaptures().should('include', 'survey sent') + }) + + it('does not show posthog logo if whiteLabel exists', () => { + cy.intercept('GET', '**/surveys/*', { + surveys: [ + { + id: '123', + name: 'Test survey', + type: 'popover', + start_date: '2021-01-01T00:00:00Z', + questions: [openTextQuestion], + appearance: { whiteLabel: true }, + }, + ], + }).as('surveys') + cy.visit('./playground/cypress') + onPageLoad() + cy.get('.PostHogSurvey123').shadow().find('.footer-branding').should('not.exist') + }) + + it('allows html customization for question and thank you element description', () => { + cy.intercept('GET', '**/surveys/*', { + surveys: [ + { + id: '123', + name: 'Test survey', + type: 'popover', + start_date: '2021-01-01T00:00:00Z', + questions: [linkQuestion], + }, + ], + }).as('surveys') + cy.visit('./playground/cypress') + onPageLoad() + cy.get('.PostHogSurvey123').shadow().find('.survey-form').should('be.visible') + cy.get('.PostHogSurvey123') + .shadow() + .find('.survey-question') + .should('have.text', 'Book an interview with us') + cy.get('.PostHogSurvey123').shadow().find('.description').should('have.html', '

html description

') + }) + + it('allows html customization for thank you message body', () => { + cy.intercept('GET', '**/surveys/*', { + surveys: [ + { + id: '123', + name: 'Test survey', + type: 'popover', + start_date: '2021-01-01T00:00:00Z', + questions: [openTextQuestion], + appearance: { + ...appearanceWithThanks, + thankYouMessageDescription: '

html thank you message!

', }, - { - type: 'link', - question: 'Would you like to participate in a user study?', - link: 'https://posthog.com', - buttonText: 'Yes', + }, + ], + }).as('surveys') + cy.visit('./playground/cypress') + onPageLoad() + cy.get('.PostHogSurvey123').shadow().find('.survey-form').should('be.visible') + cy.get('.PostHogSurvey123') + .shadow() + .find('.survey-question') + .should('have.text', 'What feedback do you have for us?') + cy.get('.PostHogSurvey123').shadow().find('.description').should('have.text', 'plain text description') + cy.get('.PostHogSurvey123').shadow().find('textarea').type('This is great!') + cy.get('.PostHogSurvey123').shadow().find('.form-submit').click() + cy.get('.PostHogSurvey123') + .shadow() + .find('.thank-you-message-body') + .should('have.html', '

html thank you message!

') + cy.phCaptures().should('include', 'survey sent') + }) + }) + + describe('Feedback widget', () => { + it('displays feedback tab and submits responses ', () => { + cy.intercept('GET', '**/surveys/*', { + surveys: [ + { + id: '123', + name: 'Feedback tab survey', + type: 'widget', + start_date: '2021-01-01T00:00:00Z', + questions: [{ type: 'open', question: 'Feedback for us?', description: 'tab feedback widget' }], + appearance: { + widgetLabel: 'Feedback', + widgetType: 'tab', + displayThankYouMessage: true, + thankyouMessageHeader: 'Thanks!', + thankyouMessageBody: 'We appreciate your feedback.', }, - ], - appearance: { - displayThankYouMessage: true, - thankyouMessageHeader: 'Thanks!', - thankyouMessageBody: 'We appreciate your feedback.', - }, - }, - ], - }).as('surveys') - cy.intercept('POST', '**/e/*').as('capture-assertion') - cy.visit('./playground/cypress') - onPageLoad() - cy.wait(500) - cy.get('.PostHogSurvey12345').shadow().find('.survey-12345-form').should('be.visible') - cy.get('.PostHogSurvey12345').shadow().find('#surveyQuestion0Choice1').click() - cy.get('.PostHogSurvey12345').shadow().find('#surveyQuestion0Choice2').click() - cy.get('.PostHogSurvey12345').shadow().find('.form-submit').eq(0).click() - cy.get('.PostHogSurvey12345') - .shadow() - .find('.question-textarea-wrapper') - .first() - .type('Because I want to learn more about PostHog') - cy.get('.PostHogSurvey12345').shadow().find('.form-submit').eq(1).click() - cy.get('.PostHogSurvey12345').shadow().find('.form-submit').eq(2).click() - cy.get('.PostHogSurvey12345').shadow().find('.form-submit').eq(3).click() - cy.wait('@capture-assertion') - cy.wait('@capture-assertion').then(async ({ request }) => { - const captures = await getBase64EncodedPayload(request) - expect(captures.map(({ event }) => event)).to.deep.equal(['survey shown', 'survey sent']) - expect(captures[1].properties['$survey_response']).to.deep.equal(['Product Updates', 'Events']) - expect(captures[1].properties).to.contain({ - $survey_id: '12345', - $survey_response_1: 'Because I want to learn more about PostHog', - $survey_response_2: null, - $survey_response_3: 'link clicked', - }) + }, + ], + }).as('surveys') + cy.visit('./playground/cypress') + onPageLoad() + cy.get('.PostHogWidget123').shadow().find('.survey-form').should('not.exist') + cy.get('.PostHogWidget123').shadow().find('.ph-survey-widget-tab').click() + cy.get('.PostHogWidget123').shadow().find('.survey-form').should('be.visible') + cy.get('.PostHogWidget123').shadow().find('.survey-question').should('have.text', 'Feedback for us?') + cy.get('.PostHogWidget123').shadow().find('.description').should('have.text', 'tab feedback widget') + cy.get('.PostHogWidget123').shadow().find('textarea').type("Why can't I use behavioral cohorts in flags?") + cy.get('.PostHogWidget123').shadow().find('.form-submit').click() + cy.phCaptures().should('include', 'survey sent') + }) + + it('wigetType is custom selector', () => { + cy.intercept('GET', '**/surveys/*', { + surveys: [ + { + id: '123', + name: 'Custom selector widget survey', + type: 'widget', + start_date: '2021-01-01T00:00:00Z', + questions: [ + { type: 'open', question: 'Feedback for us?', description: 'custom selector widget' }, + ], + appearance: { + widgetType: 'selector', + widgetSelector: '.test-surveys', + displayThankYouMessage: true, + thankyouMessageHeader: 'Thanks!', + thankyouMessageBody: 'We appreciate your feedback.', + }, + }, + ], + }).as('surveys') + cy.visit('./playground/cypress') + onPageLoad() + cy.get('.PostHogWidget123').shadow().find('.ph-survey-widget-tab').should('not.exist') + cy.get('.test-surveys').click() + cy.get('.PostHogWidget123').shadow().find('.survey-form').should('be.visible') + cy.get('.PostHogWidget123').shadow().find('.survey-question').should('have.text', 'Feedback for us?') + cy.get('.PostHogWidget123').shadow().find('.description').should('have.text', 'custom selector widget') + cy.get('.PostHogWidget123').shadow().find('textarea').type('PostHog is awesome!') + cy.get('.PostHogWidget123').shadow().find('.form-submit').click() + cy.phCaptures().should('include', 'survey sent') + }) + + it('displays multiple question surveys and thank you confirmation if enabled', () => { + cy.intercept('GET', '**/surveys/*', { + surveys: [ + { + id: '12345', + name: 'multiple question survey', + type: 'widget', + start_date: '2021-01-01T00:00:00Z', + questions: [multipleChoiceQuestion, openTextQuestion, { ...npsRatingQuestion, optional: true }], + appearance: { ...appearanceWithThanks, widgetType: 'tab', widgetLabel: 'Feedback :)' }, + }, + ], + }).as('surveys') + cy.visit('./playground/cypress') + onPageLoad() + cy.get('.PostHogWidget12345').shadow().find('.ph-survey-widget-tab').click() + cy.get('.PostHogWidget12345').shadow().find('.survey-form').should('be.visible') + cy.get('.PostHogWidget12345').shadow().find('#surveyQuestion0Choice1').click() + cy.get('.PostHogWidget12345').shadow().find('.form-submit').eq(0).click() + cy.get('.PostHogWidget12345') + .shadow() + .find('textarea') + .first() + .type('Because I want to learn more about PostHog') + cy.get('.PostHogWidget12345').shadow().find('.form-submit').click() + cy.get('.PostHogWidget12345').shadow().find('.form-submit').click() + cy.get('.PostHogWidget12345').shadow().find('.thank-you-message').should('be.visible') + cy.phCaptures().should('include', 'survey shown') + cy.phCaptures().should('include', 'survey sent') + }) + }) + + describe('Thank you message', () => { + it('shows confirmation message after submitting', () => { + cy.intercept('GET', '**/surveys/*', { + surveys: [ + { + id: '1234', + name: 'Test survey 2', + type: 'popover', + start_date: '2021-01-01T00:00:00Z', + questions: [emojiRatingQuestion], + appearance: { ...appearanceWithThanks, backgroundColor: 'black' }, + }, + ], + }).as('surveys') + cy.visit('./playground/cypress') + onPageLoad() + cy.get('.PostHogSurvey1234').shadow().find('.ratings-emoji').should('be.visible') + cy.get('.PostHogSurvey1234').shadow().find('.ratings-emoji').first().click() + cy.get('.PostHogSurvey1234').shadow().find('.form-submit').click() + cy.get('.PostHogSurvey1234').shadow().find('.thank-you-message').should('be.visible') + }) + + it('counts down with auto disappear after 5 seconds', () => { + cy.intercept('GET', '**/surveys/*', { + surveys: [ + { + id: '1234', + name: 'Test survey 2', + type: 'popover', + start_date: '2021-01-01T00:00:00Z', + questions: [emojiRatingQuestion], + appearance: { ...appearanceWithThanks, autoDisappear: true }, + }, + ], + }).as('surveys') + cy.visit('./playground/cypress') + onPageLoad() + cy.get('.PostHogSurvey1234').shadow().find('.ratings-emoji').should('be.visible') + cy.get('.PostHogSurvey1234').shadow().find('.ratings-emoji').first().click() + cy.get('.PostHogSurvey1234').shadow().find('.form-submit').click() + expect(cy.get('.PostHogSurvey1234').shadow().find('.thank-you-message').should('be.visible')) + cy.wait(5000) // mimic the 5 second timeout + expect(cy.get('.PostHogSurvey1234').shadow().find('.thank-you-message').should('not.exist')) }) - expect(cy.get('.PostHogSurvey12345').shadow().find('.thank-you-message').should('be.visible')) }) - describe('survey response capture', () => { - it('captures survey shown and survey dismissed events', () => { + describe('Survey response capture', () => { + it('captures survey sent event', () => { cy.visit('./playground/cypress') cy.intercept('GET', '**/surveys/*', { surveys: [ @@ -148,25 +571,28 @@ describe('Surveys', () => { id: '123', name: 'Test survey', description: 'description', - active: true, type: 'popover', start_date: '2021-01-01T00:00:00Z', - questions: [{ type: 'open', question: 'What is a survey event capture test?' }], + questions: [openTextQuestion], }, ], }).as('surveys') cy.intercept('POST', '**/e/*').as('capture-assertion') onPageLoad() - // first capture is $pageview + cy.get('.PostHogSurvey123').shadow().find('textarea').type('experiments is awesome!') + cy.get('.PostHogSurvey123').shadow().find('.form-submit').click() cy.wait('@capture-assertion') - cy.get('.PostHogSurvey123').shadow().find('.cancel-btn-wrapper').click() cy.wait('@capture-assertion').then(async ({ request }) => { const captures = await getBase64EncodedPayload(request) - expect(captures.map(({ event }) => event)).to.deep.equal(['survey shown', 'survey dismissed']) + expect(captures.map(({ event }) => event)).to.deep.equal(['survey shown', 'survey sent']) + expect(captures[1].properties).to.contain({ + $survey_id: '123', + $survey_response: 'experiments is awesome!', + }) }) }) - it('captures survey sent event', () => { + it('captures survey shown event', () => { cy.visit('./playground/cypress') cy.intercept('GET', '**/surveys/*', { surveys: [ @@ -174,22 +600,42 @@ describe('Surveys', () => { id: '123', name: 'Test survey', description: 'description', - active: true, type: 'popover', start_date: '2021-01-01T00:00:00Z', - questions: [{ type: 'open', question: 'What is a survey event capture test?' }], + questions: [openTextQuestion], }, ], }).as('surveys') cy.intercept('POST', '**/e/*').as('capture-assertion') onPageLoad() - cy.get('.PostHogSurvey123').shadow().find('.question-textarea-wrapper').type('product engineer') - cy.get('.PostHogSurvey123').shadow().find('.form-submit').click() cy.wait('@capture-assertion') cy.wait('@capture-assertion').then(async ({ request }) => { const captures = await getBase64EncodedPayload(request) - expect(captures.map(({ event }) => event)).to.deep.equal(['survey shown', 'survey sent']) - expect(captures[1].properties).to.contain({ $survey_id: '123', $survey_response: 'product engineer' }) + expect(captures[0].event).to.equal('survey shown') + }) + }) + + it('captures survey dismissed event', () => { + cy.visit('./playground/cypress') + cy.intercept('GET', '**/surveys/*', { + surveys: [ + { + id: '123', + name: 'Test survey', + description: 'description', + type: 'popover', + start_date: '2021-01-01T00:00:00Z', + questions: [openTextQuestion], + }, + ], + }).as('surveys') + cy.intercept('POST', '**/e/*').as('capture-assertion') + onPageLoad() + cy.get('.PostHogSurvey123').shadow().find('.cancel-btn-wrapper').click() + cy.wait('@capture-assertion') + cy.wait('@capture-assertion').then(async ({ request }) => { + const captures = await getBase64EncodedPayload(request) + expect(captures.map(({ event }) => event)).to.contain('survey dismissed') }) }) }) diff --git a/package.json b/package.json index 50f39279e..4da2f1b7d 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "babel-eslint": "10.1.0", "babel-jest": "^26.6.3", "cypress": "13.6.3", + "cypress-localstorage-commands": "^2.2.5", "eslint": "8.56.0", "eslint-config-posthog-js": "link:eslint-rules", "eslint-config-prettier": "^8.5.0", @@ -80,6 +81,7 @@ "msw": "^1.2.1", "node-fetch": "^2.6.11", "posthog-js": "link:", + "preact-render-to-string": "^6.3.1", "prettier": "^2.7.1", "rollup": "^4.9.6", "rollup-plugin-dts": "^6.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ad928ff5..6935c7afb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,9 @@ devDependencies: cypress: specifier: 13.6.3 version: 13.6.3 + cypress-localstorage-commands: + specifier: ^2.2.5 + version: 2.2.5(cypress@13.6.3) eslint: specifier: 8.56.0 version: 8.56.0 @@ -150,6 +153,9 @@ devDependencies: posthog-js: specifier: 'link:' version: 'link:' + preact-render-to-string: + specifier: ^6.3.1 + version: 6.3.1(preact@10.19.3) prettier: specifier: ^2.7.1 version: 2.7.1 @@ -4375,6 +4381,15 @@ packages: resolution: {integrity: sha512-d4ZVpCW31eWwCMe1YT3ur7mUDnTXbgwyzaL320DrcRT45rfjYxkt5QWLrmOJ+/UEAI2+fQgKe/fCjR8l4TpRgw==} dev: true + /cypress-localstorage-commands@2.2.5(cypress@13.6.3): + resolution: {integrity: sha512-07zpwzWdY+uPi1NEHFhWQNylIJqRxR78Ile05L6WT8h1Gz0OaxgBSZRuzp+pqUni/3Pk4d2ieq/cSh++ZmujEA==} + engines: {node: '>=14.0.0'} + peerDependencies: + cypress: '>=2.1.0' + dependencies: + cypress: 13.6.3 + dev: true + /cypress@13.6.3: resolution: {integrity: sha512-d/pZvgwjAyZsoyJ3FOsJT5lDsqnxQ/clMqnNc++rkHjbkkiF2h9s0JsZSyyH4QXhVFW3zPFg82jD25roFLOdZA==} engines: {node: ^16.0.0 || ^18.0.0 || >=20.0.0} @@ -8615,9 +8630,17 @@ packages: engines: {node: '>=0.10.0'} dev: true + /preact-render-to-string@6.3.1(preact@10.19.3): + resolution: {integrity: sha512-NQ28WrjLtWY6lKDlTxnFpKHZdpjfF+oE6V4tZ0rTrunHrtZp6Dm0oFrcJalt/5PNeqJz4j1DuZDS0Y6rCBoqDA==} + peerDependencies: + preact: '>=10' + dependencies: + preact: 10.19.3 + pretty-format: 3.8.0 + dev: true + /preact@10.19.3: resolution: {integrity: sha512-nHHTeFVBTHRGxJXKkKu5hT8C/YWBkPso4/Gad6xuj5dbptt9iF9NZr9pHbPhBrnT2klheu7mHTxTZ/LjwJiEiQ==} - dev: false /prelude-ls@1.1.2: resolution: {integrity: sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==} @@ -8665,6 +8688,10 @@ packages: react-is: 18.2.0 dev: true + /pretty-format@3.8.0: + resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==} + dev: true + /pretty-hrtime@1.0.3: resolution: {integrity: sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A==} engines: {node: '>= 0.8'} diff --git a/rollup.config.js b/rollup.config.js index 985ab37bd..b2fbae84e 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -58,6 +58,11 @@ export default [ preact: 'preact', }, }, + { + file: 'dist/surveys.esm.js', + format: 'es', + sourcemap: true, + }, ], plugins: [...plugins], }, diff --git a/src/__tests__/extensions/surveys.js b/src/__tests__/extensions/surveys.js deleted file mode 100644 index 8219b1bd3..000000000 --- a/src/__tests__/extensions/surveys.js +++ /dev/null @@ -1,501 +0,0 @@ -import { createShadow, callSurveys, generateSurveys } from '../../extensions/surveys' -import { SurveyType } from '../../posthog-surveys-types' -import { createMultipleQuestionSurvey, createRatingsPopup } from '../../extensions/surveys/surveys-utils' - -describe('survey display logic', () => { - beforeEach(() => { - // we have to manually reset the DOM before each test - document.getElementsByTagName('html')[0].innerHTML = '' - localStorage.clear() - jest.clearAllMocks() - }) - - test('createShadow', () => { - const surveyId = 'randomSurveyId' - const mockShadow = createShadow(`.survey-${surveyId}-form {}`, surveyId) - expect(mockShadow.mode).toBe('open') - expect(mockShadow.host.className).toBe(`PostHogSurvey${surveyId}`) - }) - - let mockSurveys = [ - { - id: 'testSurvey1', - name: 'Test survey 1', - type: SurveyType.Popover, - appearance: null, - questions: [ - { - question: 'How satisfied are you with our newest product?', - description: 'This is a question description', - type: 'rating', - display: 'number', - scale: 10, - lower_bound_label: 'Not Satisfied', - upper_bound_label: 'Very Satisfied', - }, - ], - }, - ] - const mockPostHog = { - getActiveMatchingSurveys: jest.fn().mockImplementation((callback) => callback(mockSurveys)), - get_session_replay_url: jest.fn(), - capture: jest.fn().mockImplementation((eventName) => eventName), - } - - test('does not show survey to user if they have dismissed it before', () => { - expect(localStorage.getItem(`seenSurvey_${mockSurveys[0].id}`)).toBe(null) - callSurveys(mockPostHog, false) - expect(mockPostHog.capture).toBeCalledTimes(1) - expect(mockPostHog.capture).toBeCalledWith('survey shown', { - $survey_id: 'testSurvey1', - $survey_name: 'Test survey 1', - sessionRecordingUrl: undefined, - }) - - // now we dismiss the survey - const cancelButton = document - .getElementsByClassName(`PostHogSurvey${mockSurveys[0].id}`)[0] - .shadowRoot.querySelectorAll('.form-cancel')[0] - cancelButton.click() - expect(mockPostHog.capture).toBeCalledTimes(2) - expect(mockPostHog.capture).toBeCalledWith('survey dismissed', { - $survey_id: 'testSurvey1', - $survey_name: 'Test survey 1', - sessionRecordingUrl: undefined, - $set: { - '$survey_dismissed/testSurvey1': true, - }, - }) - expect(localStorage.getItem(`seenSurvey_${mockSurveys[0].id}`)).toBe('true') - - // now we clear the DOM to imitate a new page load and call surveys again, and it should not show the survey - document.getElementsByTagName('html')[0].innerHTML = '' - callSurveys(mockPostHog, false) - expect(document.getElementsByClassName(`PostHogSurvey${mockSurveys[0].id}`)[0]).toBe(undefined) - // no additional capture events are called because the survey is not shown - expect(mockPostHog.capture).toBeCalledTimes(2) - }) - - test('does not show survey to user if they have already completed it', () => { - expect(localStorage.getItem(`seenSurvey_${mockSurveys[0].id}`)).toBe(null) - callSurveys(mockPostHog, false) - expect(mockPostHog.capture).toBeCalledTimes(1) - expect(mockPostHog.capture).toBeCalledWith('survey shown', { - $survey_id: 'testSurvey1', - $survey_name: 'Test survey 1', - sessionRecordingUrl: undefined, - }) - - // submit the survey - const ratingButton = document - .getElementsByClassName(`PostHogSurvey${mockSurveys[0].id}`)[0] - .shadowRoot.querySelectorAll('.question-0-rating-1')[0] - ratingButton.click() - const submitButton = document - .getElementsByClassName(`PostHogSurvey${mockSurveys[0].id}`)[0] - .shadowRoot.querySelectorAll('.form-submit')[0] - submitButton.click() - expect(mockPostHog.capture).toBeCalledTimes(2) - expect(mockPostHog.capture).toBeCalledWith('survey sent', { - $survey_id: 'testSurvey1', - $survey_name: 'Test survey 1', - $survey_question: 'How satisfied are you with our newest product?', - $survey_response: 1, - sessionRecordingUrl: undefined, - $set: { - '$survey_responded/testSurvey1': true, - }, - }) - expect(localStorage.getItem(`seenSurvey_${mockSurveys[0].id}`)).toBe('true') - - // now we clear the DOM to imitate a new page load and call surveys again, and it should not show the survey - document.getElementsByTagName('html')[0].innerHTML = '' - callSurveys(mockPostHog, false) - expect(document.getElementsByClassName(`PostHogSurvey${mockSurveys[0].id}`)[0]).toBe(undefined) - // no additional capture events are called because the survey is not shown - expect(mockPostHog.capture).toBeCalledTimes(2) - }) - - test('does not show survey to user if they have seen it before and survey wait period is set', () => { - mockSurveys = [ - { - id: 'testSurvey2', - name: 'Test survey 2', - type: SurveyType.Popover, - appearance: null, - conditions: { seenSurveyWaitPeriodInDays: 10 }, - questions: [ - { - question: 'How was your experience?', - description: 'This is a question description', - type: 'rating', - display: 'emoji', - scale: 5, - lower_bound_label: 'Not Good', - upper_bound_label: 'Very Good', - }, - ], - }, - ] - expect(mockSurveys[0].conditions.seenSurveyWaitPeriodInDays).toBe(10) - expect(localStorage.getItem(`seenSurvey_${mockSurveys[0].id}`)).toBe(null) - callSurveys(mockPostHog, false) - expect(mockPostHog.capture).toBeCalledTimes(1) - expect(mockPostHog.capture).toBeCalledWith('survey shown', { - $survey_id: 'testSurvey2', - $survey_name: 'Test survey 2', - sessionRecordingUrl: undefined, - }) - expect(localStorage.getItem('lastSeenSurveyDate').split('T')[0]).toBe(new Date().toISOString().split('T')[0]) - - document.getElementsByTagName('html')[0].innerHTML = '' - callSurveys(mockPostHog, false) - expect(document.getElementsByClassName(`PostHogSurvey${mockSurveys[0].id}`)[0]).toBe(undefined) - // no additional capture events are called because the survey is not shown - expect(mockPostHog.capture).toBeCalledTimes(1) - }) - - test('callSurveys runs on interval irrespective of url change', () => { - jest.useFakeTimers() - jest.spyOn(global, 'setInterval') - generateSurveys(mockPostHog) - expect(mockPostHog.getActiveMatchingSurveys).toBeCalledTimes(1) - expect(setInterval).toHaveBeenLastCalledWith(expect.any(Function), 3000) - - jest.advanceTimersByTime(3000) - expect(mockPostHog.getActiveMatchingSurveys).toBeCalledTimes(2) - expect(setInterval).toHaveBeenLastCalledWith(expect.any(Function), 3000) - }) - - test('multiple choice type question elements are unique', () => { - mockSurveys = [ - { - id: 'testSurvey2', - name: 'Test survey 2', - type: SurveyType.Popover, - appearance: null, - conditions: { seenSurveyWaitPeriodInDays: 10 }, - questions: [ - { - question: 'Which types of content would you like to see more of?', - description: 'This is a question description', - type: 'multiple_choice', - choices: ['Tutorials', 'Product Updates', 'Events', 'Other'], - }, - { - question: 'Which features do you use the most?', - description: 'This is a question description', - type: 'multiple_choice', - choices: ['Surveys', 'Feature flags', 'Analytics'], - }, - ], - }, - ] - const multipleQuestionSurveyForm = createMultipleQuestionSurvey(mockPostHog, mockSurveys[0]) - const allSelectOptions = multipleQuestionSurveyForm.querySelectorAll('input[type=checkbox]') - const uniqueIds = new Set() - allSelectOptions.forEach((element) => { - uniqueIds.add(element.id) - }) - expect(uniqueIds.size).toBe(allSelectOptions.length) - }) - - test('single choice question type radio input elements are grouped correctly by question index', () => { - mockSurveys = [ - { - id: 'testSurvey2', - name: 'Test survey 2', - type: SurveyType.Popover, - appearance: null, - questions: [ - { - question: 'Which types of content would you like to see more of?', - description: 'This is a question description', - type: 'single_choice', - choices: ['Tutorials', 'Product Updates', 'Events', 'Other'], - }, - { - question: 'Which features do you use the most?', - description: 'This is a question description', - type: 'single_choice', - choices: ['Surveys', 'Feature flags', 'Analytics'], - }, - ], - }, - ] - const multipleQuestionSurveyForm = createMultipleQuestionSurvey(mockPostHog, mockSurveys[0]) - const firstQuestionRadioInputs = multipleQuestionSurveyForm - .querySelectorAll('.tab.question-0')[0] - .querySelectorAll('input[type=radio]') - const mappedInputNames1 = [...firstQuestionRadioInputs].map((input) => input.name) - expect(mappedInputNames1.every((name) => name === 'question0')).toBe(true) - const secondQuestionRadioInputs = multipleQuestionSurveyForm - .querySelectorAll('.tab.question-1')[0] - .querySelectorAll('input[type=radio]') - const mappedInputNames2 = [...secondQuestionRadioInputs].map((input) => input.name) - expect(mappedInputNames2.every((name) => name === 'question1')).toBe(true) - }) - - test('rating questions that are on the 10 scale start at 0', () => { - mockSurveys = [ - { - id: 'testSurvey2', - name: 'Test survey 2', - type: SurveyType.Popover, - appearance: null, - questions: [ - { - question: 'How satisfied are you with our newest product?', - description: 'This is a question description', - type: 'rating', - display: 'number', - scale: 10, - lower_bound_label: 'Not Satisfied', - upper_bound_label: 'Very Satisfied', - }, - ], - }, - { - id: 'testSurvey3', - name: 'Test survey 3', - type: SurveyType.Popover, - appearance: null, - questions: [ - { - question: 'How satisfied are you with our newest product?', - description: 'This is a question description', - type: 'rating', - display: 'emoji', - scale: 3, - lower_bound_label: 'Not Satisfied', - upper_bound_label: 'Very Satisfied', - }, - ], - }, - ] - const ratingQuestion = createRatingsPopup(mockPostHog, mockSurveys[0], mockSurveys[0].questions[0], 0) - expect(ratingQuestion.querySelectorAll('.question-0-rating-0').length).toBe(1) - - // expect the first value of the rating buttons to be 1 for other scales - const ratingQuestion2 = createRatingsPopup(mockPostHog, mockSurveys[1], mockSurveys[1].questions[0], 0) - expect(ratingQuestion2.querySelectorAll('.question-0-rating-0').length).toBe(0) - expect(ratingQuestion2.querySelectorAll('.question-0-rating-1').length).toBe(1) - }) - - test('open choice value on a multiple choice question is determined by a text input', () => { - mockSurveys = [ - { - id: 'testSurvey2', - name: 'Test survey 2', - type: SurveyType.Popover, - appearance: null, - questions: [ - { - question: 'Which types of content would you like to see more of?', - description: 'This is a question description', - type: 'multiple_choice', - choices: ['Tutorials', 'Product Updates', 'Events', 'Other'], - hasOpenChoice: true, - }, - ], - }, - ] - const singleQuestionSurveyForm = createMultipleQuestionSurvey(mockPostHog, mockSurveys[0]) - - const checkboxInputs = singleQuestionSurveyForm - .querySelector('.tab.question-0') - .querySelectorAll('input[type=checkbox]') - let checkboxInputValues = [...checkboxInputs].map((input) => input.value) - expect(checkboxInputValues).toEqual(['Tutorials', 'Product Updates', 'Events', '']) - const openChoiceTextInput = singleQuestionSurveyForm - .querySelector('.tab.question-0') - .querySelector('input[type=text]') - openChoiceTextInput.value = 'NEW VALUE 1' - openChoiceTextInput.dispatchEvent(new Event('input')) - expect(singleQuestionSurveyForm.querySelector('.form-submit').disabled).toEqual(false) - checkboxInputValues = [...checkboxInputs].map((input) => input.value) - expect(checkboxInputValues).toEqual(['Tutorials', 'Product Updates', 'Events', 'NEW VALUE 1']) - checkboxInputs[0].click() - const checkboxInputsChecked = [...checkboxInputs].map((input) => input.checked) - expect(checkboxInputsChecked).toEqual([true, false, false, true]) - - singleQuestionSurveyForm.dispatchEvent(new Event('submit')) - expect(mockPostHog.capture).toBeCalledTimes(1) - expect(mockPostHog.capture).toBeCalledWith('survey sent', { - $survey_name: 'Test survey 2', - $survey_id: 'testSurvey2', - $survey_questions: ['Which types of content would you like to see more of?'], - $survey_response: ['Tutorials', 'NEW VALUE 1'], - sessionRecordingUrl: undefined, - $set: { - ['$survey_responded/testSurvey2']: true, - }, - }) - }) - - test('open choice value on a single choice question is determined by a text input', () => { - mockSurveys = [ - { - id: 'testSurvey2', - name: 'Test survey 2', - type: SurveyType.Popover, - appearance: null, - questions: [ - { - question: 'Which features do you use the most?', - description: 'This is a question description', - type: 'single_choice', - choices: ['Surveys', 'Feature flags', 'Analytics', 'Another Feature'], - hasOpenChoice: true, - }, - ], - }, - ] - const singleQuestionSurveyForm = createMultipleQuestionSurvey(mockPostHog, mockSurveys[0]) - - const radioInputs = singleQuestionSurveyForm - .querySelector('.tab.question-0') - .querySelectorAll('input[type=radio]') - let radioInputValues = [...radioInputs].map((input) => input.value) - expect(radioInputValues).toEqual(['Surveys', 'Feature flags', 'Analytics', '']) - const openChoiceTextInput = singleQuestionSurveyForm - .querySelector('.tab.question-0') - .querySelector('input[type=text]') - openChoiceTextInput.value = 'NEW VALUE 2' - openChoiceTextInput.dispatchEvent(new Event('input')) - expect(singleQuestionSurveyForm.querySelector('.form-submit').disabled).toEqual(false) - radioInputValues = [...radioInputs].map((input) => input.value) - expect(radioInputValues).toEqual(['Surveys', 'Feature flags', 'Analytics', 'NEW VALUE 2']) - const radioInputsChecked = [...radioInputs].map((input) => input.checked) - expect(radioInputsChecked).toEqual([false, false, false, true]) - - singleQuestionSurveyForm.dispatchEvent(new Event('submit')) - expect(mockPostHog.capture).toBeCalledTimes(1) - expect(mockPostHog.capture).toBeCalledWith('survey sent', { - $survey_name: 'Test survey 2', - $survey_id: 'testSurvey2', - $survey_questions: ['Which features do you use the most?'], - $survey_response: 'NEW VALUE 2', - sessionRecordingUrl: undefined, - $set: { - ['$survey_responded/testSurvey2']: true, - }, - }) - }) -}) - -describe('survey widget', () => { - beforeEach(() => { - // we have to manually reset the DOM before each test - document.getElementsByTagName('html')[0].innerHTML = '' - localStorage.clear() - jest.clearAllMocks() - }) - - let mockSurveys = [ - { - id: 'testWidget1', - name: 'Test widget 1', - type: SurveyType.Widget, - appearance: { widgetType: 'tab' }, - questions: [ - { - question: 'How satisfied are you with our newest product?', - description: 'This is a question description', - type: 'rating', - display: 'number', - scale: 10, - lower_bound_label: 'Not Satisfied', - upper_bound_label: 'Very Satisfied', - }, - ], - }, - { - id: 'testWidget2', - name: 'Test widget 2', - type: SurveyType.Widget, - appearance: { widgetType: 'tab' }, - questions: [ - { - question: 'How satisfied are you with our newest product?', - description: 'This is a question description', - type: 'rating', - display: 'emoji', - scale: 3, - lower_bound_label: 'Not Satisfied', - upper_bound_label: 'Very Satisfied', - }, - ], - }, - ] - const mockPostHog = { - getActiveMatchingSurveys: jest.fn().mockImplementation((callback) => callback(mockSurveys)), - get_session_replay_url: jest.fn(), - capture: jest.fn().mockImplementation((eventName) => eventName), - } - - test('there can be multiple widgets on the same page as long as they are unique', () => { - callSurveys(mockPostHog, false) - const widget = document - .getElementsByClassName(`PostHogWidget${mockSurveys[0].id}`)[0] - .shadowRoot.querySelectorAll('.ph-survey-widget-tab') - expect(widget.length).toEqual(1) - expect(document.querySelectorAll("div[class^='PostHogWidget']").length).toEqual(2) - callSurveys(mockPostHog, false) - expect(document.querySelectorAll("div[class^='PostHogWidget']").length).toEqual(2) - }) - - test('tab type widgets show and close the survey when clicked', () => { - mockSurveys.pop() - callSurveys(mockPostHog, false) - const shadow = document.getElementsByClassName(`PostHogWidget${mockSurveys[0].id}`)[0].shadowRoot - const widget = shadow.querySelectorAll('.ph-survey-widget-tab') - widget[0].click() - const survey = shadow.querySelectorAll(`.survey-${mockSurveys[0].id}-form`)[0] - expect(survey.style.display).toEqual('block') - widget[0].click() - expect(survey.style.display).toEqual('none') - }) - - test('selector type widget can only display the survey when the selector is present on the page', () => { - mockSurveys = [ - { - id: 'testWidget3', - name: 'Test widget 3', - type: SurveyType.Widget, - appearance: { widgetType: 'selector', widgetSelector: '.user-widget-button' }, - questions: [ - { - question: 'How satisfied are you with our newest product?', - description: 'This is a question description', - type: 'rating', - display: 'emoji', - scale: 3, - lower_bound_label: 'Not Satisfied', - upper_bound_label: 'Very Satisfied', - }, - ], - }, - ] - callSurveys(mockPostHog, false) - expect(document.getElementsByClassName(`PostHogWidget${mockSurveys[0].id}`).length).toEqual(0) - const button = document.createElement('button') - button.className = 'user-widget-button' - document.body.appendChild(button) - callSurveys(mockPostHog, false) - expect(document.getElementsByClassName(`PostHogWidget${mockSurveys[0].id}`).length).toEqual(1) - // widget should only be created once - callSurveys(mockPostHog, false) - expect(document.getElementsByClassName(`PostHogWidget${mockSurveys[0].id}`).length).toEqual(1) - // expect survey style display to be none initially - const shadow = document.getElementsByClassName(`PostHogWidget${mockSurveys[0].id}`)[0].shadowRoot - const survey = shadow.querySelectorAll(`.survey-testWidget3-form`)[0] - expect(survey.style.display).toEqual('none') - - // click on the button to show the survey test - button.click() - expect(survey.style.display).toEqual('block') - survey.querySelectorAll('.form-cancel')[0].click() - expect(survey.style.display).toEqual('none') - }) -}) diff --git a/src/__tests__/extensions/surveys.test.ts b/src/__tests__/extensions/surveys.test.ts new file mode 100644 index 000000000..eea44014c --- /dev/null +++ b/src/__tests__/extensions/surveys.test.ts @@ -0,0 +1,92 @@ +import 'regenerator-runtime/runtime' +import { generateSurveys, renderSurveysPreview } from '../../extensions/surveys' +import { createShadow } from '../../extensions/surveys/surveys-utils' +import { Survey, SurveyQuestionType, SurveyType } from '../../posthog-surveys-types' + +describe('survey display logic', () => { + beforeEach(() => { + // we have to manually reset the DOM before each test + document.getElementsByTagName('html')[0].innerHTML = '' + localStorage.clear() + jest.clearAllMocks() + }) + + test('createShadow', () => { + const surveyId = 'randomSurveyId' + const mockShadow = createShadow(`.survey-${surveyId}-form {}`, surveyId) + expect(mockShadow.mode).toBe('open') + expect(mockShadow.host.className).toBe(`PostHogSurvey${surveyId}`) + }) + + const mockSurveys: any[] = [ + { + id: 'testSurvey1', + name: 'Test survey 1', + type: SurveyType.Popover, + appearance: null, + start_date: '2021-01-01T00:00:00.000Z', + questions: [ + { + question: 'How satisfied are you with our newest product?', + description: 'This is a question description', + type: 'rating', + display: 'number', + scale: 10, + lower_bound_label: 'Not Satisfied', + upper_bound_label: 'Very Satisfied', + }, + ], + }, + ] + const mockPostHog = { + getActiveMatchingSurveys: jest.fn().mockImplementation((callback) => callback(mockSurveys)), + get_session_replay_url: jest.fn(), + capture: jest.fn().mockImplementation((eventName) => eventName), + } + + test('callSurveys runs on interval irrespective of url change', () => { + jest.useFakeTimers() + jest.spyOn(global, 'setInterval') + generateSurveys(mockPostHog) + expect(mockPostHog.getActiveMatchingSurveys).toBeCalledTimes(1) + expect(setInterval).toHaveBeenLastCalledWith(expect.any(Function), 3000) + + jest.advanceTimersByTime(3000) + expect(mockPostHog.getActiveMatchingSurveys).toBeCalledTimes(2) + expect(setInterval).toHaveBeenLastCalledWith(expect.any(Function), 3000) + }) +}) + +describe('survey render preview', () => { + test('renderSurveysPreview', () => { + const mockSurvey = { + id: 'testSurvey1', + name: 'Test survey 1', + type: SurveyType.Popover, + appearance: {}, + start_date: '2021-01-01T00:00:00.000Z', + description: 'This is a survey description', + linked_flag_key: null, + questions: [ + { + question: 'How satisfied are you with our newest product?', + description: 'This is a question description', + type: SurveyQuestionType.Rating, + display: 'number', + scale: 10, + lowerBoundLabel: 'Not Satisfied', + upperBoundLabel: 'Very Satisfied', + }, + ], + conditions: {}, + end_date: null, + targeting_flag_key: null, + } + const surveyDiv = document.createElement('div') + expect(surveyDiv.innerHTML).toBe('') + renderSurveysPreview(mockSurvey as Survey, surveyDiv, 'survey', 0) + expect(surveyDiv.getElementsByTagName('style').length).toBe(1) + expect(surveyDiv.getElementsByClassName('survey-form').length).toBe(1) + expect(surveyDiv.getElementsByClassName('survey-question').length).toBe(1) + }) +}) diff --git a/src/extensions/surveys-widget.ts b/src/extensions/surveys-widget.ts index ba5e07e23..66b96f22c 100644 --- a/src/extensions/surveys-widget.ts +++ b/src/extensions/surveys-widget.ts @@ -1,210 +1,38 @@ -import { PostHog } from '../posthog-core' import { Survey } from '../posthog-surveys-types' -import { - createMultipleQuestionSurvey, - createSingleQuestionSurvey, - setTextColors, - showQuestion, - style, -} from './surveys/surveys-utils' -import { document as _document, window as _window } from '../utils/globals' -import { addCancelListeners, createThankYouMessage } from './surveys' +import { document as _document } from '../utils/globals' // We cast the types here which is dangerous but protected by the top level generateSurveys call const document = _document as Document -const window = _window as Window & typeof globalThis -export class SurveysWidget { - instance: PostHog - survey: Survey - shadow: any - widget?: any - - constructor(instance: PostHog, survey: Survey, widget?: any) { - this.instance = instance - this.survey = survey - this.shadow = this.createWidgetShadow() - this.widget = widget - } - - createWidget(): void { - const surveyPopup = this.createSurveyForWidget() - let widget - if (this.survey.appearance?.widgetType === 'selector') { - // user supplied button - widget = document.querySelector(this.survey.appearance.widgetSelector || '') - } else if (this.survey.appearance?.widgetType === 'tab') { - widget = this.createTabWidget() - } else if (this.survey.appearance?.widgetType === 'button') { - widget = this.createButtonWidget() - } - this.widget = widget - if (this.survey.appearance?.widgetType !== 'selector') { - this.shadow.appendChild(this.widget) +export function createWidgetShadow(survey: Survey) { + const div = document.createElement('div') + div.className = `PostHogWidget${survey.id}` + const shadow = div.attachShadow({ mode: 'open' }) + const widgetStyleSheet = ` + .ph-survey-widget-tab { + position: fixed; + top: 50%; + right: 0; + background: ${survey.appearance?.widgetColor || '#e0a045'}; + color: white; + transform: rotate(-90deg) translate(0, -100%); + transform-origin: right top; + min-width: 40px; + padding: 8px 12px; + font-weight: 500; + border-radius: 3px 3px 0 0; + text-align: center; + cursor: pointer; + z-index: 9999999; } - setTextColors(this.shadow) - // reposition survey next to widget when opened - if (surveyPopup && this.survey.appearance?.widgetType === 'tab' && this.widget) { - surveyPopup.style.bottom = 'auto' - surveyPopup.style.borderBottom = `1.5px solid ${this.survey.appearance?.borderColor || '#c9c6c6'}` - surveyPopup.style.borderRadius = '10px' - const widgetPos = this.widget.getBoundingClientRect() - surveyPopup.style.top = '50%' - surveyPopup.style.left = `${widgetPos.right - 360}px` + .ph-survey-widget-tab:hover { + padding-bottom: 13px; } - if (this.widget) { - this.widget.addEventListener('click', () => { - if (surveyPopup) { - surveyPopup.style.display = surveyPopup.style.display === 'none' ? 'block' : 'none' - } - }) - this.widget.setAttribute('PHWidgetSurveyClickListener', 'true') - if (surveyPopup) { - window.addEventListener('PHSurveySent', () => { - if (surveyPopup) { - surveyPopup.style.display = 'none' - } - const tabs = document - ?.getElementsByClassName(`PostHogWidget${this.survey.id}`)[0] - ?.shadowRoot?.querySelectorAll('.tab') as NodeListOf - tabs.forEach((tab) => (tab.style.display = 'none')) - showQuestion(0, this.survey.id, this.survey.type) - }) - } + .ph-survey-widget-button { + position: fixed; } - } - - createTabWidget(): HTMLDivElement { - // make a permanent tab widget - const tab = document.createElement('div') - const html = ` -
-
-
- ${this.survey.appearance?.widgetLabel || ''} -
- ` - - tab.innerHTML = html - return tab - } - - createButtonWidget(): HTMLButtonElement { - // make a permanent button widget - const label = 'Feedback :)' - const button = document.createElement('button') - const html = ` -
-
- -
- ${label} -
- ` - button.innerHTML = html - return button - } - - private createSurveyForWidget(): HTMLFormElement | null { - const surveyStyleSheet = style(this.survey.id, this.survey.appearance) - this.shadow.appendChild(Object.assign(document.createElement('style'), { innerText: surveyStyleSheet })) - const widgetSurvey = - this.survey.questions.length > 1 - ? createMultipleQuestionSurvey(this.instance, this.survey) - : createSingleQuestionSurvey(this.instance, this.survey, this.survey.questions[0]) - if (widgetSurvey) { - widgetSurvey.style.display = 'none' - addCancelListeners(this.instance, widgetSurvey as HTMLFormElement, this.survey.id, this.survey.name) - if (this.survey.appearance?.whiteLabel) { - const allBrandingElements = widgetSurvey.getElementsByClassName('footer-branding') - for (const brandingElement of allBrandingElements) { - ;(brandingElement as HTMLAnchorElement).style.display = 'none' - } - } - this.shadow.appendChild(widgetSurvey) - if (this.survey.questions.length > 1) { - const currentQuestion = 0 - showQuestion(currentQuestion, this.survey.id, this.survey.type) - } - setTextColors(this.shadow) - window.dispatchEvent(new Event('PHSurveyShown')) - this.instance.capture('survey shown', { - $survey_name: this.survey.name, - $survey_id: this.survey.id, - sessionRecordingUrl: this.instance.get_session_replay_url?.(), - }) - if (this.survey.appearance?.displayThankYouMessage) { - window.addEventListener('PHSurveySent', () => { - const thankYouElement = createThankYouMessage(this.survey) - if (thankYouElement && this.survey.appearance?.widgetType === 'tab') { - thankYouElement.style.bottom = 'auto' - thankYouElement.style.borderBottom = `1.5px solid ${ - this.survey.appearance?.borderColor || '#c9c6c6' - }` - thankYouElement.style.borderRadius = '10px' - const widgetPos = this.widget.getBoundingClientRect() - thankYouElement.style.top = '50%' - thankYouElement.style.left = `${widgetPos.right - 400}px` - } - this.shadow.appendChild(thankYouElement) - // reposition thank you box next to widget when opened - const cancelButtons = thankYouElement.querySelectorAll('.form-cancel, .form-submit') - for (const button of cancelButtons) { - button.addEventListener('click', () => { - thankYouElement.remove() - }) - } - const countdownEl = thankYouElement.querySelector('.thank-you-message-countdown') - if (this.survey.appearance?.autoDisappear && countdownEl) { - let count = 3 - countdownEl.textContent = `(${count})` - const countdown = setInterval(() => { - count -= 1 - if (count <= 0) { - clearInterval(countdown) - thankYouElement.remove() - return - } - countdownEl.textContent = `(${count})` - }, 1000) - } - setTextColors(this.shadow) - }) - } - } - return widgetSurvey as HTMLFormElement - } - - private createWidgetShadow() { - const div = document.createElement('div') - div.className = `PostHogWidget${this.survey.id}` - const shadow = div.attachShadow({ mode: 'open' }) - const widgetStyleSheet = ` - .ph-survey-widget-tab { - position: fixed; - top: 50%; - right: 0; - background: ${this.survey.appearance?.widgetColor || '#e0a045'}; - color: white; - transform: rotate(-90deg) translate(0, -100%); - transform-origin: right top; - min-width: 40px; - padding: 8px 12px; - font-weight: 500; - border-radius: 3px 3px 0 0; - text-align: center; - cursor: pointer; - z-index: 9999999; - } - .ph-survey-widget-tab:hover { - padding-bottom: 13px; - } - .ph-survey-widget-button { - position: fixed; - } - ` - shadow.append(Object.assign(document.createElement('style'), { innerText: widgetStyleSheet })) - document.body.appendChild(div) - return shadow - } + ` + shadow.append(Object.assign(document.createElement('style'), { innerText: widgetStyleSheet })) + document.body.appendChild(div) + return shadow } diff --git a/src/extensions/surveys.tsx b/src/extensions/surveys.tsx index 30f0ecb20..3f029d5db 100644 --- a/src/extensions/surveys.tsx +++ b/src/extensions/surveys.tsx @@ -1,89 +1,47 @@ import { PostHog } from '../posthog-core' -import { Survey, SurveyType } from '../posthog-surveys-types' -import { SurveysWidget } from './surveys-widget' +import { + BasicSurveyQuestion, + LinkSurveyQuestion, + MultipleSurveyQuestion, + RatingSurveyQuestion, + Survey, + SurveyAppearance, + SurveyQuestion, + SurveyQuestionType, + SurveyType, +} from '../posthog-surveys-types' import { window as _window, document as _document } from '../utils/globals' import { - createMultipleQuestionSurvey, - createSingleQuestionSurvey, - showQuestion, - setTextColors, - cancelSVG, - closeSurveyPopup, - posthogLogo, style, + defaultSurveyAppearance, + sendSurveyEvent, + createShadow, + getContrastingTextColor, + SurveyContext, } from './surveys/surveys-utils' +import * as Preact from 'preact' +import { render } from 'preact-render-to-string' +import { createWidgetShadow } from './surveys-widget' +import { useState, useEffect, useRef, useContext } from 'preact/hooks' +import { _isNumber } from '../utils/type-utils' +import { ConfirmationMessage } from './surveys/components/ConfirmationMessage' +import { + OpenTextQuestion, + LinkQuestion, + RatingQuestion, + MultipleChoiceQuestion, +} from './surveys/components/QuestionTypes' // We cast the types here which is dangerous but protected by the top level generateSurveys call const window = _window as Window & typeof globalThis const document = _document as Document -export const createShadow = (styleSheet: string, surveyId: string) => { - const div = document.createElement('div') - div.className = `PostHogSurvey${surveyId}` - const shadow = div.attachShadow({ mode: 'open' }) - if (styleSheet) { - const styleElement = Object.assign(document.createElement('style'), { - innerText: styleSheet, - }) - shadow.appendChild(styleElement) - } - document.body.appendChild(div) - return shadow -} - -export const createThankYouMessage = (survey: Survey) => { - const thankYouHTML = ` -
-
- -
-

${ - survey.appearance?.thankYouMessageHeader || 'Thank you!' - }

-
${survey.appearance?.thankYouMessageDescription || ''}
- - ${ - survey.appearance?.whiteLabel - ? '' - : `Survey by ${posthogLogo}` - } -
- ` - const thankYouElement = Object.assign(document.createElement('div'), { - className: `thank-you-message`, - innerHTML: thankYouHTML, - }) - return thankYouElement -} - -export const addCancelListeners = ( - posthog: PostHog, - surveyPopup: HTMLFormElement, - surveyId: string, - surveyEventName: string -) => { - const cancelButtons = surveyPopup.getElementsByClassName('form-cancel') - for (const button of cancelButtons) { - button.addEventListener('click', (e) => { - e.preventDefault() - closeSurveyPopup(surveyId, surveyPopup) - posthog.capture('survey dismissed', { - $survey_name: surveyEventName, - $survey_id: surveyId, - sessionRecordingUrl: posthog.get_session_replay_url?.(), - $set: { - [`$survey_dismissed/${surveyId}`]: true, - }, - }) - }) - } - window.dispatchEvent(new Event('PHSurveyClosed')) -} - const handleWidget = (posthog: PostHog, survey: Survey) => { - const posthogWidget = new SurveysWidget(posthog, survey) - posthogWidget.createWidget() + const shadow = createWidgetShadow(survey) + const surveyStyleSheet = style(survey.appearance) + shadow.appendChild(Object.assign(document.createElement('style'), { innerText: surveyStyleSheet })) + Preact.render(, shadow) } export const callSurveys = (posthog: PostHog, forceReload: boolean = false) => { @@ -107,7 +65,7 @@ export const callSurveys = (posthog: PostHog, forceReload: boolean = false) => { if (!selectorOnPage.getAttribute('PHWidgetSurveyClickListener')) { const surveyPopup = document .querySelector(`.PostHogWidget${survey.id}`) - ?.shadowRoot?.querySelector(`.survey-${survey.id}-form`) as HTMLFormElement + ?.shadowRoot?.querySelector(`.survey-form`) as HTMLFormElement selectorOnPage.addEventListener('click', () => { if (surveyPopup) { surveyPopup.style.display = @@ -140,72 +98,46 @@ export const callSurveys = (posthog: PostHog, forceReload: boolean = false) => { } if (!localStorage.getItem(`seenSurvey_${survey.id}`)) { - const shadow = createShadow(style(survey.id, survey?.appearance), survey.id) - let surveyPopup - if (survey.questions.length < 2) { - surveyPopup = createSingleQuestionSurvey( - posthog, - survey, - survey.questions[0] - ) as HTMLFormElement - } else { - surveyPopup = createMultipleQuestionSurvey(posthog, survey) - } - if (surveyPopup) { - addCancelListeners(posthog, surveyPopup, survey.id, survey.name) - if (survey.appearance?.whiteLabel) { - const allBrandingElements = surveyPopup.getElementsByClassName('footer-branding') - for (const brandingElement of allBrandingElements) { - ;(brandingElement as HTMLAnchorElement).style.display = 'none' - } - } - shadow.appendChild(surveyPopup) - } - if (survey.questions.length > 1) { - const currentQuestion = 0 - showQuestion(currentQuestion, survey.id, survey.type) - } - setTextColors(shadow) - window.dispatchEvent(new Event('PHSurveyShown')) - posthog.capture('survey shown', { - $survey_name: survey.name, - $survey_id: survey.id, - sessionRecordingUrl: posthog.get_session_replay_url?.(), - }) - localStorage.setItem(`lastSeenSurveyDate`, new Date().toISOString()) - if (survey.appearance?.displayThankYouMessage) { - window.addEventListener('PHSurveySent', () => { - const thankYouElement = createThankYouMessage(survey) - shadow.appendChild(thankYouElement) - const cancelButtons = thankYouElement.querySelectorAll('.form-cancel, .form-submit') - for (const button of cancelButtons) { - button.addEventListener('click', () => { - thankYouElement.remove() - }) - } - const countdownEl = thankYouElement.querySelector('.thank-you-message-countdown') - if (survey.appearance?.autoDisappear && countdownEl) { - let count = 3 - countdownEl.textContent = `(${count})` - const countdown = setInterval(() => { - count -= 1 - if (count <= 0) { - clearInterval(countdown) - thankYouElement.remove() - return - } - countdownEl.textContent = `(${count})` - }, 1000) - } - setTextColors(shadow) - }) - } + const shadow = createShadow(style(survey?.appearance), survey.id) + Preact.render(, shadow) } } }) }, forceReload) } +export const renderSurveysPreview = ( + survey: Survey, + root: HTMLElement, + displayState: 'survey' | 'confirmation', + previewQuestionIndex: number +) => { + const surveyStyleSheet = style(survey.appearance) + const styleElement = Object.assign(document.createElement('style'), { innerText: surveyStyleSheet }) + root.appendChild(styleElement) + const textColor = getContrastingTextColor( + survey.appearance?.backgroundColor || defaultSurveyAppearance.backgroundColor || 'white' + ) + const surveyHtml = render( + + ) + const surveyDiv = document.createElement('div') + surveyDiv.innerHTML = surveyHtml + root.appendChild(surveyDiv) +} + // This is the main exported function export function generateSurveys(posthog: PostHog) { // NOTE: Important to ensure we never try and run surveys without a window environment @@ -219,3 +151,256 @@ export function generateSurveys(posthog: PostHog) { callSurveys(posthog, false) }, 3000) } + +export function Surveys({ + survey, + posthog, + readOnly, + style, + initialDisplayState, + previewQuestionIndex, +}: { + survey: Survey + posthog?: PostHog + readOnly?: boolean + style?: React.CSSProperties + initialDisplayState?: 'survey' | 'confirmation' | 'closed' + previewQuestionIndex?: number +}) { + const [displayState, setDisplayState] = useState<'survey' | 'confirmation' | 'closed'>( + initialDisplayState || 'survey' + ) + + useEffect(() => { + if (readOnly || !posthog) { + return + } + + window.dispatchEvent(new Event('PHSurveyShown')) + + posthog.capture('survey shown', { + $survey_name: survey.name, + $survey_id: survey.id, + sessionRecordingUrl: posthog.get_session_replay_url?.(), + }) + localStorage.setItem(`lastSeenSurveyDate`, new Date().toISOString()) + + window.addEventListener('PHSurveyClosed', () => { + setDisplayState('closed') + }) + + window.addEventListener('PHSurveySent', () => { + if (!survey.appearance?.displayThankYouMessage) { + return setDisplayState('closed') + } + setDisplayState('confirmation') + if (survey.appearance?.autoDisappear) { + setTimeout(() => { + setDisplayState('closed') + }, 5000) + } + }) + }, []) + const confirmationBoxLeftStyle = style?.left && _isNumber(style?.left) ? { left: style.left - 40 } : {} + + return ( + <> + + {displayState === 'survey' && } + {displayState === 'confirmation' && ( + setDisplayState('closed')} + /> + )} + + + ) +} + +const questionTypeMap = ( + question: SurveyQuestion, + questionIndex: number, + appearance: SurveyAppearance, + onSubmit: (res: string | string[] | number | null) => void, + closeSurveyPopup: () => void +): JSX.Element => { + const mapping = { + [SurveyQuestionType.Open]: ( + + ), + [SurveyQuestionType.Link]: ( + + ), + [SurveyQuestionType.Rating]: ( + + ), + [SurveyQuestionType.SingleChoice]: ( + + ), + [SurveyQuestionType.MultipleChoice]: ( + + ), + } + return mapping[question.type] +} + +export function Questions({ + survey, + posthog, + styleOverrides, +}: { + survey: Survey + posthog?: PostHog + styleOverrides?: React.CSSProperties +}) { + const textColor = getContrastingTextColor( + survey.appearance?.backgroundColor || defaultSurveyAppearance.backgroundColor + ) + const [questionsResponses, setQuestionsResponses] = useState({}) + const { readOnly, previewQuestionIndex } = useContext(SurveyContext) + const [currentQuestion, setCurrentQuestion] = useState(readOnly ? previewQuestionIndex : 0) + + const onNextClick = (res: string | string[] | number | null, idx: number) => { + const responseKey = idx === 0 ? `$survey_response` : `$survey_response_${idx}` + if (idx === survey.questions.length - 1) { + return sendSurveyEvent({ ...questionsResponses, [responseKey]: res }, survey, posthog) + } else { + setQuestionsResponses({ ...questionsResponses, [responseKey]: res }) + setCurrentQuestion(idx + 1) + } + } + const isMultipleQuestion = survey.questions.length > 1 + + return ( +
+ {survey.questions.map((question, idx) => { + if (isMultipleQuestion) { + return ( + <> + {currentQuestion === idx && ( +
+ {questionTypeMap( + question, + idx, + survey.appearance || defaultSurveyAppearance, + (res) => onNextClick(res, idx), + () => closeSurveyPopup(survey, posthog, readOnly) + )} +
+ )} + + ) + } + return questionTypeMap( + survey.questions[idx], + idx, + survey.appearance || defaultSurveyAppearance, + (res) => onNextClick(res, idx), + () => closeSurveyPopup(survey, posthog, readOnly) + ) + })} +
+ ) +} + +const closeSurveyPopup = (survey: Survey, posthog?: PostHog, readOnly?: boolean) => { + // TODO: state management and unit tests for this would be nice + if (readOnly || !posthog) { + return + } + posthog.capture('survey dismissed', { + $survey_name: survey.name, + $survey_id: survey.id, + sessionRecordingUrl: posthog.get_session_replay_url?.(), + $set: { + [`$survey_dismissed/${survey.id}`]: true, + }, + }) + localStorage.setItem(`seenSurvey_${survey.id}`, 'true') + window.dispatchEvent(new Event('PHSurveyClosed')) +} + +export function FeedbackWidget({ posthog, survey }: { posthog: PostHog; survey: Survey }): JSX.Element { + const [showSurvey, setShowSurvey] = useState(false) + const [styleOverrides, setStyle] = useState({}) + const widgetRef = useRef(null) + + useEffect(() => { + if (survey.appearance?.widgetType === 'tab') { + if (widgetRef.current) { + const widgetPos = widgetRef.current.getBoundingClientRect() + const style = { + top: '50%', + left: parseInt(`${widgetPos.right - 360}`), + bottom: 'auto', + borderRadius: 10, + borderBottom: `1.5px solid ${survey.appearance?.borderColor || '#c9c6c6'}`, + } + setStyle(style) + } + } + if (survey.appearance?.widgetType === 'selector') { + const widget = document.querySelector(survey.appearance.widgetSelector || '') + widget?.addEventListener('click', () => { + setShowSurvey(!showSurvey) + }) + widget?.setAttribute('PHWidgetSurveyClickListener', 'true') + } + }, []) + + return ( + <> + {survey.appearance?.widgetType === 'tab' && ( +
setShowSurvey(!showSurvey)}> +
+ {survey.appearance?.widgetLabel || ''} +
+ )} + {showSurvey && } + + ) +} diff --git a/src/extensions/surveys/components/BottomSection.tsx b/src/extensions/surveys/components/BottomSection.tsx new file mode 100644 index 000000000..974032237 --- /dev/null +++ b/src/extensions/surveys/components/BottomSection.tsx @@ -0,0 +1,53 @@ +import { window } from '../../../utils/globals' + +import { SurveyAppearance } from '../../../posthog-surveys-types' + +import { PostHogLogo } from './PostHogLogo' +import { useContext } from 'preact/hooks' +import { + SurveyContext, + defaultBackgroundColor, + defaultSurveyAppearance, + getContrastingTextColor, +} from '../surveys-utils' + +export function BottomSection({ + text, + submitDisabled, + appearance, + onSubmit, + link, +}: { + text: string + submitDisabled: boolean + appearance: SurveyAppearance + onSubmit: () => void + link?: string | null +}) { + const { readOnly } = useContext(SurveyContext) + const textColor = getContrastingTextColor(appearance.submitButtonColor || defaultSurveyAppearance.submitButtonColor) + return ( +
+
+ +
+ {!appearance.whiteLabel && ( + + )} +
+ ) +} diff --git a/src/extensions/surveys/components/ConfirmationMessage.tsx b/src/extensions/surveys/components/ConfirmationMessage.tsx new file mode 100644 index 000000000..f025fc764 --- /dev/null +++ b/src/extensions/surveys/components/ConfirmationMessage.tsx @@ -0,0 +1,46 @@ +import { BottomSection } from './BottomSection' +import { Cancel } from './QuestionHeader' +import { SurveyAppearance } from '../../../posthog-surveys-types' +import { defaultSurveyAppearance, getContrastingTextColor } from '../surveys-utils' + +export function ConfirmationMessage({ + confirmationHeader, + confirmationDescription, + appearance, + onClose, + styleOverrides, +}: { + confirmationHeader: string + confirmationDescription: string + appearance: SurveyAppearance + onClose: () => void + styleOverrides?: React.CSSProperties +}) { + const textColor = getContrastingTextColor(appearance.backgroundColor || defaultSurveyAppearance.backgroundColor) + + return ( + <> +
+
+ onClose()} /> +

+ {confirmationHeader} +

+ {confirmationDescription && ( +
+ )} + onClose()} + /> +
+
+ + ) +} diff --git a/src/extensions/surveys/components/PostHogLogo.tsx b/src/extensions/surveys/components/PostHogLogo.tsx new file mode 100644 index 000000000..9218c8ef1 --- /dev/null +++ b/src/extensions/surveys/components/PostHogLogo.tsx @@ -0,0 +1,18 @@ +import { IconPosthogLogo } from '../icons' +import { getContrastingTextColor } from '../surveys-utils' + +export function PostHogLogo({ backgroundColor }: { backgroundColor: string }) { + const textColor = getContrastingTextColor(backgroundColor) + + return ( + + Survey by {IconPosthogLogo} + + ) +} diff --git a/src/extensions/surveys/components/QuestionHeader.tsx b/src/extensions/surveys/components/QuestionHeader.tsx new file mode 100644 index 000000000..c4838339e --- /dev/null +++ b/src/extensions/surveys/components/QuestionHeader.tsx @@ -0,0 +1,32 @@ +import { SurveyContext, defaultSurveyAppearance } from '../surveys-utils' +import { cancelSVG } from '../icons' +import { useContext } from 'preact/hooks' + +export function QuestionHeader({ + question, + description, + backgroundColor, +}: { + question: string + description?: string | null + backgroundColor?: string +}) { + return ( +
+
{question}
+ {description &&
} +
+ ) +} + +export function Cancel({ onClick }: { onClick: () => void }) { + const { readOnly } = useContext(SurveyContext) + + return ( +
+ +
+ ) +} diff --git a/src/extensions/surveys/components/QuestionTypes.tsx b/src/extensions/surveys/components/QuestionTypes.tsx new file mode 100644 index 000000000..dcbbb27e1 --- /dev/null +++ b/src/extensions/surveys/components/QuestionTypes.tsx @@ -0,0 +1,341 @@ +import { + BasicSurveyQuestion, + SurveyAppearance, + LinkSurveyQuestion, + RatingSurveyQuestion, + MultipleSurveyQuestion, + SurveyQuestionType, +} from '../../../posthog-surveys-types' +import { RefObject } from 'preact' +import { useRef, useState } from 'preact/hooks' +import { _isNull, _isArray } from '../../../utils/type-utils' +import { useContrastingTextColor } from '../hooks/useContrastingTextColor' +import { + checkSVG, + dissatisfiedEmoji, + neutralEmoji, + satisfiedEmoji, + veryDissatisfiedEmoji, + verySatisfiedEmoji, +} from '../icons' +import { defaultSurveyAppearance } from '../surveys-utils' +import { BottomSection } from './BottomSection' +import { Cancel, QuestionHeader } from './QuestionHeader' + +export function OpenTextQuestion({ + question, + appearance, + onSubmit, + closeSurveyPopup, +}: { + question: BasicSurveyQuestion + appearance: SurveyAppearance + onSubmit: (text: string) => void + closeSurveyPopup: () => void +}) { + const textRef = useRef(null) + const [text, setText] = useState('') + + return ( +
+ closeSurveyPopup()} /> + + ` - : '' - } -
-
-
- -
- Survey by ${posthogLogo} -
-
-` - let formElement: HTMLFormElement | HTMLDivElement - if (survey.questions.length === 1) { - formElement = Object.assign(document.createElement('form'), { - className: `survey-${survey.id}-form`, - innerHTML: form, - onsubmit: function (e: any) { - e.preventDefault() - const surveyQuestionType = question.type - posthog.capture('survey sent', { - $survey_name: survey.name, - $survey_id: survey.id, - $survey_question: survey.questions[0].question, - $survey_response: surveyQuestionType === 'open' ? e.target.survey.value : 'link clicked', - sessionRecordingUrl: posthog.get_session_replay_url?.(), - $set: { - [`$survey_responded/${survey.id}`]: true, - }, - }) - if (surveyQuestionType === 'link' && question.link) { - window.open(question.link) - } - window.setTimeout(() => { - window.dispatchEvent(new Event('PHSurveySent')) - }, 200) - closeSurveyPopup(survey.id, formElement as HTMLFormElement) - }, - }) - } else { - formElement = Object.assign(document.createElement('div'), { - innerHTML: form, - }) - const submitButton = formElement.querySelector('.form-submit') as HTMLButtonElement - submitButton.addEventListener('click', () => { - if (surveyQuestionType === 'link') { - window.open(question.link || undefined) - } - }) - } - if (!isOptional) { - if (surveyQuestionType === 'open') { - ;(formElement.querySelector('.form-submit') as HTMLButtonElement).disabled = true - } - formElement.addEventListener('input', (e: any) => { - if (formElement.querySelector('.form-submit')) { - const submitButton = formElement.querySelector('.form-submit') as HTMLButtonElement - submitButton.disabled = !e.target.value - } - }) - } - - return formElement -} - -export const createMultipleChoicePopup = ( - posthog: PostHog, - survey: Survey, - question: MultipleSurveyQuestion, - questionIndex: number -) => { - const surveyQuestion = question.question - const surveyDescription = question.description - const surveyQuestionChoices = question.choices - const isSingleChoice = question.type === 'single_choice' - const isOptional = !!question.optional - const hasOpenChoice = !!question.hasOpenChoice - - const form = ` -
-
- -
-
${surveyQuestion}
- ${surveyDescription ? `${surveyDescription}` : ''} -
- ${surveyQuestionChoices - .map((option, idx) => { - let choiceClass = 'choice-option' - let val = option - if (hasOpenChoice && idx === surveyQuestionChoices.length - 1) { - option = `${option}:` - choiceClass += ' choice-option-open' - val = '' - } - const inputType = isSingleChoice ? 'radio' : 'checkbox' - const singleOrMultiSelectString = `
- - - ${checkSVG} -
` - return singleOrMultiSelectString - }) - .join(' ')} -
-
-
- -
- Survey by ${posthogLogo} -
- -
- ` - let formElement: HTMLFormElement | HTMLDivElement - if (survey.questions.length === 1) { - formElement = Object.assign(document.createElement('form'), { - className: `survey-${survey.id}-form`, - innerHTML: form, - onsubmit: (e: Event) => { - e.preventDefault() - const targetElement = e.target as HTMLFormElement - const selectedChoices = isSingleChoice - ? (targetElement.querySelector('input[type=radio]:checked') as HTMLInputElement)?.value - : [ - ...(targetElement.querySelectorAll( - 'input[type=checkbox]:checked' - ) as NodeListOf), - ].map((choice) => choice.value) - posthog.capture('survey sent', { - $survey_name: survey.name, - $survey_id: survey.id, - $survey_question: survey.questions[0].question, - $survey_response: selectedChoices, - sessionRecordingUrl: posthog.get_session_replay_url?.(), - $set: { - [`$survey_responded/${survey.id}`]: true, - }, - }) - window.setTimeout(() => { - window.dispatchEvent(new Event('PHSurveySent')) - }, 200) - closeSurveyPopup(survey.id, formElement as HTMLFormElement) - }, - }) - } else { - formElement = Object.assign(document.createElement('div'), { - innerHTML: form, - }) - } - if (!isOptional) { - formElement.addEventListener('change', () => { - const selectedChoices: NodeListOf = isSingleChoice - ? formElement.querySelectorAll('input[type=radio]:checked') - : formElement.querySelectorAll('input[type=checkbox]:checked') - if ((selectedChoices.length ?? 0) > 0) { - ;(formElement.querySelector('.form-submit') as HTMLButtonElement).disabled = false - } else { - ;(formElement.querySelector('.form-submit') as HTMLButtonElement).disabled = true - } - }) - } - const openChoiceWrappers = formElement.querySelectorAll('.choice-option-open') - for (const openChoiceWrapper of openChoiceWrappers) { - const textInput = openChoiceWrapper.querySelector('input[type=text]') as HTMLInputElement - const inputType = isSingleChoice ? 'radio' : 'checkbox' - const checkInput = openChoiceWrapper.querySelector(`input[type=${inputType}]`) as HTMLInputElement - openChoiceWrapper.addEventListener('click', () => { - if (checkInput?.checked || checkInput?.disabled) textInput?.focus() - }) - textInput.addEventListener('click', (e) => e.stopPropagation()) - textInput.addEventListener('input', (e) => { - const textInput = e.target as HTMLInputElement - if (checkInput) { - checkInput.value = textInput.value - if (textInput.value) { - checkInput.disabled = false - checkInput.checked = true - } else { - checkInput.disabled = true - checkInput.checked = false - } - formElement.dispatchEvent(new Event('change')) - } - }) - } - - return formElement -} - -export const closeSurveyPopup = (surveyId: string, surveyPopup: HTMLFormElement) => { - Object.assign(surveyPopup.style, { display: 'none' }) - localStorage.setItem(`seenSurvey_${surveyId}`, 'true') - window.setTimeout(() => { - window.dispatchEvent(new Event('PHSurveyClosed')) - }, 2000) - surveyPopup.reset() -} - -export function getTextColor(el: HTMLElement) { - const backgroundColor = window.getComputedStyle(el).backgroundColor - if (backgroundColor === 'rgba(0, 0, 0, 0)') { - return 'black' - } - const colorMatch = backgroundColor.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/) - if (!colorMatch) return 'black' - - const r = parseInt(colorMatch[1]) - const g = parseInt(colorMatch[2]) - const b = parseInt(colorMatch[3]) - const hsp = Math.sqrt(0.299 * (r * r) + 0.587 * (g * g) + 0.114 * (b * b)) - return hsp > 127.5 ? 'black' : 'white' -} - -export function setTextColors(parentEl: any) { - for (const el of parentEl.querySelectorAll('.auto-text-color')) { - el.style.color = getTextColor(el) - } -} - -export function showQuestion(n: number, surveyId: string, surveyType: SurveyType) { - // This function will display the specified tab of the form... - const surveyTypeClassName = surveyType === SurveyType.Popover ? 'Survey' : 'Widget' - const tabs = document - .getElementsByClassName(`PostHog${surveyTypeClassName}${surveyId}`)[0] - ?.shadowRoot?.querySelectorAll('.tab') as NodeListOf - tabs[n].style.display = 'block' -} - -export function nextQuestion(currentQuestionIdx: number, surveyId: string, surveyType: SurveyType) { - // figure out which tab to display - const surveyTypeClassName = surveyType === SurveyType.Popover ? 'Survey' : 'Widget' - const tabs = document - ?.getElementsByClassName(`PostHog${surveyTypeClassName}${surveyId}`)[0] - ?.shadowRoot?.querySelectorAll('.tab') as NodeListOf - - tabs[currentQuestionIdx].style.display = 'none' - showQuestion(currentQuestionIdx + 1, surveyId, surveyType) -} - -export const satisfiedEmoji = - '' -export const neutralEmoji = - '' -export const dissatisfiedEmoji = - '' -export const veryDissatisfiedEmoji = - '' -export const verySatisfiedEmoji = - '' -export const cancelSVG = - '' -export const posthogLogo = - '' -export const checkSVG = - '' diff --git a/src/extensions/surveys/surveys-utils.tsx b/src/extensions/surveys/surveys-utils.tsx new file mode 100644 index 000000000..093ae6bf8 --- /dev/null +++ b/src/extensions/surveys/surveys-utils.tsx @@ -0,0 +1,562 @@ +import { PostHog } from '../../posthog-core' +import { Survey, SurveyAppearance } from '../../posthog-surveys-types' +import { window as _window, document as _document } from '../../utils/globals' +import { createContext } from 'preact' +// We cast the types here which is dangerous but protected by the top level generateSurveys call +const window = _window as Window & typeof globalThis +const document = _document as Document + +export const style = (appearance: SurveyAppearance | null) => { + const positions = { + left: 'left: 30px;', + right: 'right: 30px;', + center: ` + left: 50%; + transform: translateX(-50%); + `, + } + return ` + .survey-form { + position: fixed; + margin: 0px; + bottom: 0px; + color: black; + font-weight: normal; + font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", "Roboto", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + text-align: left; + max-width: ${parseInt(appearance?.maxWidth || '290')}px; + z-index: ${parseInt(appearance?.zIndex || '99999')}; + border: 1.5px solid ${appearance?.borderColor || '#c9c6c6'}; + border-bottom: 0px; + width: 100%; + ${positions[appearance?.position || 'right'] || 'right: 30px;'} + } + .form-submit[disabled] { + opacity: 0.6; + filter: grayscale(100%); + cursor: not-allowed; + } + .survey-form { + flex-direction: column; + background: ${appearance?.backgroundColor || '#eeeded'}; + border-top-left-radius: 10px; + border-top-right-radius: 10px; + box-shadow: -6px 0 16px -8px rgb(0 0 0 / 8%), -9px 0 28px 0 rgb(0 0 0 / 5%), -12px 0 48px 16px rgb(0 0 0 / 3%); + } + .survey-form textarea { + color: #2d2d2d; + font-size: 14px; + font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", "Roboto", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + background: white; + color: black; + outline: none; + padding-left: 10px; + padding-right: 10px; + padding-top: 10px; + border-radius: 6px; + border-color: ${appearance?.borderColor || '#c9c6c6'}; + margin-top: 14px; + } + .form-submit { + box-sizing: border-box; + margin: 0; + font-family: inherit; + overflow: visible; + text-transform: none; + position: relative; + display: inline-block; + font-weight: 700; + white-space: nowrap; + text-align: center; + border: 1.5px solid transparent; + cursor: pointer; + user-select: none; + touch-action: manipulation; + padding: 12px; + font-size: 14px; + border-radius: 6px; + outline: 0; + background: ${appearance?.submitButtonColor || 'black'} !important; + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.12); + box-shadow: 0 2px 0 rgba(0, 0, 0, 0.045); + width: 100%; + } + .form-cancel { + float: right; + border: none; + background: none; + cursor: pointer; + } + .cancel-btn-wrapper { + position: absolute; + width: 35px; + height: 35px; + border-radius: 100%; + top: 0; + right: 0; + transform: translate(50%, -50%); + background: white; + border: 1.5px solid ${appearance?.borderColor || '#c9c6c6'}; + display: flex; + justify-content: center; + align-items: center; + } + .bolded { font-weight: 600; } + .buttons { + display: flex; + justify-content: center; + } + .footer-branding { + font-size: 11px; + margin-top: 10px; + text-align: center; + display: flex; + justify-content: center; + gap: 4px; + align-items: center; + font-weight: 500; + background: ${appearance?.backgroundColor || '#eeeded'}; + text-decoration: none; + } + .survey-box { + padding: 20px 25px 10px; + display: flex; + flex-direction: column; + border-radius: 10px; + } + .survey-question { + font-weight: 500; + font-size: 14px; + background: ${appearance?.backgroundColor || '#eeeded'}; + } + .question-textarea-wrapper { + display: flex; + flex-direction: column; + } + .description { + font-size: 13px; + margin-top: 5px; + opacity: .60; + background: ${appearance?.backgroundColor || '#eeeded'}; + } + .ratings-number { + background-color: ${appearance?.ratingButtonColor || 'white'}; + font-size: 14px; + padding: 8px 0px; + border: none; + } + .ratings-number:hover { + cursor: pointer; + } + .rating-options { + margin-top: 14px; + } + .rating-options-number { + display: grid; + border-radius: 6px; + overflow: hidden; + border: 1.5px solid ${appearance?.borderColor || '#c9c6c6'}; + } + .rating-options-number > .ratings-number { + border-right: 1px solid ${appearance?.borderColor || '#c9c6c6'}; + } + .rating-options-number > .ratings-number:last-of-type { + border-right: 0px; + } + .rating-options-number .rating-active { + background: ${appearance?.ratingButtonActiveColor || 'black'}; + } + .rating-options-emoji { + display: flex; + justify-content: space-between; + } + .ratings-emoji { + font-size: 16px; + background-color: transparent; + border: none; + padding: 0px; + } + .ratings-emoji:hover { + cursor: pointer; + } + .ratings-emoji.rating-active svg { + fill: ${appearance?.ratingButtonActiveColor || 'black'}; + } + .emoji-svg { + fill: ${appearance?.ratingButtonColor || '#c9c6c6'}; + } + .rating-text { + display: flex; + flex-direction: row; + font-size: 11px; + justify-content: space-between; + margin-top: 6px; + background: ${appearance?.backgroundColor || '#eeeded'}; + opacity: .60; + } + .multiple-choice-options { + margin-top: 13px; + font-size: 14px; + } + .multiple-choice-options .choice-option { + display: flex; + align-items: center; + gap: 4px; + font-size: 13px; + cursor: pointer; + margin-bottom: 5px; + position: relative; + } + .multiple-choice-options > .choice-option:last-of-type { + margin-bottom: 0px; + } + .multiple-choice-options input { + cursor: pointer; + position: absolute; + opacity: 0; + } + .choice-check { + position: absolute; + right: 10px; + background: white; + } + .choice-check svg { + display: none; + } + .multiple-choice-options .choice-option:hover .choice-check svg { + display: inline-block; + opacity: .25; + } + .multiple-choice-options input:checked + label + .choice-check svg { + display: inline-block; + opacity: 100% !important; + } + .multiple-choice-options input:checked + label { + font-weight: bold; + border: 1.5px solid rgba(0,0,0); + } + .multiple-choice-options input:checked + label input { + font-weight: bold; + } + .multiple-choice-options label { + width: 100%; + cursor: pointer; + padding: 10px; + border: 1.5px solid rgba(0,0,0,.25); + border-radius: 4px; + background: white; + } + .multiple-choice-options .choice-option-open label { + padding-right: 30px; + display: flex; + flex-wrap: wrap; + gap: 8px; + max-width: 100%; + } + .multiple-choice-options .choice-option-open label span { + width: 100%; + } + .multiple-choice-options .choice-option-open input:disabled + label { + opacity: 0.6; + } + .multiple-choice-options .choice-option-open label input { + position: relative; + opacity: 1; + flex-grow: 1; + border: 0; + outline: 0; + } + .thank-you-message { + position: fixed; + bottom: 0px; + z-index: ${parseInt(appearance?.zIndex || '99999')}; + box-shadow: -6px 0 16px -8px rgb(0 0 0 / 8%), -9px 0 28px 0 rgb(0 0 0 / 5%), -12px 0 48px 16px rgb(0 0 0 / 3%); + font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", "Roboto", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + border-top-left-radius: 10px; + border-top-right-radius: 10px; + padding: 20px 25px 10px; + background: ${appearance?.backgroundColor || '#eeeded'}; + border: 1.5px solid ${appearance?.borderColor || '#c9c6c6'}; + text-align: center; + max-width: ${parseInt(appearance?.maxWidth || '290')}px; + min-width: 150px; + width: 100%; + ${positions[appearance?.position || 'right'] || 'right: 30px;'} + } + .thank-you-message-body { + margin-top: 6px; + font-size: 14px; + background: ${appearance?.backgroundColor || '#eeeded'}; + } + .thank-you-message-header { + margin: 10px 0px 0px; + background: ${appearance?.backgroundColor || '#eeeded'}; + } + .thank-you-message-container .form-submit { + margin-top: 20px; + margin-bottom: 10px; + } + .thank-you-message-countdown { + margin-left: 6px; + } + .bottom-section { + margin-top: 14px; + } + ` +} + +function nameToHex(name: string) { + return { + aliceblue: '#f0f8ff', + antiquewhite: '#faebd7', + aqua: '#00ffff', + aquamarine: '#7fffd4', + azure: '#f0ffff', + beige: '#f5f5dc', + bisque: '#ffe4c4', + black: '#000000', + blanchedalmond: '#ffebcd', + blue: '#0000ff', + blueviolet: '#8a2be2', + brown: '#a52a2a', + burlywood: '#deb887', + cadetblue: '#5f9ea0', + chartreuse: '#7fff00', + chocolate: '#d2691e', + coral: '#ff7f50', + cornflowerblue: '#6495ed', + cornsilk: '#fff8dc', + crimson: '#dc143c', + cyan: '#00ffff', + darkblue: '#00008b', + darkcyan: '#008b8b', + darkgoldenrod: '#b8860b', + darkgray: '#a9a9a9', + darkgreen: '#006400', + darkkhaki: '#bdb76b', + darkmagenta: '#8b008b', + darkolivegreen: '#556b2f', + darkorange: '#ff8c00', + darkorchid: '#9932cc', + darkred: '#8b0000', + darksalmon: '#e9967a', + darkseagreen: '#8fbc8f', + darkslateblue: '#483d8b', + darkslategray: '#2f4f4f', + darkturquoise: '#00ced1', + darkviolet: '#9400d3', + deeppink: '#ff1493', + deepskyblue: '#00bfff', + dimgray: '#696969', + dodgerblue: '#1e90ff', + firebrick: '#b22222', + floralwhite: '#fffaf0', + forestgreen: '#228b22', + fuchsia: '#ff00ff', + gainsboro: '#dcdcdc', + ghostwhite: '#f8f8ff', + gold: '#ffd700', + goldenrod: '#daa520', + gray: '#808080', + green: '#008000', + greenyellow: '#adff2f', + honeydew: '#f0fff0', + hotpink: '#ff69b4', + 'indianred ': '#cd5c5c', + indigo: '#4b0082', + ivory: '#fffff0', + khaki: '#f0e68c', + lavender: '#e6e6fa', + lavenderblush: '#fff0f5', + lawngreen: '#7cfc00', + lemonchiffon: '#fffacd', + lightblue: '#add8e6', + lightcoral: '#f08080', + lightcyan: '#e0ffff', + lightgoldenrodyellow: '#fafad2', + lightgrey: '#d3d3d3', + lightgreen: '#90ee90', + lightpink: '#ffb6c1', + lightsalmon: '#ffa07a', + lightseagreen: '#20b2aa', + lightskyblue: '#87cefa', + lightslategray: '#778899', + lightsteelblue: '#b0c4de', + lightyellow: '#ffffe0', + lime: '#00ff00', + limegreen: '#32cd32', + linen: '#faf0e6', + magenta: '#ff00ff', + maroon: '#800000', + mediumaquamarine: '#66cdaa', + mediumblue: '#0000cd', + mediumorchid: '#ba55d3', + mediumpurple: '#9370d8', + mediumseagreen: '#3cb371', + mediumslateblue: '#7b68ee', + mediumspringgreen: '#00fa9a', + mediumturquoise: '#48d1cc', + mediumvioletred: '#c71585', + midnightblue: '#191970', + mintcream: '#f5fffa', + mistyrose: '#ffe4e1', + moccasin: '#ffe4b5', + navajowhite: '#ffdead', + navy: '#000080', + oldlace: '#fdf5e6', + olive: '#808000', + olivedrab: '#6b8e23', + orange: '#ffa500', + orangered: '#ff4500', + orchid: '#da70d6', + palegoldenrod: '#eee8aa', + palegreen: '#98fb98', + paleturquoise: '#afeeee', + palevioletred: '#d87093', + papayawhip: '#ffefd5', + peachpuff: '#ffdab9', + peru: '#cd853f', + pink: '#ffc0cb', + plum: '#dda0dd', + powderblue: '#b0e0e6', + purple: '#800080', + red: '#ff0000', + rosybrown: '#bc8f8f', + royalblue: '#4169e1', + saddlebrown: '#8b4513', + salmon: '#fa8072', + sandybrown: '#f4a460', + seagreen: '#2e8b57', + seashell: '#fff5ee', + sienna: '#a0522d', + silver: '#c0c0c0', + skyblue: '#87ceeb', + slateblue: '#6a5acd', + slategray: '#708090', + snow: '#fffafa', + springgreen: '#00ff7f', + steelblue: '#4682b4', + tan: '#d2b48c', + teal: '#008080', + thistle: '#d8bfd8', + tomato: '#ff6347', + turquoise: '#40e0d0', + violet: '#ee82ee', + wheat: '#f5deb3', + white: '#ffffff', + whitesmoke: '#f5f5f5', + yellow: '#ffff00', + yellowgreen: '#9acd32', + }[name.toLowerCase()] +} + +function hex2rgb(c: string) { + if (c[0] === '#') { + const hexColor = c.replace(/^#/, '') + const r = parseInt(hexColor.slice(0, 2), 16) + const g = parseInt(hexColor.slice(2, 4), 16) + const b = parseInt(hexColor.slice(4, 6), 16) + return 'rgb(' + r + ',' + g + ',' + b + ')' + } + return 'rgb(255, 255, 255)' +} + +export function getContrastingTextColor(color: string = defaultBackgroundColor) { + let rgb + if (color[0] === '#') { + rgb = hex2rgb(color) + } + if (color.startsWith('rgb')) { + rgb = color + } + // otherwise it's a color name + const nameColorToHex = nameToHex(color) + if (nameColorToHex) { + rgb = hex2rgb(nameColorToHex) + } + if (!rgb) { + return 'black' + } + const colorMatch = rgb.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/) + if (colorMatch) { + const r = parseInt(colorMatch[1]) + const g = parseInt(colorMatch[2]) + const b = parseInt(colorMatch[3]) + const hsp = Math.sqrt(0.299 * (r * r) + 0.587 * (g * g) + 0.114 * (b * b)) + return hsp > 127.5 ? 'black' : 'white' + } + return 'black' +} +export function getTextColor(el: HTMLElement) { + const backgroundColor = window.getComputedStyle(el).backgroundColor + if (backgroundColor === 'rgba(0, 0, 0, 0)') { + return 'black' + } + const colorMatch = backgroundColor.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/) + if (!colorMatch) return 'black' + + const r = parseInt(colorMatch[1]) + const g = parseInt(colorMatch[2]) + const b = parseInt(colorMatch[3]) + const hsp = Math.sqrt(0.299 * (r * r) + 0.587 * (g * g) + 0.114 * (b * b)) + return hsp > 127.5 ? 'black' : 'white' +} + +export const defaultSurveyAppearance: SurveyAppearance = { + backgroundColor: '#eeeded', + submitButtonColor: 'black', + ratingButtonColor: 'white', + ratingButtonActiveColor: 'black', + borderColor: '#c9c6c6', + placeholder: 'Start typing...', + whiteLabel: false, + displayThankYouMessage: true, + thankYouMessageHeader: 'Thank you for your feedback!', + position: 'right', +} + +export const defaultBackgroundColor = '#eeeded' + +export const createShadow = (styleSheet: string, surveyId: string) => { + const div = document.createElement('div') + div.className = `PostHogSurvey${surveyId}` + const shadow = div.attachShadow({ mode: 'open' }) + if (styleSheet) { + const styleElement = Object.assign(document.createElement('style'), { + innerText: styleSheet, + }) + shadow.appendChild(styleElement) + } + document.body.appendChild(div) + return shadow +} + +export const sendSurveyEvent = ( + responses: Record = {}, + survey: Survey, + posthog?: PostHog +) => { + if (!posthog) return + localStorage.setItem(`seenSurvey_${survey.id}`, 'true') + posthog.capture('survey sent', { + $survey_name: survey.name, + $survey_id: survey.id, + $survey_questions: survey.questions.map((question) => question.question), + sessionRecordingUrl: posthog.get_session_replay_url?.(), + ...responses, + $set: { + [`$survey_responded/${survey.id}`]: true, + }, + }) + window.dispatchEvent(new Event('PHSurveySent')) +} + +export const SurveyContext = createContext<{ + readOnly: boolean + previewQuestionIndex: number + textColor: string +}>({ + readOnly: false, + previewQuestionIndex: 0, + textColor: 'black', +}) diff --git a/src/loader-surveys.ts b/src/loader-surveys.ts index 9e7bf8e1f..d8c25e0de 100644 --- a/src/loader-surveys.ts +++ b/src/loader-surveys.ts @@ -1,6 +1,7 @@ import { generateSurveys } from './extensions/surveys' import { window } from './utils/globals' +export { renderSurveysPreview } from './extensions/surveys' if (window) { ;(window as any).extendPostHogWithSurveys = generateSurveys diff --git a/src/posthog-surveys-types.ts b/src/posthog-surveys-types.ts index 01a9b475e..ae0deb9e0 100644 --- a/src/posthog-surveys-types.ts +++ b/src/posthog-surveys-types.ts @@ -8,6 +8,7 @@ export interface SurveyAppearance { // keep in sync with frontend/src/types.ts -> SurveyAppearance backgroundColor?: string submitButtonColor?: string + // text color is deprecated, use auto contrast text color instead textColor?: string // deprecate submit button text eventually submitButtonText?: string @@ -54,7 +55,7 @@ export interface BasicSurveyQuestion extends SurveyQuestionBase { export interface LinkSurveyQuestion extends SurveyQuestionBase { type: SurveyQuestionType.Link - link: string | null + link?: string | null } export interface RatingSurveyQuestion extends SurveyQuestionBase { diff --git a/tsconfig.json b/tsconfig.json index 8c211704e..243e5ee0d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,5 +24,6 @@ "jsxFactory": "h", "jsxFragmentFactory": "Fragment" }, - "include": ["src/*.ts*"] + "include": ["src/*.ts*"], + "exclude": ["src/__tests__/**/*.ts*"] }