Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"eslint": "eslint --ext .ts --ext .tsx --max-warnings 0 .",
"dev:web": "turbo run dev --parallel --cache-dir='.turbo' --filter=@tolgee/web",
"develop": "turbo run develop --parallel --cache-dir='.turbo'",
"develop:web": "npm run develop -- --filter=@tolgee/web-testapp...",
"develop:web": "npm run develop -- --filter=@tolgee/vanilla-testapp...",
"develop:react": "npm run develop -- --filter=@tolgee/react-testapp...",
"develop:vue": "npm run develop -- --filter=@tolgee/vue-testapp...",
"develop:svelte": "npm run develop -- --filter=@tolgee/svelte-testapp...",
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/Controller/Plugins/Plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ export function Plugins(
apiKey,
apiUrl,
projectId,
branch,
observerOptions,
tagNewKeys,
filterTag,
Expand All @@ -199,6 +200,7 @@ export function Plugins(
apiKey: apiKey!,
apiUrl: apiUrl!,
projectId,
branch,
highlight: self.highlight,
changeTranslation,
findPositions,
Expand Down Expand Up @@ -266,7 +268,8 @@ export function Plugins(
}) as BackendGetRecordInternal,

getBackendDevRecord: (async ({ language, namespace }) => {
const { apiKey, apiUrl, projectId, filterTag } = getInitialOptions();
const { apiKey, apiUrl, projectId, branch, filterTag } =
getInitialOptions();

if (!apiKey || !apiUrl || !self.hasDevBackend()) {
return undefined;
Expand All @@ -276,6 +279,7 @@ export function Plugins(
apiKey,
apiUrl,
projectId,
branch,
language,
namespace,
filterTag,
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/Controller/State/initState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ export type TolgeeOptionsInternal = {
*/
projectId?: number | string;

/**
* Branch to use for translations (default: none or default branch)
*/
branch?: string;

/**
* Used when auto detection is not available or is turned off
*/
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/types/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type BackendDevProps = {
apiUrl?: string;
apiKey?: string;
projectId?: number | string;
branch?: string;
filterTag?: string[];
};

Expand Down Expand Up @@ -156,6 +157,7 @@ export type UiProps = {
apiUrl: string;
apiKey: string;
projectId: number | string | undefined;
branch?: string;
highlight: HighlightInterface;
findPositions: (key?: string | undefined, ns?: NsFallback) => KeyPosition[];
changeTranslation: ChangeTranslationInterface;
Expand Down
6 changes: 5 additions & 1 deletion packages/web/src/package/DevBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ function createDevBackend(): BackendDevMiddleware {
getRecord({
apiUrl,
apiKey,
projectId,
branch,
language,
namespace,
projectId,
filterTag,
fetch,
}) {
Expand All @@ -21,6 +22,9 @@ function createDevBackend(): BackendDevMiddleware {
url = createUrl(apiUrl, `/v2/projects/translations/${language}`);
}

if (branch) {
url.searchParams.append('branch', branch);
}
if (namespace) {
url.searchParams.append('ns', namespace);
}
Expand Down
22 changes: 22 additions & 0 deletions packages/web/src/package/__test__/fetch.apiUrl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,26 @@ describe('can handle relative urls in apiUrl', () => {
'https://test.com/abcd/v2/projects/translations/en'
);
});

it('dev backend includes branch and ns when provided', async () => {
const fetchMock = f.fetchWithResponse({});
const tolgee = TolgeeCore()
.use(DevBackend())
.init({
language: 'en',
availableLanguages: ['en'],
fetch: fetchMock,
apiUrl: 'https://test.com',
apiKey: 'test',
branch: 'feature/test',
});
await expect(
tolgee.loadRecord({ language: 'en', namespace: 'home' })
).resolves.toEqual({});
expect(fetchMock).toHaveBeenCalledTimes(1);
const args = fetchMock.mock.calls[0] as any;
const url = new URL(args[0]);
expect(url.searchParams.get('branch')).toEqual('feature/test');
expect(url.searchParams.get('ns')).toEqual('home');
});
});
16 changes: 16 additions & 0 deletions packages/web/src/package/ui/KeyDialog/ErrorAlert.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,22 @@ function getErrorContent({ code, params, message }: HttpError, apiUrl: string) {
</>
);

case 'operation_not_permitted_in_read_only_mode':
return (
<>
<AlertTitle>Read-only mode</AlertTitle>
This branch is protected or your access is read-only.
</>
);

case 'branch_not_found':
return (
<>
<AlertTitle>Branch not found</AlertTitle>
Check the branch name or switch to an existing branch.
</>
);

case 'fetch_error':
return `Failed to fetch (${apiUrl})`;

Expand Down
1 change: 1 addition & 0 deletions packages/web/src/package/ui/KeyDialog/KeyDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const KeyDialog = ({ uiProps, keyData }: Props) => {
apiUrl={uiProps.apiUrl}
apiKey={uiProps.apiKey}
projectId={uiProps.projectId}
branch={uiProps.branch}
>
{open && (
<DialogProvider
Expand Down
54 changes: 32 additions & 22 deletions packages/web/src/package/ui/KeyDialog/KeyForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,26 @@ const ScHeadingRight = styled('div')`
flex-grow: 1;
`;

const ScKey = styled('p')`
const ScTitle = styled(ScFieldTitle)`
justify-content: start;
gap: 4px;
align-items: center;
`;

const ScValue = styled('p')`
margin: 0px;
`;

const ScKeyHint = styled('span')`
const ScHint = styled('span')`
color: grey;
`;

const ScLinkIcon = styled(Link)`
display: grid;
font-size: 16px;
margin: 0px 0px;
`;

const ScFieldsWrapper = styled('div')`
margin-top: 10px;
`;
Expand All @@ -72,18 +84,6 @@ const ScControls = styled('div')`
min-height: 36px;
`;

const ScKeyTitle = styled(ScFieldTitle)`
justify-content: start;
gap: 4px;
align-items: center;
`;

const ScLinkIcon = styled(Link)`
display: grid;
font-size: 16px;
margin: 0px 0px;
`;

export const KeyForm = () => {
const theme = useTheme();
const { setUseBrowserWindow, onClose, onSave, setSelectedNs } =
Expand All @@ -95,6 +95,7 @@ export const KeyForm = () => {
const input = useDialogContext((c) => c.input);
const keyData = useDialogContext((c) => c.keyData);
const formDisabled = useDialogContext((c) => c.formDisabled);
const readOnly = useDialogContext((c) => c.readOnly);
const loading = useDialogContext((c) => c.loading);
const error = useDialogContext((c) => c.error);
const submitError = useDialogContext((c) => c.submitError);
Expand All @@ -105,6 +106,7 @@ export const KeyForm = () => {
const selectedNs = useDialogContext((c) => c.selectedNs);
const permissions = useDialogContext((c) => c.permissions);
const filterTagMissing = useDialogContext((c) => c.filterTagMissing);
const branch = useDialogContext((c) => c.uiProps.branch);

const screenshotsView = permissions.canViewScreenshots;
const viewPluralCheckbox = permissions.canEditPlural && pluralsSupported;
Expand Down Expand Up @@ -152,7 +154,13 @@ export const KeyForm = () => {
)}
<ScHeadingRight>{!loading && <LanguageSelect />}</ScHeadingRight>
</ScHeading>
<ScKeyTitle>
{branch && (
<>
<ScTitle>Branch</ScTitle>
<ScValue>{branch}</ScValue>
</>
)}
<ScTitle>
Key
{linkToPlatform && (
<Tooltip title="Open key in Tolgee platform">
Expand All @@ -167,13 +175,11 @@ export const KeyForm = () => {
</ScLinkIcon>
</Tooltip>
)}
</ScKeyTitle>
<ScKey>
</ScTitle>
<ScValue>
{input}
<ScKeyHint>
{!keyExists && ready && " (key doesn't exist yet)"}
</ScKeyHint>
</ScKey>
<ScHint>{!keyExists && ready && " (key doesn't exist yet)"}</ScHint>
</ScValue>
<NsSelect
options={fallbackNamespaces}
value={selectedNs}
Expand All @@ -199,7 +205,11 @@ export const KeyForm = () => {
)}
{formDisabled && ready && (
<ErrorAlert
error={new HttpError('permissions_not_sufficient_to_edit')}
error={
readOnly
? new HttpError('operation_not_permitted_in_read_only_mode')
: new HttpError('permissions_not_sufficient_to_edit')
}
severity="info"
/>
)}
Expand Down
25 changes: 20 additions & 5 deletions packages/web/src/package/ui/KeyDialog/dialogContext/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ export const [DialogProvider, useDialogActions, useDialogContext] =
const [_isPlural, setIsPlural] = useState<boolean>();
const [_pluralArgName, setPluralArgName] = useState<string>();
const [submitError, setSubmitError] = useState<HttpError>();
const [readOnly, setReadOnly] = useState(false);
const branchParam = props.uiProps.branch;

const filterTagMissing =
Boolean(props.uiProps.filterTag?.length) &&
Expand All @@ -95,7 +97,8 @@ export const [DialogProvider, useDialogActions, useDialogContext] =
// reset when key changes
setIsPlural(undefined);
setPluralArgName(undefined);
}, [props.keyName, props.namespace]);
setReadOnly(false);
}, [props.keyName, props.namespace, props.uiProps.branch]);

const {
screenshots,
Expand Down Expand Up @@ -161,6 +164,7 @@ export const [DialogProvider, useDialogActions, useDialogContext] =
filterKeyName: [props.keyName],
filterNamespace: [selectedNs],
languages: selectedLanguages,
branch: branchParam,
},
options: {
enabled: Boolean(scopesLoadable.data),
Expand Down Expand Up @@ -271,9 +275,11 @@ export const [DialogProvider, useDialogActions, useDialogContext] =
const linkToPlatform =
scopesLoadable.data?.projectId !== undefined
? `${props.uiProps.apiUrl}/projects/${
scopesLoadable.data?.projectId
}/translations/single?key=${props.keyName}${
selectedNs !== undefined ? `&ns=${selectedNs}` : ''
scopesLoadable.data.projectId
}/translations/single${
branchParam ? `/tree/${encodeURIComponent(branchParam)}` : ''
}?key=${encodeURIComponent(props.keyName)}${
selectedNs ? `&ns=${encodeURIComponent(selectedNs)}` : ''
}`
: undefined;

Expand Down Expand Up @@ -329,6 +335,7 @@ export const [DialogProvider, useDialogActions, useDialogContext] =
content: {
'application/json': {
name: props.keyName,
branch: branchParam,
namespace: selectedNs || undefined,
translations: newTranslations,
states: newStates,
Expand All @@ -347,6 +354,7 @@ export const [DialogProvider, useDialogActions, useDialogContext] =
content: {
'application/json': {
name: props.keyName,
branch: branchParam,
namespace: selectedNs || undefined,
translations: newTranslations,
states: newStates,
Expand Down Expand Up @@ -388,6 +396,12 @@ export const [DialogProvider, useDialogActions, useDialogContext] =
} catch (e: any) {
// eslint-disable-next-line no-console
console.error(e);
if (
e instanceof HttpError &&
e.code === 'operation_not_permitted_in_read_only_mode'
) {
setReadOnly(true);
}
setSubmitError(e);
Comment on lines +399 to 405
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid duplicate alerts for read-only submit errors.
When the read-only error occurs, readOnly is set but submitError is still stored, which can lead to a second ErrorAlert in the UI. Consider skipping submitError for this case so only the read-only info banner shows.

💡 Suggested fix
-        if (
-          e instanceof HttpError &&
-          e.code === 'operation_not_permitted_in_read_only_mode'
-        ) {
-          setReadOnly(true);
-        }
-        setSubmitError(e);
+        if (
+          e instanceof HttpError &&
+          e.code === 'operation_not_permitted_in_read_only_mode'
+        ) {
+          setReadOnly(true);
+          setSubmitError(undefined);
+        } else {
+          setSubmitError(e);
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (
e instanceof HttpError &&
e.code === 'operation_not_permitted_in_read_only_mode'
) {
setReadOnly(true);
}
setSubmitError(e);
if (
e instanceof HttpError &&
e.code === 'operation_not_permitted_in_read_only_mode'
) {
setReadOnly(true);
setSubmitError(undefined);
} else {
setSubmitError(e);
}
🤖 Prompt for AI Agents
In `@packages/web/src/package/ui/KeyDialog/dialogContext/index.ts` around lines
399 - 405, The handler stores read-only state via setReadOnly(true) but still
calls setSubmitError(e), causing duplicate ErrorAlert and the read-only banner;
change the logic in the error handling (the block that checks e instanceof
HttpError && e.code === 'operation_not_permitted_in_read_only_mode') to
setReadOnly(true) and skip calling setSubmitError for that specific error code
so only the read-only info/banner is shown; retain setSubmitError(e) for all
other errors.

} finally {
setSaving(false);
Expand Down Expand Up @@ -478,7 +492,7 @@ export const [DialogProvider, useDialogActions, useDialogContext] =
updateKey.error ||
galleryError;

const formDisabled = loading || !permissions.canSubmitForm;
const formDisabled = loading || !permissions.canSubmitForm || readOnly;

const contextValue = {
input: props.keyName,
Expand All @@ -492,6 +506,7 @@ export const [DialogProvider, useDialogActions, useDialogContext] =
availableLanguages,
selectedLanguages: putBaseLangFirstTags(selectedLanguages, baseLang?.tag),
formDisabled,
readOnly,
keyData,
translationsForm,
container,
Expand Down
4 changes: 3 additions & 1 deletion packages/web/src/package/ui/client/QueryProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type GlobalOptions = {
apiKey: string;
apiUrl: string;
projectId: string | number | undefined;
branch?: string;
};

const queryClient = new QueryClient({
Expand All @@ -26,9 +27,10 @@ export const QueryProvider = ({
apiUrl,
apiKey,
projectId,
branch,
}: React.PropsWithChildren<Props>) => {
return (
<QueryContext.Provider value={{ apiUrl, apiKey, projectId }}>
<QueryContext.Provider value={{ apiUrl, apiKey, projectId, branch }}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</QueryContext.Provider>
);
Expand Down
Loading
Loading