@posthog/core 1.27.8 → 1.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/logs/index.d.ts +94 -4
- package/dist/logs/index.d.ts.map +1 -1
- package/dist/logs/index.js +147 -4
- package/dist/logs/index.mjs +148 -5
- package/dist/logs/logs-utils.d.ts +2 -1
- package/dist/logs/logs-utils.d.ts.map +1 -1
- package/dist/logs/types.d.ts +140 -8
- package/dist/logs/types.d.ts.map +1 -1
- package/dist/posthog-core-stateless.d.ts +34 -0
- package/dist/posthog-core-stateless.d.ts.map +1 -1
- package/dist/posthog-core-stateless.js +39 -0
- package/dist/posthog-core-stateless.mjs +39 -0
- package/dist/surveys/events.d.ts +22 -0
- package/dist/surveys/events.d.ts.map +1 -0
- package/dist/surveys/events.js +95 -0
- package/dist/surveys/events.mjs +43 -0
- package/dist/surveys/index.d.ts +4 -0
- package/dist/surveys/index.d.ts.map +1 -0
- package/dist/surveys/index.js +83 -0
- package/dist/surveys/index.mjs +4 -0
- package/dist/surveys/translations.d.ts +38 -0
- package/dist/surveys/translations.d.ts.map +1 -0
- package/dist/surveys/translations.js +207 -0
- package/dist/surveys/translations.mjs +158 -0
- package/dist/testing/test-utils.d.ts.map +1 -1
- package/dist/testing/test-utils.js +1 -0
- package/dist/testing/test-utils.mjs +1 -0
- package/dist/types.d.ts +31 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/logger.d.ts +1 -1
- package/dist/utils/logger.d.ts.map +1 -1
- package/dist/utils/logger.js +3 -0
- package/dist/utils/logger.mjs +3 -0
- package/package.json +26 -2
- package/src/index.ts +12 -1
- package/src/logs/index.spec.ts +891 -17
- package/src/logs/index.ts +337 -13
- package/src/logs/logs-utils.spec.ts +2 -1
- package/src/logs/logs-utils.ts +1 -1
- package/src/logs/types.ts +150 -25
- package/src/posthog-core-stateless.ts +64 -0
- package/src/surveys/events.spec.ts +52 -0
- package/src/surveys/events.ts +80 -0
- package/src/surveys/index.ts +18 -0
- package/src/surveys/translations.spec.ts +205 -0
- package/src/surveys/translations.ts +244 -0
- package/src/testing/test-utils.ts +1 -0
- package/src/types.ts +38 -2
- package/src/utils/logger.ts +6 -2
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { OtlpLogsPayload } from '@posthog/types'
|
|
1
2
|
import { SimpleEventEmitter } from './eventemitter'
|
|
2
3
|
import { getFeatureFlagValue, normalizeFlagsResponse } from './featureFlagUtils'
|
|
3
4
|
import { gzipCompress, isGzipSupported } from './gzip'
|
|
@@ -94,6 +95,23 @@ function isPostHogFetchContentTooLargeError(err: unknown): err is PostHogFetchHt
|
|
|
94
95
|
return typeof err === 'object' && err instanceof PostHogFetchHttpError && err.status === 413
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Outcome of a logs batch send. Keeps HTTP error classification inside core
|
|
100
|
+
* (single source of truth — same policy events already use in `_flush()`) so
|
|
101
|
+
* PostHogLogs doesn't need to know about specific error types.
|
|
102
|
+
*
|
|
103
|
+
* - ok → records are accepted; drop them from the queue
|
|
104
|
+
* - too-large → 413; caller should halve batch size and retry same records
|
|
105
|
+
* - retry-later → network error; caller keeps records and retries next cycle
|
|
106
|
+
* - fatal → anything else (auth, malformed, etc.); caller drops the
|
|
107
|
+
* batch and surfaces the error
|
|
108
|
+
*/
|
|
109
|
+
export type SendLogsBatchOutcome =
|
|
110
|
+
| { kind: 'ok' }
|
|
111
|
+
| { kind: 'too-large' }
|
|
112
|
+
| { kind: 'retry-later'; error: unknown }
|
|
113
|
+
| { kind: 'fatal'; error: unknown }
|
|
114
|
+
|
|
97
115
|
export enum QuotaLimitedFeature {
|
|
98
116
|
FeatureFlags = 'feature_flags',
|
|
99
117
|
Recordings = 'recordings',
|
|
@@ -1179,6 +1197,52 @@ export abstract class PostHogCoreStateless {
|
|
|
1179
1197
|
this._events.emit('flush', sentMessages)
|
|
1180
1198
|
}
|
|
1181
1199
|
|
|
1200
|
+
/**
|
|
1201
|
+
* Sends a pre-built OTLP logs payload to `/i/v1/logs`. Returns a tagged
|
|
1202
|
+
* outcome instead of throwing so PostHogLogs doesn't have to know about the
|
|
1203
|
+
* core's error class hierarchy. Error classification lives here (single
|
|
1204
|
+
* source of truth, same policy the events `_flush()` uses for its own
|
|
1205
|
+
* 413 / network / fatal handling).
|
|
1206
|
+
*
|
|
1207
|
+
* 413 is passed through as `too-large` (not auto-retried) so the caller can
|
|
1208
|
+
* shrink `maxBatchRecordsPerPost` and retry the same records.
|
|
1209
|
+
*/
|
|
1210
|
+
async _sendLogsBatch(payload: OtlpLogsPayload): Promise<SendLogsBatchOutcome> {
|
|
1211
|
+
const serialized = JSON.stringify(payload)
|
|
1212
|
+
const url = `${this.host}/i/v1/logs?token=${encodeURIComponent(this.apiKey)}`
|
|
1213
|
+
|
|
1214
|
+
const gzippedPayload = !this.disableCompression ? await gzipCompress(serialized, this.isDebug) : null
|
|
1215
|
+
const fetchOptions: PostHogFetchOptions = {
|
|
1216
|
+
method: 'POST',
|
|
1217
|
+
headers: {
|
|
1218
|
+
...this.getCustomHeaders(),
|
|
1219
|
+
'Content-Type': 'application/json',
|
|
1220
|
+
...(gzippedPayload !== null && { 'Content-Encoding': 'gzip' }),
|
|
1221
|
+
},
|
|
1222
|
+
body: gzippedPayload || serialized,
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
try {
|
|
1226
|
+
await this.fetchWithRetry(url, fetchOptions, {
|
|
1227
|
+
retryCheck: (err) => {
|
|
1228
|
+
if (isPostHogFetchContentTooLargeError(err)) {
|
|
1229
|
+
return false
|
|
1230
|
+
}
|
|
1231
|
+
return isPostHogFetchError(err)
|
|
1232
|
+
},
|
|
1233
|
+
})
|
|
1234
|
+
return { kind: 'ok' }
|
|
1235
|
+
} catch (err) {
|
|
1236
|
+
if (isPostHogFetchContentTooLargeError(err)) {
|
|
1237
|
+
return { kind: 'too-large' }
|
|
1238
|
+
}
|
|
1239
|
+
if (err instanceof PostHogFetchNetworkError) {
|
|
1240
|
+
return { kind: 'retry-later', error: err }
|
|
1241
|
+
}
|
|
1242
|
+
return { kind: 'fatal', error: err }
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1182
1246
|
private async fetchWithRetry(
|
|
1183
1247
|
url: string,
|
|
1184
1248
|
options: PostHogFetchOptions,
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import {
|
|
3
|
+
buildSurveyResponseProperties,
|
|
4
|
+
getSurveyInteractionProperty,
|
|
5
|
+
getSurveyResponseKey,
|
|
6
|
+
getSurveyResponseValue,
|
|
7
|
+
surveyHasResponses,
|
|
8
|
+
} from './events'
|
|
9
|
+
|
|
10
|
+
describe('survey event helpers', () => {
|
|
11
|
+
const survey = {
|
|
12
|
+
id: 'survey-1',
|
|
13
|
+
current_iteration: 2,
|
|
14
|
+
questions: [
|
|
15
|
+
{ id: 'q1', question: 'Rate us', originalQuestionIndex: 0 },
|
|
16
|
+
{ id: 'q2', question: 'Anything else?', originalQuestionIndex: 1 },
|
|
17
|
+
],
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
it('builds response properties with current and legacy response keys', () => {
|
|
21
|
+
const responses = {
|
|
22
|
+
[getSurveyResponseKey('q1')]: 5,
|
|
23
|
+
[getSurveyResponseKey('q2')]: ['fast', 'clear'],
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
expect(buildSurveyResponseProperties(responses, survey)).toEqual({
|
|
27
|
+
$survey_questions: [
|
|
28
|
+
{ id: 'q1', question: 'Rate us', response: 5 },
|
|
29
|
+
{ id: 'q2', question: 'Anything else?', response: ['fast', 'clear'] },
|
|
30
|
+
],
|
|
31
|
+
$survey_response_q1: 5,
|
|
32
|
+
$survey_response_q2: ['fast', 'clear'],
|
|
33
|
+
$survey_response: 5,
|
|
34
|
+
$survey_response_1: ['fast', 'clear'],
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('copies array response values before returning them', () => {
|
|
39
|
+
const responses = { [getSurveyResponseKey('q1')]: ['a'] }
|
|
40
|
+
|
|
41
|
+
const response = getSurveyResponseValue(responses, 'q1')
|
|
42
|
+
|
|
43
|
+
expect(response).toEqual(['a'])
|
|
44
|
+
expect(response).not.toBe(responses.$survey_response_q1)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('detects non-nullish responses and builds interaction property names', () => {
|
|
48
|
+
expect(surveyHasResponses({ $survey_response_q1: null })).toBe(false)
|
|
49
|
+
expect(surveyHasResponses({ $survey_response_q1: 0 })).toBe(true)
|
|
50
|
+
expect(getSurveyInteractionProperty(survey, 'responded')).toBe('$survey_responded/survey-1/2')
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { SurveyResponses, SurveyResponseValue } from '../types'
|
|
2
|
+
import { isArray, isNullish, isUndefined } from '../utils'
|
|
3
|
+
|
|
4
|
+
export const SURVEY_LANGUAGE_PROPERTY = '$survey_language'
|
|
5
|
+
|
|
6
|
+
export function getSurveyResponseKey(questionId: string): string {
|
|
7
|
+
return `$survey_response_${questionId}`
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function getSurveyOldResponseKey(originalQuestionIndex: number): string {
|
|
11
|
+
return originalQuestionIndex === 0 ? '$survey_response' : `$survey_response_${originalQuestionIndex}`
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getSurveyResponseValue(
|
|
15
|
+
responses: SurveyResponses,
|
|
16
|
+
questionId?: string
|
|
17
|
+
): SurveyResponseValue | undefined {
|
|
18
|
+
if (!questionId) {
|
|
19
|
+
return null
|
|
20
|
+
}
|
|
21
|
+
const response = responses[getSurveyResponseKey(questionId)]
|
|
22
|
+
if (isArray(response)) {
|
|
23
|
+
return [...response]
|
|
24
|
+
}
|
|
25
|
+
return response
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function buildSurveyResponseProperties(
|
|
29
|
+
responses: SurveyResponses = {},
|
|
30
|
+
survey: SurveyForResponses
|
|
31
|
+
): Record<string, unknown> {
|
|
32
|
+
const oldFormatResponses: SurveyResponses = {}
|
|
33
|
+
survey.questions.forEach((question: SurveyQuestionForResponses) => {
|
|
34
|
+
if (isUndefined(question.originalQuestionIndex)) {
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
const oldResponseKey = getSurveyOldResponseKey(question.originalQuestionIndex)
|
|
38
|
+
const response = getSurveyResponseValue(responses, question.id)
|
|
39
|
+
if (!isUndefined(response)) {
|
|
40
|
+
oldFormatResponses[oldResponseKey] = response
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
$survey_questions: survey.questions.map((question: SurveyQuestionForResponses) => ({
|
|
46
|
+
id: question.id,
|
|
47
|
+
question: question.question,
|
|
48
|
+
response: getSurveyResponseValue(responses, question.id),
|
|
49
|
+
})),
|
|
50
|
+
...responses,
|
|
51
|
+
...oldFormatResponses,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function surveyHasResponses(responses: SurveyResponses = {}): boolean {
|
|
56
|
+
return Object.values(responses).some((response) => !isNullish(response))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getSurveyInteractionProperty(survey: SurveyWithIteration, action: string): string {
|
|
60
|
+
let surveyProperty = `$survey_${action}/${survey.id}`
|
|
61
|
+
if (survey.current_iteration && survey.current_iteration > 0) {
|
|
62
|
+
surveyProperty = `$survey_${action}/${survey.id}/${survey.current_iteration}`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return surveyProperty
|
|
66
|
+
}
|
|
67
|
+
type SurveyQuestionForResponses = {
|
|
68
|
+
id?: string
|
|
69
|
+
question: string
|
|
70
|
+
originalQuestionIndex?: number
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type SurveyForResponses = {
|
|
74
|
+
questions: SurveyQuestionForResponses[]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type SurveyWithIteration = {
|
|
78
|
+
id: string
|
|
79
|
+
current_iteration?: number | null
|
|
80
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export { getValidationError, getLengthFromRules, getRequirementsHint } from './validation'
|
|
2
|
+
export {
|
|
3
|
+
buildSurveyResponseProperties,
|
|
4
|
+
getSurveyInteractionProperty,
|
|
5
|
+
getSurveyOldResponseKey,
|
|
6
|
+
getSurveyResponseKey,
|
|
7
|
+
getSurveyResponseValue,
|
|
8
|
+
SURVEY_LANGUAGE_PROPERTY,
|
|
9
|
+
surveyHasResponses,
|
|
10
|
+
} from './events'
|
|
11
|
+
export {
|
|
12
|
+
applySurveyTranslation,
|
|
13
|
+
detectSurveyLanguage,
|
|
14
|
+
findBestTranslationMatch,
|
|
15
|
+
getBaseLanguage,
|
|
16
|
+
getLanguageFromStoredPersonProperties,
|
|
17
|
+
normalizeLanguageCode,
|
|
18
|
+
} from './translations'
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import {
|
|
3
|
+
applySurveyTranslation,
|
|
4
|
+
detectSurveyLanguage,
|
|
5
|
+
findBestTranslationMatch,
|
|
6
|
+
getLanguageFromStoredPersonProperties,
|
|
7
|
+
} from './translations'
|
|
8
|
+
import { Survey, SurveyQuestionType, SurveyType } from '../types'
|
|
9
|
+
|
|
10
|
+
const createBaseSurvey = (): Survey => ({
|
|
11
|
+
id: 'test-survey',
|
|
12
|
+
name: 'Test Survey',
|
|
13
|
+
description: 'Test Description',
|
|
14
|
+
type: SurveyType.Popover,
|
|
15
|
+
questions: [
|
|
16
|
+
{
|
|
17
|
+
type: SurveyQuestionType.Open,
|
|
18
|
+
question: 'What do you think?',
|
|
19
|
+
id: 'q1',
|
|
20
|
+
originalQuestionIndex: 0,
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
appearance: {
|
|
24
|
+
thankYouMessageHeader: 'Thank you!',
|
|
25
|
+
thankYouMessageDescription: 'We appreciate your feedback',
|
|
26
|
+
thankYouMessageCloseButtonText: 'Close',
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('survey translations', () => {
|
|
31
|
+
describe('detectSurveyLanguage', () => {
|
|
32
|
+
it.each([
|
|
33
|
+
{
|
|
34
|
+
name: 'prioritizes override language',
|
|
35
|
+
input: {
|
|
36
|
+
overrideLanguage: 'de',
|
|
37
|
+
storedPersonProperties: { language: 'es' },
|
|
38
|
+
locale: 'fr',
|
|
39
|
+
},
|
|
40
|
+
expected: 'de',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'uses person language when override is missing',
|
|
44
|
+
input: {
|
|
45
|
+
storedPersonProperties: { language: 'es' },
|
|
46
|
+
locale: 'fr',
|
|
47
|
+
},
|
|
48
|
+
expected: 'es',
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'falls back to locale',
|
|
52
|
+
input: {
|
|
53
|
+
storedPersonProperties: { some_other_property: 'value' },
|
|
54
|
+
locale: 'fr-CA',
|
|
55
|
+
},
|
|
56
|
+
expected: 'fr-CA',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'returns null when no sources exist',
|
|
60
|
+
input: {
|
|
61
|
+
storedPersonProperties: { some_other_property: 'value' },
|
|
62
|
+
},
|
|
63
|
+
expected: null,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'trims override language',
|
|
67
|
+
input: {
|
|
68
|
+
overrideLanguage: ' es ',
|
|
69
|
+
},
|
|
70
|
+
expected: 'es',
|
|
71
|
+
},
|
|
72
|
+
])('$name', ({ input, expected }) => {
|
|
73
|
+
expect(detectSurveyLanguage(input)).toBe(expected)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('reads language from stored person properties', () => {
|
|
77
|
+
expect(getLanguageFromStoredPersonProperties({ language: 'it' })).toBe('it')
|
|
78
|
+
expect(getLanguageFromStoredPersonProperties({ language: ' ' })).toBeNull()
|
|
79
|
+
expect(getLanguageFromStoredPersonProperties({})).toBeNull()
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('findBestTranslationMatch', () => {
|
|
84
|
+
it('supports exact and base-language matches', () => {
|
|
85
|
+
expect(findBestTranslationMatch({ fr: {}, 'fr-CA': {} }, 'FR-ca')).toBe('fr-CA')
|
|
86
|
+
expect(findBestTranslationMatch({ fr: {} }, 'fr-CA')).toBe('fr')
|
|
87
|
+
expect(findBestTranslationMatch({ es: {} }, 'de')).toBeNull()
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('applySurveyTranslation', () => {
|
|
92
|
+
it('returns original survey when no translations exist', () => {
|
|
93
|
+
const survey = createBaseSurvey()
|
|
94
|
+
const result = applySurveyTranslation(survey, 'fr')
|
|
95
|
+
|
|
96
|
+
expect(result.survey).toEqual(survey)
|
|
97
|
+
expect(result.matchedKey).toBeNull()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('applies survey-level translations', () => {
|
|
101
|
+
const survey = createBaseSurvey()
|
|
102
|
+
survey.translations = {
|
|
103
|
+
fr: {
|
|
104
|
+
name: 'Enquete Test',
|
|
105
|
+
thankYouMessageHeader: 'Merci',
|
|
106
|
+
thankYouMessageDescription: 'Merci pour votre retour',
|
|
107
|
+
thankYouMessageCloseButtonText: 'Fermer',
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const result = applySurveyTranslation(survey, 'fr')
|
|
112
|
+
|
|
113
|
+
expect(result.survey.name).toBe('Enquete Test')
|
|
114
|
+
expect(result.survey.appearance?.thankYouMessageHeader).toBe('Merci')
|
|
115
|
+
expect(result.survey.appearance?.thankYouMessageDescription).toBe('Merci pour votre retour')
|
|
116
|
+
expect(result.survey.appearance?.thankYouMessageCloseButtonText).toBe('Fermer')
|
|
117
|
+
expect(result.matchedKey).toBe('fr')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('applies question-level translations', () => {
|
|
121
|
+
const survey = createBaseSurvey()
|
|
122
|
+
survey.questions = [
|
|
123
|
+
{
|
|
124
|
+
type: SurveyQuestionType.Rating,
|
|
125
|
+
question: 'How was it?',
|
|
126
|
+
id: 'q1',
|
|
127
|
+
originalQuestionIndex: 0,
|
|
128
|
+
display: 'number',
|
|
129
|
+
scale: 5,
|
|
130
|
+
lowerBoundLabel: 'Bad',
|
|
131
|
+
upperBoundLabel: 'Great',
|
|
132
|
+
translations: {
|
|
133
|
+
pt: {
|
|
134
|
+
question: 'Como foi?',
|
|
135
|
+
lowerBoundLabel: 'Ruim',
|
|
136
|
+
upperBoundLabel: 'Otimo',
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
type: SurveyQuestionType.MultipleChoice,
|
|
142
|
+
question: 'Pick one',
|
|
143
|
+
id: 'q2',
|
|
144
|
+
originalQuestionIndex: 1,
|
|
145
|
+
choices: ['One', 'Other'],
|
|
146
|
+
hasOpenChoice: true,
|
|
147
|
+
translations: {
|
|
148
|
+
pt: {
|
|
149
|
+
choices: ['Um', 'Outro'],
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
const result = applySurveyTranslation(survey, 'pt-BR')
|
|
156
|
+
|
|
157
|
+
expect(result.survey.questions[0].question).toBe('Como foi?')
|
|
158
|
+
expect('lowerBoundLabel' in result.survey.questions[0] && result.survey.questions[0].lowerBoundLabel).toBe('Ruim')
|
|
159
|
+
expect('upperBoundLabel' in result.survey.questions[0] && result.survey.questions[0].upperBoundLabel).toBe(
|
|
160
|
+
'Otimo'
|
|
161
|
+
)
|
|
162
|
+
expect('choices' in result.survey.questions[1] && result.survey.questions[1].choices).toEqual(['Um', 'Outro'])
|
|
163
|
+
expect(result.matchedKey).toBe('pt')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('uses question-level match when there is no survey-level translation', () => {
|
|
167
|
+
const survey = createBaseSurvey()
|
|
168
|
+
survey.questions[0].translations = {
|
|
169
|
+
de: {
|
|
170
|
+
question: 'Was denkst du?',
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const result = applySurveyTranslation(survey, 'de')
|
|
175
|
+
|
|
176
|
+
expect(result.survey.questions[0].question).toBe('Was denkst du?')
|
|
177
|
+
expect(result.matchedKey).toBe('de')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('preserves custom survey and question fields for shared consumers', () => {
|
|
181
|
+
const survey = {
|
|
182
|
+
name: 'Custom Survey',
|
|
183
|
+
customSurveyField: true,
|
|
184
|
+
questions: [
|
|
185
|
+
{
|
|
186
|
+
question: 'Pick one',
|
|
187
|
+
customQuestionField: 'native-renderer',
|
|
188
|
+
translations: {
|
|
189
|
+
fr: {
|
|
190
|
+
question: 'Choisissez une option',
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const result = applySurveyTranslation(survey, 'fr')
|
|
198
|
+
|
|
199
|
+
expect(result.survey.customSurveyField).toBe(true)
|
|
200
|
+
expect(result.survey.questions[0].question).toBe('Choisissez une option')
|
|
201
|
+
expect(result.survey.questions[0].customQuestionField).toBe('native-renderer')
|
|
202
|
+
expect(result.matchedKey).toBe('fr')
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
})
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { SurveyQuestionTranslation, SurveyTranslation } from '../types'
|
|
2
|
+
import { createLogger, isArray, isUndefined } from '../utils'
|
|
3
|
+
|
|
4
|
+
const logger = createLogger('[SurveyTranslations]')
|
|
5
|
+
|
|
6
|
+
export type DetectSurveyLanguageOptions = {
|
|
7
|
+
overrideLanguage?: unknown
|
|
8
|
+
storedPersonProperties?: unknown
|
|
9
|
+
locale?: unknown
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function getTrimmedLanguage(value: unknown): string | null {
|
|
13
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getLanguageFromStoredPersonProperties(storedPersonProperties: unknown): string | null {
|
|
17
|
+
if (
|
|
18
|
+
!storedPersonProperties ||
|
|
19
|
+
typeof storedPersonProperties !== 'object' ||
|
|
20
|
+
!('language' in storedPersonProperties)
|
|
21
|
+
) {
|
|
22
|
+
return null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return getTrimmedLanguage(storedPersonProperties.language)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function detectSurveyLanguage({
|
|
29
|
+
overrideLanguage,
|
|
30
|
+
storedPersonProperties,
|
|
31
|
+
locale,
|
|
32
|
+
}: DetectSurveyLanguageOptions): string | null {
|
|
33
|
+
const explicitLanguage = getTrimmedLanguage(overrideLanguage)
|
|
34
|
+
if (explicitLanguage) {
|
|
35
|
+
logger.info(`Using override display language: ${explicitLanguage}`)
|
|
36
|
+
return explicitLanguage
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const personLanguage = getLanguageFromStoredPersonProperties(storedPersonProperties)
|
|
40
|
+
if (personLanguage) {
|
|
41
|
+
logger.info(`Using person property language: ${personLanguage}`)
|
|
42
|
+
return personLanguage
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const detectedLocale = getTrimmedLanguage(locale)
|
|
46
|
+
if (detectedLocale) {
|
|
47
|
+
logger.info(`Using detected locale: ${detectedLocale}`)
|
|
48
|
+
return detectedLocale
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
logger.info('No user language detected')
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function normalizeLanguageCode(languageCode: string): string {
|
|
56
|
+
return languageCode.toLowerCase()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getBaseLanguage(languageCode: string): string {
|
|
60
|
+
return languageCode.split('-')[0]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function findBestTranslationMatch(
|
|
64
|
+
translations: Record<string, unknown> | undefined,
|
|
65
|
+
targetLanguage: string
|
|
66
|
+
): string | null {
|
|
67
|
+
if (!translations || !targetLanguage) {
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const normalizedTarget = normalizeLanguageCode(targetLanguage)
|
|
72
|
+
|
|
73
|
+
const exactMatch = Object.keys(translations).find((key) => normalizeLanguageCode(key) === normalizedTarget)
|
|
74
|
+
if (exactMatch) {
|
|
75
|
+
logger.debug(`Found exact translation match: ${exactMatch}`)
|
|
76
|
+
return exactMatch
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (normalizedTarget.includes('-')) {
|
|
80
|
+
const baseLanguage = getBaseLanguage(normalizedTarget)
|
|
81
|
+
const baseMatch = Object.keys(translations).find((key) => normalizeLanguageCode(key) === baseLanguage)
|
|
82
|
+
if (baseMatch) {
|
|
83
|
+
logger.debug(`Found base language translation match: ${baseMatch} (from ${targetLanguage})`)
|
|
84
|
+
return baseMatch
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isTranslatedChoices(
|
|
92
|
+
questionTranslation: SurveyQuestionTranslation
|
|
93
|
+
): questionTranslation is SurveyQuestionTranslation & { choices: string[] } {
|
|
94
|
+
return isArray(questionTranslation.choices)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function hasThankYouTranslation(translation: SurveyTranslation): boolean {
|
|
98
|
+
return (
|
|
99
|
+
!isUndefined(translation.thankYouMessageHeader) ||
|
|
100
|
+
!isUndefined(translation.thankYouMessageDescription) ||
|
|
101
|
+
!isUndefined(translation.thankYouMessageCloseButtonText)
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
type TranslatableSurveyAppearance = {
|
|
106
|
+
thankYouMessageHeader?: string
|
|
107
|
+
thankYouMessageDescription?: string | null
|
|
108
|
+
thankYouMessageCloseButtonText?: string
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
type TranslatableSurveyQuestion = {
|
|
112
|
+
question: string
|
|
113
|
+
description?: string | null
|
|
114
|
+
buttonText?: string
|
|
115
|
+
link?: string | null
|
|
116
|
+
lowerBoundLabel?: string
|
|
117
|
+
upperBoundLabel?: string
|
|
118
|
+
choices?: string[]
|
|
119
|
+
translations?: Record<string, SurveyQuestionTranslation>
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
type TranslatableSurvey<TQuestion extends TranslatableSurveyQuestion = TranslatableSurveyQuestion> = {
|
|
123
|
+
name: string
|
|
124
|
+
translations?: Record<string, SurveyTranslation>
|
|
125
|
+
appearance?: TranslatableSurveyAppearance | null
|
|
126
|
+
questions: TQuestion[]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function mergeQuestionTranslation<TQuestion extends TranslatableSurveyQuestion>(
|
|
130
|
+
question: TQuestion,
|
|
131
|
+
targetLanguage: string
|
|
132
|
+
): { question: TQuestion; matchedKey: string | null; hasChanges: boolean } {
|
|
133
|
+
const translationKey = findBestTranslationMatch(question.translations, targetLanguage)
|
|
134
|
+
if (!translationKey) {
|
|
135
|
+
return { question, matchedKey: null, hasChanges: false }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const questionTranslation = question.translations?.[translationKey]
|
|
139
|
+
if (!questionTranslation) {
|
|
140
|
+
return { question, matchedKey: null, hasChanges: false }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const translated: TQuestion = { ...question }
|
|
144
|
+
let hasChanges = false
|
|
145
|
+
|
|
146
|
+
if (!isUndefined(questionTranslation.question)) {
|
|
147
|
+
translated.question = questionTranslation.question
|
|
148
|
+
hasChanges = true
|
|
149
|
+
}
|
|
150
|
+
if (!isUndefined(questionTranslation.description)) {
|
|
151
|
+
translated.description = questionTranslation.description
|
|
152
|
+
hasChanges = true
|
|
153
|
+
}
|
|
154
|
+
if (!isUndefined(questionTranslation.buttonText)) {
|
|
155
|
+
translated.buttonText = questionTranslation.buttonText
|
|
156
|
+
hasChanges = true
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if ('link' in translated && !isUndefined(questionTranslation.link)) {
|
|
160
|
+
translated.link = questionTranslation.link
|
|
161
|
+
hasChanges = true
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if ('lowerBoundLabel' in translated && !isUndefined(questionTranslation.lowerBoundLabel)) {
|
|
165
|
+
translated.lowerBoundLabel = questionTranslation.lowerBoundLabel
|
|
166
|
+
hasChanges = true
|
|
167
|
+
}
|
|
168
|
+
if ('upperBoundLabel' in translated && !isUndefined(questionTranslation.upperBoundLabel)) {
|
|
169
|
+
translated.upperBoundLabel = questionTranslation.upperBoundLabel
|
|
170
|
+
hasChanges = true
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if ('choices' in translated && isTranslatedChoices(questionTranslation)) {
|
|
174
|
+
translated.choices = questionTranslation.choices
|
|
175
|
+
hasChanges = true
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
question: hasChanges ? translated : question,
|
|
180
|
+
matchedKey: hasChanges ? translationKey : null,
|
|
181
|
+
hasChanges,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function applySurveyTranslation<
|
|
186
|
+
TQuestion extends TranslatableSurveyQuestion,
|
|
187
|
+
TSurvey extends TranslatableSurvey<TQuestion>,
|
|
188
|
+
>(survey: TSurvey, targetLanguage: string): { survey: TSurvey; matchedKey: string | null } {
|
|
189
|
+
const translationKey = findBestTranslationMatch(survey.translations, targetLanguage)
|
|
190
|
+
|
|
191
|
+
const translated: TSurvey = { ...survey }
|
|
192
|
+
let hasTranslation = false
|
|
193
|
+
|
|
194
|
+
if (translationKey) {
|
|
195
|
+
const translation = survey.translations?.[translationKey]
|
|
196
|
+
if (translation) {
|
|
197
|
+
logger.info(`Applying survey-level translation for language: ${translationKey}`)
|
|
198
|
+
|
|
199
|
+
if (!isUndefined(translation.name)) {
|
|
200
|
+
translated.name = translation.name
|
|
201
|
+
hasTranslation = true
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (translated.appearance) {
|
|
205
|
+
translated.appearance = { ...translated.appearance }
|
|
206
|
+
|
|
207
|
+
if (!isUndefined(translation.thankYouMessageHeader)) {
|
|
208
|
+
translated.appearance.thankYouMessageHeader = translation.thankYouMessageHeader
|
|
209
|
+
hasTranslation = true
|
|
210
|
+
}
|
|
211
|
+
if (!isUndefined(translation.thankYouMessageDescription)) {
|
|
212
|
+
translated.appearance.thankYouMessageDescription = translation.thankYouMessageDescription
|
|
213
|
+
hasTranslation = true
|
|
214
|
+
}
|
|
215
|
+
if (!isUndefined(translation.thankYouMessageCloseButtonText)) {
|
|
216
|
+
translated.appearance.thankYouMessageCloseButtonText = translation.thankYouMessageCloseButtonText
|
|
217
|
+
hasTranslation = true
|
|
218
|
+
}
|
|
219
|
+
} else if (hasThankYouTranslation(translation)) {
|
|
220
|
+
hasTranslation = true
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const translatedResults = survey.questions.map((question) => mergeQuestionTranslation(question, targetLanguage))
|
|
226
|
+
const translatedQuestions = translatedResults.map((result) => result.question)
|
|
227
|
+
const anyQuestionTranslated = translatedResults.some((result) => result.hasChanges)
|
|
228
|
+
|
|
229
|
+
let questionMatchedKey: string | null = null
|
|
230
|
+
if (!translationKey) {
|
|
231
|
+
questionMatchedKey = translatedResults.find((result) => result.matchedKey)?.matchedKey || null
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (anyQuestionTranslated) {
|
|
235
|
+
translated.questions = translatedQuestions
|
|
236
|
+
hasTranslation = true
|
|
237
|
+
logger.info(`Applied question-level translations for language: ${targetLanguage}`)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
survey: translated,
|
|
242
|
+
matchedKey: hasTranslation ? translationKey || questionMatchedKey : null,
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -36,6 +36,7 @@ export const delay = (ms: number): Promise<void> => {
|
|
|
36
36
|
|
|
37
37
|
export const createMockLogger = (): Logger => {
|
|
38
38
|
return {
|
|
39
|
+
debug: jest.fn((...args) => console.debug(...args)),
|
|
39
40
|
info: jest.fn((...args) => console.log(...args)),
|
|
40
41
|
warn: jest.fn((...args) => console.warn(...args)),
|
|
41
42
|
error: jest.fn((...args) => console.error(...args)),
|