FN-7477: clarify desktop remote server setup
Clarifies the desktop Connection Manager so local mode is distinct from adding saved remote servers. - Split the desktop local server card from the remote server profile flow and keep the editor closed until adding or editing a remote. - Add desktop-specific remote server copy, accessible labels with profile URLs, and responsive styling for the revised layout. - Update Connection Manager tests, native shell docs, localized strings, resource types, and the Fusion changeset. Files changed: .changeset/fn-7477-connection-manager-clarity.md | 7 + docs/native-shell.md | 5 +- .../components/NativeShellConnectionManager.css | 65 ++++++--- .../components/NativeShellConnectionManager.tsx | 153 ++++++++++++-------- .../NativeShellConnectionManager.test.tsx | 156 +++++++++++++++------ packages/i18n/locales/en/app.json | 15 +- packages/i18n/locales/es/app.json | 15 +- packages/i18n/locales/fr/app.json | 15 +- packages/i18n/locales/ko/app.json | 15 +- packages/i18n/locales/zh-CN/app.json | 15 +- packages/i18n/locales/zh-TW/app.json | 15 +- packages/i18n/src/resources.d.ts | 13 +- 12 files changed, 334 insertions(+), 155 deletions(-) Fusion-Task-Id: FN-7477 Fusion-Task-Lineage: adc2df09-d713-4ccd-8fb6-20a32c6a553e Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7477-connection-manager-clarity.md
Normal file
7
.changeset/fn-7477-connection-manager-clarity.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Clarify the desktop Connection Manager add-remote flow.
|
||||
category: fix
|
||||
dev: Desktop Connection Manager now separates Local Server context from saved remote profiles and collapses the remote editor until add/edit.
|
||||
@@ -57,11 +57,12 @@ Profiles are first-class saved objects shared by onboarding and Connection Manag
|
||||
|
||||
Connection Manager supports:
|
||||
|
||||
- **Desktop Switch server** shows **Local Server** as a selectable destination in the same list as saved remote/server profiles. Selecting **Local Server** calls `setDesktopMode("local")` and returns the shell to the embedded/local Fusion server without deleting remote profiles.
|
||||
- **Desktop Switch server** presents the built-in **Local Server** separately from saved **Remote servers**. Local Server is always available in the desktop shell; selecting it calls `setDesktopMode("local")` and returns the shell to the embedded/local Fusion server without deleting remote profiles.
|
||||
- **Add remote server** is the desktop CTA for saving another Fusion server profile. The remote profile editor stays collapsed until a user chooses **Add remote server** or edits an existing saved profile, so local-only desktop users do not see an empty setup form.
|
||||
- **Use** (activate a saved remote profile). In desktop local mode, using a remote profile first switches desktop mode back to `remote`, then activates the selected profile.
|
||||
- **Edit** (update name/URL/token)
|
||||
- **Delete**
|
||||
- **Add connection**
|
||||
- **Mobile Add connection / Scan QR** remains focused on remote profile setup and does not show the desktop-only Local Server guidance.
|
||||
|
||||
Activation updates `activeProfileId` and stamps `lastUsedAt` on the selected profile.
|
||||
|
||||
|
||||
@@ -2,21 +2,17 @@
|
||||
width: min(100%, 42rem);
|
||||
}
|
||||
|
||||
.native-shell-connection-manager__mode-row {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
padding: 0 var(--space-xl) var(--space-md);
|
||||
}
|
||||
|
||||
.native-shell-connection-manager__profiles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding: 0 var(--space-xl);
|
||||
max-height: 16rem;
|
||||
max-height: 18rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.native-shell-connection-manager__overview,
|
||||
.native-shell-connection-manager__section-heading,
|
||||
.native-shell-connection-manager__profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -24,40 +20,56 @@
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.native-shell-connection-manager__destination {
|
||||
width: 100%;
|
||||
border-color: var(--border);
|
||||
color: var(--text);
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.native-shell-connection-manager__destination:hover,
|
||||
.native-shell-connection-manager__destination:focus-visible {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.native-shell-connection-manager__destination > span:first-child {
|
||||
.native-shell-connection-manager__overview-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.native-shell-connection-manager__profile-actions {
|
||||
.native-shell-connection-manager__overview-copy h3,
|
||||
.native-shell-connection-manager__section-heading h3,
|
||||
.native-shell-connection-manager__editor h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.native-shell-connection-manager__overview-copy p,
|
||||
.native-shell-connection-manager__section-heading p {
|
||||
margin: var(--space-xs) 0 0;
|
||||
}
|
||||
|
||||
.native-shell-connection-manager__section-heading {
|
||||
padding: var(--space-sm) 0;
|
||||
}
|
||||
|
||||
.native-shell-connection-manager__profile-actions,
|
||||
.native-shell-connection-manager__mobile-actions {
|
||||
display: inline-flex;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.native-shell-connection-manager__mobile-actions {
|
||||
padding: var(--space-md) var(--space-xl) 0;
|
||||
}
|
||||
|
||||
.native-shell-connection-manager__editor {
|
||||
padding-top: var(--space-lg);
|
||||
}
|
||||
|
||||
.native-shell-connection-manager__editor h3 {
|
||||
padding-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.native-shell-connection-manager__empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.native-shell-connection-manager__empty-state p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.native-shell-connection-manager__active-pill {
|
||||
display: inline-flex;
|
||||
margin-top: var(--space-xs);
|
||||
@@ -75,13 +87,20 @@
|
||||
background: color-mix(in srgb, var(--color-warning) 8%, transparent);
|
||||
}
|
||||
|
||||
.native-shell-connection-manager__standalone-error {
|
||||
margin: var(--space-md) var(--space-xl) 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.native-shell-connection-manager__overview,
|
||||
.native-shell-connection-manager__section-heading,
|
||||
.native-shell-connection-manager__profile {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.native-shell-connection-manager__mode-row {
|
||||
flex-wrap: wrap;
|
||||
.native-shell-connection-manager__section-heading > .btn,
|
||||
.native-shell-connection-manager__overview > .btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
|
||||
if (!open) return null;
|
||||
|
||||
const isAddingConnection = editingProfileId === "__new__";
|
||||
const isEditorOpen = editingProfileId !== null;
|
||||
const editingProfile = isAddingConnection
|
||||
? null
|
||||
: shellState.profiles.find((profile) => profile.id === editingProfileId) ?? activeProfile;
|
||||
@@ -37,6 +38,12 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const startNewRemoteDraft = () => {
|
||||
setEditingProfileId("__new__");
|
||||
setDraft({ name: "", serverUrl: "", authToken: "" });
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const saveCurrent = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
@@ -120,44 +127,57 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
|
||||
|
||||
<div className="native-shell-connection-manager__profiles">
|
||||
{isDesktopShell && (
|
||||
<button
|
||||
type="button"
|
||||
className="card native-shell-connection-manager__profile native-shell-connection-manager__destination"
|
||||
onClick={() => void handleUseLocalServer()}
|
||||
aria-pressed={isDesktopLocalActive}
|
||||
>
|
||||
<span>
|
||||
<strong>{t("shell.localServerTitle", "Local Server")}</strong>
|
||||
<span className="settings-muted">{t("shell.localServerDescription", "Use the embedded Fusion server on this device.")}</span>
|
||||
{isDesktopLocalActive && <span className="native-shell-connection-manager__active-pill">{t("shell.activePill", "Active")}</span>}
|
||||
</span>
|
||||
<span className="native-shell-connection-manager__profile-actions" aria-hidden="true">
|
||||
<span className="btn btn-sm">{isDesktopLocalActive ? t("shell.localServerActive", "Current") : t("shell.use", "Use")}</span>
|
||||
</span>
|
||||
</button>
|
||||
<>
|
||||
{/*
|
||||
FNXC:DesktopConnectionManager 2026-07-03-16:25:
|
||||
Desktop Connection Manager must explain that Local Server is built in and remote servers are saved profiles. Keep the remote editor collapsed until the user explicitly adds or edits a remote server so first-run local mode never looks like incomplete setup.
|
||||
*/}
|
||||
<section className="card native-shell-connection-manager__overview" aria-labelledby="native-shell-connection-manager-local-heading">
|
||||
<div className="native-shell-connection-manager__overview-copy">
|
||||
<h3 id="native-shell-connection-manager-local-heading">{t("shell.localServerTitle", "Local Server")}</h3>
|
||||
<p className="settings-muted">{t("shell.localServerDescription", "Use the embedded Fusion server on this device.")}</p>
|
||||
{isDesktopLocalActive && <span className="native-shell-connection-manager__active-pill">{t("shell.activePill", "Active")}</span>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => void handleUseLocalServer()}
|
||||
aria-label={isDesktopLocalActive ? t("shell.currentLocalServer", "Current Local Server") : t("shell.useLocalServer", "Use Local Server")}
|
||||
aria-pressed={isDesktopLocalActive}
|
||||
>
|
||||
{isDesktopLocalActive ? t("shell.localServerActive", "Current") : t("shell.use", "Use")}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div className="native-shell-connection-manager__section-heading">
|
||||
<div>
|
||||
<h3>{t("shell.remoteServersTitle", "Remote servers")}</h3>
|
||||
<p className="settings-muted">{t("shell.remoteServersDescription", "Save Fusion servers you want this desktop app to open later.")}</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={startNewRemoteDraft}>
|
||||
{t("shell.addRemoteServer", "Add remote server")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{shellState.profiles.length === 0 ? (
|
||||
<div className="card native-shell-connection-manager__empty-state">
|
||||
<p className="settings-muted">{t("shell.noServersSaved", "No remote servers saved yet.")}</p>
|
||||
<div className="native-shell-connection-manager__profile-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => {
|
||||
setEditingProfileId("__new__");
|
||||
setDraft({ name: "", serverUrl: "", authToken: "" });
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
{t("shell.addServer", "Add server")}
|
||||
</button>
|
||||
{shellState.host === "mobile-shell" && (
|
||||
<p className="settings-muted">
|
||||
{isDesktopShell
|
||||
? t("shell.noRemoteServersDesktop", "No remote servers saved yet. Add one only when you want this desktop app to open another Fusion server.")
|
||||
: t("shell.noServersSaved", "No remote servers saved yet.")}
|
||||
</p>
|
||||
{!isDesktopShell && (
|
||||
<div className="native-shell-connection-manager__profile-actions">
|
||||
<button type="button" className="btn btn-sm" onClick={startNewRemoteDraft}>
|
||||
{t("shell.addServer", "Add server")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm" onClick={() => void handleScanQr()}>
|
||||
{t("shell.scanQr", "Scan QR")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
shellState.profiles.map((profile) => (
|
||||
@@ -171,7 +191,7 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
aria-label={t("shell.editProfile", "Edit {{name}}", { name: profile.name })}
|
||||
aria-label={t("shell.editProfile", "Edit {{name}} at {{url}}", { name: profile.name, url: profile.serverUrl })}
|
||||
onClick={() => {
|
||||
setEditingProfileId(profile.id);
|
||||
setDraft(profile);
|
||||
@@ -179,42 +199,53 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
|
||||
>
|
||||
{t("actions.edit", "Edit")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm" aria-label={t("shell.useProfile", "Use {{name}}", { name: profile.name })} onClick={() => void handleUseProfile(profile.id)}>{t("shell.use", "Use")}</button>
|
||||
<button type="button" className="btn btn-sm btn-danger" aria-label={t("shell.deleteProfile", "Delete {{name}}", { name: profile.name })} onClick={() => setDeleteCandidate(profile)}>{t("actions.delete", "Delete")}</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
aria-label={t("shell.useProfile", "Use {{name}} at {{url}}", { name: profile.name, url: profile.serverUrl })}
|
||||
onClick={() => void handleUseProfile(profile.id)}
|
||||
>
|
||||
{t("shell.use", "Use")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-danger"
|
||||
aria-label={t("shell.deleteProfile", "Delete {{name}} at {{url}}", { name: profile.name, url: profile.serverUrl })}
|
||||
onClick={() => setDeleteCandidate(profile)}
|
||||
>
|
||||
{t("actions.delete", "Delete")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="native-shell-connection-manager__mode-row">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
setEditingProfileId("__new__");
|
||||
setDraft({ name: "", serverUrl: "", authToken: "" });
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
{t("shell.addConnection", "Add connection")}
|
||||
</button>
|
||||
{shellState.host === "mobile-shell" && (
|
||||
{!isDesktopShell && shellState.profiles.length > 0 && (
|
||||
<div className="native-shell-connection-manager__mobile-actions">
|
||||
<button type="button" className="btn" onClick={startNewRemoteDraft}>
|
||||
{t("shell.addConnection", "Add connection")}
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={() => void handleScanQr()}>
|
||||
{t("shell.scanQr", "Scan QR")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group native-shell-connection-manager__editor">
|
||||
<label htmlFor="native-shell-connection-manager-name">{t("shell.nameLabel", "Name")}</label>
|
||||
<input id="native-shell-connection-manager-name" className="input" value={workingName} onChange={(event) => setDraft((value) => ({ ...value, name: event.target.value }))} />
|
||||
<label htmlFor="native-shell-connection-manager-url">{t("shell.serverUrlLabel", "Server URL")}</label>
|
||||
<input id="native-shell-connection-manager-url" className="input" value={workingUrl} onChange={(event) => setDraft((value) => ({ ...value, serverUrl: event.target.value }))} />
|
||||
<label htmlFor="native-shell-connection-manager-token">{t("shell.authTokenLabel", "Auth token (optional)")}</label>
|
||||
<input id="native-shell-connection-manager-token" className="input" type="password" value={workingToken ?? ""} onChange={(event) => setDraft((value) => ({ ...value, authToken: event.target.value }))} />
|
||||
{error && <p className="form-error" role="alert">{error}</p>}
|
||||
</div>
|
||||
{error && !isEditorOpen && <p className="form-error native-shell-connection-manager__standalone-error" role="alert">{error}</p>}
|
||||
|
||||
{isEditorOpen && (
|
||||
<div className="form-group native-shell-connection-manager__editor">
|
||||
<h3>{isAddingConnection ? t("shell.addRemoteServer", "Add remote server") : t("shell.editRemoteServer", "Edit remote server")}</h3>
|
||||
<label htmlFor="native-shell-connection-manager-name">{t("shell.nameLabel", "Name")}</label>
|
||||
<input id="native-shell-connection-manager-name" className="input" value={workingName} onChange={(event) => setDraft((value) => ({ ...value, name: event.target.value }))} />
|
||||
<label htmlFor="native-shell-connection-manager-url">{t("shell.serverUrlLabel", "Server URL")}</label>
|
||||
<input id="native-shell-connection-manager-url" className="input" value={workingUrl} onChange={(event) => setDraft((value) => ({ ...value, serverUrl: event.target.value }))} />
|
||||
<label htmlFor="native-shell-connection-manager-token">{t("shell.authTokenLabel", "Auth token (optional)")}</label>
|
||||
<input id="native-shell-connection-manager-token" className="input" type="password" value={workingToken ?? ""} onChange={(event) => setDraft((value) => ({ ...value, authToken: event.target.value }))} />
|
||||
{error && <p className="form-error" role="alert">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deleteCandidate && (
|
||||
<div className="native-shell-connection-manager__delete-confirm" role="alertdialog" aria-label={t("shell.deleteConfirmLabel", "Delete server confirmation")}>
|
||||
@@ -228,8 +259,12 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
|
||||
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn" onClick={onClose}>{t("actions.close", "Close")}</button>
|
||||
<button type="button" className="btn" onClick={resetEditor}>{t("actions.cancel", "Cancel")}</button>
|
||||
<button type="button" className="btn btn-primary" onClick={() => void saveCurrent()} disabled={!workingUrl.trim()}>{t("actions.save", "Save")}</button>
|
||||
{isEditorOpen && (
|
||||
<>
|
||||
<button type="button" className="btn" onClick={resetEditor}>{t("actions.cancel", "Cancel")}</button>
|
||||
<button type="button" className="btn btn-primary" onClick={() => void saveCurrent()} disabled={!workingUrl.trim()}>{t("actions.save", "Save")}</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { NativeShellConnectionManager } from "../NativeShellConnectionManager";
|
||||
|
||||
@@ -6,10 +6,11 @@ function createShellApi() {
|
||||
return {
|
||||
getState: vi.fn(),
|
||||
listProfiles: vi.fn(),
|
||||
saveProfile: vi.fn(async (input?: { id?: string }) => ({
|
||||
saveProfile: vi.fn(async (input?: { id?: string; name?: string; serverUrl?: string; authToken?: string | null }) => ({
|
||||
id: input?.id ?? "p2",
|
||||
name: "Prod",
|
||||
serverUrl: "https://fusion.example.com",
|
||||
name: input?.name || "Prod",
|
||||
serverUrl: input?.serverUrl || "https://fusion.example.com",
|
||||
authToken: input?.authToken ?? null,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
})),
|
||||
@@ -32,7 +33,37 @@ const remoteProfile = {
|
||||
};
|
||||
|
||||
describe("NativeShellConnectionManager", () => {
|
||||
it("shows a desktop Local Server destination and switches back from an active remote profile", async () => {
|
||||
it("explains desktop local mode with no remote profiles and hides the editor until add", () => {
|
||||
const shellApi = createShellApi();
|
||||
render(
|
||||
<NativeShellConnectionManager
|
||||
open={true}
|
||||
shellApi={shellApi}
|
||||
shellState={{ host: "desktop-shell", desktopMode: "local", activeProfileId: null, profiles: [] }}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Local Server" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Use the embedded Fusion server on this device.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Current Local Server" })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByRole("heading", { name: "Remote servers" })).toBeInTheDocument();
|
||||
expect(screen.getByText(/No remote servers saved yet/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Add remote server" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Add connection" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /^Local$/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /^Remote$/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Name")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add remote server" }));
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Add remote server" })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Name")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Server URL")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Auth token (optional)")).toHaveAttribute("type", "password");
|
||||
});
|
||||
|
||||
it("keeps the Local Server destination available from desktop remote mode", async () => {
|
||||
const shellApi = createShellApi();
|
||||
render(
|
||||
<NativeShellConnectionManager
|
||||
@@ -43,38 +74,42 @@ describe("NativeShellConnectionManager", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /Local Server/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Local Server" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Use Local Server" })).toHaveAttribute("aria-pressed", "false");
|
||||
expect(screen.getByText("Remote")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Local Server/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Use Local Server" }));
|
||||
|
||||
await waitFor(() => expect(shellApi.setDesktopMode).toHaveBeenCalledWith("local"));
|
||||
expect(shellApi.setActiveProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks Local Server active in desktop local mode and switches remote profile use into remote mode", async () => {
|
||||
it("separates populated desktop remote profiles from local state and exposes deterministic actions", async () => {
|
||||
const shellApi = createShellApi();
|
||||
const duplicateNameProfiles = [
|
||||
remoteProfile,
|
||||
{ ...remoteProfile, id: "remote-2", serverUrl: "https://other.example.com" },
|
||||
];
|
||||
render(
|
||||
<NativeShellConnectionManager
|
||||
open={true}
|
||||
shellApi={shellApi}
|
||||
shellState={{
|
||||
host: "desktop-shell",
|
||||
desktopMode: "local",
|
||||
activeProfileId: "remote-1",
|
||||
profiles: [
|
||||
remoteProfile,
|
||||
{ ...remoteProfile, id: "remote-2", serverUrl: "https://other.example.com" },
|
||||
],
|
||||
}}
|
||||
shellState={{ host: "desktop-shell", desktopMode: "local", activeProfileId: "remote-1", profiles: duplicateNameProfiles }}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /Local Server/i })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Current Local Server" })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByRole("button", { name: "Add remote server" })).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Server URL")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getAllByLabelText("Use Remote")[1]!);
|
||||
const firstCard = screen.getByText("https://fusion.example.com").closest(".native-shell-connection-manager__profile");
|
||||
expect(firstCard).not.toBeNull();
|
||||
expect(within(firstCard as HTMLElement).getByRole("button", { name: "Edit Remote at https://fusion.example.com" })).toBeInTheDocument();
|
||||
expect(within(firstCard as HTMLElement).getByRole("button", { name: "Use Remote at https://fusion.example.com" })).toBeInTheDocument();
|
||||
expect(within(firstCard as HTMLElement).getByRole("button", { name: "Delete Remote at https://fusion.example.com" })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Use Remote at https://other.example.com" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(shellApi.setDesktopMode).toHaveBeenCalledWith("remote");
|
||||
@@ -83,7 +118,7 @@ describe("NativeShellConnectionManager", () => {
|
||||
expect(shellApi.setDesktopMode.mock.invocationCallOrder[0]).toBeLessThan(shellApi.setActiveProfile.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it("renders Local Server deterministically with no remote profiles and an unset desktop mode", () => {
|
||||
it("validates and saves a new desktop remote profile only after add is chosen", async () => {
|
||||
const shellApi = createShellApi();
|
||||
render(
|
||||
<NativeShellConnectionManager
|
||||
@@ -94,12 +129,54 @@ describe("NativeShellConnectionManager", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /Local Server/i })).toBeInTheDocument();
|
||||
expect(screen.getByText("No remote servers saved yet.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Add server" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add remote server" }));
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Prod" } });
|
||||
fireEvent.change(screen.getByLabelText("Server URL"), { target: { value: "ftp://fusion.example.com" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Server URL must use http or https");
|
||||
expect(shellApi.saveProfile).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Server URL"), { target: { value: "https://prod.example.com" } });
|
||||
fireEvent.change(screen.getByLabelText("Auth token (optional)"), { target: { value: "secret-token" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(shellApi.saveProfile).toHaveBeenCalledWith(expect.objectContaining({
|
||||
id: undefined,
|
||||
name: "Prod",
|
||||
serverUrl: "https://prod.example.com",
|
||||
authToken: "secret-token",
|
||||
}));
|
||||
expect(shellApi.setActiveProfile).toHaveBeenCalledWith("p2");
|
||||
});
|
||||
expect(screen.queryByText("secret-token")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows active profile indicator and requires delete confirmation", async () => {
|
||||
it("edits an existing profile from a collapsed desktop editor", async () => {
|
||||
const shellApi = createShellApi();
|
||||
render(
|
||||
<NativeShellConnectionManager
|
||||
open={true}
|
||||
shellApi={shellApi}
|
||||
shellState={{ host: "desktop-shell", desktopMode: "remote", activeProfileId: "remote-1", profiles: [remoteProfile] }}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByLabelText("Server URL")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Edit Remote at https://fusion.example.com" }));
|
||||
expect(screen.getByRole("heading", { name: "Edit remote server" })).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByDisplayValue("https://fusion.example.com"), { target: { value: "https://next.example.com" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(shellApi.saveProfile).toHaveBeenCalledWith(expect.objectContaining({ id: "remote-1", serverUrl: "https://next.example.com" }));
|
||||
expect(shellApi.setActiveProfile).toHaveBeenCalledWith("remote-1");
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps mobile QR, manual add, active state, and delete confirmation available", async () => {
|
||||
const shellApi = createShellApi();
|
||||
render(
|
||||
<NativeShellConnectionManager
|
||||
@@ -110,9 +187,20 @@ describe("NativeShellConnectionManager", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("button", { name: /Local Server/i })).toBeNull();
|
||||
expect(screen.queryByRole("heading", { name: "Local Server" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Add remote server" })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByLabelText("Delete Prod"));
|
||||
expect(screen.getByRole("button", { name: "Add connection" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Scan QR" })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Scan QR" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(shellApi.startQrScan).toHaveBeenCalled();
|
||||
expect(screen.getByDisplayValue("https://qr.example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete Prod at https://fusion.example.com" }));
|
||||
expect(screen.getByRole("alertdialog", { name: "Delete server confirmation" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
|
||||
@@ -121,27 +209,7 @@ describe("NativeShellConnectionManager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("edits and saves active profile", async () => {
|
||||
const shellApi = createShellApi();
|
||||
render(
|
||||
<NativeShellConnectionManager
|
||||
open={true}
|
||||
shellApi={shellApi}
|
||||
shellState={{ host: "mobile-shell", activeProfileId: "p1", profiles: [{ id: "p1", name: "Prod", serverUrl: "https://fusion.example.com", authToken: null, createdAt: "", updatedAt: "" }] }}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue("https://fusion.example.com"), { target: { value: "https://next.example.com" } });
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(shellApi.saveProfile).toHaveBeenCalledWith(expect.objectContaining({ id: "p1", serverUrl: "https://next.example.com" }));
|
||||
expect(shellApi.setActiveProfile).toHaveBeenCalledWith("p1");
|
||||
});
|
||||
});
|
||||
|
||||
it("supports empty-state recovery and QR import without a mobile Local Server option", async () => {
|
||||
it("keeps mobile empty state focused on QR and manual entry without desktop guidance", async () => {
|
||||
const shellApi = createShellApi();
|
||||
render(
|
||||
<NativeShellConnectionManager
|
||||
@@ -152,13 +220,13 @@ describe("NativeShellConnectionManager", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("button", { name: /Local Server/i })).toBeNull();
|
||||
expect(screen.queryByRole("heading", { name: "Local Server" })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("No remote servers saved yet.")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getAllByText("Scan QR")[0]!);
|
||||
expect(screen.getByRole("button", { name: "Add server" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Scan QR" })).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Server URL")).not.toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(shellApi.startQrScan).toHaveBeenCalled();
|
||||
expect(screen.getByDisplayValue("https://qr.example.com")).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add server" }));
|
||||
expect(screen.getByLabelText("Server URL")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6956,8 +6956,8 @@
|
||||
"defaultProfileName": "Remote Server",
|
||||
"deleteConfirmLabel": "Delete server confirmation",
|
||||
"deleteConfirmMessage": "Delete {{name}}? This removes the saved profile.",
|
||||
"deleteProfile": "Delete {{name}}",
|
||||
"editProfile": "Edit {{name}}",
|
||||
"deleteProfile": "Delete {{name}} at {{url}}",
|
||||
"editProfile": "Edit {{name}} at {{url}}",
|
||||
"modeLocal": "Local",
|
||||
"modeRemote": "Remote",
|
||||
"nameLabel": "Name",
|
||||
@@ -6966,10 +6966,17 @@
|
||||
"serverUrlLabel": "Server URL",
|
||||
"serverUrlProtocolError": "Server URL must use http or https",
|
||||
"use": "Use",
|
||||
"useProfile": "Use {{name}}",
|
||||
"useProfile": "Use {{name}} at {{url}}",
|
||||
"localServerTitle": "Local Server",
|
||||
"localServerDescription": "Use the embedded Fusion server on this device.",
|
||||
"localServerActive": "Current"
|
||||
"localServerActive": "Current",
|
||||
"addRemoteServer": "Add remote server",
|
||||
"editRemoteServer": "Edit remote server",
|
||||
"remoteServersTitle": "Remote servers",
|
||||
"remoteServersDescription": "Save Fusion servers you want this desktop app to open later.",
|
||||
"noRemoteServersDesktop": "No remote servers saved yet. Add one only when you want this desktop app to open another Fusion server.",
|
||||
"useLocalServer": "Use Local Server",
|
||||
"currentLocalServer": "Current Local Server"
|
||||
},
|
||||
"skills": {
|
||||
"addSkill": "Add a skill…",
|
||||
|
||||
@@ -6946,8 +6946,8 @@
|
||||
"defaultProfileName": "Servidor remoto",
|
||||
"deleteConfirmLabel": "Confirmación de eliminación del servidor",
|
||||
"deleteConfirmMessage": "¿Eliminar {{name}}? Esto eliminará el perfil guardado.",
|
||||
"deleteProfile": "Eliminar {{name}}",
|
||||
"editProfile": "Editar {{name}}",
|
||||
"deleteProfile": "Eliminar {{name}} en {{url}}",
|
||||
"editProfile": "Editar {{name}} en {{url}}",
|
||||
"modeLocal": "Local",
|
||||
"modeRemote": "Remoto",
|
||||
"nameLabel": "Nombre",
|
||||
@@ -6956,10 +6956,17 @@
|
||||
"serverUrlLabel": "URL del servidor",
|
||||
"serverUrlProtocolError": "La URL del servidor debe usar http o https",
|
||||
"use": "Usar",
|
||||
"useProfile": "Usar {{name}}",
|
||||
"useProfile": "Usar {{name}} en {{url}}",
|
||||
"localServerTitle": "Servidor local",
|
||||
"localServerDescription": "Usa el servidor Fusion integrado en este dispositivo.",
|
||||
"localServerActive": "Actual"
|
||||
"localServerActive": "Actual",
|
||||
"addRemoteServer": "Añadir servidor remoto",
|
||||
"editRemoteServer": "Editar servidor remoto",
|
||||
"remoteServersTitle": "Servidores remotos",
|
||||
"remoteServersDescription": "Guarda los servidores Fusion que quieras que esta aplicación de escritorio abra más tarde.",
|
||||
"noRemoteServersDesktop": "Aún no hay servidores remotos guardados. Añade uno solo cuando quieras que esta aplicación de escritorio abra otro servidor Fusion.",
|
||||
"useLocalServer": "Usar servidor local",
|
||||
"currentLocalServer": "Servidor local actual"
|
||||
},
|
||||
"skills": {
|
||||
"addSkill": "Añadir una habilidad…",
|
||||
|
||||
@@ -6946,8 +6946,8 @@
|
||||
"defaultProfileName": "Serveur distant",
|
||||
"deleteConfirmLabel": "Confirmation de suppression du serveur",
|
||||
"deleteConfirmMessage": "Supprimer {{name}} ? Cela supprimera le profil enregistré.",
|
||||
"deleteProfile": "Supprimer {{name}}",
|
||||
"editProfile": "Modifier {{name}}",
|
||||
"deleteProfile": "Supprimer {{name}} sur {{url}}",
|
||||
"editProfile": "Modifier {{name}} sur {{url}}",
|
||||
"modeLocal": "Local",
|
||||
"modeRemote": "Distant",
|
||||
"nameLabel": "Nom",
|
||||
@@ -6956,10 +6956,17 @@
|
||||
"serverUrlLabel": "URL du serveur",
|
||||
"serverUrlProtocolError": "L'URL du serveur doit utiliser http ou https",
|
||||
"use": "Utiliser",
|
||||
"useProfile": "Utiliser {{name}}",
|
||||
"useProfile": "Utiliser {{name}} sur {{url}}",
|
||||
"localServerTitle": "Serveur local",
|
||||
"localServerDescription": "Utiliser le serveur Fusion intégré sur cet appareil.",
|
||||
"localServerActive": "Actuel"
|
||||
"localServerActive": "Actuel",
|
||||
"addRemoteServer": "Ajouter un serveur distant",
|
||||
"editRemoteServer": "Modifier le serveur distant",
|
||||
"remoteServersTitle": "Serveurs distants",
|
||||
"remoteServersDescription": "Enregistrez les serveurs Fusion que cette application de bureau doit ouvrir plus tard.",
|
||||
"noRemoteServersDesktop": "Aucun serveur distant enregistré pour l'instant. Ajoutez-en un uniquement si cette application de bureau doit ouvrir un autre serveur Fusion.",
|
||||
"useLocalServer": "Utiliser le serveur local",
|
||||
"currentLocalServer": "Serveur local actuel"
|
||||
},
|
||||
"skills": {
|
||||
"addSkill": "Ajouter une compétence…",
|
||||
|
||||
@@ -6946,8 +6946,8 @@
|
||||
"defaultProfileName": "원격 서버",
|
||||
"deleteConfirmLabel": "서버 삭제 확인",
|
||||
"deleteConfirmMessage": "{{name}}을(를) 삭제하시겠습니까? 저장된 프로필이 제거됩니다.",
|
||||
"deleteProfile": "{{name}} 삭제",
|
||||
"editProfile": "{{name}} 편집",
|
||||
"deleteProfile": "{{url}}의 {{name}} 삭제",
|
||||
"editProfile": "{{url}}의 {{name}} 편집",
|
||||
"modeLocal": "로컬",
|
||||
"modeRemote": "원격",
|
||||
"nameLabel": "이름",
|
||||
@@ -6956,10 +6956,17 @@
|
||||
"serverUrlLabel": "서버 URL",
|
||||
"serverUrlProtocolError": "서버 URL은 http 또는 https를 사용해야 합니다",
|
||||
"use": "사용",
|
||||
"useProfile": "{{name}} 사용",
|
||||
"useProfile": "{{url}}의 {{name}} 사용",
|
||||
"localServerTitle": "로컬 서버",
|
||||
"localServerDescription": "이 기기의 내장 Fusion 서버를 사용합니다.",
|
||||
"localServerActive": "현재"
|
||||
"localServerActive": "현재",
|
||||
"addRemoteServer": "원격 서버 추가",
|
||||
"editRemoteServer": "원격 서버 편집",
|
||||
"remoteServersTitle": "원격 서버",
|
||||
"remoteServersDescription": "이 데스크톱 앱에서 나중에 열 Fusion 서버를 저장합니다.",
|
||||
"noRemoteServersDesktop": "저장된 원격 서버가 없습니다. 이 데스크톱 앱에서 다른 Fusion 서버를 열어야 할 때만 추가하세요.",
|
||||
"useLocalServer": "로컬 서버 사용",
|
||||
"currentLocalServer": "현재 로컬 서버"
|
||||
},
|
||||
"skills": {
|
||||
"addSkill": "스킬 추가…",
|
||||
|
||||
@@ -6946,8 +6946,8 @@
|
||||
"defaultProfileName": "远程服务器",
|
||||
"deleteConfirmLabel": "删除服务器确认",
|
||||
"deleteConfirmMessage": "删除 {{name}}?这将移除保存的配置文件。",
|
||||
"deleteProfile": "删除 {{name}}",
|
||||
"editProfile": "编辑 {{name}}",
|
||||
"deleteProfile": "删除 {{url}} 上的 {{name}}",
|
||||
"editProfile": "编辑 {{url}} 上的 {{name}}",
|
||||
"modeLocal": "本地",
|
||||
"modeRemote": "远程",
|
||||
"nameLabel": "名称",
|
||||
@@ -6956,10 +6956,17 @@
|
||||
"serverUrlLabel": "服务器 URL",
|
||||
"serverUrlProtocolError": "服务器 URL 必须使用 http 或 https",
|
||||
"use": "使用",
|
||||
"useProfile": "使用 {{name}}",
|
||||
"useProfile": "使用 {{url}} 上的 {{name}}",
|
||||
"localServerTitle": "本地服务器",
|
||||
"localServerDescription": "使用此设备上的内置 Fusion 服务器。",
|
||||
"localServerActive": "当前"
|
||||
"localServerActive": "当前",
|
||||
"addRemoteServer": "添加远程服务器",
|
||||
"editRemoteServer": "编辑远程服务器",
|
||||
"remoteServersTitle": "远程服务器",
|
||||
"remoteServersDescription": "保存你希望此桌面应用稍后打开的 Fusion 服务器。",
|
||||
"noRemoteServersDesktop": "尚未保存远程服务器。仅当你希望此桌面应用打开另一个 Fusion 服务器时再添加。",
|
||||
"useLocalServer": "使用本地服务器",
|
||||
"currentLocalServer": "当前本地服务器"
|
||||
},
|
||||
"skills": {
|
||||
"addSkill": "添加技能…",
|
||||
|
||||
@@ -6946,8 +6946,8 @@
|
||||
"defaultProfileName": "遠端伺服器",
|
||||
"deleteConfirmLabel": "刪除伺服器確認",
|
||||
"deleteConfirmMessage": "刪除 {{name}}?這將移除已保存的設定檔。",
|
||||
"deleteProfile": "刪除 {{name}}",
|
||||
"editProfile": "編輯 {{name}}",
|
||||
"deleteProfile": "刪除 {{url}} 上的 {{name}}",
|
||||
"editProfile": "編輯 {{url}} 上的 {{name}}",
|
||||
"modeLocal": "本機",
|
||||
"modeRemote": "遠端",
|
||||
"nameLabel": "名稱",
|
||||
@@ -6956,10 +6956,17 @@
|
||||
"serverUrlLabel": "伺服器 URL",
|
||||
"serverUrlProtocolError": "伺服器 URL 必須使用 http 或 https",
|
||||
"use": "使用",
|
||||
"useProfile": "使用 {{name}}",
|
||||
"useProfile": "使用 {{url}} 上的 {{name}}",
|
||||
"localServerTitle": "本機伺服器",
|
||||
"localServerDescription": "使用此裝置上的內建 Fusion 伺服器。",
|
||||
"localServerActive": "目前"
|
||||
"localServerActive": "目前",
|
||||
"addRemoteServer": "新增遠端伺服器",
|
||||
"editRemoteServer": "編輯遠端伺服器",
|
||||
"remoteServersTitle": "遠端伺服器",
|
||||
"remoteServersDescription": "儲存你希望此桌面應用稍後開啟的 Fusion 伺服器。",
|
||||
"noRemoteServersDesktop": "尚未儲存遠端伺服器。只有在你希望此桌面應用開啟另一個 Fusion 伺服器時才需要新增。",
|
||||
"useLocalServer": "使用本機伺服器",
|
||||
"currentLocalServer": "目前的本機伺服器"
|
||||
},
|
||||
"skills": {
|
||||
"addSkill": "新增技能…",
|
||||
|
||||
13
packages/i18n/src/resources.d.ts
vendored
13
packages/i18n/src/resources.d.ts
vendored
@@ -6951,27 +6951,34 @@ export default interface Resources {
|
||||
"shell": {
|
||||
"activePill": "Active",
|
||||
"addConnection": "Add connection",
|
||||
"addRemoteServer": "Add remote server",
|
||||
"addServer": "Add server",
|
||||
"authTokenLabel": "Auth token (optional)",
|
||||
"connectionManager": "Connection Manager",
|
||||
"connectionManagerLabel": "Connection Manager",
|
||||
"defaultProfileName": "Remote Server",
|
||||
"currentLocalServer": "Current Local Server",
|
||||
"editRemoteServer": "Edit remote server",
|
||||
"deleteConfirmLabel": "Delete server confirmation",
|
||||
"deleteConfirmMessage": "Delete {{name}}? This removes the saved profile.",
|
||||
"deleteProfile": "Delete {{name}}",
|
||||
"editProfile": "Edit {{name}}",
|
||||
"deleteProfile": "Delete {{name}} at {{url}}",
|
||||
"editProfile": "Edit {{name}} at {{url}}",
|
||||
"localServerActive": "Current",
|
||||
"localServerDescription": "Use the embedded Fusion server on this device.",
|
||||
"localServerTitle": "Local Server",
|
||||
"modeLocal": "Local",
|
||||
"modeRemote": "Remote",
|
||||
"nameLabel": "Name",
|
||||
"noRemoteServersDesktop": "No remote servers saved yet. Add one only when you want this desktop app to open another Fusion server.",
|
||||
"noServersSaved": "No remote servers saved yet.",
|
||||
"remoteServersDescription": "Save Fusion servers you want this desktop app to open later.",
|
||||
"remoteServersTitle": "Remote servers",
|
||||
"scanQr": "Scan QR",
|
||||
"serverUrlLabel": "Server URL",
|
||||
"serverUrlProtocolError": "Server URL must use http or https",
|
||||
"use": "Use",
|
||||
"useProfile": "Use {{name}}"
|
||||
"useLocalServer": "Use Local Server",
|
||||
"useProfile": "Use {{name}} at {{url}}"
|
||||
},
|
||||
"skills": {
|
||||
"addSkill": "Add a skill…",
|
||||
|
||||
Reference in New Issue
Block a user