feat(editor): F4.5a the device sheet for PCs, notebooks, switches and modems
Measured first: the new oracle SheetRun types 77 texts into FILIUS' own settings panels and records what was marked red, what the device took and what the field showed afterwards. Java's two judgements disagree (red text taken, black text refused, any int as a modem port, a refused text dropped without a word); here one rule decides both (packages/doc fields.ts), a refused text stays marked in the field, and only what Java's model accepts reaches the document. - doc: settings.ts (name, label policy, addresses, IP forwarding, DHCP stopping the own DHCP server, LAN/WLAN with the wireless connection, switch SSID dropping its wireless hosts, retention time, cloud, modem port as its pair), sheet.ts (what the sheet shows) - fls: modemPair/modemPort in one place - editor: DeviceSheet beside the surface or below it on a phone, opened with the selection; every change one undo step - not adopted from Java: the name overwritten by the MAC address, the access point search depending on unrelated devices Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@ import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { build, preview, type PreviewServer } from 'vite';
|
||||
import type { TopologyView } from '@mdg-filius/doc';
|
||||
import type { DeviceSheet, TopologyView } from '@mdg-filius/doc';
|
||||
|
||||
const config = join(dirname(fileURLToPath(import.meta.url)), '../vite.config.ts');
|
||||
|
||||
@@ -30,6 +30,11 @@ export function drawing(page: Page): Promise<TopologyView> {
|
||||
return page.evaluate(() => (window as unknown as { filiusDev: { view: () => TopologyView } }).filiusDev.view());
|
||||
}
|
||||
|
||||
/** What the device sheet reads of a device. */
|
||||
export function sheetOf(page: Page, id: string): Promise<DeviceSheet | undefined> {
|
||||
return page.evaluate((each) => (window as unknown as { filiusDev: { sheet: (id: string) => DeviceSheet | undefined } }).filiusDev.sheet(each), id);
|
||||
}
|
||||
|
||||
/** A device's button as data-box says it (surface pixels). */
|
||||
export async function boxOf(page: Page, id: string): Promise<{ x: number; y: number; width: number; height: number }> {
|
||||
const [x, y, width, height] = (await page.locator(`g.device[data-node="${id}"]`).getAttribute('data-box'))!.split(' ').map(Number) as [number, number, number, number];
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// The device sheet in Chromium (Playwright): it opens with the device that
|
||||
// is chosen, shows Java's fields, takes a field when it is left, marks and
|
||||
// keeps a text its rule refuses, moves a notebook into a wireless network —
|
||||
// beside the surface on a wide screen, below it on a phone.
|
||||
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import type { HostSheet } from '@mdg-filius/doc';
|
||||
import { drawing, onDevice, openEditor, serveEditor, sheetOf } from './editorPage.js';
|
||||
|
||||
let server: { readonly base: string; readonly close: () => Promise<void> };
|
||||
|
||||
test.beforeAll(async () => {
|
||||
server = await serveEditor();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
async function deviceNamed(page: Page, name: string): Promise<string> {
|
||||
return (await drawing(page)).nodes.find((node) => node.name === name)!.id;
|
||||
}
|
||||
|
||||
async function choose(page: Page, id: string): Promise<void> {
|
||||
const at = await onDevice(page, id);
|
||||
await page.mouse.click(at.x, at.y);
|
||||
}
|
||||
|
||||
const sheet = (page: Page) => page.getByRole('complementary', { name: 'Einstellungen' });
|
||||
const field = (page: Page, name: string) => sheet(page).locator(`input[name="${name}"]`);
|
||||
|
||||
test("opens with the chosen device, shows Java's fields beside the surface, and closes letting go of it", async ({ page }) => {
|
||||
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
|
||||
await expect(sheet(page)).toHaveCount(0);
|
||||
const id = await deviceNamed(page, 'Notebook 1.10');
|
||||
await choose(page, id);
|
||||
await expect(sheet(page)).toBeVisible();
|
||||
await expect(sheet(page).getByRole('heading')).toHaveText('Notebook');
|
||||
const values = (await sheetOf(page, id)) as HostSheet;
|
||||
await expect(field(page, 'name')).toHaveValue('Notebook 1.10');
|
||||
await expect(field(page, 'macAddress')).toHaveValue(values.macAddress);
|
||||
await expect(field(page, 'ipAddress')).toHaveValue(values.ipAddress);
|
||||
await expect(field(page, 'subnetMask')).toHaveValue(values.subnetMask);
|
||||
await expect(field(page, 'defaultGateway')).toHaveValue(values.defaultGateway);
|
||||
await expect(sheet(page).getByLabel('Kabelgebunden (LAN)')).toBeChecked();
|
||||
await expect(sheet(page).getByLabel('DHCP zur Konfiguration verwenden')).not.toBeChecked();
|
||||
const surface = (await page.locator('svg.filius-canvas').boundingBox())!;
|
||||
const aside = (await sheet(page).boundingBox())!;
|
||||
expect(aside.x).toBeGreaterThanOrEqual(surface.x + surface.width - 1);
|
||||
expect(aside.width).toBe(340);
|
||||
await sheet(page).getByRole('button', { name: 'Schließen' }).click();
|
||||
await expect(sheet(page)).toHaveCount(0);
|
||||
await expect(page.locator(`g.device[data-node="${id}"]`)).not.toHaveAttribute('data-selected', 'true');
|
||||
});
|
||||
|
||||
test('marks a refused address while it is typed, keeps it with what still holds, and takes a mended one', async ({ page }) => {
|
||||
await openEditor(page, server.base, 'new');
|
||||
await page.locator('[data-palette="pc"]').click();
|
||||
await expect(sheet(page)).toBeVisible();
|
||||
const id = (await drawing(page)).nodes[0]!.id;
|
||||
const ip = field(page, 'ipAddress');
|
||||
await ip.fill('10.0.0.256');
|
||||
await expect(ip).toHaveAttribute('aria-invalid', 'true');
|
||||
await field(page, 'subnetMask').focus();
|
||||
await expect(sheet(page).getByRole('status')).toHaveText('Nicht übernommen – es gilt weiterhin 192.168.0.10.');
|
||||
await expect(ip).toHaveValue('10.0.0.256');
|
||||
expect(((await sheetOf(page, id)) as HostSheet).ipAddress).toBe('192.168.0.10');
|
||||
await ip.fill('010.0.0.5');
|
||||
await expect(ip).toHaveAttribute('aria-invalid', 'false');
|
||||
await expect(sheet(page).getByRole('status')).toHaveCount(0);
|
||||
await ip.press('Enter');
|
||||
await expect(ip).toHaveValue('10.0.0.5');
|
||||
expect(((await sheetOf(page, id)) as HostSheet).ipAddress).toBe('10.0.0.5');
|
||||
// Escape gives a text up; the empty gateway is taken, the empty address is not.
|
||||
await ip.fill('1.2.3');
|
||||
await ip.press('Escape');
|
||||
await expect(ip).toHaveValue('10.0.0.5');
|
||||
await expect(sheet(page)).toBeVisible();
|
||||
await field(page, 'defaultGateway').fill('');
|
||||
await field(page, 'defaultGateway').press('Enter');
|
||||
await ip.fill('');
|
||||
await ip.press('Enter');
|
||||
await expect(sheet(page).getByRole('status')).toHaveText('Nicht übernommen – es gilt weiterhin 10.0.0.5.');
|
||||
});
|
||||
|
||||
test('takes a change back as one step, and shows the address as the name', async ({ page }) => {
|
||||
await openEditor(page, server.base, 'new');
|
||||
await page.locator('[data-palette="pc"]').click();
|
||||
const id = (await drawing(page)).nodes[0]!.id;
|
||||
await field(page, 'ipAddress').fill('10.0.0.9');
|
||||
await field(page, 'ipAddress').press('Enter');
|
||||
await sheet(page).getByLabel('IP-Adresse als Name verwenden').check();
|
||||
await expect.poll(async () => (await drawing(page)).nodes[0]!.label).toBe('10.0.0.9');
|
||||
await expect(field(page, 'name')).toBeDisabled();
|
||||
await expect(field(page, 'name')).toHaveValue('10.0.0.9');
|
||||
await page.getByRole('button', { name: 'Rückgängig' }).click();
|
||||
await expect.poll(async () => (await drawing(page)).nodes[0]!.label).toBe('Rechner');
|
||||
await expect(field(page, 'name')).toBeEnabled();
|
||||
await page.getByRole('button', { name: 'Rückgängig' }).click();
|
||||
await expect(field(page, 'ipAddress')).toHaveValue('192.168.0.10');
|
||||
expect(((await sheetOf(page, id)) as HostSheet).ipAddress).toBe('192.168.0.10');
|
||||
});
|
||||
|
||||
test('takes the text of one device when another is chosen, and shows the other', async ({ page }) => {
|
||||
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
|
||||
const first = await deviceNamed(page, 'Notebook 1.10');
|
||||
const second = await deviceNamed(page, 'Notebook 1.11');
|
||||
await choose(page, first);
|
||||
await field(page, 'dnsServer').fill('192.168.1.53');
|
||||
await choose(page, second);
|
||||
await expect(sheet(page)).toHaveAttribute('data-device', second);
|
||||
expect(((await sheetOf(page, first)) as HostSheet).dnsServer).toBe('192.168.1.53');
|
||||
await expect(field(page, 'name')).toHaveValue('Notebook 1.11');
|
||||
await expect(field(page, 'dnsServer')).toHaveValue(((await sheetOf(page, second)) as HostSheet).dnsServer);
|
||||
});
|
||||
|
||||
test('offers no text of one device to the next, a refused one neither', async ({ page }) => {
|
||||
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
|
||||
const first = await deviceNamed(page, 'Notebook 1.10');
|
||||
const second = await deviceNamed(page, 'Notebook 1.11');
|
||||
await choose(page, first);
|
||||
await field(page, 'ipAddress').fill('300.0.0.1');
|
||||
await choose(page, second);
|
||||
await expect(sheet(page)).toHaveAttribute('data-device', second);
|
||||
await expect(field(page, 'ipAddress')).toHaveValue(((await sheetOf(page, second)) as HostSheet).ipAddress);
|
||||
await expect(field(page, 'ipAddress')).toHaveAttribute('aria-invalid', 'false');
|
||||
await expect(sheet(page).getByRole('status')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('closes the address fields while DHCP configures the card, as Java does', async ({ page }) => {
|
||||
await openEditor(page, server.base, 'new');
|
||||
await page.locator('[data-palette="pc"]').click();
|
||||
const fields = ['ipAddress', 'subnetMask', 'defaultGateway', 'dnsServer'];
|
||||
await sheet(page).getByLabel('DHCP zur Konfiguration verwenden').check();
|
||||
for (const name of fields) await expect(field(page, name)).toBeDisabled();
|
||||
await expect(field(page, 'name')).toBeEnabled();
|
||||
await sheet(page).getByLabel('DHCP zur Konfiguration verwenden').uncheck();
|
||||
for (const name of fields) await expect(field(page, name)).toBeEnabled();
|
||||
});
|
||||
|
||||
test("joins a notebook to a switch's wireless network, drawn as wireless", async ({ page }) => {
|
||||
await openEditor(page, server.base, 'new');
|
||||
await page.locator('[data-palette="networkSwitch"]').click();
|
||||
const hub = (await drawing(page)).nodes[0]!.id;
|
||||
await field(page, 'ssid').fill('Schule');
|
||||
await field(page, 'ssid').press('Enter');
|
||||
await sheet(page).getByLabel('als Wolke darstellen').check();
|
||||
await expect.poll(async () => (await drawing(page)).nodes[0]!.cloud).toBe(true);
|
||||
await page.locator('[data-palette="notebook"]').click();
|
||||
await expect(sheet(page).getByRole('heading')).toHaveText('Notebook');
|
||||
await expect(sheet(page).locator('select[name="ssid"]')).toBeDisabled();
|
||||
await sheet(page).getByLabel('Drahtlos (WLAN)').check();
|
||||
await sheet(page).locator('select[name="ssid"]').selectOption('Schule');
|
||||
await expect.poll(async () => (await drawing(page)).links.map((link) => [link.targetNode, link.wireless])).toEqual([[hub, true]]);
|
||||
await sheet(page).getByLabel('Kabelgebunden (LAN)').check();
|
||||
await expect.poll(async () => (await drawing(page)).links).toEqual([]);
|
||||
});
|
||||
|
||||
test.describe('on a phone', () => {
|
||||
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
|
||||
|
||||
test('opens below the surface with a tap, the device still in sight', async ({ page }) => {
|
||||
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
|
||||
const id = await deviceNamed(page, 'Notebook 1.10');
|
||||
const at = await onDevice(page, id);
|
||||
await page.touchscreen.tap(at.x, at.y);
|
||||
await expect(sheet(page)).toBeVisible();
|
||||
const surface = (await page.locator('svg.filius-canvas').boundingBox())!;
|
||||
const aside = (await sheet(page).boundingBox())!;
|
||||
expect(aside.y).toBeGreaterThanOrEqual(surface.y + surface.height - 1);
|
||||
expect(aside.height).toBeLessThanOrEqual(844 / 2);
|
||||
expect(surface.height).toBeGreaterThan(200);
|
||||
const fieldBox = (await field(page, 'ipAddress').boundingBox())!;
|
||||
expect(fieldBox.height).toBeGreaterThanOrEqual(44);
|
||||
});
|
||||
});
|
||||
|
||||
test('speaks English when asked', async ({ page }) => {
|
||||
await openEditor(page, server.base, 'new', '&lang=en');
|
||||
await page.locator('[data-palette="networkSwitch"]').click();
|
||||
const aside = page.getByRole('complementary', { name: 'Settings' });
|
||||
await expect(aside.getByText('Wi-Fi Name (SSID)')).toBeVisible();
|
||||
await expect(aside.getByText('Retention Time SAT Entries (Seconds)')).toBeVisible();
|
||||
await expect(aside.getByLabel('Display as Cloud')).not.toBeChecked();
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
// The editor on a document: the palette, the surface, the tools of the design
|
||||
// mode (F4.4b). The device sheet and the simulation follow (F4.5, F4.6).
|
||||
// mode (F4.4b), the device sheet of the selected device (F4.5). The
|
||||
// simulation follows (F4.6).
|
||||
//
|
||||
// Every change goes through packages/doc's commands with this editor's own
|
||||
// origin, so that its undo takes back what this person did and nothing of
|
||||
@@ -13,6 +14,7 @@ import type { Point } from './canvas/viewport.js';
|
||||
import { ContextMenu, type MenuItem } from './design/ContextMenu.js';
|
||||
import { Palette, type PaletteKind } from './design/Palette.js';
|
||||
import { RouterDialog } from './design/RouterDialog.js';
|
||||
import { DeviceSheet } from './sheet/DeviceSheet.js';
|
||||
import { useTranslate } from './i18n/i18n.js';
|
||||
|
||||
/** The document's drawing, read anew after every change. */
|
||||
@@ -152,6 +154,8 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) {
|
||||
return items;
|
||||
};
|
||||
|
||||
const selected = selection?.kind === 'device' ? view.nodes.find((node) => node.id === selection.id) : undefined;
|
||||
|
||||
// The keyboard: undo and redo, delete what is selected, Escape lets go.
|
||||
useEffect(() => {
|
||||
const key = (event: KeyboardEvent) => {
|
||||
@@ -186,58 +190,64 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) {
|
||||
}}
|
||||
onDevice={onDevice}
|
||||
/>
|
||||
<div style={{ position: 'relative', flex: 1, minWidth: 0 }}>
|
||||
<Canvas
|
||||
ref={canvas}
|
||||
view={view}
|
||||
tool={cableTool ? 'cable' : 'select'}
|
||||
selection={selection}
|
||||
cableFrom={cableFrom}
|
||||
onSelect={setSelection}
|
||||
onMoveDevice={(id, x, y) => moveDevice(doc, id, x, y, origin)}
|
||||
onMoveEnd={() => manager?.stopCapturing()}
|
||||
onCableStart={setCableFrom}
|
||||
onCable={onCable}
|
||||
onMenu={(target, at) => {
|
||||
setSelection(target);
|
||||
setMenu({ target, at });
|
||||
}}
|
||||
/>
|
||||
<div role="toolbar" style={{ position: 'absolute', left: 12, top: 12, display: 'flex', gap: 6 }}>
|
||||
<button type="button" style={toolButton} disabled={!canUndo} onClick={() => manager?.undo()} aria-label={t('design.undo')} title={t('design.undo')}>
|
||||
↶
|
||||
</button>
|
||||
<button type="button" style={toolButton} disabled={!canRedo} onClick={() => manager?.redo()} aria-label={t('design.redo')} title={t('design.redo')}>
|
||||
↷
|
||||
</button>
|
||||
</div>
|
||||
{menu === null ? null : <ContextMenu at={menu.at} items={menuItems(menu.target)} onClose={() => setMenu(null)} />}
|
||||
{routerAt === null ? null : (
|
||||
<RouterDialog
|
||||
onChoose={(interfaces) => {
|
||||
place('router', routerAt, interfaces);
|
||||
setRouterAt(null);
|
||||
<div className="editor-work">
|
||||
<div className="editor-area">
|
||||
<Canvas
|
||||
ref={canvas}
|
||||
view={view}
|
||||
tool={cableTool ? 'cable' : 'select'}
|
||||
selection={selection}
|
||||
cableFrom={cableFrom}
|
||||
onSelect={setSelection}
|
||||
onMoveDevice={(id, x, y) => moveDevice(doc, id, x, y, origin)}
|
||||
onMoveEnd={() => manager?.stopCapturing()}
|
||||
onCableStart={setCableFrom}
|
||||
onCable={onCable}
|
||||
onMenu={(target, at) => {
|
||||
setSelection(target);
|
||||
setMenu({ target, at });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{message === null ? null : (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
bottom: 72,
|
||||
transform: 'translateX(-50%)',
|
||||
padding: '10px 16px',
|
||||
borderRadius: 8,
|
||||
background: 'var(--danger-soft)',
|
||||
color: 'var(--danger-on-soft)',
|
||||
border: '1px solid var(--danger)',
|
||||
maxWidth: '80%',
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
<div role="toolbar" style={{ position: 'absolute', left: 12, top: 12, display: 'flex', gap: 6 }}>
|
||||
<button type="button" style={toolButton} disabled={!canUndo} onClick={() => manager?.undo()} aria-label={t('design.undo')} title={t('design.undo')}>
|
||||
↶
|
||||
</button>
|
||||
<button type="button" style={toolButton} disabled={!canRedo} onClick={() => manager?.redo()} aria-label={t('design.redo')} title={t('design.redo')}>
|
||||
↷
|
||||
</button>
|
||||
</div>
|
||||
{menu === null ? null : <ContextMenu at={menu.at} items={menuItems(menu.target)} onClose={() => setMenu(null)} />}
|
||||
{routerAt === null ? null : (
|
||||
<RouterDialog
|
||||
onChoose={(interfaces) => {
|
||||
place('router', routerAt, interfaces);
|
||||
setRouterAt(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{message === null ? null : (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
bottom: 72,
|
||||
transform: 'translateX(-50%)',
|
||||
padding: '10px 16px',
|
||||
borderRadius: 8,
|
||||
background: 'var(--danger-soft)',
|
||||
color: 'var(--danger-on-soft)',
|
||||
border: '1px solid var(--danger)',
|
||||
maxWidth: '80%',
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Java shows the settings of the device that is chosen; closing them lets go of it, as a click beside it does. */}
|
||||
{selected === undefined ? null : (
|
||||
<DeviceSheet doc={doc} id={selected.id} label={selected.label} origin={origin} onChanged={() => manager?.stopCapturing()} onMessage={setMessage} onClose={() => setSelection(null)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,31 @@
|
||||
"router.cancel": "Abbrechen",
|
||||
"router.prompt": "Wählen Sie die Anzahl der Schnittstellen des Vermittlungsrechners aus!",
|
||||
"router.title": "Anzahl Schnittstellen",
|
||||
"sheet.cable": "Kabelgebunden (LAN)",
|
||||
"sheet.cablePlugged": "Das Kabel ist noch eingesteckt.",
|
||||
"sheet.chooseNetwork": "Bitte auswählen",
|
||||
"sheet.close": "Schließen",
|
||||
"sheet.cloud": "als Wolke darstellen",
|
||||
"sheet.connection": "Anschluss",
|
||||
"sheet.defaultGateway": "Gateway",
|
||||
"sheet.dhcpClient": "DHCP zur Konfiguration verwenden",
|
||||
"sheet.dnsServer": "Domain Name Server",
|
||||
"sheet.empty": "(leer)",
|
||||
"sheet.invalidValue": "Dieser Wert ist ungültig.",
|
||||
"sheet.ipAddress": "IP-Adresse",
|
||||
"sheet.ipAsName": "IP-Adresse als Name verwenden",
|
||||
"sheet.ipForwarding": "IP-Weiterleitung aktivieren",
|
||||
"sheet.macAddress": "MAC-Adresse",
|
||||
"sheet.macAsName": "MAC-Adresse als Name verwenden",
|
||||
"sheet.name": "Name",
|
||||
"sheet.network": "Drahtloses Netzwerk (SSID)",
|
||||
"sheet.notTaken": "Nicht übernommen – es gilt weiterhin {value}.",
|
||||
"sheet.port": "Port",
|
||||
"sheet.retention": "Gültigkeitsdauer SAT-Einträge (in Sek.)",
|
||||
"sheet.subnetMask": "Netzmaske",
|
||||
"sheet.switchSsid": "Name WLAN (SSID)",
|
||||
"sheet.title": "Einstellungen",
|
||||
"sheet.wireless": "Drahtlos (WLAN)",
|
||||
"zoom.fit": "Alles zeigen",
|
||||
"zoom.in": "Vergrößern",
|
||||
"zoom.level": "{percent} %",
|
||||
|
||||
@@ -33,6 +33,31 @@
|
||||
"router.cancel": "Cancel",
|
||||
"router.prompt": "Choose number of NIC of the router!",
|
||||
"router.title": "Number of NIC",
|
||||
"sheet.cable": "Cable (LAN)",
|
||||
"sheet.cablePlugged": "The cable is still plugged in.",
|
||||
"sheet.chooseNetwork": "Please select",
|
||||
"sheet.close": "Close",
|
||||
"sheet.cloud": "Display as Cloud",
|
||||
"sheet.connection": "Connection",
|
||||
"sheet.defaultGateway": "Gateway",
|
||||
"sheet.dhcpClient": "Use DHCP for configuration",
|
||||
"sheet.dnsServer": "Domain Name Server",
|
||||
"sheet.empty": "(empty)",
|
||||
"sheet.invalidValue": "This value is invalid.",
|
||||
"sheet.ipAddress": "IP Address",
|
||||
"sheet.ipAsName": "Use IP address as name",
|
||||
"sheet.ipForwarding": "Activate IP forwarding",
|
||||
"sheet.macAddress": "MAC Address",
|
||||
"sheet.macAsName": "Use MAC address as name",
|
||||
"sheet.name": "Name",
|
||||
"sheet.network": "Wireless Network (SSID)",
|
||||
"sheet.notTaken": "Not taken – still {value}.",
|
||||
"sheet.port": "Port",
|
||||
"sheet.retention": "Retention Time SAT Entries (Seconds)",
|
||||
"sheet.subnetMask": "Netmask",
|
||||
"sheet.switchSsid": "Wi-Fi Name (SSID)",
|
||||
"sheet.title": "Settings",
|
||||
"sheet.wireless": "Wireless (Wi-Fi)",
|
||||
"zoom.fit": "Show all",
|
||||
"zoom.in": "Zoom in",
|
||||
"zoom.level": "{percent} %",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { StrictMode, useEffect, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import * as Y from 'yjs';
|
||||
import { emptyProject, topologyView, writeProject } from '@mdg-filius/doc';
|
||||
import { deviceSheet, emptyProject, topologyView, writeProject } from '@mdg-filius/doc';
|
||||
import { openProjectArchive } from '@mdg-filius/fls';
|
||||
import { startBridge } from './bridge/dashboardBridge.js';
|
||||
import { Editor } from './Editor.js';
|
||||
@@ -62,7 +62,7 @@ function App() {
|
||||
|
||||
// The development page lets its browser tests read the document's drawing (never the editor embedded in the dashboard).
|
||||
useEffect(() => {
|
||||
(window as unknown as { filiusDev?: unknown }).filiusDev = doc === null ? undefined : { view: () => topologyView(doc) };
|
||||
(window as unknown as { filiusDev?: unknown }).filiusDev = doc === null ? undefined : { view: () => topologyView(doc), sheet: (id: string) => deviceSheet(doc, id) };
|
||||
}, [doc]);
|
||||
|
||||
const t = (key: string) => translate(language, key);
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
// The device sheet (plan §7.1): the settings of the selected device, the
|
||||
// fields of FILIUS' settings panel of its kind (JHostKonfiguration,
|
||||
// JSwitchKonfiguration, JModemKonfiguration) in the panels' order. Java
|
||||
// shows the panel when a device is chosen; so does the sheet, beside the
|
||||
// surface or below it on a phone. Each change is one step of the person's
|
||||
// undo. A router's and a home router's sheet name the device only, until
|
||||
// their own panels follow (F4.5b).
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type * as Y from 'yjs';
|
||||
import {
|
||||
deviceSheet,
|
||||
renameDevice,
|
||||
setDhcpClient,
|
||||
setHostAddress,
|
||||
setHostLabels,
|
||||
setHostWireless,
|
||||
setIpForwarding,
|
||||
setModemPort,
|
||||
setSwitchCloud,
|
||||
setSwitchRetention,
|
||||
setSwitchSsid,
|
||||
type CheckedField,
|
||||
type DeviceSheet as Sheet,
|
||||
type HostSheet,
|
||||
type ModemSheet,
|
||||
type SwitchSheet,
|
||||
type WirelessError,
|
||||
} from '@mdg-filius/doc';
|
||||
import { iconOf } from '../canvas/layout.js';
|
||||
import { ICON_URLS } from '../canvas/icons.js';
|
||||
import { useTranslate } from '../i18n/i18n.js';
|
||||
import { SheetCheck, SheetField } from './SheetField.js';
|
||||
import './sheet.css';
|
||||
|
||||
/** The device's sheet, read anew after every change of the document. */
|
||||
function useDeviceSheet(doc: Y.Doc, id: string): Sheet | undefined {
|
||||
const [sheet, setSheet] = useState(() => deviceSheet(doc, id));
|
||||
useEffect(() => {
|
||||
setSheet(deviceSheet(doc, id));
|
||||
const update = () => setSheet(deviceSheet(doc, id));
|
||||
doc.on('update', update);
|
||||
return () => doc.off('update', update);
|
||||
}, [doc, id]);
|
||||
return sheet;
|
||||
}
|
||||
|
||||
export interface DeviceSheetProps {
|
||||
readonly doc: Y.Doc;
|
||||
readonly id: string;
|
||||
/** What the surface writes under the icon (a host's address or MAC address, when it shows them). */
|
||||
readonly label: string;
|
||||
readonly origin: object;
|
||||
/** After each change: ends the undo step. */
|
||||
readonly onChanged: () => void;
|
||||
readonly onMessage: (message: string) => void;
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
interface Change {
|
||||
readonly doc: Y.Doc;
|
||||
readonly id: string;
|
||||
readonly origin: object;
|
||||
/** Runs a change and ends the undo step. */
|
||||
readonly run: <T>(change: () => T) => T;
|
||||
}
|
||||
|
||||
export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onClose }: DeviceSheetProps) {
|
||||
const t = useTranslate();
|
||||
const sheet = useDeviceSheet(doc, id);
|
||||
if (sheet === undefined) return null;
|
||||
const change: Change = {
|
||||
doc,
|
||||
id,
|
||||
origin,
|
||||
run: (each) => {
|
||||
const result = each();
|
||||
onChanged();
|
||||
return result;
|
||||
},
|
||||
};
|
||||
const title = t(`device.${sheet.kind}`);
|
||||
return (
|
||||
<aside className="device-sheet" aria-label={t('sheet.title')} data-device={id}>
|
||||
<header>
|
||||
<img src={ICON_URLS[iconOf(sheet.kind, sheet.kind === 'networkSwitch' && sheet.cloud)]} alt="" />
|
||||
<h2>{title}</h2>
|
||||
<button type="button" className="sheet-close" onClick={onClose} aria-label={t('sheet.close')} title={t('sheet.close')}>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
{/* A field per device: a text typed for one device is not offered to the next. */}
|
||||
<div key={id}>
|
||||
{sheet.kind === 'pc' || sheet.kind === 'notebook' ? (
|
||||
<HostFields sheet={sheet} label={label} change={change} onMessage={onMessage} />
|
||||
) : sheet.kind === 'networkSwitch' ? (
|
||||
<SwitchFields sheet={sheet} change={change} />
|
||||
) : sheet.kind === 'remoteLink' ? (
|
||||
<ModemFields sheet={sheet} change={change} />
|
||||
) : (
|
||||
<NameField name={sheet.name} change={change} />
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function NameField({ name, change, shown, disabled = false }: { readonly name: string; readonly change: Change; readonly shown?: string; readonly disabled?: boolean }) {
|
||||
const t = useTranslate();
|
||||
return (
|
||||
<SheetField
|
||||
name="name"
|
||||
label={t('sheet.name')}
|
||||
value={shown ?? name}
|
||||
rule="name"
|
||||
disabled={disabled}
|
||||
onCommit={(text) => change.run(() => (renameDevice(change.doc, change.id, text, change.origin), { value: text }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function HostFields({ sheet, label, change, onMessage }: { readonly sheet: HostSheet; readonly label: string; readonly change: Change; readonly onMessage: (message: string) => void }) {
|
||||
const t = useTranslate();
|
||||
const address =
|
||||
(field: 'ipAddress' | 'subnetMask' | 'defaultGateway' | 'dnsServer') =>
|
||||
(text: string): CheckedField =>
|
||||
change.run(() => setHostAddress(change.doc, change.id, field, text, change.origin));
|
||||
// Java's list offers the switches' networks; an SSID none of them has shows as "please choose" (JHostKonfiguration.updateSsid).
|
||||
const chosen = sheet.networks.includes(sheet.ssid) ? sheet.ssid : '';
|
||||
const wireless = (on: boolean, ssid: string) => {
|
||||
const result = change.run(() => setHostWireless(change.doc, change.id, on, ssid, crypto.randomUUID(), change.origin));
|
||||
if ('error' in result) onMessage(wirelessMessage(t, result.error));
|
||||
};
|
||||
const labelled = sheet.labels.ipAddress || sheet.labels.macAddress;
|
||||
return (
|
||||
<>
|
||||
{/* Java's name field shows what the surface writes, and is closed while that is the address. */}
|
||||
<NameField name={sheet.name} shown={labelled ? label : sheet.name} disabled={labelled} change={change} />
|
||||
<SheetField name="macAddress" label={t('sheet.macAddress')} value={sheet.macAddress} rule="name" readOnly onCommit={() => ({ invalid: true })} />
|
||||
<SheetField name="ipAddress" label={t('sheet.ipAddress')} value={sheet.ipAddress} rule="ipAddress" inputMode="decimal" disabled={sheet.dhcpClient} onCommit={address('ipAddress')} />
|
||||
<SheetField name="subnetMask" label={t('sheet.subnetMask')} value={sheet.subnetMask} rule="subnetMask" inputMode="decimal" disabled={sheet.dhcpClient} onCommit={address('subnetMask')} />
|
||||
<SheetField
|
||||
name="defaultGateway"
|
||||
label={t('sheet.defaultGateway')}
|
||||
value={sheet.defaultGateway}
|
||||
rule="ipAddressOrEmpty"
|
||||
inputMode="decimal"
|
||||
disabled={sheet.dhcpClient}
|
||||
onCommit={address('defaultGateway')}
|
||||
/>
|
||||
<SheetField name="dnsServer" label={t('sheet.dnsServer')} value={sheet.dnsServer} rule="ipAddressOrEmpty" inputMode="decimal" disabled={sheet.dhcpClient} onCommit={address('dnsServer')} />
|
||||
<fieldset className="sheet-group" role="radiogroup" aria-label={t('sheet.connection')}>
|
||||
<label className="sheet-check">
|
||||
<input type="radio" name="connection" value="cable" checked={!sheet.wireless} onChange={() => wireless(false, chosen)} />
|
||||
{t('sheet.cable')}
|
||||
</label>
|
||||
<label className={sheet.wirelessPossible ? 'sheet-check' : 'sheet-check disabled'}>
|
||||
<input type="radio" name="connection" value="wireless" checked={sheet.wireless} disabled={!sheet.wirelessPossible} onChange={() => wireless(true, chosen)} />
|
||||
{t('sheet.wireless')}
|
||||
</label>
|
||||
</fieldset>
|
||||
<label className="sheet-field">
|
||||
<span>{t('sheet.network')}</span>
|
||||
<select name="ssid" value={chosen} disabled={!sheet.wireless} onChange={(event) => wireless(sheet.wireless, event.target.value)}>
|
||||
<option value="">{t('sheet.chooseNetwork')}</option>
|
||||
{sheet.networks.map((network) => (
|
||||
<option key={network} value={network}>
|
||||
{network}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<SheetCheck
|
||||
name="ipAsName"
|
||||
label={t('sheet.ipAsName')}
|
||||
checked={sheet.labels.ipAddress}
|
||||
onChange={(on) => change.run(() => setHostLabels(change.doc, change.id, { ...sheet.labels, ipAddress: on }, change.origin))}
|
||||
/>
|
||||
<SheetCheck
|
||||
name="macAsName"
|
||||
label={t('sheet.macAsName')}
|
||||
checked={sheet.labels.macAddress}
|
||||
onChange={(on) => change.run(() => setHostLabels(change.doc, change.id, { ...sheet.labels, macAddress: on }, change.origin))}
|
||||
/>
|
||||
<SheetCheck name="ipForwarding" label={t('sheet.ipForwarding')} checked={sheet.ipForwarding} onChange={(on) => change.run(() => setIpForwarding(change.doc, change.id, on, change.origin))} />
|
||||
<SheetCheck name="dhcpClient" label={t('sheet.dhcpClient')} checked={sheet.dhcpClient} onChange={(on) => change.run(() => setDhcpClient(change.doc, change.id, on, change.origin))} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function wirelessMessage(t: ReturnType<typeof useTranslate>, error: WirelessError): string {
|
||||
return error === 'cablePlugged' ? t('sheet.cablePlugged') : t(`cable.${error}`);
|
||||
}
|
||||
|
||||
function SwitchFields({ sheet, change }: { readonly sheet: SwitchSheet; readonly change: Change }) {
|
||||
const t = useTranslate();
|
||||
return (
|
||||
<>
|
||||
<NameField name={sheet.name} change={change} />
|
||||
<SheetField name="ssid" label={t('sheet.switchSsid')} value={sheet.ssid} rule="ssid" onCommit={(text) => change.run(() => setSwitchSsid(change.doc, change.id, text, change.origin))} />
|
||||
<SheetCheck name="cloud" label={t('sheet.cloud')} checked={sheet.cloud} onChange={(on) => change.run(() => setSwitchCloud(change.doc, change.id, on, change.origin))} />
|
||||
<SheetField
|
||||
name="retention"
|
||||
label={t('sheet.retention')}
|
||||
value={String(sheet.retentionSeconds)}
|
||||
rule="seconds"
|
||||
inputMode="numeric"
|
||||
onCommit={(text) => change.run(() => setSwitchRetention(change.doc, change.id, text, change.origin))}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ModemFields({ sheet, change }: { readonly sheet: ModemSheet; readonly change: Change }) {
|
||||
const t = useTranslate();
|
||||
return (
|
||||
<>
|
||||
<NameField name={sheet.name} change={change} />
|
||||
<SheetField
|
||||
name="port"
|
||||
label={t('sheet.port')}
|
||||
value={String(sheet.port)}
|
||||
rule="port"
|
||||
inputMode="numeric"
|
||||
onCommit={(text) => change.run(() => setModemPort(change.doc, change.id, text, change.origin))}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// A text field of the device sheet. Java takes a field's text when it loses
|
||||
// the focus or Enter is pressed, and colours a text its rule refuses red
|
||||
// while it is typed. A refused text stays here, marked, with what the device
|
||||
// keeps meanwhile, instead of being replaced without a word as Java does;
|
||||
// Escape gives it up. A value the device already holds that the rule
|
||||
// refuses (from a file, or written by someone else) is marked, never mended
|
||||
// (plan I6).
|
||||
|
||||
import { useId, useState, type KeyboardEvent } from 'react';
|
||||
import { checkField, type CheckedField, type FieldRule } from '@mdg-filius/doc';
|
||||
import { useTranslate } from '../i18n/i18n.js';
|
||||
|
||||
export interface SheetFieldProps {
|
||||
readonly label: string;
|
||||
/** What the device holds. */
|
||||
readonly value: string;
|
||||
readonly rule: FieldRule;
|
||||
/** Gives the text to the device; says whether it was taken. */
|
||||
readonly onCommit: (text: string) => CheckedField;
|
||||
readonly disabled?: boolean;
|
||||
readonly readOnly?: boolean;
|
||||
/** A name for the browser tests and the label's input mode. */
|
||||
readonly name: string;
|
||||
readonly inputMode?: 'text' | 'decimal' | 'numeric';
|
||||
}
|
||||
|
||||
export function SheetField({ label, value, rule, onCommit, disabled = false, readOnly = false, name, inputMode = 'text' }: SheetFieldProps) {
|
||||
const t = useTranslate();
|
||||
const id = useId();
|
||||
// What is typed and not yet taken; null shows the device's value.
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const [refused, setRefused] = useState(false);
|
||||
const shown = draft ?? value;
|
||||
const marked = !readOnly && 'invalid' in checkField(rule, shown);
|
||||
|
||||
const commit = () => {
|
||||
if (draft === null || readOnly) return;
|
||||
if (draft === value) {
|
||||
setDraft(null);
|
||||
setRefused(false);
|
||||
return;
|
||||
}
|
||||
const result = onCommit(draft);
|
||||
if ('value' in result) {
|
||||
setDraft(null);
|
||||
setRefused(false);
|
||||
} else {
|
||||
setRefused(true);
|
||||
}
|
||||
};
|
||||
|
||||
const key = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') commit();
|
||||
if (event.key === 'Escape') {
|
||||
setDraft(null);
|
||||
setRefused(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hint = refused && draft !== null ? t('sheet.notTaken', { value: value === '' ? t('sheet.empty') : value }) : draft === null && marked ? t('sheet.invalidValue') : null;
|
||||
return (
|
||||
<label className="sheet-field" htmlFor={id}>
|
||||
<span>{label}</span>
|
||||
<input
|
||||
id={id}
|
||||
name={name}
|
||||
value={shown}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
inputMode={inputMode}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-invalid={marked}
|
||||
aria-describedby={hint === null ? undefined : `${id}-hint`}
|
||||
onChange={(event) => {
|
||||
setDraft(event.target.value);
|
||||
setRefused(false);
|
||||
}}
|
||||
onBlur={commit}
|
||||
onKeyDown={key}
|
||||
/>
|
||||
{hint === null ? null : (
|
||||
<span id={`${id}-hint`} className="sheet-hint" role="status">
|
||||
{hint}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function SheetCheck({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
disabled = false,
|
||||
name,
|
||||
}: {
|
||||
readonly label: string;
|
||||
readonly checked: boolean;
|
||||
readonly onChange: (checked: boolean) => void;
|
||||
readonly disabled?: boolean;
|
||||
readonly name: string;
|
||||
}) {
|
||||
return (
|
||||
<label className={disabled ? 'sheet-check disabled' : 'sheet-check'}>
|
||||
<input type="checkbox" name={name} checked={checked} disabled={disabled} onChange={(event) => onChange(event.target.checked)} />
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/* The device sheet beside the surface on a wide screen, below it on a phone
|
||||
* (the dashboard's phone width, 639 px): the surface shrinks instead of
|
||||
* being covered, so the device stays in sight. */
|
||||
.editor-work {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.editor-area {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.device-sheet {
|
||||
width: 340px;
|
||||
flex: none;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
padding: 12px 16px 24px;
|
||||
border-left: 1px solid var(--border-default);
|
||||
background: var(--surface-card);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.editor-work {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.device-sheet {
|
||||
width: auto;
|
||||
max-height: 50%;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--border-default);
|
||||
border-radius: 16px 16px 0 0;
|
||||
box-shadow: 0 -4px 16px color-mix(in srgb, var(--shadow-color) 15%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.device-sheet header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.device-sheet header img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.device-sheet h2 {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.device-sheet .sheet-close {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font: inherit;
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sheet-field {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.sheet-field > span {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sheet-field input,
|
||||
.sheet-field select {
|
||||
min-height: 44px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-primary);
|
||||
/* 16 px: Safari on the iPad zooms into smaller fields. */
|
||||
font: inherit;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.sheet-field input:focus-visible,
|
||||
.sheet-field select:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.sheet-field input:disabled,
|
||||
.sheet-field select:disabled {
|
||||
color: var(--text-muted);
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
.sheet-field input[readonly] {
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
/* Java colours a text its rule refuses red (EingabenUeberpruefung.farbeFalsch). */
|
||||
.sheet-field input[aria-invalid='true'] {
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.sheet-field .sheet-hint {
|
||||
font-size: 13px;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.sheet-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 44px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sheet-check input {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.sheet-check.disabled {
|
||||
color: var(--text-muted);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.sheet-group {
|
||||
margin: 8px 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
@@ -39,11 +39,30 @@ const FROM_JAVA: Readonly<Record<string, string>> = {
|
||||
'cable.lan': 'guievents_msg25',
|
||||
'cable.wirelessNotPossible': 'guievents_msg26',
|
||||
'router.title': 'guicontainer_msg2',
|
||||
'sheet.name': 'jhostkonfiguration_msg1',
|
||||
'sheet.ipAddress': 'jhostkonfiguration_msg3',
|
||||
'sheet.subnetMask': 'jhostkonfiguration_msg4',
|
||||
'sheet.defaultGateway': 'jhostkonfiguration_msg5',
|
||||
'sheet.dnsServer': 'jhostkonfiguration_msg6',
|
||||
'sheet.dhcpClient': 'jhostkonfiguration_msg7',
|
||||
'sheet.macAddress': 'jhostkonfiguration_msg9',
|
||||
'sheet.ipAsName': 'jhostkonfiguration_msg10',
|
||||
'sheet.ipForwarding': 'jhostkonfiguration_msg11',
|
||||
'sheet.macAsName': 'jhostkonfiguration_msg12',
|
||||
'sheet.wireless': 'jhostkonfiguration_msg13',
|
||||
'sheet.cable': 'jhostkonfiguration_msg14',
|
||||
'sheet.chooseNetwork': 'jhostkonfiguration_msg16',
|
||||
'sheet.cloud': 'jswitchkonfiguration_msg3',
|
||||
'sheet.switchSsid': 'jswitchkonfiguration_msg4',
|
||||
'sheet.retention': 'jswitchkonfiguration_msg5',
|
||||
'sheet.port': 'jmodemkonfiguration_msg4',
|
||||
};
|
||||
|
||||
/** Java's words with an error of Java's mended: the prompt says "Vermittlungsrechnerns" (guicontainer_msg1). */
|
||||
/** Java's words, mended: the prompt says "Vermittlungsrechnerns" (guicontainer_msg1). */
|
||||
const FROM_JAVA_MENDED: Readonly<Record<string, readonly [string, (java: string) => string]>> = {
|
||||
'router.prompt': ['guicontainer_msg1', (java) => java.replace(' \\n ', ' ').replace('Vermittlungsrechnerns', 'Vermittlungsrechners')],
|
||||
// A label above its list, without the colon of Java's label beside it.
|
||||
'sheet.network': ['jhostkonfiguration_msg15', (java) => java.replace(/:$/, '')],
|
||||
};
|
||||
|
||||
describe('the catalogs', () => {
|
||||
|
||||
Reference in New Issue
Block a user