mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
This commit is contained in:
@@ -101,17 +101,15 @@ describe('<TeamPanel />', () => {
|
||||
await waitFor(() => expect(refreshTeams).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('disables the Create button until a name is typed and calls createTeam on submit', async () => {
|
||||
it('does not render a create-team control and shows the personal-team note', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderPanel(Panel);
|
||||
|
||||
const createBtn = screen.getByRole('button', { name: 'Create' });
|
||||
expect(createBtn).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Team name'), { target: { value: 'New Team' } });
|
||||
expect(createBtn).not.toBeDisabled();
|
||||
fireEvent.click(createBtn);
|
||||
await waitFor(() => expect(teamApiMock.createTeam).toHaveBeenCalledWith('New Team'));
|
||||
// Create was removed (issue #3723): backend caps one owned team per user.
|
||||
expect(screen.queryByRole('button', { name: 'Create' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText('Team name')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/personal team is created automatically/i)).toBeInTheDocument();
|
||||
expect(teamApiMock.createTeam).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('joins a team when the user submits a join code', async () => {
|
||||
@@ -161,13 +159,13 @@ describe('<TeamPanel />', () => {
|
||||
expect(navigateToTeamManagement).toHaveBeenCalledWith('team-a');
|
||||
});
|
||||
|
||||
it('surfaces the localized error when createTeam rejects', async () => {
|
||||
teamApiMock.createTeam.mockRejectedValueOnce(new Error('boom'));
|
||||
it('falls back to the localized error when joinTeam rejects without a reason', async () => {
|
||||
teamApiMock.joinTeam.mockRejectedValueOnce(new Error('boom'));
|
||||
const Panel = await importPanel();
|
||||
renderPanel(Panel);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Team name'), { target: { value: 'New Team' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
|
||||
await waitFor(() => expect(screen.getByText('Failed to create team')).toBeInTheDocument());
|
||||
fireEvent.change(screen.getByPlaceholderText('Invite code'), { target: { value: 'X' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Join' }));
|
||||
await waitFor(() => expect(screen.getByText('boom')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import Button from '../../ui/Button';
|
||||
import { SettingsBadge, SettingsSection, SettingsTextField } from '../controls';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
import SettingsPanel from '../layout/SettingsPanel';
|
||||
import { teamErrorMessage } from './teamErrorMessage';
|
||||
|
||||
const log = debug('core-rpc:error');
|
||||
|
||||
@@ -21,10 +22,8 @@ const TeamPanel = () => {
|
||||
const { snapshot, teams, refresh, refreshTeams } = useCoreState();
|
||||
const user = snapshot.currentUser;
|
||||
|
||||
const [newTeamName, setNewTeamName] = useState('');
|
||||
const [joinCode, setJoinCode] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isJoining, setIsJoining] = useState(false);
|
||||
const [isSwitching, setIsSwitching] = useState<string | null>(null);
|
||||
const [isLeaving, setIsLeaving] = useState<string | null>(null);
|
||||
@@ -60,26 +59,6 @@ const TeamPanel = () => {
|
||||
});
|
||||
}, [refreshTeamsWithLoading]);
|
||||
|
||||
const handleCreateTeam = async () => {
|
||||
const name = newTeamName.trim();
|
||||
if (!name) return;
|
||||
setIsCreating(true);
|
||||
setError(null);
|
||||
try {
|
||||
await teamApi.createTeam(name);
|
||||
setNewTeamName('');
|
||||
await refreshTeamsWithLoading();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err && typeof err === 'object' && 'error' in err
|
||||
? String(err.error)
|
||||
: t('team.failedToCreate')
|
||||
);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJoinTeam = async () => {
|
||||
const code = joinCode.trim();
|
||||
if (!code) return;
|
||||
@@ -90,11 +69,7 @@ const TeamPanel = () => {
|
||||
setJoinCode('');
|
||||
await Promise.all([refresh(), refreshTeamsWithLoading()]);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err && typeof err === 'object' && 'error' in err
|
||||
? String(err.error)
|
||||
: t('team.invalidInviteCode')
|
||||
);
|
||||
setError(teamErrorMessage(err, t('team.invalidInviteCode')));
|
||||
} finally {
|
||||
setIsJoining(false);
|
||||
}
|
||||
@@ -108,11 +83,7 @@ const TeamPanel = () => {
|
||||
await teamApi.switchTeam(teamId);
|
||||
await Promise.all([refresh(), refreshTeamsWithLoading()]);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err && typeof err === 'object' && 'error' in err
|
||||
? String(err.error)
|
||||
: t('team.failedToSwitch')
|
||||
);
|
||||
setError(teamErrorMessage(err, t('team.failedToSwitch')));
|
||||
} finally {
|
||||
setIsSwitching(null);
|
||||
}
|
||||
@@ -133,11 +104,7 @@ const TeamPanel = () => {
|
||||
await Promise.all([refresh(), refreshTeamsWithLoading()]);
|
||||
setTeamToLeave(null);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err && typeof err === 'object' && 'error' in err
|
||||
? String(err.error)
|
||||
: t('team.failedToLeave')
|
||||
);
|
||||
setError(teamErrorMessage(err, t('team.failedToLeave')));
|
||||
} finally {
|
||||
setIsLeaving(null);
|
||||
}
|
||||
@@ -260,29 +227,8 @@ const TeamPanel = () => {
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
<SettingsSection title={t('team.createNewTeam')}>
|
||||
<div className="flex gap-2 px-4 py-3">
|
||||
<SettingsTextField
|
||||
className="flex-1"
|
||||
value={newTeamName}
|
||||
onChange={e => setNewTeamName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && void handleCreateTeam()}
|
||||
placeholder={t('team.teamName')}
|
||||
aria-label={t('team.teamName')}
|
||||
inputSize="sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => void handleCreateTeam()}
|
||||
disabled={isCreating || !newTeamName.trim()}>
|
||||
{isCreating ? t('team.creating') : t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t('team.joinExistingTeam')}>
|
||||
<p className="px-4 pt-3 text-xs text-content-muted">{t('team.personalAutoCreatedNote')}</p>
|
||||
<div className="flex gap-2 px-4 py-3">
|
||||
<SettingsTextField
|
||||
mono
|
||||
|
||||
@@ -25,6 +25,7 @@ vi.mock('../../../../lib/i18n/I18nContext', () => ({
|
||||
'team.yourTeams': 'Your Teams',
|
||||
'team.createNewTeam': 'Create New Team',
|
||||
'team.joinExistingTeam': 'Join Existing Team',
|
||||
'team.personalAutoCreatedNote': 'Personal team is auto-created. Join below.',
|
||||
'team.teamName': 'Team Name',
|
||||
'team.inviteCode': 'Invite Code',
|
||||
'team.creating': 'Creating...',
|
||||
@@ -151,37 +152,24 @@ describe('TeamPanel — role badge rendering (line 165)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('TeamPanel — create team (line 281)', () => {
|
||||
describe('TeamPanel — create team removed (issue #3723)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreateTeam.mockResolvedValue({});
|
||||
setupState({ teams: [] });
|
||||
});
|
||||
|
||||
it('calls createTeam when form is submitted (line 281)', async () => {
|
||||
it('does not render a create-team control', () => {
|
||||
render(<TeamPanel />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Team Name') ?? screen.getByLabelText('Team Name');
|
||||
fireEvent.change(input, { target: { value: 'My New Team' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateTeam).toHaveBeenCalledWith('My New Team');
|
||||
});
|
||||
// The "Create New Team" section is gone — backend caps one owned team per
|
||||
// user, so the control could never succeed (always 404'd).
|
||||
expect(screen.queryByRole('button', { name: 'Create' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Team Name')).not.toBeInTheDocument();
|
||||
expect(mockCreateTeam).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows an error banner when createTeam fails', async () => {
|
||||
mockCreateTeam.mockRejectedValue({ error: 'Team limit reached' });
|
||||
it('renders the personal-team explanatory note', () => {
|
||||
render(<TeamPanel />);
|
||||
|
||||
const input = screen.getByLabelText('Team Name');
|
||||
fireEvent.change(input, { target: { value: 'New Team' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Team limit reached')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Personal team is auto-created. Join below.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -197,7 +185,6 @@ describe('TeamPanel — join team (line 304)', () => {
|
||||
|
||||
const input = screen.getByLabelText('Invite Code');
|
||||
fireEvent.change(input, { target: { value: 'CODE-XYZ' } });
|
||||
// Need to find the Join button — it's the second submit button (after Create)
|
||||
const joinBtn = screen.getByRole('button', { name: /^Join$/i });
|
||||
fireEvent.click(joinBtn);
|
||||
|
||||
@@ -218,6 +205,25 @@ describe('TeamPanel — join team (line 304)', () => {
|
||||
expect(screen.getByText('Code not found')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces the real backend reason from a CoreRpcError (issue #3723)', async () => {
|
||||
// Production rejects with a CoreRpcError — which has no `.error` field, so
|
||||
// the old `'error' in err` check dropped the reason. Verify it now shows.
|
||||
mockJoinTeam.mockRejectedValue(
|
||||
new CoreRpcError(
|
||||
'POST /teams/join failed (400 Bad Request): {"error":"Invite expired"}',
|
||||
'unknown'
|
||||
)
|
||||
);
|
||||
render(<TeamPanel />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Invite Code'), { target: { value: 'OLD-CODE' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Join$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invite expired')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('TeamPanel — leave team modal (line 327)', () => {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { CoreRpcError } from '../../../services/coreRpcClient';
|
||||
import { teamErrorMessage } from './teamErrorMessage';
|
||||
|
||||
const FALLBACK = 'Something went wrong';
|
||||
|
||||
describe('teamErrorMessage', () => {
|
||||
it('prefers a structured reason from CoreRpcError.data', () => {
|
||||
const err = new CoreRpcError('GET /teams failed (403 Forbidden)', 'unknown', undefined, {
|
||||
message: 'Not a member of this team',
|
||||
});
|
||||
expect(teamErrorMessage(err, FALLBACK)).toBe('Not a member of this team');
|
||||
});
|
||||
|
||||
it('lifts the human field out of a JSON body in CoreRpcError.message', () => {
|
||||
const err = new CoreRpcError(
|
||||
'POST /teams/join failed (400 Bad Request): {"error":"Invite expired"}',
|
||||
'unknown'
|
||||
);
|
||||
expect(teamErrorMessage(err, FALLBACK)).toBe('Invite expired');
|
||||
});
|
||||
|
||||
it('returns a clean message when there is no RPC prefix', () => {
|
||||
const err = new CoreRpcError('Session expired. Please log in again.', 'auth_expired');
|
||||
expect(teamErrorMessage(err, FALLBACK)).toBe('Session expired. Please log in again.');
|
||||
});
|
||||
|
||||
it('falls back when the body is a raw HTML error page', () => {
|
||||
const err = new CoreRpcError(
|
||||
'POST /teams failed (404 Not Found): <!DOCTYPE html><html><body>Not Found</body></html>',
|
||||
'unknown'
|
||||
);
|
||||
expect(teamErrorMessage(err, FALLBACK)).toBe(FALLBACK);
|
||||
});
|
||||
|
||||
it('falls back when a JSON body has no human-readable field', () => {
|
||||
const err = new CoreRpcError(
|
||||
'POST /teams/join failed (400 Bad Request): {"code":"E_BAD"}',
|
||||
'unknown'
|
||||
);
|
||||
expect(teamErrorMessage(err, FALLBACK)).toBe(FALLBACK);
|
||||
});
|
||||
|
||||
it('falls back when nothing remains after the RPC prefix', () => {
|
||||
const err = new CoreRpcError(
|
||||
'DELETE /teams/t1 failed (500 Internal Server Error): ',
|
||||
'unknown'
|
||||
);
|
||||
expect(teamErrorMessage(err, FALLBACK)).toBe(FALLBACK);
|
||||
});
|
||||
|
||||
it('handles the legacy plain { error } rejection shape', () => {
|
||||
expect(teamErrorMessage({ error: 'Team limit reached' }, FALLBACK)).toBe('Team limit reached');
|
||||
});
|
||||
|
||||
it('surfaces a bare Error message', () => {
|
||||
expect(teamErrorMessage(new Error('boom'), FALLBACK)).toBe('boom');
|
||||
});
|
||||
|
||||
it('falls back for non-object rejections', () => {
|
||||
expect(teamErrorMessage(null, FALLBACK)).toBe(FALLBACK);
|
||||
expect(teamErrorMessage(undefined, FALLBACK)).toBe(FALLBACK);
|
||||
expect(teamErrorMessage('nope', FALLBACK)).toBe(FALLBACK);
|
||||
});
|
||||
|
||||
it('caps an overly long reason', () => {
|
||||
const long = 'x'.repeat(500);
|
||||
const out = teamErrorMessage({ error: long }, FALLBACK);
|
||||
expect(out.length).toBeLessThanOrEqual(200);
|
||||
expect(out.endsWith('…')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { CoreRpcError } from '../../../services/coreRpcClient';
|
||||
|
||||
/**
|
||||
* Extract a human-readable failure reason from a team RPC rejection.
|
||||
*
|
||||
* Team ops call the hosted backend through `callCoreRpc`, which rejects with a
|
||||
* {@link CoreRpcError}. The real reason lives in either `err.data` (structured
|
||||
* JSON-RPC `error.data`) or `err.message` — the latter shaped by the Rust
|
||||
* `flatten_authed_error` net as `"POST /teams/join failed (400 Bad Request):
|
||||
* <body>"`. The previous TeamPanel catch checked `'error' in err`, which never
|
||||
* matched a `CoreRpcError` (it has no `.error` field), so every failure fell
|
||||
* back to a generic banner and the backend reason was dropped (issue #3723).
|
||||
*
|
||||
* This helper surfaces the backend reason when one is recoverable and falls
|
||||
* back to the caller-supplied localized string otherwise. It also handles the
|
||||
* legacy plain `{ error }` / `{ message }` rejection shape so existing call
|
||||
* sites keep working.
|
||||
*/
|
||||
|
||||
const RPC_PREFIX = /^(?:GET|POST|PUT|DELETE|PATCH)\s+\S+\s+failed\s*\([^)]*\):?\s*/i;
|
||||
const MAX_LEN = 200;
|
||||
|
||||
function cap(value: string): string {
|
||||
const oneLine = value.replace(/\s+/g, ' ').trim();
|
||||
return oneLine.length > MAX_LEN ? `${oneLine.slice(0, MAX_LEN - 1)}…` : oneLine;
|
||||
}
|
||||
|
||||
function firstString(obj: Record<string, unknown>, keys: string[]): string | null {
|
||||
for (const key of keys) {
|
||||
const value = obj[key];
|
||||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Pull a human field out of a structured error body / `error.data`. */
|
||||
function reasonFromData(data: unknown): string | null {
|
||||
if (data && typeof data === 'object') {
|
||||
const reason = firstString(data as Record<string, unknown>, ['message', 'error', 'detail']);
|
||||
if (reason) return cap(reason);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the `"<VERB> <path> failed (<status>):"` prefix the core prepends and
|
||||
* recover the meaningful tail. Returns `null` when nothing presentable remains
|
||||
* (empty body, or a raw HTML error page that must never reach the user).
|
||||
*/
|
||||
function cleanRpcMessage(message: string): string | null {
|
||||
const body = message.replace(RPC_PREFIX, '').trim();
|
||||
if (!body) return null;
|
||||
// Never surface a raw HTML 404/error page (the POST /teams 404 case).
|
||||
if (/^<(?:!doctype|html)/i.test(body)) return null;
|
||||
// Backend errors arrive as a JSON body — lift the human field out of it.
|
||||
if (body.startsWith('{')) {
|
||||
try {
|
||||
const fromJson = reasonFromData(JSON.parse(body));
|
||||
// Structured but no human field → let the caller fall back.
|
||||
return fromJson;
|
||||
} catch {
|
||||
// Not valid JSON — fall through and surface the raw tail.
|
||||
}
|
||||
}
|
||||
return cap(body);
|
||||
}
|
||||
|
||||
export function teamErrorMessage(err: unknown, fallback: string): string {
|
||||
if (err instanceof CoreRpcError) {
|
||||
return reasonFromData(err.data) ?? cleanRpcMessage(err.message) ?? fallback;
|
||||
}
|
||||
if (err && typeof err === 'object') {
|
||||
const reason = firstString(err as Record<string, unknown>, ['error', 'detail']);
|
||||
if (reason) return cap(reason);
|
||||
const message = (err as Record<string, unknown>).message;
|
||||
if (typeof message === 'string' && message.trim()) {
|
||||
return cleanRpcMessage(message) ?? cap(message);
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
@@ -1767,6 +1767,8 @@ const messages: TranslationMap = {
|
||||
'team.teamName': 'اسم الفريق',
|
||||
'team.creating': 'جارٍ الإنشاء...',
|
||||
'team.joinExistingTeam': 'الانضمام إلى فريق موجود',
|
||||
'team.personalAutoCreatedNote':
|
||||
'يتم إنشاء فريقك الشخصي تلقائيًا. للتعاون، انضم إلى فريق موجود باستخدام رمز دعوة أدناه.',
|
||||
'team.inviteCode': 'رمز الدعوة',
|
||||
'team.joining': 'جارٍ الانضمام...',
|
||||
'team.join': 'انضمام',
|
||||
|
||||
@@ -1805,6 +1805,8 @@ const messages: TranslationMap = {
|
||||
'team.teamName': 'টিমের নাম',
|
||||
'team.creating': 'তৈরি হচ্ছে...',
|
||||
'team.joinExistingTeam': 'বিদ্যমান টিমে যোগ দিন',
|
||||
'team.personalAutoCreatedNote':
|
||||
'আপনার ব্যক্তিগত টিম স্বয়ংক্রিয়ভাবে তৈরি হয়। সহযোগিতার জন্য, নিচে একটি আমন্ত্রণ কোড দিয়ে বিদ্যমান টিমে যোগ দিন।',
|
||||
'team.inviteCode': 'আমন্ত্রণ কোড',
|
||||
'team.joining': 'যোগ দেওয়া হচ্ছে...',
|
||||
'team.join': 'যোগ দিন',
|
||||
|
||||
@@ -1852,6 +1852,8 @@ const messages: TranslationMap = {
|
||||
'team.teamName': 'Teamname',
|
||||
'team.creating': 'Erstellen...',
|
||||
'team.joinExistingTeam': 'Tritt einem bestehenden Team bei',
|
||||
'team.personalAutoCreatedNote':
|
||||
'Dein persönliches Team wird automatisch erstellt. Um zusammenzuarbeiten, tritt unten mit einem Einladungscode einem bestehenden Team bei.',
|
||||
'team.inviteCode': 'Einladungscode',
|
||||
'team.joining': 'Beitritt...',
|
||||
'team.join': 'Mach mit',
|
||||
|
||||
@@ -2170,6 +2170,8 @@ const en: TranslationMap = {
|
||||
'team.teamName': 'Team name',
|
||||
'team.creating': 'Creating...',
|
||||
'team.joinExistingTeam': 'Join Existing Team',
|
||||
'team.personalAutoCreatedNote':
|
||||
'Your personal team is created automatically. To collaborate, join an existing team with an invite code below.',
|
||||
'team.inviteCode': 'Invite code',
|
||||
'team.joining': 'Joining...',
|
||||
'team.join': 'Join',
|
||||
|
||||
@@ -1840,6 +1840,8 @@ const messages: TranslationMap = {
|
||||
'team.teamName': 'Nombre del equipo',
|
||||
'team.creating': 'Creando...',
|
||||
'team.joinExistingTeam': 'Unirse a un equipo existente',
|
||||
'team.personalAutoCreatedNote':
|
||||
'Tu equipo personal se crea automáticamente. Para colaborar, únete a un equipo existente con un código de invitación a continuación.',
|
||||
'team.inviteCode': 'Código de invitación',
|
||||
'team.joining': 'Uniéndose...',
|
||||
'team.join': 'Unirse',
|
||||
|
||||
@@ -1852,6 +1852,8 @@ const messages: TranslationMap = {
|
||||
'team.teamName': "Nom de l'équipe",
|
||||
'team.creating': 'Création…',
|
||||
'team.joinExistingTeam': 'Rejoindre une équipe existante',
|
||||
'team.personalAutoCreatedNote':
|
||||
'Votre équipe personnelle est créée automatiquement. Pour collaborer, rejoignez une équipe existante avec un code d’invitation ci-dessous.',
|
||||
'team.inviteCode': "Code d'invitation",
|
||||
'team.joining': 'Adhésion…',
|
||||
'team.join': 'Rejoindre',
|
||||
|
||||
@@ -1803,6 +1803,8 @@ const messages: TranslationMap = {
|
||||
'team.teamName': 'टीम का नाम',
|
||||
'team.creating': 'बन रही है...',
|
||||
'team.joinExistingTeam': 'मौजूदा टीम जॉइन करें',
|
||||
'team.personalAutoCreatedNote':
|
||||
'आपकी व्यक्तिगत टीम स्वतः बन जाती है। सहयोग के लिए, नीचे आमंत्रण कोड के साथ किसी मौजूदा टीम में शामिल हों।',
|
||||
'team.inviteCode': 'इनवाइट कोड',
|
||||
'team.joining': 'जॉइन हो रहे हैं...',
|
||||
'team.join': 'जॉइन करें',
|
||||
|
||||
@@ -1812,6 +1812,8 @@ const messages: TranslationMap = {
|
||||
'team.teamName': 'Nama tim',
|
||||
'team.creating': 'Membuat...',
|
||||
'team.joinExistingTeam': 'Bergabung dengan Tim yang Ada',
|
||||
'team.personalAutoCreatedNote':
|
||||
'Tim pribadi Anda dibuat secara otomatis. Untuk berkolaborasi, gabung ke tim yang sudah ada dengan kode undangan di bawah.',
|
||||
'team.inviteCode': 'Kode undangan',
|
||||
'team.joining': 'Bergabung...',
|
||||
'team.join': 'Bergabung',
|
||||
|
||||
@@ -1837,6 +1837,8 @@ const messages: TranslationMap = {
|
||||
'team.teamName': 'Nome team',
|
||||
'team.creating': 'Creazione...',
|
||||
'team.joinExistingTeam': 'Unisciti a un team esistente',
|
||||
'team.personalAutoCreatedNote':
|
||||
'Il tuo team personale viene creato automaticamente. Per collaborare, unisciti a un team esistente con un codice di invito qui sotto.',
|
||||
'team.inviteCode': 'Codice invito',
|
||||
'team.joining': 'Adesione...',
|
||||
'team.join': 'Unisciti',
|
||||
|
||||
@@ -1787,6 +1787,8 @@ const messages: TranslationMap = {
|
||||
'team.teamName': '팀 이름',
|
||||
'team.creating': '생성 중...',
|
||||
'team.joinExistingTeam': '기존 팀 참가',
|
||||
'team.personalAutoCreatedNote':
|
||||
'개인 팀은 자동으로 생성됩니다. 협업하려면 아래 초대 코드로 기존 팀에 참여하세요.',
|
||||
'team.inviteCode': '초대 코드',
|
||||
'team.joining': '참가 중...',
|
||||
'team.join': '참가',
|
||||
|
||||
@@ -1825,6 +1825,8 @@ const messages: TranslationMap = {
|
||||
'team.teamName': 'Nazwa zespołu',
|
||||
'team.creating': 'Tworzenie...',
|
||||
'team.joinExistingTeam': 'Dołącz do istniejącego zespołu',
|
||||
'team.personalAutoCreatedNote':
|
||||
'Twój zespół osobisty jest tworzony automatycznie. Aby współpracować, dołącz do istniejącego zespołu, używając kodu zaproszenia poniżej.',
|
||||
'team.inviteCode': 'Kod zaproszenia',
|
||||
'team.joining': 'Dołączanie...',
|
||||
'team.join': 'Dołącz',
|
||||
|
||||
@@ -1844,6 +1844,8 @@ const messages: TranslationMap = {
|
||||
'team.teamName': 'Nome da equipe',
|
||||
'team.creating': 'Criando...',
|
||||
'team.joinExistingTeam': 'Entrar em Equipe Existente',
|
||||
'team.personalAutoCreatedNote':
|
||||
'Sua equipe pessoal é criada automaticamente. Para colaborar, entre em uma equipe existente com um código de convite abaixo.',
|
||||
'team.inviteCode': 'Código de convite',
|
||||
'team.joining': 'Entrando...',
|
||||
'team.join': 'Entrar',
|
||||
|
||||
@@ -1820,6 +1820,8 @@ const messages: TranslationMap = {
|
||||
'team.teamName': 'Название команды',
|
||||
'team.creating': 'Создание...',
|
||||
'team.joinExistingTeam': 'Вступить в существующую команду',
|
||||
'team.personalAutoCreatedNote':
|
||||
'Ваша личная команда создаётся автоматически. Чтобы работать вместе, присоединитесь к существующей команде по коду приглашения ниже.',
|
||||
'team.inviteCode': 'Код приглашения',
|
||||
'team.joining': 'Вход...',
|
||||
'team.join': 'Вступить',
|
||||
|
||||
@@ -1709,6 +1709,8 @@ const messages: TranslationMap = {
|
||||
'team.teamName': '团队名称',
|
||||
'team.creating': '创建中...',
|
||||
'team.joinExistingTeam': '加入已有团队',
|
||||
'team.personalAutoCreatedNote':
|
||||
'您的个人团队会自动创建。如需协作,请使用下方的邀请码加入现有团队。',
|
||||
'team.inviteCode': '邀请码',
|
||||
'team.joining': '加入中...',
|
||||
'team.join': '加入',
|
||||
|
||||
Reference in New Issue
Block a user