mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-25 15:17:26 +08:00
feat(i18n): refactor language constants and improve GP pipeline
- Extract SUPPORTED_LANGUAGES into src/frontend/src/constants/languages.ts - Update LanguageForm to import from shared constants, add aria-label - Add settings.languageSelectAriaLabel key to en.json - Update LanguageSelector component to use shared SUPPORTED_LANGUAGES - Fix gp_client and download_translations scripts - Update GP upload/download/nightly GitHub Actions workflows - Add GP script tests
This commit is contained in:
3
.github/workflows/gp-download.yml
vendored
3
.github/workflows/gp-download.yml
vendored
@ -1,9 +1,6 @@
|
||||
name: GP Download Translations
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- feat/globalization-pipeline # temp: remove before merge
|
||||
schedule:
|
||||
- cron: "0 6 * * *" # Daily at 6am UTC
|
||||
workflow_dispatch:
|
||||
|
||||
1
.github/workflows/gp-upload.yml
vendored
1
.github/workflows/gp-upload.yml
vendored
@ -4,7 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- feat/globalization-pipeline # temp: remove before merge
|
||||
paths:
|
||||
- "src/frontend/src/locales/en.json"
|
||||
workflow_dispatch:
|
||||
|
||||
13
.github/workflows/nightly_build.yml
vendored
13
.github/workflows/nightly_build.yml
vendored
@ -210,6 +210,7 @@ jobs:
|
||||
name: Download translations from Globalization Pipeline
|
||||
needs: [validate-inputs, resolve-release-branch, create-nightly-tag]
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
environment: GP-test
|
||||
permissions:
|
||||
contents: write
|
||||
@ -246,9 +247,9 @@ jobs:
|
||||
git push
|
||||
|
||||
frontend-tests-linux:
|
||||
if: always() && github.repository == 'langflow-ai/langflow' && !inputs.skip_frontend_tests && needs.download-gp-bundle.result != 'cancelled'
|
||||
if: always() && github.repository == 'langflow-ai/langflow' && !inputs.skip_frontend_tests
|
||||
name: Run Frontend Tests - Linux
|
||||
needs: [resolve-release-branch, create-nightly-tag, download-gp-bundle]
|
||||
needs: [resolve-release-branch, create-nightly-tag]
|
||||
uses: ./.github/workflows/typescript_test.yml
|
||||
with:
|
||||
tests_folder: "tests"
|
||||
@ -262,9 +263,9 @@ jobs:
|
||||
TAVILY_API_KEY: ${{ secrets.TAVILY_API_KEY }}
|
||||
|
||||
frontend-tests-windows:
|
||||
if: always() && github.repository == 'langflow-ai/langflow' && !inputs.skip_frontend_tests && needs.download-gp-bundle.result != 'cancelled'
|
||||
if: always() && github.repository == 'langflow-ai/langflow' && !inputs.skip_frontend_tests
|
||||
name: Run Frontend Tests - Windows
|
||||
needs: [resolve-release-branch, create-nightly-tag, download-gp-bundle]
|
||||
needs: [resolve-release-branch, create-nightly-tag]
|
||||
# Windows tests are non-blocking - the release-nightly-build job only checks
|
||||
# frontend-tests-linux.result, allowing Windows tests to fail without blocking the build.
|
||||
# This gives us visibility into Windows-specific issues while we stabilize the tests.
|
||||
@ -305,10 +306,10 @@ jobs:
|
||||
# ref: ${{ needs.create-nightly-tag.outputs.tag }}
|
||||
|
||||
release-nightly-build:
|
||||
if: github.repository == 'langflow-ai/langflow' && always() && needs.frontend-tests-linux.result != 'failure' && needs.backend-unit-tests.result != 'failure' && needs.download-gp-bundle.result != 'failure'
|
||||
if: github.repository == 'langflow-ai/langflow' && always() && needs.frontend-tests-linux.result != 'failure' && needs.backend-unit-tests.result != 'failure'
|
||||
name: Run Nightly Langflow Build
|
||||
needs:
|
||||
[validate-inputs, create-nightly-tag, frontend-tests-linux, frontend-tests-windows, backend-unit-tests, download-gp-bundle]
|
||||
[validate-inputs, create-nightly-tag, frontend-tests-linux, frontend-tests-windows, backend-unit-tests]
|
||||
uses: ./.github/workflows/release_nightly.yml
|
||||
with:
|
||||
build_docker_base: true
|
||||
|
||||
@ -6,6 +6,7 @@ Usage:
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from gp_client import TARGET_LANGS, get_strings
|
||||
@ -19,6 +20,7 @@ def main():
|
||||
output_dir = Path(args.output)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
failed = []
|
||||
for lang in TARGET_LANGS:
|
||||
print(f"Downloading '{lang}' translations...")
|
||||
try:
|
||||
@ -44,6 +46,11 @@ def main():
|
||||
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" Error downloading '{lang}': {e}")
|
||||
failed.append(lang)
|
||||
|
||||
if failed:
|
||||
print(f"\nFAILED languages: {failed}")
|
||||
sys.exit(1)
|
||||
|
||||
print("\nDone.")
|
||||
|
||||
|
||||
@ -22,6 +22,7 @@ GP_INSTANCE = os.getenv("GP_INSTANCE", "langflow-test")
|
||||
GP_BUNDLE = os.getenv("GP_BUNDLE", "langflow-ui")
|
||||
TARGET_LANGS = ["fr", "ja", "es", "de", "pt", "zh-Hans"]
|
||||
REQUEST_TIMEOUT = 30
|
||||
VERIFY_SSL = os.getenv("GP_VERIFY_SSL", "true").lower() != "false"
|
||||
|
||||
|
||||
def get_headers(url, method, body=None):
|
||||
@ -57,7 +58,7 @@ def get_headers(url, method, body=None):
|
||||
def list_bundles():
|
||||
"""List all bundles in the GP instance."""
|
||||
url = f"{BASE_URL}/{GP_INSTANCE}/v2/bundles"
|
||||
response = requests.get(url, headers=get_headers(url, "GET"), verify=False, timeout=REQUEST_TIMEOUT) # noqa: S501
|
||||
response = requests.get(url, headers=get_headers(url, "GET"), verify=VERIFY_SSL, timeout=REQUEST_TIMEOUT)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
@ -70,7 +71,7 @@ def create_bundle(source_lang="en"):
|
||||
url,
|
||||
headers=get_headers(url, "PUT", body),
|
||||
json=body,
|
||||
verify=False, # noqa: S501
|
||||
verify=VERIFY_SSL,
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
@ -84,7 +85,7 @@ def upload_strings(strings, lang="en"):
|
||||
url,
|
||||
headers=get_headers(url, "PUT", strings),
|
||||
json=strings,
|
||||
verify=False, # noqa: S501
|
||||
verify=VERIFY_SSL,
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
@ -94,6 +95,6 @@ def upload_strings(strings, lang="en"):
|
||||
def get_strings(lang):
|
||||
"""Download translated strings for a language from GP."""
|
||||
url = f"{BASE_URL}/{GP_INSTANCE}/v2/bundles/{GP_BUNDLE}/{lang}"
|
||||
response = requests.get(url, headers=get_headers(url, "GET"), verify=False, timeout=REQUEST_TIMEOUT) # noqa: S501
|
||||
response = requests.get(url, headers=get_headers(url, "GET"), verify=VERIFY_SSL, timeout=REQUEST_TIMEOUT)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
0
scripts/gp/tests/__init__.py
Normal file
0
scripts/gp/tests/__init__.py
Normal file
100
scripts/gp/tests/test_download_translations.py
Normal file
100
scripts/gp/tests/test_download_translations.py
Normal file
@ -0,0 +1,100 @@
|
||||
"""Tests for download_translations.py."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _run_main(output_dir: str):
|
||||
"""Helper to invoke download_translations.main() with an --output argument."""
|
||||
with patch("sys.argv", ["download_translations.py", "--output", output_dir]):
|
||||
import importlib
|
||||
|
||||
import scripts.gp.download_translations as dl_mod # noqa: PLC0415
|
||||
|
||||
importlib.reload(dl_mod)
|
||||
dl_mod.main()
|
||||
|
||||
|
||||
SAMPLE_RESPONSE = {
|
||||
"resourceStrings": {
|
||||
"hello": {"value": "Bonjour"},
|
||||
"bye": {"value": "Au revoir"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TestDownloadTranslations:
|
||||
def test_writes_json_files_for_each_language(self, tmp_path):
|
||||
with (
|
||||
patch("scripts.gp.download_translations.get_strings", return_value=SAMPLE_RESPONSE),
|
||||
patch("scripts.gp.download_translations.TARGET_LANGS", ["fr", "es"]),
|
||||
):
|
||||
_run_main(str(tmp_path))
|
||||
|
||||
for lang in ["fr", "es"]:
|
||||
out = tmp_path / f"{lang}.json"
|
||||
assert out.exists()
|
||||
data = json.loads(out.read_text(encoding="utf-8"))
|
||||
assert data == {"hello": "Bonjour", "bye": "Au revoir"}
|
||||
|
||||
def test_skips_language_with_empty_strings(self, tmp_path):
|
||||
def _get_strings(lang):
|
||||
if lang == "ja":
|
||||
return {"resourceStrings": {}}
|
||||
return SAMPLE_RESPONSE
|
||||
|
||||
with (
|
||||
patch("scripts.gp.download_translations.get_strings", side_effect=_get_strings),
|
||||
patch("scripts.gp.download_translations.TARGET_LANGS", ["fr", "ja"]),
|
||||
):
|
||||
_run_main(str(tmp_path))
|
||||
|
||||
assert (tmp_path / "fr.json").exists()
|
||||
assert not (tmp_path / "ja.json").exists()
|
||||
|
||||
def test_exits_with_error_on_partial_failure(self, tmp_path):
|
||||
def _get_strings(lang):
|
||||
if lang == "de":
|
||||
raise ConnectionError("network error")
|
||||
return SAMPLE_RESPONSE
|
||||
|
||||
with (
|
||||
patch("scripts.gp.download_translations.get_strings", side_effect=_get_strings),
|
||||
patch("scripts.gp.download_translations.TARGET_LANGS", ["fr", "de"]),
|
||||
):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
_run_main(str(tmp_path))
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
# Successful language should still have been written
|
||||
assert (tmp_path / "fr.json").exists()
|
||||
|
||||
def test_exits_cleanly_when_all_succeed(self, tmp_path):
|
||||
with (
|
||||
patch("scripts.gp.download_translations.get_strings", return_value=SAMPLE_RESPONSE),
|
||||
patch("scripts.gp.download_translations.TARGET_LANGS", ["fr"]),
|
||||
):
|
||||
# Should not raise
|
||||
_run_main(str(tmp_path))
|
||||
|
||||
assert (tmp_path / "fr.json").exists()
|
||||
|
||||
def test_handles_flat_string_values_in_response(self, tmp_path):
|
||||
flat_response = {
|
||||
"resourceStrings": {
|
||||
"hello": "Hola",
|
||||
"bye": "Adiós",
|
||||
}
|
||||
}
|
||||
|
||||
with (
|
||||
patch("scripts.gp.download_translations.get_strings", return_value=flat_response),
|
||||
patch("scripts.gp.download_translations.TARGET_LANGS", ["es"]),
|
||||
):
|
||||
_run_main(str(tmp_path))
|
||||
|
||||
data = json.loads((tmp_path / "es.json").read_text(encoding="utf-8"))
|
||||
assert data == {"hello": "Hola", "bye": "Adiós"}
|
||||
226
scripts/gp/tests/test_gp_client.py
Normal file
226
scripts/gp/tests/test_gp_client.py
Normal file
@ -0,0 +1,226 @@
|
||||
"""Tests for gp_client.py — HMAC auth and API operations."""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _expected_signature(password: str, message: str) -> str:
|
||||
sig = hmac.new(
|
||||
bytes(password, "ISO-8859-1"),
|
||||
msg=bytes(message, "ISO-8859-1"),
|
||||
digestmod=hashlib.sha1,
|
||||
).digest()
|
||||
return base64.b64encode(sig).decode()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_headers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetHeaders:
|
||||
def test_get_request_authorization_format(self):
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{"GP_ADMIN_USER_ID": "user123", "GP_ADMIN_PASSWORD": "secret"},
|
||||
):
|
||||
import importlib
|
||||
import scripts.gp.gp_client as gp_client # noqa: PLC0415
|
||||
importlib.reload(gp_client)
|
||||
|
||||
headers = gp_client.get_headers("https://example.com/api", "GET")
|
||||
|
||||
assert headers["Authorization"].startswith("GP-HMAC user123:")
|
||||
assert "GP-Date" in headers
|
||||
assert headers["accept"] == "application/json"
|
||||
assert "Content-Type" not in headers
|
||||
|
||||
def test_put_request_includes_content_type(self):
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{"GP_ADMIN_USER_ID": "user123", "GP_ADMIN_PASSWORD": "secret"},
|
||||
):
|
||||
import importlib
|
||||
import scripts.gp.gp_client as gp_client # noqa: PLC0415
|
||||
importlib.reload(gp_client)
|
||||
|
||||
headers = gp_client.get_headers("https://example.com/api", "PUT", {"key": "val"})
|
||||
|
||||
assert headers["Content-Type"] == "application/json"
|
||||
|
||||
def test_patch_request_uses_merge_patch_content_type(self):
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{"GP_ADMIN_USER_ID": "user123", "GP_ADMIN_PASSWORD": "secret"},
|
||||
):
|
||||
import importlib
|
||||
import scripts.gp.gp_client as gp_client # noqa: PLC0415
|
||||
importlib.reload(gp_client)
|
||||
|
||||
headers = gp_client.get_headers("https://example.com/api", "PATCH", {})
|
||||
|
||||
assert headers["Content-Type"] == "application/merge-patch+json"
|
||||
|
||||
def test_hmac_signature_is_deterministic_for_same_inputs(self):
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{"GP_ADMIN_USER_ID": "user123", "GP_ADMIN_PASSWORD": "secret"},
|
||||
):
|
||||
import importlib
|
||||
import scripts.gp.gp_client as gp_client # noqa: PLC0415
|
||||
importlib.reload(gp_client)
|
||||
|
||||
with patch("scripts.gp.gp_client.datetime") as mock_dt:
|
||||
mock_dt.now.return_value.strftime.return_value = "Thu, 26 Mar 2026 12:00:00 UTC"
|
||||
|
||||
h1 = gp_client.get_headers("https://example.com", "GET")
|
||||
h2 = gp_client.get_headers("https://example.com", "GET")
|
||||
|
||||
assert h1["Authorization"] == h2["Authorization"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_bundles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestListBundles:
|
||||
def test_returns_parsed_json_on_success(self):
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"bundleIds": ["langflow-ui"]}
|
||||
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{"GP_ADMIN_USER_ID": "u", "GP_ADMIN_PASSWORD": "p"},
|
||||
), patch("scripts.gp.gp_client.requests.get", return_value=mock_response) as mock_get:
|
||||
import importlib
|
||||
import scripts.gp.gp_client as gp_client # noqa: PLC0415
|
||||
importlib.reload(gp_client)
|
||||
|
||||
result = gp_client.list_bundles()
|
||||
|
||||
mock_get.assert_called_once()
|
||||
mock_response.raise_for_status.assert_called_once()
|
||||
assert result == {"bundleIds": ["langflow-ui"]}
|
||||
|
||||
def test_raises_on_http_error(self):
|
||||
import requests as req
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.side_effect = req.exceptions.HTTPError("401 Unauthorized")
|
||||
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{"GP_ADMIN_USER_ID": "u", "GP_ADMIN_PASSWORD": "p"},
|
||||
), patch("scripts.gp.gp_client.requests.get", return_value=mock_response):
|
||||
import importlib
|
||||
import scripts.gp.gp_client as gp_client # noqa: PLC0415
|
||||
importlib.reload(gp_client)
|
||||
|
||||
with pytest.raises(req.exceptions.HTTPError):
|
||||
gp_client.list_bundles()
|
||||
|
||||
def test_raises_on_timeout(self):
|
||||
import requests as req
|
||||
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{"GP_ADMIN_USER_ID": "u", "GP_ADMIN_PASSWORD": "p"},
|
||||
), patch(
|
||||
"scripts.gp.gp_client.requests.get",
|
||||
side_effect=req.exceptions.Timeout,
|
||||
):
|
||||
import importlib
|
||||
import scripts.gp.gp_client as gp_client # noqa: PLC0415
|
||||
importlib.reload(gp_client)
|
||||
|
||||
with pytest.raises(req.exceptions.Timeout):
|
||||
gp_client.list_bundles()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# upload_strings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestUploadStrings:
|
||||
def test_successful_upload(self):
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"status": "ok"}
|
||||
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{"GP_ADMIN_USER_ID": "u", "GP_ADMIN_PASSWORD": "p"},
|
||||
), patch("scripts.gp.gp_client.requests.put", return_value=mock_response) as mock_put:
|
||||
import importlib
|
||||
import scripts.gp.gp_client as gp_client # noqa: PLC0415
|
||||
importlib.reload(gp_client)
|
||||
|
||||
result = gp_client.upload_strings({"hello": "Hello"})
|
||||
|
||||
mock_put.assert_called_once()
|
||||
assert result == {"status": "ok"}
|
||||
|
||||
def test_raises_on_server_error(self):
|
||||
import requests as req
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.side_effect = req.exceptions.HTTPError("500")
|
||||
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{"GP_ADMIN_USER_ID": "u", "GP_ADMIN_PASSWORD": "p"},
|
||||
), patch("scripts.gp.gp_client.requests.put", return_value=mock_response):
|
||||
import importlib
|
||||
import scripts.gp.gp_client as gp_client # noqa: PLC0415
|
||||
importlib.reload(gp_client)
|
||||
|
||||
with pytest.raises(req.exceptions.HTTPError):
|
||||
gp_client.upload_strings({"hello": "Hello"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_strings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetStrings:
|
||||
def test_returns_parsed_json(self):
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"resourceStrings": {"hello": {"value": "Bonjour"}}
|
||||
}
|
||||
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{"GP_ADMIN_USER_ID": "u", "GP_ADMIN_PASSWORD": "p"},
|
||||
), patch("scripts.gp.gp_client.requests.get", return_value=mock_response):
|
||||
import importlib
|
||||
import scripts.gp.gp_client as gp_client # noqa: PLC0415
|
||||
importlib.reload(gp_client)
|
||||
|
||||
result = gp_client.get_strings("fr")
|
||||
|
||||
assert result["resourceStrings"]["hello"]["value"] == "Bonjour"
|
||||
|
||||
def test_raises_on_403(self):
|
||||
import requests as req
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.side_effect = req.exceptions.HTTPError("403 Forbidden")
|
||||
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{"GP_ADMIN_USER_ID": "u", "GP_ADMIN_PASSWORD": "p"},
|
||||
), patch("scripts.gp.gp_client.requests.get", return_value=mock_response):
|
||||
import importlib
|
||||
import scripts.gp.gp_client as gp_client # noqa: PLC0415
|
||||
importlib.reload(gp_client)
|
||||
|
||||
with pytest.raises(req.exceptions.HTTPError):
|
||||
gp_client.get_strings("fr")
|
||||
76
scripts/gp/tests/test_upload_strings.py
Normal file
76
scripts/gp/tests/test_upload_strings.py
Normal file
@ -0,0 +1,76 @@
|
||||
"""Tests for upload_strings.py."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _run_main(source_path: str):
|
||||
"""Helper to invoke upload_strings.main() with a --source argument."""
|
||||
with patch("sys.argv", ["upload_strings.py", "--source", source_path]):
|
||||
import importlib
|
||||
|
||||
import scripts.gp.upload_strings as upload_mod # noqa: PLC0415
|
||||
|
||||
importlib.reload(upload_mod)
|
||||
upload_mod.main()
|
||||
|
||||
|
||||
class TestUploadStrings:
|
||||
def test_uploads_strings_when_bundle_exists(self, tmp_path):
|
||||
source = tmp_path / "en.json"
|
||||
source.write_text(json.dumps({"hello": "Hello", "bye": "Bye"}), encoding="utf-8")
|
||||
|
||||
with (
|
||||
patch("scripts.gp.upload_strings.list_bundles", return_value={"bundleIds": ["langflow-ui"]}),
|
||||
patch("scripts.gp.upload_strings.create_bundle") as mock_create,
|
||||
patch("scripts.gp.upload_strings.upload_strings") as mock_upload,
|
||||
patch("scripts.gp.upload_strings.GP_BUNDLE", "langflow-ui"),
|
||||
):
|
||||
_run_main(str(source))
|
||||
|
||||
mock_create.assert_not_called()
|
||||
mock_upload.assert_called_once_with({"hello": "Hello", "bye": "Bye"})
|
||||
|
||||
def test_creates_bundle_when_missing_then_uploads(self, tmp_path):
|
||||
source = tmp_path / "en.json"
|
||||
source.write_text(json.dumps({"hello": "Hello"}), encoding="utf-8")
|
||||
|
||||
with (
|
||||
patch("scripts.gp.upload_strings.list_bundles", return_value={"bundleIds": []}),
|
||||
patch("scripts.gp.upload_strings.create_bundle") as mock_create,
|
||||
patch("scripts.gp.upload_strings.upload_strings") as mock_upload,
|
||||
patch("scripts.gp.upload_strings.GP_BUNDLE", "langflow-ui"),
|
||||
):
|
||||
_run_main(str(source))
|
||||
|
||||
mock_create.assert_called_once()
|
||||
mock_upload.assert_called_once_with({"hello": "Hello"})
|
||||
|
||||
def test_empty_json_file_uploads_empty_dict(self, tmp_path):
|
||||
source = tmp_path / "en.json"
|
||||
source.write_text("{}", encoding="utf-8")
|
||||
|
||||
with (
|
||||
patch("scripts.gp.upload_strings.list_bundles", return_value={"bundleIds": ["langflow-ui"]}),
|
||||
patch("scripts.gp.upload_strings.create_bundle"),
|
||||
patch("scripts.gp.upload_strings.upload_strings") as mock_upload,
|
||||
patch("scripts.gp.upload_strings.GP_BUNDLE", "langflow-ui"),
|
||||
):
|
||||
_run_main(str(source))
|
||||
|
||||
mock_upload.assert_called_once_with({})
|
||||
|
||||
def test_raises_when_source_file_missing(self, tmp_path):
|
||||
missing = str(tmp_path / "missing.json")
|
||||
|
||||
with (
|
||||
patch("scripts.gp.upload_strings.list_bundles", return_value={"bundleIds": []}),
|
||||
patch("scripts.gp.upload_strings.create_bundle"),
|
||||
patch("scripts.gp.upload_strings.upload_strings"),
|
||||
):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
_run_main(missing)
|
||||
@ -1,19 +1,10 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SUPPORTED_LANGUAGES } from "@/constants/languages";
|
||||
import { useTypesStore } from "@/stores/typesStore";
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: "en", label: "English" },
|
||||
{ code: "fr", label: "Français" },
|
||||
{ code: "es", label: "Español" },
|
||||
{ code: "de", label: "Deutsch" },
|
||||
{ code: "pt", label: "Português" },
|
||||
{ code: "ja", label: "日本語" },
|
||||
{ code: "zh-Hans", label: "中文" },
|
||||
];
|
||||
|
||||
export const LanguageSelector = () => {
|
||||
const { i18n } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const setTypes = useTypesStore((state) => state.setTypes);
|
||||
|
||||
@ -26,11 +17,12 @@ export const LanguageSelector = () => {
|
||||
|
||||
return (
|
||||
<select
|
||||
aria-label={t("settings.languageSelectAriaLabel")}
|
||||
value={i18n.language}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
className="rounded border border-border bg-background px-1 py-0.5 text-sm text-foreground"
|
||||
>
|
||||
{LANGUAGES.map((lang) => (
|
||||
{SUPPORTED_LANGUAGES.map((lang) => (
|
||||
<option key={lang.code} value={lang.code}>
|
||||
{lang.label}
|
||||
</option>
|
||||
|
||||
9
src/frontend/src/constants/languages.ts
Normal file
9
src/frontend/src/constants/languages.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export const SUPPORTED_LANGUAGES = [
|
||||
{ code: "en", label: "English" },
|
||||
{ code: "fr", label: "Français" },
|
||||
{ code: "es", label: "Español" },
|
||||
{ code: "de", label: "Deutsch" },
|
||||
{ code: "pt", label: "Português" },
|
||||
{ code: "ja", label: "日本語" },
|
||||
{ code: "zh-Hans", label: "中文" },
|
||||
] as const;
|
||||
11
src/frontend/src/i18next.d.ts
vendored
Normal file
11
src/frontend/src/i18next.d.ts
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
import "i18next";
|
||||
import type en from "./locales/en.json";
|
||||
|
||||
declare module "i18next" {
|
||||
interface CustomTypeOptions {
|
||||
defaultNS: "translation";
|
||||
resources: {
|
||||
translation: typeof en;
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -176,6 +176,7 @@
|
||||
"settings.languageTitle": "Language",
|
||||
"settings.languageDescription": "Choose the display language for the Langflow interface.",
|
||||
"settings.languageRecommended": "Recommended",
|
||||
"settings.languageSelectAriaLabel": "Select language",
|
||||
"settings.profilePictureTitle": "Profile Picture",
|
||||
"settings.profilePictureDescription": "Choose the image that appears as your profile picture.",
|
||||
"settings.apiKeysTitle": "Langflow API Keys",
|
||||
|
||||
@ -0,0 +1,92 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { SUPPORTED_LANGUAGES } from "@/constants/languages";
|
||||
|
||||
const mockChangeLanguage = jest.fn();
|
||||
const mockInvalidateQueries = jest.fn();
|
||||
const mockSetTypes = jest.fn();
|
||||
|
||||
jest.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { changeLanguage: mockChangeLanguage, language: "en" },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }),
|
||||
}));
|
||||
|
||||
jest.mock("@/stores/typesStore", () => ({
|
||||
useTypesStore: (selector: (s: { setTypes: typeof mockSetTypes }) => unknown) =>
|
||||
selector({ setTypes: mockSetTypes }),
|
||||
}));
|
||||
|
||||
jest.mock("@/components/ui/card", () => ({
|
||||
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
CardHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
CardTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
CardDescription: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
CardContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
import LanguageFormComponent from "../index";
|
||||
|
||||
describe("LanguageFormComponent", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("renders all supported language options", () => {
|
||||
render(<LanguageFormComponent />);
|
||||
const select = screen.getByRole("combobox");
|
||||
const options = select.querySelectorAll("option");
|
||||
expect(options).toHaveLength(SUPPORTED_LANGUAGES.length);
|
||||
SUPPORTED_LANGUAGES.forEach((lang) => {
|
||||
expect(screen.getByRole("option", { name: new RegExp(lang.label) })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls i18n.changeLanguage with the selected language code", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LanguageFormComponent />);
|
||||
await user.selectOptions(screen.getByRole("combobox"), "fr");
|
||||
expect(mockChangeLanguage).toHaveBeenCalledWith("fr");
|
||||
});
|
||||
|
||||
it("saves selected language to localStorage", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LanguageFormComponent />);
|
||||
await user.selectOptions(screen.getByRole("combobox"), "ja");
|
||||
expect(localStorage.getItem("languagePreference")).toBe("ja");
|
||||
});
|
||||
|
||||
it("calls setTypes with empty object on language change", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LanguageFormComponent />);
|
||||
await user.selectOptions(screen.getByRole("combobox"), "de");
|
||||
expect(mockSetTypes).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
it("invalidates useGetTypes query on language change", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LanguageFormComponent />);
|
||||
await user.selectOptions(screen.getByRole("combobox"), "es");
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ["useGetTypes"],
|
||||
});
|
||||
});
|
||||
|
||||
it("shows recommended label for English option", () => {
|
||||
render(<LanguageFormComponent />);
|
||||
const enOption = screen.getByRole("option", { name: /English/i });
|
||||
expect(enOption.textContent).toContain("settings.languageRecommended");
|
||||
});
|
||||
|
||||
it("does not show recommended label for non-English options", () => {
|
||||
render(<LanguageFormComponent />);
|
||||
const frOption = screen.getByRole("option", { name: /Français/i });
|
||||
expect(frOption.textContent).not.toContain("settings.languageRecommended");
|
||||
});
|
||||
});
|
||||
@ -1,5 +1,6 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SUPPORTED_LANGUAGES } from "@/constants/languages";
|
||||
import { useTypesStore } from "@/stores/typesStore";
|
||||
import {
|
||||
Card,
|
||||
@ -9,16 +10,6 @@ import {
|
||||
CardTitle,
|
||||
} from "../../../../../../components/ui/card";
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: "en", label: "English" },
|
||||
{ code: "fr", label: "Français" },
|
||||
{ code: "es", label: "Español" },
|
||||
{ code: "de", label: "Deutsch" },
|
||||
{ code: "pt", label: "Português" },
|
||||
{ code: "ja", label: "日本語" },
|
||||
{ code: "zh-Hans", label: "中文" },
|
||||
];
|
||||
|
||||
const LanguageFormComponent = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
@ -39,11 +30,12 @@ const LanguageFormComponent = () => {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<select
|
||||
aria-label={t("settings.languageSelectAriaLabel")}
|
||||
value={i18n.language}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
className="rounded border border-border bg-background px-2 py-1.5 text-sm text-foreground"
|
||||
>
|
||||
{LANGUAGES.map((lang) => (
|
||||
{SUPPORTED_LANGUAGES.map((lang) => (
|
||||
<option key={lang.code} value={lang.code}>
|
||||
{lang.label}
|
||||
{lang.code === "en"
|
||||
|
||||
Reference in New Issue
Block a user