diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f0923c4..05449b2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,7 +17,7 @@ jobs: node-version: '20.x' - name: Cache node modules - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: ~/.npm key: cache-node-modules-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} diff --git a/src/constants.ts b/src/constants.ts index 2573e23..f630aac 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,2 +1,3 @@ export const API_KEY_LOCAL_STORAGE = '__tolgee_apiKey'; export const API_URL_LOCAL_STORAGE = '__tolgee_apiUrl'; +export const BRANCH_LOCAL_STORAGE = '__tolgee_branch'; diff --git a/src/content/contentScript.ts b/src/content/contentScript.ts index 9b7c6ee..d9f560a 100644 --- a/src/content/contentScript.ts +++ b/src/content/contentScript.ts @@ -1,4 +1,8 @@ -import { API_KEY_LOCAL_STORAGE, API_URL_LOCAL_STORAGE } from '../constants'; +import { + API_KEY_LOCAL_STORAGE, + API_URL_LOCAL_STORAGE, + BRANCH_LOCAL_STORAGE, +} from '../constants'; import { LibConfig } from '../types'; import { injectUiLib } from './injectUiLib'; import { Messages } from './Messages'; @@ -13,6 +17,7 @@ const getAppliedCredenials = () => { return { apiKey: sessionStorage.getItem(API_KEY_LOCAL_STORAGE), apiUrl: sessionStorage.getItem(API_URL_LOCAL_STORAGE), + branch: sessionStorage.getItem(BRANCH_LOCAL_STORAGE), }; }; @@ -72,6 +77,11 @@ messages.listenRuntime('SET_CREDENTIALS', async (data) => { } else { sessionStorage.removeItem(API_URL_LOCAL_STORAGE); } + if (data.branch) { + sessionStorage.setItem(BRANCH_LOCAL_STORAGE, data.branch); + } else { + sessionStorage.removeItem(BRANCH_LOCAL_STORAGE); + } location.reload(); updateState(configuration, messages); }); diff --git a/src/popup/TolgeeDetector.tsx b/src/popup/TolgeeDetector.tsx index 03deb90..310d005 100644 --- a/src/popup/TolgeeDetector.tsx +++ b/src/popup/TolgeeDetector.tsx @@ -1,5 +1,6 @@ -import React from 'react'; +import React, { useState } from 'react'; import { + Autocomplete, Box, Button, CircularProgress, @@ -26,7 +27,9 @@ export const TolgeeDetector = () => { libConfig, tolgeePresent, credentialsCheck, + branches, } = state; + const [branchOpen, setBranchOpen] = useState(false); const handleApplyChange = async () => { if (appliedValues) { @@ -66,7 +69,8 @@ export const TolgeeDetector = () => { const valuesNotChanged = isInDevelopmentMode && libConfig?.config.apiKey === values?.apiKey && - libConfig?.config.apiUrl === values?.apiUrl; + libConfig?.config.apiUrl === values?.apiUrl && + (libConfig?.config.branch || '') === (values?.branch || ''); return ( { )} + {typeof credentialsCheck === 'object' && + credentialsCheck?.branchingEnabled && ( + setBranchOpen(true)} + onClose={() => setBranchOpen(false)} + freeSolo + size="small" + disablePortal + slotProps={{ + popper: { + placement: 'bottom', + modifiers: [{ name: 'flip', enabled: false }], + }, + }} + ListboxProps={{ style: { maxHeight: 150 } }} + options={branches ?? []} + getOptionLabel={(option) => + typeof option === 'string' ? option : option.name + } + value={ + branches?.find((b) => b.name === values?.branch) ?? + values?.branch ?? + null + } + onChange={(_e: any, newValue: any) => { + dispatch({ + type: 'CHANGE_VALUES', + payload: { + branch: + typeof newValue === 'string' + ? newValue + : newValue?.name ?? '', + }, + }); + }} + onInputChange={(_e: any, newInput: string, reason: string) => { + if (reason === 'input') { + dispatch({ + type: 'CHANGE_VALUES', + payload: { branch: newInput }, + }); + } + }} + renderOption={(props, option) => ( +
  • + {option.name} + {option.isDefault && ( + + default + + )} +
  • + )} + renderInput={(params) => ( + + )} + /> + )} { @@ -25,6 +26,7 @@ export const storeValues = async (values: Values | null) => { [origin]: { apiUrl: values.apiUrl, apiKey: values.apiKey, + branch: values.branch, }, }); } else { @@ -45,6 +47,7 @@ export const loadValues = async () => { return { apiKey: data?.apiKey, apiUrl: data?.apiUrl, + branch: data?.branch, }; } catch (e) { console.error(e); diff --git a/src/popup/tools.ts b/src/popup/tools.ts index 6d97a7e..5f331e0 100644 --- a/src/popup/tools.ts +++ b/src/popup/tools.ts @@ -1,6 +1,7 @@ export type Values = { apiUrl?: string; apiKey?: string; + branch?: string; }; export const validateValues = (values?: Values | null) => { @@ -15,7 +16,9 @@ export const compareValues = ( values2?: Values | null ) => { return ( - values1?.apiKey === values2?.apiKey && values2?.apiUrl === values2?.apiUrl + values1?.apiKey === values2?.apiKey && + values1?.apiUrl === values2?.apiUrl && + (values1?.branch || '') === (values2?.branch || '') ); }; diff --git a/src/popup/useDetectorForm.tsx b/src/popup/useDetectorForm.tsx index 7da82c6..735f5a6 100644 --- a/src/popup/useDetectorForm.tsx +++ b/src/popup/useDetectorForm.tsx @@ -11,13 +11,20 @@ import { RuntimeMessage } from '../content/Messages'; type ProjectInfo = { projectName: string; + projectId: number; scopes: string[]; userFullName: string; + branchingEnabled: boolean; }; type CredentialsCheck = null | 'loading' | 'invalid' | ProjectInfo; type TolgeePresent = 'loading' | 'present' | 'not_present' | 'legacy'; +type BranchOption = { + name: string; + isDefault: boolean; +}; + const initialState = { values: null as Values | null, storedValues: null as Values | null, @@ -27,6 +34,7 @@ const initialState = { libConfig: null as LibConfig | null, error: null as string | null, frameId: null as number | null, + branches: null as BranchOption[] | null, }; type State = typeof initialState; @@ -43,7 +51,8 @@ type Action = | { type: 'APPLY_VALUES' } | { type: 'CLEAR_ALL' } | { type: 'STORE_VALUES' } - | { type: 'LOAD_VALUES' }; + | { type: 'LOAD_VALUES' } + | { type: 'SET_BRANCHES'; payload: BranchOption[] | null }; export const useDetectorForm = () => { const { applyRequired, apply } = useApplier(); @@ -57,6 +66,7 @@ export const useDetectorForm = () => { const newValues = { apiKey: libData?.config?.apiKey, apiUrl: libData?.config?.apiUrl, + branch: libData?.config?.branch, }; if (state.libConfig !== null && state.frameId !== frameId) { return { @@ -98,20 +108,29 @@ export const useDetectorForm = () => { storedValues: action.payload, values: action.payload, }; - case 'APPLY_VALUES': + case 'APPLY_VALUES': { // sync values with storage/localStorage apply(); + const branchEnabled = + typeof state.credentialsCheck === 'object' && + state.credentialsCheck?.branchingEnabled; + const effectiveBranch = branchEnabled + ? state.values?.branch + : undefined; return { ...state, appliedValues: { apiKey: state.values?.apiKey, apiUrl: state.values?.apiUrl, + branch: effectiveBranch, }, storedValues: { apiKey: state.values?.apiKey, apiUrl: state.values?.apiUrl, + branch: effectiveBranch, }, }; + } case 'CLEAR_ALL': { apply(); return { @@ -137,6 +156,11 @@ export const useDetectorForm = () => { appliedValues: state.storedValues, values: state.storedValues, }; + case 'SET_BRANCHES': + return { + ...state, + branches: action.payload, + }; default: // @ts-expect-error action type is type uknown throw new Error(`Unknown action ${action.type}`); @@ -260,8 +284,10 @@ export const useDetectorForm = () => { data && setCredentialsCheck({ projectName: data.projectName, + projectId: data.projectId, scopes: data.scopes, userFullName: data.userFullName, + branchingEnabled: data.branchingEnabled ?? false, }); }); } else { @@ -272,5 +298,51 @@ export const useDetectorForm = () => { }; }, [checkableValues?.apiUrl, checkableValues?.apiKey]); + // fetch branches when credentials are valid and branching is enabled + useEffect(() => { + let cancelled = false; + const check = state.credentialsCheck; + if ( + typeof check === 'object' && + check?.branchingEnabled && + validateValues(checkableValues) + ) { + const url = normalizeUrl(checkableValues!.apiUrl); + fetch( + `${url}/v2/projects/${check.projectId}/branches?ak=${ + checkableValues!.apiKey + }&size=100` + ) + .then((r) => { + if (!r.ok) { + throw new Error('Failed to load branches'); + } + return r.json(); + }) + .then((data) => { + if (!cancelled) { + dispatch({ + type: 'SET_BRANCHES', + payload: + data?._embedded?.branches?.map((b: any) => ({ + name: b.name, + isDefault: b.isDefault, + })) ?? null, + }); + } + }) + .catch(() => { + if (!cancelled) { + dispatch({ type: 'SET_BRANCHES', payload: null }); + } + }); + } else { + dispatch({ type: 'SET_BRANCHES', payload: null }); + } + return () => { + cancelled = true; + }; + }, [state.credentialsCheck]); + return [state, dispatch] as const; }; diff --git a/src/types.ts b/src/types.ts index b58f096..8cbfac0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,7 @@ export type LibConfig = { config: { apiUrl: ''; apiKey: ''; + branch?: string; // @deprecated older versions mode?: 'production' | 'development'; };