From 68497263fa6d38efb4a097dd967db99d09a76335 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sun, 12 Jul 2026 19:26:21 +0200 Subject: [PATCH] feat(autoform): Add multiLang props for input & editor --- apps/api/src/locales/@vitnode/core/en.json | 1 + apps/docs/content/docs/ui/auto-form.mdx | 76 ++ apps/docs/content/docs/ui/editor.mdx | 42 + apps/docs/content/docs/ui/input.mdx | 45 ++ apps/docs/src/locales/@vitnode/blog/pl.json | 80 ++ apps/docs/src/locales/@vitnode/core/en.json | 1 + apps/docs/src/locales/@vitnode/core/pl.json | 748 ++++++++++++++++++ apps/docs/src/vitnode.config.ts | 4 + .../src/api/lib/save-language-words.test.ts | 80 ++ .../src/api/lib/save-language-words.ts | 54 ++ .../components/form/fields/editor.test.tsx | 129 ++- .../src/components/form/fields/editor.tsx | 69 +- .../src/components/form/fields/input.test.tsx | 149 ++++ .../src/components/form/fields/input.tsx | 98 ++- .../src/components/form/fields/multi-lang.tsx | 91 +++ .../src/components/languages-provider.tsx | 21 + .../src/lib/helpers/multi-lang.test.ts | 96 +++ .../vitnode/src/lib/helpers/multi-lang.ts | 85 ++ packages/vitnode/src/locales/en.json | 1 + .../src/views/admin/views/core/test.tsx | 5 +- .../vitnode/src/views/layouts/provider.tsx | 9 +- packages/vitnode/src/ws/manager.test.ts | 131 +++ packages/vitnode/src/ws/manager.ts | 31 +- 23 files changed, 2022 insertions(+), 24 deletions(-) create mode 100644 apps/docs/src/locales/@vitnode/blog/pl.json create mode 100644 apps/docs/src/locales/@vitnode/core/pl.json create mode 100644 packages/vitnode/src/api/lib/save-language-words.test.ts create mode 100644 packages/vitnode/src/api/lib/save-language-words.ts create mode 100644 packages/vitnode/src/components/form/fields/input.test.tsx create mode 100644 packages/vitnode/src/components/form/fields/multi-lang.tsx create mode 100644 packages/vitnode/src/components/languages-provider.tsx create mode 100644 packages/vitnode/src/lib/helpers/multi-lang.test.ts create mode 100644 packages/vitnode/src/lib/helpers/multi-lang.ts create mode 100644 packages/vitnode/src/ws/manager.test.ts diff --git a/apps/api/src/locales/@vitnode/core/en.json b/apps/api/src/locales/@vitnode/core/en.json index 0b4e7ff58..ef462dc08 100644 --- a/apps/api/src/locales/@vitnode/core/en.json +++ b/apps/api/src/locales/@vitnode/core/en.json @@ -102,6 +102,7 @@ "go_back": "Go back", "select_option": "Select an option", "select_options": "Select options", + "select_language": "Select language", "go_to_prev_page": "Go to previous page", "go_to_next_page": "Go to next page", "errors": { diff --git a/apps/docs/content/docs/ui/auto-form.mdx b/apps/docs/content/docs/ui/auto-form.mdx index 55dd75627..f29997f5b 100644 --- a/apps/docs/content/docs/ui/auto-form.mdx +++ b/apps/docs/content/docs/ui/auto-form.mdx @@ -296,6 +296,82 @@ const formSchema = z.object({ }); ``` +## Multi-language fields + +Some values - a category name, a page title - need a translation per language. +`AutoFormInput` and `AutoFormEditor` accept a `multiLang` prop that collects the +value in every enabled language. The field renders a language **select** (shown +only when more than one language is enabled) that switches which language you +are editing; it does **not** change the app-wide locale, only the value inside +that field. + +The value is stored as an array matching the +[`core_languages_words`](/docs/dev/working-with-users/roles) table - one +`{ languageCode, value }` entry per language. Declare the field with the +`multiLangValueSchema` helper so the Zod schema matches that array shape (its +`minLength` / `maxLength` apply to each language's value): + +```ts +import { multiLangValueSchema } from "@vitnode/core/lib/helpers/multi-lang"; + +const formSchema = z.object({ + name: multiLangValueSchema({ minLength: 1, maxLength: 255 }).min(1), +}); +``` + +```tsx + , + }, + ]} + onSubmit={values => { + // values.name => [{ languageCode: "en", value: "News" }, ...] + }} +/> +``` + +### Saving to the backend + +The form only produces `{ languageCode, value }[]`. On the backend, persist it +with `saveLanguageWords`, which fills the remaining `core_languages_words` +columns - the `(pluginCode, tableName, variable, itemId)` tuple that identifies +the field - and replaces the existing rows in a single transaction: + +```ts title="plugins/{plugin_name}/src/api/routes/create-category.route.ts" +import { buildRoute } from "@vitnode/core/api/lib/route"; +import { saveLanguageWords } from "@vitnode/core/api/lib/save-language-words"; + +export const createCategoryRoute = buildRoute({ + handler: async c => { + const { name } = c.req.valid("json"); + + const [category] = await c + .get("db") + .insert(blog_categories) + .values({}) + .returning({ id: blog_categories.id }); + + await saveLanguageWords(c, { + pluginCode: "blog", + tableName: "blog_categories", + variable: "name", + itemId: category.id, + values: name, // the { languageCode, value }[] from the form + }); + + return c.json({ id: category.id }, 201); + }, +}); +``` + +To read the translations back for an edit form, query `core_languages_words` by +the same tuple and return `{ languageCode, value }[]` - the shape +[`resolveRoleNames`](/docs/dev/working-with-users/roles) produces for role names. + ## Custom Fields Because Auto Form fields are controlled entirely by the `component` property function, creating a custom field component is just a matter of rendering your own UI using the provided props. The props passed to the `component` function contain the field properties managed by `react-hook-form` along with Zod validation details. diff --git a/apps/docs/content/docs/ui/editor.mdx b/apps/docs/content/docs/ui/editor.mdx index 53bb14d1e..f31d5c403 100644 --- a/apps/docs/content/docs/ui/editor.mdx +++ b/apps/docs/content/docs/ui/editor.mdx @@ -67,6 +67,42 @@ const [content, setContent] = useState("

Hello World! 🌎️

"); +## Multi-language + +Set the `multiLang` prop to edit the content in every enabled language. A +language **select** (shown only when more than one language is enabled) switches +which language you are editing - it does **not** change the app-wide locale, +only the content inside this editor. + +The value becomes an array matching the `core_languages_words` table: + +```ts +[ + { languageCode: "en", value: "

Hello

" }, + { languageCode: "pl", value: "

Cześć

" }, +]; +``` + +Declare the field with the `multiLangValueSchema` helper and enable `multiLang`: + +```ts +import { multiLangValueSchema } from "@vitnode/core/lib/helpers/multi-lang"; + +const formSchema = z.object({ + content: multiLangValueSchema({ minLength: 1 }).min(1), +}); +``` + +```tsx +{ + id: "content", + component: props => , +} +``` + +Persist the array on the backend with `saveLanguageWords`. See +[Auto Form - Multi-language fields](/docs/ui/auto-form#multi-language-fields). + ## Rendering Content Use `EditorContent` to render the stored HTML as read-only content outside of @@ -108,5 +144,11 @@ import { TypeTable } from "fumadocs-ui/components/type-table"; type: "string", default: "", }, + multiLang: { + description: + "Edit the content per language. The value becomes a { languageCode, value }[] array and a language select is shown when more than one language is enabled.", + type: "boolean", + default: "false", + }, }} /> diff --git a/apps/docs/content/docs/ui/input.mdx b/apps/docs/content/docs/ui/input.mdx index ad6bb31e4..86fb2ea3e 100644 --- a/apps/docs/content/docs/ui/input.mdx +++ b/apps/docs/content/docs/ui/input.mdx @@ -68,6 +68,45 @@ import { Input } from '@vitnode/core/components/ui/input'; +## Multi-language + +Set the `multiLang` prop to collect the value in every enabled language. The +field renders a language **select** (shown only when more than one language is +enabled) that switches which language you are editing - it does **not** change +the app-wide locale, only the value inside this input. + +The value becomes an array matching the `core_languages_words` table, one entry +per language you edited: + +```ts +[ + { languageCode: "en", value: "Category" }, + { languageCode: "pl", value: "Kategoria" }, +]; +``` + +Declare the field with the `multiLangValueSchema` helper so the schema matches +the array shape (its `minLength` / `maxLength` apply to each language's value): + +```ts +import { multiLangValueSchema } from "@vitnode/core/lib/helpers/multi-lang"; + +const formSchema = z.object({ + name: multiLangValueSchema({ minLength: 1, maxLength: 255 }).min(1), +}); +``` + +```tsx +{ + id: "name", + component: props => , +} +``` + +On the backend, persist the array with `saveLanguageWords` - it fills the +remaining `core_languages_words` columns. See +[Auto Form - Multi-language fields](/docs/ui/auto-form#multi-language-fields). + ## Props import { TypeTable } from "fumadocs-ui/components/type-table"; @@ -97,5 +136,11 @@ import { TypeTable } from "fumadocs-ui/components/type-table"; type: "string", default: "", }, + multiLang: { + description: + "Collect the value per language. The value becomes a { languageCode, value }[] array and a language select is shown when more than one language is enabled.", + type: "boolean", + default: "false", + }, }} /> diff --git a/apps/docs/src/locales/@vitnode/blog/pl.json b/apps/docs/src/locales/@vitnode/blog/pl.json new file mode 100644 index 000000000..a05c74435 --- /dev/null +++ b/apps/docs/src/locales/@vitnode/blog/pl.json @@ -0,0 +1,80 @@ +{ + "@vitnode/blog": { + "title": "Blog", + "admin": { + "nav": { + "posts": "Wpisy", + "categories": "Kategorie" + }, + "categories": { + "desc": "Zarządzaj kategoriami wpisów na blogu.", + "table": { + "title": "Tytuł", + "updated_at": "Zaktualizowano" + }, + "delete": { + "title": "Usuń kategorię", + "desc": "Czy na pewno chcesz usunąć kategorię ? Tej akcji nie można cofnąć.", + "confirm": "Tak, usuń tę kategorię", + "success": "Kategoria została pomyślnie usunięta." + }, + "create": { + "title": "Utwórz kategorię", + "desc": "Nowa kategoria dla wpisów na blogu.", + "form": { + "title": { + "label": "Tytuł", + "already_exists": "Kategoria o tym tytule już istnieje." + } + }, + "submit": "Utwórz" + }, + "edit": { + "title": "Edytuj kategorię", + "submit": "Zapisz zmiany" + } + }, + "posts": { + "desc": "Twórz wpisy na blogu i zarządzaj nimi.", + "table": { + "title": "Tytuł", + "category": "Kategoria", + "updated_at": "Zaktualizowano" + }, + "create": { + "title": "Utwórz wpis", + "desc": "Napisz nowy artykuł na swój blog.", + "form": { + "title": { + "label": "Tytuł", + "already_exists": "Wpis o tym tytule już istnieje." + }, + "content": "Treść", + "category": "Kategoria" + }, + "submit": "Utwórz wpis" + }, + "edit": { + "title": "Edytuj wpis", + "submit": "Zapisz zmiany" + }, + "delete": { + "title": "Usuń wpis", + "desc": "Czy na pewno chcesz usunąć wpis ? Tej akcji nie można cofnąć.", + "confirm": "Tak, usuń ten wpis", + "success": "Wpis został pomyślnie usunięty." + } + } + } + }, + "@vitnode/blog:posts": "Wpisy", + "@vitnode/blog:posts:can_view": "Wyświetlanie listy wpisów", + "@vitnode/blog:posts:can_create": "Tworzenie wpisów", + "@vitnode/blog:posts:can_edit": "Edytowanie wpisów", + "@vitnode/blog:posts:can_delete": "Usuwanie wpisów", + "@vitnode/blog:categories": "Kategorie", + "@vitnode/blog:categories:can_view": "Wyświetlanie listy kategorii", + "@vitnode/blog:categories:can_create": "Tworzenie kategorii", + "@vitnode/blog:categories:can_edit": "Edytowanie kategorii", + "@vitnode/blog:categories:can_delete": "Usuwanie kategorii" +} diff --git a/apps/docs/src/locales/@vitnode/core/en.json b/apps/docs/src/locales/@vitnode/core/en.json index 0b4e7ff58..ef462dc08 100644 --- a/apps/docs/src/locales/@vitnode/core/en.json +++ b/apps/docs/src/locales/@vitnode/core/en.json @@ -102,6 +102,7 @@ "go_back": "Go back", "select_option": "Select an option", "select_options": "Select options", + "select_language": "Select language", "go_to_prev_page": "Go to previous page", "go_to_next_page": "Go to next page", "errors": { diff --git a/apps/docs/src/locales/@vitnode/core/pl.json b/apps/docs/src/locales/@vitnode/core/pl.json new file mode 100644 index 000000000..bfc5f9ef2 --- /dev/null +++ b/apps/docs/src/locales/@vitnode/core/pl.json @@ -0,0 +1,748 @@ +{ + "@vitnode/core": { + "title": "Rdzeń" + }, + "@vitnode/core:users": "Użytkownicy", + "@vitnode/core:users:can_view": "Wyświetlanie listy użytkowników", + "@vitnode/core:users:can_create": "Tworzenie użytkowników", + "@vitnode/core:users:can_edit": "Edytowanie użytkowników", + "@vitnode/core:users:can_edit_admin": "Edytowanie użytkowników z uprawnieniami administratora", + "@vitnode/core:roles": "Role", + "@vitnode/core:roles:can_manage": "Zarządzanie rolami", + "@vitnode/core:debug": "Panel debugowania", + "@vitnode/core:debug:can_view": "Wyświetlanie panelu debugowania", + "@vitnode/core:debug:can_clear_cache": "Czyszczenie pamięci podręcznej", + "@vitnode/core:system": "System", + "@vitnode/core:system:can_view": "Wyświetlanie integracji", + "@vitnode/core:system:can_send_test_email": "Wysyłanie testowej wiadomości e-mail", + "@vitnode/core:system:can_test_storage": "Testowanie magazynu plików", + "@vitnode/core:files": "Pliki", + "@vitnode/core:files:can_view": "Wyświetlanie przesłanych plików", + "@vitnode/core:files:can_download": "Pobieranie plików", + "@vitnode/core:files:can_delete": "Usuwanie plików", + "@vitnode/core:queue": "Zadania w kolejce", + "@vitnode/core:queue:can_view": "Wyświetlanie zadań w kolejce", + "@vitnode/core:staff_moderators": "Zespół: Moderatorzy", + "@vitnode/core:staff_moderators:can_view": "Wyświetlanie listy moderatorów", + "@vitnode/core:staff_moderators:can_create": "Tworzenie moderatorów", + "@vitnode/core:staff_moderators:can_edit": "Edytowanie uprawnień moderatorów", + "@vitnode/core:staff_moderators:can_delete": "Usuwanie moderatorów", + "@vitnode/core:staff_admins": "Zespół: Administratorzy", + "@vitnode/core:staff_admins:can_view": "Wyświetlanie listy administratorów", + "@vitnode/core:staff_admins:can_create": "Tworzenie administratorów", + "@vitnode/core:staff_admins:can_edit": "Edytowanie uprawnień administratorów", + "@vitnode/core:staff_admins:can_delete": "Usuwanie administratorów", + "core": { + "global": { + "close": "Zamknij", + "confirm": "Potwierdź", + "previous": "Poprzedni", + "next": "Następny", + "current_page": "Bieżąca strona", + "go_to_page": "Przejdź do strony", + "previous_page": "Poprzednia strona", + "next_page": "Następna strona", + "remove": "Usuń", + "editor": { + "undo": "Cofnij", + "redo": "Ponów", + "paragraph": "Akapit", + "heading": "Nagłówek {level}", + "bold": "Pogrubienie", + "italic": "Kursywa", + "underline": "Podkreślenie", + "ordered_list": "Lista numerowana", + "bullet_list": "Lista punktowana", + "text_format_more": { + "label": "Więcej formatów tekstu", + "strike": "Przekreślenie" + }, + "alignments": { + "left": "Wyrównaj do lewej", + "center": "Wyśrodkuj", + "right": "Wyrównaj do prawej", + "justify": "Wyjustuj" + } + }, + "theme_switcher": "Przełącz motyw", + "language_switcher": "Zmień język", + "toggle_sidebar": "Przełącz pasek boczny", + "no_results": { + "title": "Nie znaleziono wyników", + "desc": "Spróbuj zmienić kryteria wyszukiwania lub filtrowania." + }, + "are_you_sure_want_to_leave_form": { + "title": "Czy na pewno chcesz opuścić ten formularz?", + "desc": "Twoje zmiany nie zostaną zapisane.", + "cancel": "Anuluj", + "confirm": "Tak, opuść" + }, + "confirm_action": { + "title": "Czy jesteś pewien?", + "desc": "Tej akcji nie można cofnąć.", + "cancel": "Anuluj", + "confirm": "Tak, potwierdź" + }, + "search_placeholder": "Szukaj...", + "results_not_found": "Nie znaleziono wyników", + "selected_count": "Wybrano: {count}", + "clear_filters": "Wyczyść filtry", + "date": "{date, date}", + "date_medium": "{date, date, medium}", + "date_short": "{date, date, short}", + "register": "Zarejestruj się", + "login": "Zaloguj się", + "save": "Zapisz", + "submit": "Wyślij", + "cancel": "Anuluj", + "optional": "Opcjonalne", + "loading": "Ładowanie...", + "or": "lub", + "back_home": "Powrót do strony głównej", + "go_back": "Wróć", + "select_option": "Wybierz opcję", + "select_options": "Wybierz opcje", + "go_to_prev_page": "Przejdź do poprzedniej strony", + "go_to_next_page": "Przejdź do następnej strony", + "errors": { + "title": "Ups! Coś poszło nie tak.", + "internal_server_error": "Wewnętrzny błąd serwera.", + "field_required": "To pole jest wymagane.", + "field_min_length": "To pole musi mieć co najmniej {min} znaków.", + "captcha_internal_error": "Weryfikacja captcha nie powiodła się. Spróbuj ponownie później.", + "404": { + "title": "Nie znaleziono strony", + "desc": "Ups! Strona, której szukasz, nie istnieje." + }, + "500": { + "title": "Wewnętrzny błąd serwera", + "desc": "Przepraszamy, występują problemy techniczne po stronie serwera." + }, + "400": { + "title": "Nieprawidłowe żądanie", + "desc": "Żądania nie udało się przetworzyć z powodu nieprawidłowych parametrów." + }, + "403": { + "title": "Brak dostępu", + "desc": "Nie masz uprawnień do dostępu do tego zasobu." + }, + "409": { + "title": "Konflikt", + "desc": "Nie można było ukończyć żądania z powodu konfliktu z bieżącym stanem zasobu." + } + }, + "user_bar": { + "my_profile": "Mój profil", + "log_out": "Wyloguj się", + "mod_cp": "Panel moderatora", + "admin_cp": "Panel administracyjny", + "settings": "Ustawienia", + "files": "Moje pliki" + } + }, + "files": { + "title": "Moje pliki", + "desc": "Pliki przesłane na Twoje konto.", + "list": { + "preview": "Podgląd", + "name": "Nazwa", + "folder": "Folder", + "size": "Rozmiar", + "dimensions": "Wymiary", + "metadata": "Metadane", + "createdAt": "Przesłano" + }, + "metadata": { + "title": "Metadane", + "empty": "—" + }, + "actions": { + "download": "Pobierz", + "delete": "Usuń" + }, + "download": { + "error": "Nie udało się pobrać pliku." + }, + "delete": { + "title": "Usuń plik", + "desc": "Czy na pewno chcesz usunąć ten plik? Spowoduje to jego trwałe usunięcie i nie można tego cofnąć.", + "confirm": "Usuń", + "success": "Plik został usunięty." + }, + "noResults": { + "title": "Brak plików", + "description": "Przesłane pliki pojawią się tutaj." + } + }, + "auth": { + "sso": { + "or": "Lub kontynuuj przez", + "access_denied": "Odmówiono dostępu do aplikacji lub żądanie wygasło. Spróbuj ponownie.", + "email_exists": { + "title": "Nie możesz zalogować się przez ", + "desc": "Konto z tym adresem e-mail już istnieje. Zaloguj się inną metodą i połącz swoje konto w ustawieniach profilu.", + "sign_in": "Przejdź do strony logowania" + } + }, + "sign_up": { + "desc": "Witaj! Utwórz konto, aby rozpocząć.", + "already_have_account": "Masz już konto? Zaloguj się.", + "submit": "Zarejestruj się", + "username": { + "label": "Nazwa użytkownika", + "min_length": "Nazwa użytkownika musi mieć co najmniej 3 znaki.", + "max_length": "Nazwa użytkownika może mieć maksymalnie 32 znaki.", + "exists": "Nazwa użytkownika już istnieje.", + "your_user_code": "Twój kod użytkownika: " + }, + "email": { + "label": "Adres e-mail", + "invalid": "Nieprawidłowy adres e-mail.", + "exists": "Adres e-mail już istnieje." + }, + "password": { + "label": "Hasło", + "invalid": "Hasło jest zbyt słabe.", + "requirements": { + "label": "Hasło powinno zawierać:", + "min_length": "Co najmniej 8 znaków", + "uppercase": "Co najmniej jedną wielką literę", + "number": "Co najmniej jedną cyfrę", + "special_char": "Co najmniej jeden znak specjalny" + } + }, + "terms": { + "label": "Akceptuję regulamin", + "required": "Musisz zaakceptować regulamin.", + "desc": "Wyrażasz zgodę na nasze dokumenty prawne i zasady." + }, + "newsletter": { + "label": "Newsletter", + "desc": "Otrzymuj najnowsze wiadomości i aktualizacje." + }, + "email_confirmation": { + "title": "Sprawdź swoją skrzynkę e-mail", + "desc": "Wysłaliśmy link potwierdzający na Twój adres e-mail", + "check_spam": "Jeśli nie widzisz wiadomości w skrzynce odbiorczej, sprawdź folder ze spamem." + } + }, + "sign_in": { + "desc": "Witaj ponownie! Zaloguj się na swoje konto.", + "do_not_have_account": "Nie masz konta? Zarejestruj się.", + "email": { + "label": "Adres e-mail", + "invalid": "Nieprawidłowy adres e-mail." + }, + "password": { + "label": "Hasło", + "required": "Hasło jest wymagane.", + "reset": "Nie pamiętasz hasła?" + }, + "errors": { + "access_denied": { + "title": "Nieprawidłowe dane logowania", + "desc": "Adres e-mail lub hasło były nieprawidłowe. Spróbuj ponownie (upewnij się, że Caps Lock jest wyłączony)." + } + }, + "submit": "Zaloguj się" + }, + "reset_password": { + "title": "Zresetuj hasło", + "desc": "Wprowadź poniżej swój adres e-mail, aby otrzymać link do resetowania hasła.", + "submit": "Wyślij link resetujący", + "confirmation": { + "title": "Sprawdź swoją skrzynkę e-mail", + "desc": "Wysłaliśmy link do resetowania hasła na Twój adres e-mail:", + "check_spam": "Jeśli nie widzisz wiadomości w skrzynce odbiorczej, sprawdź folder ze spamem." + }, + "email": { + "subject": "Zresetuj swoje hasło", + "greeting": "Witaj {name}!", + "intro": "Otrzymaliśmy prośbę o zresetowanie hasła do Twojego konta.", + "button": "Zresetuj hasło", + "instructions": "Kliknij powyższy przycisk, aby zresetować hasło. Ze względów bezpieczeństwa link wygaśnie za 30 minut.", + "no_action": "Jeśli nie prosiłeś o zresetowanie hasła, możesz bezpiecznie zignorować tę wiadomość. Twoje hasło pozostanie bez zmian.", + "security_note": "Ze względów bezpieczeństwa: to żądanie zostało wysłane z adresu IP: {ip}", + "help": "Jeśli masz problem z powyższym przyciskiem, skopiuj i wklej poniższy adres URL do przeglądarki:", + "expire_time": "Ten link wygaśnie {date}" + } + }, + "change_password": { + "title": "Zmień hasło", + "desc": "Wprowadź poniżej swoje nowe hasło.", + "submit": "Zmień hasło", + "success": { + "title": "Hasło zostało pomyślnie zmienione", + "desc": "Możesz teraz zalogować się przy użyciu nowego hasła." + } + }, + "settings": { + "title": "Ustawienia", + "desc": "Zarządzaj swoim profilem, zabezpieczeniami i preferencjami konta.", + "nav": { + "overview": "Przegląd", + "security": "Zabezpieczenia" + } + } + } + }, + "admin": { + "dashboard": { + "dev_mode": "Tryb deweloperski", + "version": "Wersja: {version}" + }, + "global": { + "nav": { + "core": "Rdzeń", + "dashboard": "Pulpit", + "users": { + "title": "Użytkownicy", + "list": "Lista użytkowników", + "roles": "Role" + }, + "staff": { + "title": "Zespół", + "moderators": "Moderatorzy", + "admins": "Administratorzy" + }, + "user_bar": { + "home_page": "Strona główna", + "debug": "Panel debugowania", + "log_out": "Wyloguj się" + }, + "system": { + "title": "System", + "integrations": "Integracje", + "files": "Pliki" + }, + "advanced": { + "title": "Zaawansowane", + "cron": "Zadania cron", + "queue": "Zadania w kolejce" + } + } + }, + "advanced": { + "cron": { + "title": "Zadania cron", + "desc": "Zarządzaj i monitoruj zaplanowane zadania.", + "list": { + "name": "Nazwa", + "pluginId": "ID wtyczki", + "module": "Moduł", + "schedule": "Harmonogram", + "lastRun": { + "title": "Ostatnie uruchomienie", + "never": "Nigdy" + }, + "nextRun": { + "title": "Następne uruchomienie", + "never": "Nigdy" + }, + "actions": { + "runNow": { + "label": "Uruchom zadanie teraz", + "success": "Zadanie cron zostało pomyślnie wykonane." + } + } + } + }, + "queue": { + "title": "Zadania w kolejce", + "desc": "Monitoruj zadania w tle oczekujące na przetworzenie przez proces cron.", + "list": { + "name": "Zadanie", + "queue": "Kolejka", + "status": "Status", + "attempts": "Próby", + "availableAt": "Dostępne od", + "createdAt": "Utworzono", + "lastError": "Ostatni błąd", + "statusFilter": "Status" + }, + "status": { + "pending": "Oczekujące", + "processing": "Przetwarzanie", + "completed": "Ukończone", + "failed": "Nieudane" + } + } + }, + "user": { + "list": { + "desc": "Zarządzaj użytkownikami swojej aplikacji.", + "user": "Użytkownik", + "createdAt": "Utworzono", + "emailNotVerified": "E-mail niezweryfikowany", + "edit": "Edytuj profil", + "roles": "Role", + "email": "Adres e-mail", + "searchPlaceholder": "Szukaj użytkowników po adresie e-mail lub nazwie...", + "noResults": { + "title": "Nie znaleziono użytkowników", + "description": "Spróbuj zmienić kryteria wyszukiwania." + } + }, + "show": { + "title": "Profil użytkownika", + "joined": "Dołączył", + "emailNotVerified": "E-mail niezweryfikowany", + "coverPlaceholder": "Brak obrazu tła", + "editCover": "Edytuj obraz tła", + "editAvatar": "Edytuj awatar", + "editName": "Edytuj nazwę użytkownika", + "editEmail": "Edytuj adres e-mail", + "uploadImage": "Prześlij obraz", + "removeImage": "Usuń obraz", + "goToProfile": "Przejdź do profilu publicznego", + "updateSuccess": "Profil został zaktualizowany.", + "editNameCode": "Edytuj kod nazwy", + "editNameCodeDesc": "Kod nazwy to unikalny identyfikator używany w adresie URL profilu tego użytkownika oraz w oznaczeniach @wzmianka.", + "editNameCodeWarningTitle": "Ta zmiana uszkodzi istniejące linki", + "editNameCodeWarning": "Zmiana kodu nazwy uszkodzi istniejące linki @wzmianek i zmieni adres URL profilu publicznego. Osoby korzystające ze starego linku nie dotrą już do tego profilu.", + "newNameCode": "Nowy kod nazwy", + "confirmNameCode": "Wpisz bieżący kod nazwy (), aby potwierdzić", + "nameCodeInvalid": "Dozwolone są tylko litery, cyfry i myślniki.", + "nameCodeSame": "Nowy kod nazwy musi różnić się od bieżącego.", + "nameCodeConfirmMismatch": "To nie zgadza się z bieżącym kodem nazwy.", + "nameCodeExists": "Ten kod nazwy jest już zajęty.", + "saveNameCode": "Zmień kod nazwy", + "rolesTitle": "Role", + "primaryRole": "Rola główna", + "secondaryRoles": "Role dodatkowe", + "editRoles": "Edytuj role", + "editRolesDesc": "Ustaw rolę główną tego użytkownika oraz dowolne role dodatkowe. Rola główna jest unikalna, natomiast role dodatkowe można łączyć.", + "selectRole": "Wybierz rolę", + "addSecondaryRole": "Dodaj rolę", + "removeRole": "Usuń rolę", + "saveRoles": "Zapisz role" + }, + "verify_email": { + "label": "Zweryfikuj adres e-mail", + "success": "Adres e-mail został zweryfikowany." + }, + "create": { + "title": "Dodaj użytkownika", + "desc": "Utwórz nowe konto dla użytkownika.", + "name": { + "label": "Nazwa użytkownika", + "min_length": "Nazwa użytkownika musi mieć co najmniej 3 znaki.", + "max_length": "Nazwa użytkownika może mieć maksymalnie 32 znaki.", + "exists": "Ta nazwa użytkownika jest już zajęta." + }, + "email": { + "label": "Adres e-mail", + "invalid": "Wprowadź prawidłowy adres e-mail.", + "exists": "Ten adres e-mail jest już zarejestrowany." + }, + "password": { + "label": "Hasło", + "invalid": "Hasło musi mieć co najmniej 8 znaków." + }, + "submit": "Utwórz użytkownika", + "success": "Użytkownik „{name}” został utworzony." + } + }, + "role": { + "list": { + "desc": "Zarządzaj rolami w swojej aplikacji.", + "role": "Rola", + "usersCount": "Liczba użytkowników", + "updatedAt": "Zaktualizowano", + "searchPlaceholder": "Szukaj ról po nazwie...", + "openUsersTooltip": "Wyświetl użytkowników z tą rolą", + "noResults": { + "title": "Nie znaleziono ról", + "description": "Spróbuj zmienić kryteria wyszukiwania." + } + } + }, + "staff": { + "title": "Zespół", + "desc": "Zarządzaj zespołem swojej aplikacji.", + "protected": "Chroniony", + "self": "Nie możesz edytować własnych uprawnień", + "delete": { + "title": "Usunąć członka zespołu?", + "desc": "Spowoduje to odebranie przyznanego dostępu do zespołu. Tej akcji nie można cofnąć.", + "confirm": "Tak, usuń", + "success": "Członek zespołu został usunięty." + }, + "tabs": { + "moderators": "Moderatorzy", + "admins": "Administratorzy" + }, + "table": { + "role": "Rola", + "user": "Użytkownik", + "permissions": "Uprawnienia", + "unrestricted": "Bez ograniczeń", + "restricted": "Ograniczone", + "updatedAt": "Zaktualizowano", + "edit": "Edytuj uprawnienia" + }, + "edit": { + "title": "Edytuj uprawnienia", + "subject": "Dla", + "back": "Wstecz", + "save": "Zapisz zmiany", + "success": "Uprawnienia zostały pomyślnie zaktualizowane.", + "error": "Nie udało się zaktualizować uprawnień.", + "protected": "Ten wpis jest chroniony i jego uprawnień nie można edytować.", + "self": "Nie możesz edytować własnych uprawnień zespołu, w tym wpisu dla swojej roli głównej.", + "no_permissions": "Żadna wtyczka nie zadeklarowała jeszcze uprawnień zespołu.", + "select_all": "Włącz wszystkie", + "clear_all": "Wyłącz wszystkie", + "search_plugins": "Szukaj wtyczek", + "search_empty": "Żadna wtyczka nie pasuje do wyszukiwania.", + "granted": "przyznano {granted}/{total}", + "requires": "Wymaga {permission}", + "mode": { + "label": "Poziom dostępu", + "unrestricted": { + "label": "Bez ograniczeń", + "desc": "Przyznaj wszystkie uprawnienia, w tym te dodane w przyszłości." + }, + "restricted": { + "label": "Ograniczone", + "desc": "Wybierz dokładnie, które uprawnienia mają obowiązywać." + } + } + }, + "create": { + "admins": "Dodaj administratora", + "moderators": "Dodaj moderatora", + "desc": "Przyznaj dostęp do zespołu roli lub konkretnemu użytkownikowi.", + "button": "Dodaj członka", + "back": "Wstecz", + "assign_to": "Przypisz do", + "tabs": { + "role": "Rola", + "role_desc": "Przyznaj dostęp do zespołu wszystkim osobom z daną rolą.", + "user": "Użytkownik", + "user_desc": "Przyznaj dostęp do zespołu jednej osobie." + }, + "select_role": "Wybierz rolę", + "search_user": "Szukaj po nazwie lub adresie e-mail", + "submit": "Dodaj członka", + "success": "Członek zespołu został dodany.", + "error": "Nie udało się dodać członka zespołu.", + "already_exists": "Ta rola lub ten użytkownik jest już członkiem zespołu." + }, + "moderators": { + "title": "Moderatorzy", + "desc": "Zarządzaj moderatorami swojej aplikacji.", + "create": "Dodaj moderatora", + "noResults": { + "title": "Nie znaleziono moderatorów", + "description": "Przypisz rolę lub użytkownika, aby przyznać uprawnienia moderatora." + } + }, + "admins": { + "title": "Administratorzy", + "desc": "Zarządzaj administratorami swojej aplikacji.", + "create": "Dodaj administratora", + "noResults": { + "title": "Nie znaleziono administratorów", + "description": "Przypisz rolę lub użytkownika, aby przyznać uprawnienia administratora." + } + } + }, + "system": { + "integrations": { + "title": "Integracje", + "desc": "Status podstawowych usług obsługujących tę instancję.", + "read_more": "Czytaj więcej", + "status": { + "active": "Aktywna", + "inactive": "Nieaktywna", + "warning": "Nieosiągalna" + }, + "websocket": { + "title": "WebSocket", + "desc": "Aktualizacje na żywo i powiadomienia wysyłane do połączonych klientów w czasie rzeczywistym." + }, + "redis": { + "title": "Redis", + "desc": "Współdzielona pamięć podręczna i magazyn ogranicznika żądań. Przy wyłączeniu przechodzi na pamięć wewnętrzną.", + "down": "Skonfigurowany, ale nieosiągalny — sprawdź połączenie z Redis." + }, + "email": { + "title": "E-mail", + "desc": "Wiadomości transakcyjne, takie jak potwierdzenia rejestracji i resetowanie hasła.", + "test": { + "label": "Testowa wiadomość e-mail", + "title": "Wyślij testową wiadomość e-mail", + "desc": "Wyślij wiadomość e-mail na dowolny adres, aby potwierdzić, że adapter e-mail działa.", + "info": { + "title": "Jak zweryfikować konfigurację", + "desc": "Jeśli wiadomość dotrze do skrzynki odbiorczej, adapter e-mail jest skonfigurowany poprawnie. Jeśli się nie pojawi, sprawdź ustawienia adaptera oraz logi systemowe." + }, + "to": { + "label": "Odbiorca", + "invalid": "Wprowadź prawidłowy adres e-mail." + }, + "subject": { + "label": "Temat", + "default": "Testowa wiadomość e-mail VitNode" + }, + "content": { + "label": "Treść", + "default": "To jest testowa wiadomość e-mail wysłana z panelu administracyjnego VitNode." + }, + "submit": "Wyślij testową wiadomość e-mail", + "success": "Testowa wiadomość e-mail została wysłana. Sprawdź skrzynkę odbiorczą odbiorcy, aby potwierdzić konfigurację." + } + }, + "storage": { + "title": "Magazyn", + "desc": "Przesyłanie plików, takich jak awatary i załączniki, przechowywanych za pomocą adaptera magazynu.", + "test": { + "label": "Testuj magazyn", + "title": "Testuj magazyn plików", + "desc": "Prześlij plik i wykonaj test pełnego obiegu, aby potwierdzić, że adapter magazynu działa.", + "info": { + "title": "Jak zweryfikować konfigurację", + "desc": "Prześlij testowy obraz. Jeśli operacja się powiedzie, adapter magazynu jest skonfigurowany poprawnie. Jeśli się nie powiedzie, sprawdź ustawienia adaptera oraz logi systemowe." + }, + "upload": { + "label": "Prześlij testowy obraz", + "desc": "Kliknij, aby wybrać obraz", + "pending": "Przesyłanie…", + "done": "Przesłano", + "error": "Przesyłanie nie powiodło się — spróbuj ponownie", + "success": "Obraz został przesłany — jest przechowywany w Twoim adapterze." + } + } + }, + "captcha": { + "title": "Captcha", + "desc": "Ochrona przed botami w publicznych formularzach, takich jak rejestracja i resetowanie hasła.", + "type": { + "cloudflare_turnstile": "Cloudflare Turnstile", + "recaptcha_v3": "reCAPTCHA v3" + } + }, + "cron": { + "title": "Zadania cron", + "desc": "Zaplanowane zadania w tle, takie jak czyszczenie wygasłych sesji i tokenów.", + "insecure": "Używany jest domyślny CRON_SECRET — ustaw bezpieczny w środowisku produkcyjnym.", + "not_configured": "Nie skonfigurowano adaptera cron — zadania nie będą uruchamiać się automatycznie.", + "stale": "Żadne zadanie nie zostało uruchomione od ponad 6 godzin — cron może być źle skonfigurowany lub zatrzymany. Sprawdź konfigurację cron.", + "jobs": "{count, plural, =0 {Brak zaplanowanych zadań} one {# zaplanowane zadanie} few {# zaplanowane zadania} many {# zaplanowanych zadań} other {# zaplanowanych zadań}}" + }, + "queue": { + "title": "Zadania w kolejce", + "desc": "Zadania w tle umieszczone w kolejce w bazie danych i przetwarzane przez proces cron.", + "tasks": "{count, plural, =0 {Brak zarejestrowanych zadań} one {# zarejestrowane zadanie} few {# zarejestrowane zadania} many {# zarejestrowanych zadań} other {# zarejestrowanych zadań}}", + "queued": "{pending} oczekujących · {processing} uruchomionych", + "cron_stale": "Offline — proces cron przetwarzający kolejkę nie działa. Napraw zadania cron, aby wznowić przetwarzanie." + } + }, + "files": { + "title": "Pliki", + "desc": "Przeglądaj, pobieraj i usuwaj pliki przesłane do tej instancji.", + "list": { + "preview": "Podgląd", + "name": "Nazwa", + "folder": "Folder", + "size": "Rozmiar", + "dimensions": "Wymiary", + "uploadedBy": "Przesłane przez", + "metadata": "Metadane", + "createdAt": "Przesłano", + "no_preview": "Brak podglądu" + }, + "anonymous": "—", + "metadata": { + "empty": "—", + "title": "Metadane" + }, + "actions": { + "download": "Pobierz", + "delete": "Usuń" + }, + "download": { + "error": "Nie udało się pobrać pliku." + }, + "delete": { + "title": "Usuń plik", + "desc": "Czy na pewno chcesz usunąć ten plik? Spowoduje to jego trwałe usunięcie z magazynu i nie można tego cofnąć.", + "confirm": "Usuń", + "success": "Plik został usunięty." + }, + "noResults": { + "title": "Nie przesłano żadnych plików", + "description": "Pliki przesłane w obrębie witryny pojawią się tutaj." + } + } + }, + "debug": { + "title": "Panel debugowania", + "desc": "Sprawdzaj logi, błędy i inne informacje debugowania.", + "queue": { + "title": "Zadania w kolejce", + "empty": "Brak aktywnych zadań w kolejce.", + "counts": { + "pending": "Oczekujące", + "processing": "Przetwarzanie", + "completed": "Ukończone", + "failed": "Nieudane" + }, + "list": { + "name": "Zadanie", + "queue": "Kolejka", + "status": "Status", + "attempts": "Próby", + "availableAt": "Dostępne od" + } + }, + "actions": { + "clear_cache": { + "label": "Wyczyść pamięć podręczną", + "title": "Czy na pewno chcesz wyczyścić pamięć podręczną?", + "desc": "Ta akcja usunie wszystkie dane z pamięci podręcznej, co może tymczasowo wpłynąć na wydajność.", + "success": "Pamięć podręczna została pomyślnie wyczyszczona.", + "confirm": "Tak, wyczyść pamięć podręczną" + } + }, + "logs": { + "title": "Logi systemowe", + "created_at": "Utworzono", + "plugin": "Wtyczka", + "content": "Treść", + "type": "Typ", + "types": { + "warn": "Ostrzeżenie", + "error": "Błąd", + "debug": "Debug" + }, + "status_code": "Kod statusu", + "more": { + "title": "Więcej szczegółów", + "desc": "ID logu: ", + "log_overview": { + "title": "Przegląd logu", + "log_type": "Typ logu", + "status_code": "Kod statusu", + "log_id": "ID logu", + "created_at": "Utworzono", + "plugin": "Wtyczka", + "user": "Użytkownik" + }, + "request_information": { + "title": "Informacje o żądaniu", + "ip_address": "Adres IP", + "request_method": "Metoda żądania", + "request_url": "Adres URL żądania", + "user_agent": "Agent użytkownika" + }, + "log_content": { + "title": "Treść logu", + "full_log": "Pełna treść logu" + } + } + } + } + } +} diff --git a/apps/docs/src/vitnode.config.ts b/apps/docs/src/vitnode.config.ts index 708f35f2f..31dbee076 100644 --- a/apps/docs/src/vitnode.config.ts +++ b/apps/docs/src/vitnode.config.ts @@ -15,6 +15,10 @@ export const vitNodeConfig = buildConfig({ code: "en", name: "English", }, + { + code: "pl", + name: "Polski", + }, ], defaultLocale: "en", }, diff --git a/packages/vitnode/src/api/lib/save-language-words.test.ts b/packages/vitnode/src/api/lib/save-language-words.test.ts new file mode 100644 index 000000000..9247a8c0b --- /dev/null +++ b/packages/vitnode/src/api/lib/save-language-words.test.ts @@ -0,0 +1,80 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { describe, expect, it, vi } from "vitest"; + +import { core_languages_words } from "@/database/languages"; + +import { saveLanguageWords } from "./save-language-words"; + +const createDbMock = () => { + const insertValues = vi.fn(); + const where = vi.fn(); + const tx = { + delete: vi.fn(() => ({ where })), + insert: vi.fn(() => ({ values: insertValues })), + }; + const db = { + transaction: vi.fn(async (cb: (t: typeof tx) => Promise) => cb(tx)), + }; + + return { db, tx, insertValues, where }; +}; + +const createContext = (db: unknown): Context => + ({ get: (key: string) => (key === "db" ? db : undefined) }) as Context; + +describe("saveLanguageWords", () => { + it("replaces the tuple rows: delete then insert the mapped values", async () => { + const { db, tx, insertValues } = createDbMock(); + + await saveLanguageWords(createContext(db), { + pluginCode: "blog", + tableName: "blog_categories", + variable: "name", + itemId: 7, + values: [ + { languageCode: "en", value: "News" }, + { languageCode: "pl", value: "Aktualności" }, + ], + }); + + expect(db.transaction).toHaveBeenCalledOnce(); + expect(tx.delete).toHaveBeenCalledWith(core_languages_words); + expect(tx.insert).toHaveBeenCalledWith(core_languages_words); + expect(insertValues).toHaveBeenCalledWith([ + { + languageCode: "en", + pluginCode: "blog", + itemId: 7, + value: "News", + tableName: "blog_categories", + variable: "name", + }, + { + languageCode: "pl", + pluginCode: "blog", + itemId: 7, + value: "Aktualności", + tableName: "blog_categories", + variable: "name", + }, + ]); + }); + + it("deletes but does not insert when values is empty", async () => { + const { db, tx, insertValues } = createDbMock(); + + await saveLanguageWords(createContext(db), { + pluginCode: "blog", + tableName: "blog_categories", + variable: "name", + itemId: 7, + values: [], + }); + + expect(tx.delete).toHaveBeenCalledOnce(); + expect(tx.insert).not.toHaveBeenCalled(); + expect(insertValues).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/vitnode/src/api/lib/save-language-words.ts b/packages/vitnode/src/api/lib/save-language-words.ts new file mode 100644 index 000000000..98d6613f1 --- /dev/null +++ b/packages/vitnode/src/api/lib/save-language-words.ts @@ -0,0 +1,54 @@ +import type { Context } from "hono"; + +import { and, eq } from "drizzle-orm"; + +import type { MultiLangValue } from "@/lib/helpers/multi-lang"; + +import { core_languages_words } from "@/database/languages"; + +export interface SaveLanguageWordsArgs { + itemId: number; + pluginCode: string; + tableName: string; + values: MultiLangValue; + variable: string; +} + +// Write mirror of `resolveRoleNames`: persists a `multiLang` field value (the +// `{ languageCode, value }[]` produced by the form) into `core_languages_words`. +// The remaining columns - the `(pluginCode, tableName, variable, itemId)` tuple +// that identifies the field - are supplied here by the backend. There is no +// unique constraint on that tuple, so the write replaces the existing rows +// (delete + insert) inside a single transaction. +export const saveLanguageWords = async ( + c: Context, + { pluginCode, tableName, variable, itemId, values }: SaveLanguageWordsArgs, +): Promise => { + await c.get("db").transaction(async tx => { + await tx + .delete(core_languages_words) + .where( + and( + eq(core_languages_words.pluginCode, pluginCode), + eq(core_languages_words.tableName, tableName), + eq(core_languages_words.variable, variable), + eq(core_languages_words.itemId, itemId), + ), + ); + + if (values.length === 0) { + return; + } + + await tx.insert(core_languages_words).values( + values.map(({ languageCode, value }) => ({ + languageCode, + pluginCode, + itemId, + value, + tableName, + variable, + })), + ); + }); +}; diff --git a/packages/vitnode/src/components/form/fields/editor.test.tsx b/packages/vitnode/src/components/form/fields/editor.test.tsx index dcd0bcb55..252796926 100644 --- a/packages/vitnode/src/components/form/fields/editor.test.tsx +++ b/packages/vitnode/src/components/form/fields/editor.test.tsx @@ -1,7 +1,8 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { type FieldValues, useForm } from "react-hook-form"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { LanguagesProvider } from "@/components/languages-provider"; import { Form, FormField } from "@/components/ui/form"; import { AutoFormEditor } from "./editor"; @@ -111,3 +112,129 @@ describe("AutoFormEditor", () => { expect(editor.getAttribute("aria-invalid")).toBe("false"); }); }); + +const LANGUAGES = [ + { code: "en", name: "English" }, + { code: "pl", name: "Polski" }, +]; + +const MultiLangHarness = ({ + onSubmit = vi.fn(), + defaultValue, + languages = LANGUAGES, +}: { + defaultValue?: unknown; + languages?: { code: string; name: string }[]; + onSubmit?: (values: FieldValues) => void; +}) => { + const form = useForm({ + defaultValues: { content: defaultValue } as FieldValues, + }); + + return ( + +
+ ( + + )} + /> + + +
+ ); +}; + +describe("AutoFormEditor multiLang", () => { + beforeEach(() => { + Element.prototype.scrollIntoView = vi.fn(); + Element.prototype.hasPointerCapture = vi.fn(() => false); + Element.prototype.setPointerCapture = vi.fn(); + Element.prototype.releasePointerCapture = vi.fn(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("renders the language select when more than one language is enabled", () => { + render(); + + expect(screen.getByRole("combobox")).toBeDefined(); + }); + + it("does not render the language select for a single language", () => { + render(); + + expect(screen.queryByRole("combobox")).toBeNull(); + }); + + it("writes editor content as a { languageCode, value }[] array", async () => { + const onSubmit = vi.fn(); + render(); + + fireEvent.change(screen.getByTestId("editor"), { + target: { value: "

Hello

" }, + }); + fireEvent.click(screen.getByRole("button", { name: "submit" })); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + { content: [{ languageCode: "en", value: "

Hello

" }] }, + expect.anything(), + ); + }); + }); + + it("swaps the edited value per language without touching others", async () => { + const onSubmit = vi.fn(); + render( + Hello

" }, + { languageCode: "pl", value: "

Cześć

" }, + ]} + onSubmit={onSubmit} + />, + ); + + expect(screen.getByTestId("editor").value).toBe( + "

Hello

", + ); + + fireEvent.click(screen.getByRole("combobox")); + const option = await screen.findByRole("option", { name: "Polski" }); + fireEvent.pointerDown(option); + fireEvent.click(option); + + await waitFor(() => { + expect(screen.getByTestId("editor").value).toBe( + "

Cześć

", + ); + }); + + fireEvent.change(screen.getByTestId("editor"), { + target: { value: "

Hej

" }, + }); + fireEvent.click(screen.getByRole("button", { name: "submit" })); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + { + content: [ + { languageCode: "en", value: "

Hello

" }, + { languageCode: "pl", value: "

Hej

" }, + ], + }, + expect.anything(), + ); + }); + }); +}); diff --git a/packages/vitnode/src/components/form/fields/editor.tsx b/packages/vitnode/src/components/form/fields/editor.tsx index 2b9570ad2..5c900cb02 100644 --- a/packages/vitnode/src/components/form/fields/editor.tsx +++ b/packages/vitnode/src/components/form/fields/editor.tsx @@ -7,6 +7,58 @@ import type { ItemAutoFormComponentProps } from "../auto-form"; import { AutoFormDesc } from "../common/desc"; import { AutoFormLabel } from "../common/label"; +import { MultiLangSelect, useMultiLangField } from "./multi-lang"; + +type AutoFormEditorProps = ItemAutoFormComponentProps & + Omit, "onChange" | "value"> & { + multiLang?: boolean; + }; + +const MultiLangEditor = ({ + label, + labelRight, + description, + isOptional, + field, + ...props +}: Omit & { + isOptional?: boolean; +}) => { + const { languages, selected, setSelected, currentValue, setValue } = + useMultiLangField(field); + + return ( + <> +
+ {label && ( + + {label} + + )} + {languages.length > 1 && ( + + )} +
+ + + + + + {description && {description}} + + + ); +}; export const AutoFormEditor = ({ label, @@ -16,9 +68,22 @@ export const AutoFormEditor = ({ field, // eslint-disable-next-line @typescript-eslint/no-unused-vars itemParams, + multiLang, ...props -}: ItemAutoFormComponentProps & - Omit, "onChange" | "value">) => { +}: AutoFormEditorProps) => { + if (multiLang) { + return ( + + ); + } + return ( <> {label && ( diff --git a/packages/vitnode/src/components/form/fields/input.test.tsx b/packages/vitnode/src/components/form/fields/input.test.tsx new file mode 100644 index 000000000..3e8607813 --- /dev/null +++ b/packages/vitnode/src/components/form/fields/input.test.tsx @@ -0,0 +1,149 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { type FieldValues, useForm } from "react-hook-form"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { InputParams } from "@/lib/helpers/auto-form"; + +import { LanguagesProvider } from "@/components/languages-provider"; +import { Form, FormField } from "@/components/ui/form"; + +import { AutoFormInput } from "./input"; + +vi.mock("next-intl", () => ({ + useLocale: () => "en", + useTranslations: () => (key: string) => key, +})); + +const LANGUAGES = [ + { code: "en", name: "English" }, + { code: "pl", name: "Polski" }, +]; + +const Harness = ({ + onSubmit = vi.fn(), + defaultValue, + languages = LANGUAGES, + itemParams, +}: { + defaultValue?: unknown; + itemParams?: InputParams; + languages?: { code: string; name: string }[]; + onSubmit?: (values: FieldValues) => void; +}) => { + const form = useForm({ + defaultValues: { name: defaultValue } as FieldValues, + }); + + return ( + +
+ ( + + )} + /> + + +
+ ); +}; + +describe("AutoFormInput multiLang", () => { + beforeEach(() => { + Element.prototype.scrollIntoView = vi.fn(); + Element.prototype.hasPointerCapture = vi.fn(() => false); + Element.prototype.setPointerCapture = vi.fn(); + Element.prototype.releasePointerCapture = vi.fn(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("renders the language select when more than one language is enabled", () => { + render(); + + expect(screen.getByText("Name")).toBeDefined(); + expect(screen.getByRole("combobox")).toBeDefined(); + }); + + it("does not render the language select for a single language", () => { + render(); + + expect(screen.queryByRole("combobox")).toBeNull(); + }); + + it("writes the typed value as a { languageCode, value }[] array", async () => { + const onSubmit = vi.fn(); + render(); + + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "Hello" }, + }); + fireEvent.click(screen.getByRole("button", { name: "submit" })); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + { name: [{ languageCode: "en", value: "Hello" }] }, + expect.anything(), + ); + }); + }); + + it("shows the selected language's value and edits only that language", async () => { + const onSubmit = vi.fn(); + render( + , + ); + + const input = screen.getByRole("textbox"); + expect(input.value).toBe("Hello"); + + // Switch the per-input language to Polski. + fireEvent.click(screen.getByRole("combobox")); + const option = await screen.findByRole("option", { name: "Polski" }); + fireEvent.pointerDown(option); + fireEvent.click(option); + + await waitFor(() => { + expect(screen.getByRole("textbox").value).toBe("Cześć"); + }); + + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "Hej" }, + }); + fireEvent.click(screen.getByRole("button", { name: "submit" })); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + { + name: [ + { languageCode: "en", value: "Hello" }, + { languageCode: "pl", value: "Hej" }, + ], + }, + expect.anything(), + ); + }); + }); + + it("applies the value maxLength from itemParams to the input", () => { + render(); + + expect(screen.getByRole("textbox").getAttribute("maxLength")).toBe("10"); + }); +}); diff --git a/packages/vitnode/src/components/form/fields/input.tsx b/packages/vitnode/src/components/form/fields/input.tsx index 6f9c00d52..2cf445840 100644 --- a/packages/vitnode/src/components/form/fields/input.tsx +++ b/packages/vitnode/src/components/form/fields/input.tsx @@ -1,4 +1,9 @@ -import { InputGroup, InputGroupInput } from "@/components/ui/input-group"; +import { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from "@/components/ui/input-group"; +import { getMultiLangConstraints } from "@/lib/helpers/multi-lang"; import type { ItemAutoFormComponentProps } from "../auto-form"; @@ -6,19 +11,102 @@ import { FormControl, FormMessage } from "../../ui/form"; import { Input } from "../../ui/input"; import { AutoFormDesc } from "../common/desc"; import { AutoFormLabel } from "../common/label"; +import { MultiLangSelect, useMultiLangField } from "./multi-lang"; + +type AutoFormInputProps = ItemAutoFormComponentProps & + Omit, "value"> & { + multiLang?: boolean; + }; + +const MultiLangInput = ({ + label, + labelRight, + description, + isOptional, + field, + itemParams, + pattern, + type, + ...props +}: Omit & { + isOptional?: boolean; +}) => { + const { languages, selected, setSelected, currentValue, setValue } = + useMultiLangField(field); + const { maxLength, minLength } = getMultiLangConstraints(itemParams); + + return ( + <> + {label && ( + + {label} + + )} + + + + { + field.onBlur(); + props.onBlur?.(e); + }} + onChange={e => { + setValue(e.target.value); + props.onChange?.(e); + }} + pattern={pattern} + type={type ?? "text"} + value={currentValue} + /> + {languages.length > 1 && ( + + + + )} + + + + {description && {description}} + + + ); +}; export const AutoFormInput = ({ label, labelRight, description, - otherProps: { isOptional, maxLength, minLength, pattern, type }, + otherProps, field, - // eslint-disable-next-line @typescript-eslint/no-unused-vars itemParams, children, + multiLang, ...props -}: ItemAutoFormComponentProps & - Omit, "value">) => { +}: AutoFormInputProps) => { + if (multiLang) { + return ( + + ); + } + + const { isOptional, maxLength, minLength, pattern, type } = otherProps; + return ( <> {label && ( diff --git a/packages/vitnode/src/components/form/fields/multi-lang.tsx b/packages/vitnode/src/components/form/fields/multi-lang.tsx new file mode 100644 index 000000000..4ea0ee4d3 --- /dev/null +++ b/packages/vitnode/src/components/form/fields/multi-lang.tsx @@ -0,0 +1,91 @@ +"use client"; + +import type { ControllerRenderProps, FieldValues } from "react-hook-form"; + +import { LanguagesIcon } from "lucide-react"; +import { useLocale, useTranslations } from "next-intl"; +import React from "react"; + +import type { MultiLangValue } from "@/lib/helpers/multi-lang"; +import type { LocaleConfig } from "@/vitnode.config"; + +import { useLanguages } from "@/components/languages-provider"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { getLangValue, upsertLangValue } from "@/lib/helpers/multi-lang"; + +export { multiLangValueSchema } from "@/lib/helpers/multi-lang"; +export type { + MultiLangValue, + MultiLangValueItem, +} from "@/lib/helpers/multi-lang"; + +export const useMultiLangField = ( + field: ControllerRenderProps, +) => { + const languages = useLanguages(); + const locale = useLocale(); + const [selected, setSelected] = React.useState( + () => + languages.find(language => language.code === locale)?.code ?? + languages[0]?.code ?? + locale, + ); + + const value = field.value as MultiLangValue | undefined; + + const setValue = (newValue: string) => { + field.onChange(upsertLangValue(value, selected, newValue)); + }; + + return { + languages, + selected, + setSelected, + currentValue: getLangValue(value, selected), + setValue, + }; +}; + +export const MultiLangSelect = ({ + languages, + onSelect, + selected, +}: { + languages: LocaleConfig[]; + onSelect: (code: string) => void; + selected: string; +}) => { + const t = useTranslations("core.global"); + + return ( + + ); +}; diff --git a/packages/vitnode/src/components/languages-provider.tsx b/packages/vitnode/src/components/languages-provider.tsx new file mode 100644 index 000000000..07113abb3 --- /dev/null +++ b/packages/vitnode/src/components/languages-provider.tsx @@ -0,0 +1,21 @@ +"use client"; + +import React from "react"; + +import type { LocaleConfig } from "@/vitnode.config"; + +const LanguagesContext = React.createContext([]); + +export const LanguagesProvider = ({ + children, + languages, +}: { + children: React.ReactNode; + languages: LocaleConfig[]; +}) => ( + + {children} + +); + +export const useLanguages = (): LocaleConfig[] => React.use(LanguagesContext); diff --git a/packages/vitnode/src/lib/helpers/multi-lang.test.ts b/packages/vitnode/src/lib/helpers/multi-lang.test.ts new file mode 100644 index 000000000..aa80ad183 --- /dev/null +++ b/packages/vitnode/src/lib/helpers/multi-lang.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; + +import { + getLangValue, + getMultiLangConstraints, + multiLangValueSchema, + upsertLangValue, +} from "./multi-lang"; + +describe("upsertLangValue", () => { + it("inserts a new language entry when it does not exist", () => { + expect(upsertLangValue(undefined, "en", "Hello")).toEqual([ + { languageCode: "en", value: "Hello" }, + ]); + expect( + upsertLangValue([{ languageCode: "en", value: "Hello" }], "pl", "Cześć"), + ).toEqual([ + { languageCode: "en", value: "Hello" }, + { languageCode: "pl", value: "Cześć" }, + ]); + }); + + it("updates the existing entry for the language", () => { + expect( + upsertLangValue( + [ + { languageCode: "en", value: "Hello" }, + { languageCode: "pl", value: "Cześć" }, + ], + "en", + "Hi", + ), + ).toEqual([ + { languageCode: "en", value: "Hi" }, + { languageCode: "pl", value: "Cześć" }, + ]); + }); + + it("does not mutate the input array", () => { + const value = [{ languageCode: "en", value: "Hello" }]; + upsertLangValue(value, "en", "Hi"); + upsertLangValue(value, "pl", "Cześć"); + + expect(value).toEqual([{ languageCode: "en", value: "Hello" }]); + }); +}); + +describe("getLangValue", () => { + it("returns the value for the language, empty string otherwise", () => { + const value = [{ languageCode: "en", value: "Hello" }]; + + expect(getLangValue(value, "en")).toBe("Hello"); + expect(getLangValue(value, "pl")).toBe(""); + expect(getLangValue(undefined, "en")).toBe(""); + }); +}); + +describe("multiLangValueSchema", () => { + it("accepts an array of { languageCode, value }", () => { + const result = multiLangValueSchema().safeParse([ + { languageCode: "en", value: "Hello" }, + ]); + + expect(result.success).toBe(true); + }); + + it("enforces min/max length on the value", () => { + const schema = multiLangValueSchema({ minLength: 2, maxLength: 5 }); + + expect(schema.safeParse([{ languageCode: "en", value: "a" }]).success).toBe( + false, + ); + expect( + schema.safeParse([{ languageCode: "en", value: "toolong" }]).success, + ).toBe(false); + expect( + schema.safeParse([{ languageCode: "en", value: "ok" }]).success, + ).toBe(true); + }); +}); + +describe("getMultiLangConstraints", () => { + it("reads value min/max length from itemParams", () => { + expect( + getMultiLangConstraints({ value: { maxLength: 255, minLength: 1 } }), + ).toEqual({ maxLength: 255, minLength: 1 }); + }); + + it("returns an empty object when there are no constraints", () => { + expect(getMultiLangConstraints(undefined)).toEqual({}); + expect(getMultiLangConstraints({ value: {} })).toEqual({ + maxLength: undefined, + minLength: undefined, + }); + }); +}); diff --git a/packages/vitnode/src/lib/helpers/multi-lang.ts b/packages/vitnode/src/lib/helpers/multi-lang.ts new file mode 100644 index 000000000..719321157 --- /dev/null +++ b/packages/vitnode/src/lib/helpers/multi-lang.ts @@ -0,0 +1,85 @@ +import { z } from "zod"; + +import type { InputParams } from "./auto-form"; + +import { getNestedParam } from "./auto-form"; + +export interface MultiLangValueItem { + languageCode: string; + value: string; +} + +export type MultiLangValue = MultiLangValueItem[]; + +export const multiLangValueSchema = ({ + maxLength, + minLength, +}: { + maxLength?: number; + minLength?: number; +} = {}) => { + let value = z.string(); + if (minLength !== undefined) { + value = value.min(minLength); + } + if (maxLength !== undefined) { + value = value.max(maxLength); + } + + return z.array( + z.object({ + languageCode: z.string(), + value, + }), + ); +}; + +export const getLangValue = ( + value: MultiLangValue | undefined, + languageCode: string, +): string => + (Array.isArray(value) ? value : []).find( + item => item.languageCode === languageCode, + )?.value ?? ""; + +export const upsertLangValue = ( + value: MultiLangValue | undefined, + languageCode: string, + newValue: string, +): MultiLangValue => { + const current = Array.isArray(value) ? value : []; + + if (current.some(item => item.languageCode === languageCode)) { + return current.map(item => + item.languageCode === languageCode ? { ...item, value: newValue } : item, + ); + } + + return [...current, { languageCode, value: newValue }]; +}; + +// The `value` constraints (min/max length) of a `multiLang` field live on the +// array item, so they arrive as `itemParams.value` rather than top-level +// `otherProps`. Pull them back out for the per-language input. +export const getMultiLangConstraints = ( + itemParams?: InputParams, +): { maxLength?: number; minLength?: number } => { + const valueParams = itemParams + ? getNestedParam(itemParams, "value") + : undefined; + + if (!valueParams || typeof valueParams !== "object") { + return {}; + } + + return { + maxLength: + "maxLength" in valueParams && typeof valueParams.maxLength === "number" + ? valueParams.maxLength + : undefined, + minLength: + "minLength" in valueParams && typeof valueParams.minLength === "number" + ? valueParams.minLength + : undefined, + }; +}; diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 0b4e7ff58..ef462dc08 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -102,6 +102,7 @@ "go_back": "Go back", "select_option": "Select an option", "select_options": "Select options", + "select_language": "Select language", "go_to_prev_page": "Go to previous page", "go_to_next_page": "Go to next page", "errors": { diff --git a/packages/vitnode/src/views/admin/views/core/test.tsx b/packages/vitnode/src/views/admin/views/core/test.tsx index aa8d610cb..75ebda4f9 100644 --- a/packages/vitnode/src/views/admin/views/core/test.tsx +++ b/packages/vitnode/src/views/admin/views/core/test.tsx @@ -82,7 +82,9 @@ export const TestView = () => { fields={[ { id: "editor", - component: props => , + component: props => ( + + ), }, { id: "provider", @@ -90,6 +92,7 @@ export const TestView = () => { ), diff --git a/packages/vitnode/src/views/layouts/provider.tsx b/packages/vitnode/src/views/layouts/provider.tsx index 18d78d097..4d5a4ff37 100644 --- a/packages/vitnode/src/views/layouts/provider.tsx +++ b/packages/vitnode/src/views/layouts/provider.tsx @@ -9,6 +9,7 @@ import type { VitNodeConfig } from "@/vitnode.config"; import { CONFIG } from "@/lib/config"; +import { LanguagesProvider } from "../../components/languages-provider"; import { ThemeProvider } from "../../components/theme-provider"; import { Toaster } from "../../components/ui/sonner"; import { TooltipProvider } from "../../components/ui/tooltip"; @@ -16,7 +17,7 @@ import { TooltipProvider } from "../../components/ui/tooltip"; export const RootProvider = ({ children, toaster, - config: { debug, theme, progressBar }, + config: { debug, theme, progressBar, i18n }, }: { children: React.ReactNode; config: VitNodeConfig; @@ -66,7 +67,11 @@ export const RootProvider = ({ }} shallowRouting={progressBar?.shallowRouting ?? true} > - {children} + + + {children} + + diff --git a/packages/vitnode/src/ws/manager.test.ts b/packages/vitnode/src/ws/manager.test.ts new file mode 100644 index 000000000..c7c62192f --- /dev/null +++ b/packages/vitnode/src/ws/manager.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createWebSocketManager } from "./manager"; + +class FakeWebSocket { + constructor(public url: string) { + wsInstances.push(this); + } + static readonly CLOSED = 3; + static readonly CLOSING = 2; + static readonly CONNECTING = 0; + + static readonly OPEN = 1; + onclose: (() => void) | null = null; + onerror: (() => void) | null = null; + onmessage: ((event: { data: unknown }) => void) | null = null; + onopen: (() => void) | null = null; + readyState = FakeWebSocket.CONNECTING; + + sent: string[] = []; + + // Real browsers deliver the close event asynchronously, so `close()` + // deliberately does not invoke `onclose` here. + close() { + this.readyState = FakeWebSocket.CLOSED; + } + + // Simulates the browser delivering the deferred close event. + fireClose() { + this.readyState = FakeWebSocket.CLOSED; + this.onclose?.(); + } + + fireOpen() { + this.readyState = FakeWebSocket.OPEN; + this.onopen?.(); + } + + send(data: string) { + this.sent.push(data); + } +} + +class FakeBroadcastChannel { + constructor(public name: string) { + bcInstances.push(this); + } + closed = false; + onmessage: ((event: { data: unknown }) => void) | null = null; + + posted: unknown[] = []; + + close() { + this.closed = true; + } + + postMessage(message: unknown) { + if (this.closed) { + const error = new Error( + "Failed to execute 'postMessage' on 'BroadcastChannel': Channel is closed", + ); + error.name = "InvalidStateError"; + throw error; + } + this.posted.push(message); + } +} + +let wsInstances: FakeWebSocket[] = []; +let bcInstances: FakeBroadcastChannel[] = []; + +beforeEach(() => { + wsInstances = []; + bcInstances = []; + vi.stubGlobal("WebSocket", FakeWebSocket); + vi.stubGlobal("BroadcastChannel", FakeBroadcastChannel); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const createManager = () => + createWebSocketManager({ + url: "ws://localhost/api/ws", + onMessage: vi.fn(), + onReadyStateChange: vi.fn(), + }); + +describe("createWebSocketManager", () => { + it("detaches socket handlers on destroy", () => { + const manager = createManager(); + const ws = wsInstances.at(-1); + expect(ws).toBeDefined(); + ws?.fireOpen(); + + manager.destroy(); + + expect(ws?.onclose).toBeNull(); + expect(ws?.onopen).toBeNull(); + expect(ws?.onmessage).toBeNull(); + expect(ws?.onerror).toBeNull(); + }); + + it("does not post on the channel after a deferred close event (InvalidStateError regression)", () => { + const manager = createManager(); + const ws = wsInstances.at(-1); + const channel = bcInstances.at(-1); + ws?.fireOpen(); + + manager.destroy(); + expect(channel?.closed).toBe(true); + + // The browser delivers the socket's close event after teardown; it must + // not attempt to post on the already-closed BroadcastChannel. + expect(() => ws?.fireClose()).not.toThrow(); + }); + + it("guards cross-tab posts once destroyed", () => { + const manager = createManager(); + const channel = bcInstances.at(-1); + + manager.destroy(); + + // A queued cross-tab message arriving during teardown must not throw on + // the closed channel. + expect(() => + channel?.onmessage?.({ data: { type: "request-state" } }), + ).not.toThrow(); + }); +}); diff --git a/packages/vitnode/src/ws/manager.ts b/packages/vitnode/src/ws/manager.ts index 128cf742c..11ea9f16b 100644 --- a/packages/vitnode/src/ws/manager.ts +++ b/packages/vitnode/src/ws/manager.ts @@ -69,7 +69,10 @@ export const createWebSocketManager = ({ ? null : new BroadcastChannel(CHANNEL_NAME); - const post = (message: CrossTabMessage) => channel?.postMessage(message); + const post = (message: CrossTabMessage) => { + if (isDestroyed) return; + channel?.postMessage(message); + }; const setReadyState = (state: number) => { readyState = state; @@ -85,6 +88,18 @@ export const createWebSocketManager = ({ } }; + // Detach handlers first so the socket's asynchronous close event can't fire + // spurious reconnects or cross-tab posts after teardown. + const closeSocket = () => { + if (!socket) return; + socket.onclose = null; + socket.onerror = null; + socket.onmessage = null; + socket.onopen = null; + socket.close(); + socket = null; + }; + // --- Leader: owns the real socket --------------------------------------- const openSocket = () => { const ws = new WebSocket(url); @@ -134,16 +149,7 @@ export const createWebSocketManager = ({ const reconnectLeader = () => { if (!isLeader || isDestroyed) return; if (reconnectTimeout) clearTimeout(reconnectTimeout); - - if (socket) { - socket.onclose = null; - socket.onerror = null; - socket.onmessage = null; - socket.onopen = null; - socket.close(); - socket = null; - } - + closeSocket(); openSocket(); }; @@ -221,8 +227,7 @@ export const createWebSocketManager = ({ const destroy = () => { isDestroyed = true; if (reconnectTimeout) clearTimeout(reconnectTimeout); - socket?.close(); - socket = null; + closeSocket(); releaseLeadership?.(); channel?.close(); };