This commit is contained in:
seb
2026-08-08 16:58:48 +02:00
commit 253d0f95ee
27 changed files with 4083 additions and 0 deletions

178
src/dev/ComponentDocs.jsx Normal file
View File

@@ -0,0 +1,178 @@
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import Paper from '@mui/material/Paper';
import Stack from '@mui/material/Stack';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Typography from '@mui/material/Typography';
import { componentDocs } from 'virtual:component-docs';
/**
* Look up a component's docs from the registry extracted by the Vite plugin.
*/
export function getComponentDoc(name) {
return componentDocs[name];
}
/**
* Props documentation table.
* `props`: [{ name, type, required?, default?, description }]
*/
export function PropsTable({ props }) {
return (
<TableContainer
component={Paper}
variant="outlined"
sx={{ bgcolor: 'background.paper' }}
>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ width: '18%' }}>Prop</TableCell>
<TableCell sx={{ width: '24%' }}>Type</TableCell>
<TableCell sx={{ width: '12%' }}>Default</TableCell>
<TableCell>Description</TableCell>
</TableRow>
</TableHead>
<TableBody>
{props.map((p) => (
<TableRow
key={p.name}
sx={{ '&:last-child td, &:last-child th': { borderBottom: 0 } }}
>
<TableCell sx={{ py: 0.75 }}>
<Stack
direction="row"
spacing={1}
alignItems="center"
sx={{ flexWrap: 'wrap' }}
>
<Box
component="code"
sx={{ fontFamily: 'monospace', fontWeight: 600, fontSize: 13 }}
>
{p.name}
</Box>
{p.required ? (
<Chip
label="required"
size="small"
color="error"
sx={{ height: 18, fontSize: 10 }}
/>
) : null}
</Stack>
</TableCell>
<TableCell sx={{ py: 0.75 }}>
<Box
component="code"
sx={{
fontFamily: 'monospace',
fontSize: 12,
color: 'primary.main',
}}
>
{p.type}
</Box>
</TableCell>
<TableCell sx={{ py: 0.75 }}>
{p.default != null ? (
<Box component="code" sx={{ fontFamily: 'monospace', fontSize: 12 }}>
{p.default}
</Box>
) : (
'—'
)}
</TableCell>
<TableCell sx={{ py: 0.75, color: 'text.secondary' }}>
{p.description}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
}
/** Behaviour notes rendered as a bulleted list. */
export function BehaviourList({ items }) {
return (
<Box component="ul" sx={{ m: 0, pl: 2.5 }}>
{items.map((item, i) => (
<Box
component="li"
key={i}
sx={{ typography: 'body2', color: 'text.secondary', mb: 0.5 }}
>
{item}
</Box>
))}
</Box>
);
}
/**
* Full documentation block for one component. Reads the component's docs from
* the registry extracted by the Vite plugin (from JSDoc in the source files).
*
* name — component name as tagged with `@component` in the source JSDoc
*
* Optionally accepts overrides for any field (e.g. `props`/`behaviour`) when a
* mock page wants to add context on top of the source docs.
*/
export function ComponentDocs({ name, ...overrides }) {
const doc = { ...getComponentDoc(name), ...overrides };
const { importPath, defaultExport, description, props = [], behaviour = [] } = doc;
const importLine = importPath
? defaultExport
? `import ${name} from '${importPath}'`
: `import { ${name} } from '${importPath}'`
: null;
return (
<Paper variant="outlined" sx={{ mt: 3, p: 2.5 }}>
<Typography variant="h6" sx={{ mb: 0.5 }}>
{name}
</Typography>
{importLine ? (
<Typography
variant="caption"
component="code"
color="text.secondary"
sx={{ display: 'block', fontFamily: 'monospace', mb: 1 }}
>
{importLine}
</Typography>
) : null}
{description ? (
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{description}
</Typography>
) : null}
{props.length > 0 ? (
<>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Props
</Typography>
<PropsTable props={props} />
</>
) : null}
{behaviour.length > 0 ? (
<>
<Typography variant="subtitle2" sx={{ mt: 2.5, mb: 1 }}>
Behaviour
</Typography>
<BehaviourList items={behaviour} />
</>
) : null}
</Paper>
);
}
export default ComponentDocs;

55
src/dev/DevApp.jsx Normal file
View File

@@ -0,0 +1,55 @@
import { Component } from 'react';
import Box from '@mui/material/Box';
import Tabs from '@mui/material/Tabs';
import Tab from '@mui/material/Tab';
import Typography from '@mui/material/Typography';
import PanelsMockPage from './mock-pages/PanelsMockPage';
import TilesMockPage from './mock-pages/TilesMockPage';
import PillsMockPage from './mock-pages/PillsMockPage';
import TextFieldsMockPage from './mock-pages/TextFieldsMockPage';
import ListsMockPage from './mock-pages/ListsMockPage';
const PAGES = [
{ id: 'panels', label: 'Panels', node: <PanelsMockPage /> },
{ id: 'lists', label: 'Listen', node: <ListsMockPage /> },
{ id: 'tiles', label: 'Tiles', node: <TilesMockPage /> },
{ id: 'pills', label: 'Pills', node: <PillsMockPage /> },
{ id: 'textfields', label: 'Text fields', node: <TextFieldsMockPage /> },
];
/**
* Dev harness shell. Each tab renders a "mock page" exercising the library's
* components with fake data — no backend, no redux. Future test cases will be
* written against the same mock pages.
*/
class DevApp extends Component {
constructor(props) {
super(props);
this.state = { tab: 'panels' };
}
render() {
const { tab } = this.state;
const page = PAGES.find((p) => p.id === tab);
return (
<Box sx={{ p: 3, maxWidth: 1100, mx: 'auto' }}>
<Typography variant="h4" gutterBottom>
dashgui — component gallery
</Typography>
<Tabs
value={tab}
onChange={(_e, next) => this.setState({ tab: next })}
sx={{ mb: 3 }}
>
{PAGES.map((p) => (
<Tab key={p.id} value={p.id} label={p.label} />
))}
</Tabs>
{page?.node}
</Box>
);
}
}
export { DevApp };

17
src/dev/main.jsx Normal file
View File

@@ -0,0 +1,17 @@
import { createRoot } from 'react-dom/client';
import CssBaseline from '@mui/material/CssBaseline';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import { DevApp } from './DevApp';
// Standalone harness — mocks the consuming app's theme/bootstrap so the GUI
// primitives can be inspected without a backend.
const theme = createTheme({
shape: { borderRadius: 4 },
});
createRoot(document.getElementById('root')).render(
<ThemeProvider theme={theme}>
<CssBaseline />
<DevApp />
</ThemeProvider>,
);

View File

@@ -0,0 +1,154 @@
import { Component } from 'react';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import RecordList from '../../lists/RecordList';
import { ComponentDocs } from '../ComponentDocs';
const MOCK_ROWS = [
{
id: 1,
number: 'L-1001',
date: '2026-08-01',
party: 'Müller GmbH',
status: 'fertig',
},
{
id: 2,
number: 'L-1002',
date: '2026-08-03',
party: 'Schmidt & Co',
status: 'offen',
},
{
id: 3,
number: 'L-1003',
date: '2026-08-05',
party: 'Weber OHG',
status: 'offen',
},
];
const COLUMNS = [
{ key: 'number', label: 'Nummer' },
{ key: 'date', label: 'Lieferdatum' },
{ key: 'party', label: 'Lieferant' },
{
key: 'status',
label: 'Status',
render: (row) => (
<Chip
size="small"
label={row.status}
color={row.status === 'fertig' ? 'success' : 'default'}
/>
),
},
];
/**
* Mock page exercising RecordList with fake in-memory rows — no storage
* manager, no router, no toaster. Mirrors the shape the four dashfront list
* pages share (Lieferungen / Angebote / Aufträge / Kunden).
*/
class ListsMockPage extends Component {
constructor(props) {
super(props);
this.state = {
status: 'ready',
rows: MOCK_ROWS,
log: [],
};
}
simulateLoading = () => {
this.setState({ status: 'loading' });
setTimeout(() => this.setState({ status: 'ready' }), 800);
};
simulateFailure = () => {
this.setState({ status: 'failed' });
};
handleCreate = () => {
this.log('create');
};
handleEdit = (row) => {
this.log(`edit ${row.number}`);
};
handleDelete = (row) => {
this.log(`delete ${row.number}`);
this.setState((s) => ({ rows: s.rows.filter((r) => r.id !== row.id) }));
};
log(msg) {
this.setState((s) => ({ log: [msg, ...s.log].slice(0, 6) }));
}
render() {
const { status, rows, log } = this.state;
const failedNode = status === 'failed' ? (
<Typography variant="body2" color="error">
Mock-Fehlerzustand (hier würde BackendError stehen).
</Typography>
) : null;
return (
<Box>
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<Button size="small" variant="outlined" onClick={this.simulateLoading}>
Laden
</Button>
<Button size="small" variant="outlined" color="error" onClick={this.simulateFailure}>
Fehler
</Button>
<Button
size="small"
variant="outlined"
onClick={() =>
this.setState({ status: 'ready', rows: MOCK_ROWS, log: [] })
}
>
Reset
</Button>
</Box>
<RecordList
status={status}
error={failedNode}
rows={rows}
columns={COLUMNS}
createLabel="Neue Lieferung"
onCreate={this.handleCreate}
onEdit={this.handleEdit}
onDelete={this.handleDelete}
deleteDisabled={false}
emptyMessage="Noch keine Lieferungen."
color="#fed7aa"
getEditLabel={(r) => `„${r.number}“ bearbeiten`}
getDeleteLabel={(r) => `„${r.number}“ löschen`}
/>
{log.length > 0 ? (
<Box sx={{ mt: 2 }}>
<Typography variant="caption" color="text.secondary">
Aktionen:
</Typography>
{log.map((entry, i) => (
<Typography key={i} variant="caption" display="block">
• {entry}
</Typography>
))}
</Box>
) : null}
<ComponentDocs name="RecordList" />
</Box>
);
}
}
export default ListsMockPage;

View File

@@ -0,0 +1,128 @@
import { Component } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { EditablePanelTile, PanelTile, PanelTileGrid } from '../../panels';
import SingleLineTextEdit from '../../SingleLineTextEdit';
import { ComponentDocs } from '../ComponentDocs';
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Mock page exercising PanelTile / EditablePanelTile with fake in-memory data.
* `onError` is passed to simulate the consuming app's error snackbar.
*/
class PanelsMockPage extends Component {
constructor(props) {
super(props);
this.state = {
companyName: 'ACME GmbH',
productName: 'Erdnussbutter 350g',
lastSaved: null,
errors: [],
};
}
handleSave = async (next) => {
// Simulated latency so the save spinner is visible.
await sleep(600);
this.setState({ companyName: next, lastSaved: next });
};
handleProductSave = async (next) => {
await sleep(600);
this.setState({ productName: next, lastSaved: next });
};
// Mirrors the consuming app's notifyError: returns a normalized { message }.
handleError = (err) => {
const message = String(err?.message ?? err ?? 'Unbekannter Fehler');
this.setState((prev) => ({ errors: [message, ...prev.errors].slice(0, 5) }));
return { message };
};
render() {
const { companyName, productName, lastSaved, errors } = this.state;
return (
<Box>
<PanelTileGrid>
<EditablePanelTile
wide={2}
title="Firma"
value={companyName}
onSave={this.handleSave}
onError={this.handleError}
display={
<Typography variant="body1">{companyName || '—'}</Typography>
}
edit={({ draft, onChange, inputRef }) => (
<SingleLineTextEdit
label="Name"
value={draft}
onChange={onChange}
inputRef={inputRef}
/>
)}
/>
<EditablePanelTile
wide={1}
title="Produkt"
color="#e2e8f0"
value={productName}
onSave={this.handleProductSave}
onError={this.handleError}
display={
<Typography variant="body1">{productName || '—'}</Typography>
}
edit={({ draft, onChange, inputRef }) => (
<SingleLineTextEdit
label="Name"
value={draft}
onChange={onChange}
inputRef={inputRef}
/>
)}
/>
<PanelTile wide={1} title="Nur anzeigen" color="#fef3c7">
<Typography variant="body2">
PanelTile ohne action — read-only.
</Typography>
</PanelTile>
</PanelTileGrid>
<Box sx={{ mt: 2 }}>
<Typography variant="caption" color="text.secondary">
{lastSaved != null
? `Zuletzt gespeichert: ${lastSaved}`
: 'Noch nichts gespeichert.'}
</Typography>
</Box>
{errors.length > 0 ? (
<Box sx={{ mt: 2 }}>
<Typography variant="caption" color="error">
Mock-Fehler (würde als Snackbar erscheinen):
</Typography>
{errors.map((e, i) => (
<Typography key={i} variant="caption" display="block" color="error">
• {e}
</Typography>
))}
</Box>
) : null}
<ComponentDocs name="PanelTile" />
<ComponentDocs name="PanelTileGrid" />
<ComponentDocs name="EditablePanelTile" />
</Box>
);
}
}
export default PanelsMockPage;

View File

@@ -0,0 +1,55 @@
import { Component } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import ToggleOnIcon from '@mui/icons-material/ToggleOn';
import {
ActionElevatedPill,
ToggleElevatedPill,
} from '../../ElevatedPill';
import ElevatedPill from '../../ElevatedPill';
import { ComponentDocs } from '../ComponentDocs';
class PillsMockPage extends Component {
constructor(props) {
super(props);
this.state = { enabled: true, active: true };
}
render() {
const { enabled, active } = this.state;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Typography variant="body2" sx={{ mr: 1 }}>
Toggle:
</Typography>
<ToggleElevatedPill
label="Sichtbar"
checked={enabled}
onToggle={(next) => this.setState({ enabled: next })}
/>
</Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Typography variant="body2" sx={{ mr: 1 }}>
Action:
</Typography>
<ActionElevatedPill
label="Aktiv"
active={active}
onClick={() => this.setState((s) => ({ active: !s.active }))}
icon={<ToggleOnIcon sx={{ fontSize: 18 }} />}
/>
<ElevatedPill label="Statisch" />
</Box>
<ComponentDocs name="ElevatedPill" />
<ComponentDocs name="ToggleElevatedPill" />
<ComponentDocs name="ActionElevatedPill" />
</Box>
);
}
}
export default PillsMockPage;

View File

@@ -0,0 +1,48 @@
import { Component } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import SingleLineTextEdit from '../../SingleLineTextEdit';
import { ComponentDocs } from '../ComponentDocs';
class TextFieldsMockPage extends Component {
constructor(props) {
super(props);
this.state = { name: '', note: 'Schreibgeschützt' };
}
render() {
const { name, note } = this.state;
return (
<Box>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
maxWidth: 480,
}}
>
<SingleLineTextEdit
label="Name"
value={name}
onChange={(next) => this.setState({ name: next })}
helperText="Gemeinsames Einzeilen-Feld"
/>
<SingleLineTextEdit
label="Nur lesen"
value={note}
readOnly
onChange={() => {}}
/>
<Typography variant="caption" color="text.secondary">
Aktueller Wert: {JSON.stringify(name)}
</Typography>
</Box>
<ComponentDocs name="SingleLineTextEdit" />
</Box>
);
}
}
export default TextFieldsMockPage;

View File

@@ -0,0 +1,76 @@
import { Component } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { Tile, tileBaseSx, TILE_SIZE } from '../../tiles';
import FolderIcon from '@mui/icons-material/FolderOutlined';
import AddIcon from '@mui/icons-material/Add';
import { ComponentDocs } from '../ComponentDocs';
class MockFolderTile extends Tile {
renderIcon() {
return <FolderIcon color="action" sx={{ fontSize: 40 }} />;
}
}
class MockAddTile extends Tile {
renderIcon() {
return <AddIcon color="primary" sx={{ fontSize: 40 }} />;
}
}
/**
* Mock page exercising the base Tile primitive with subclasses, mirroring how
* dashfront's ActionTile/CategoryTile/etc. extend it.
*/
class TilesMockPage extends Component {
constructor(props) {
super(props);
this.state = { clicks: 0 };
}
render() {
const { clicks } = this.state;
return (
<Box>
<Typography variant="body2" sx={{ mb: 2 }}>
Klicks: {clicks}
</Typography>
<Box
sx={{
display: 'grid',
gridTemplateColumns: `repeat(auto-fill, minmax(${TILE_SIZE}px, 1fr))`,
gap: 2,
maxWidth: 640,
}}
>
<MockFolderTile
label="Ordner"
secondary="3 Artikel"
onClick={() => this.setState((s) => ({ clicks: s.clicks + 1 }))}
/>
<MockAddTile
label="Neu"
secondary="anlegen"
onClick={() => this.setState((s) => ({ clicks: s.clicks + 1 }))}
/>
<MockFolderTile
label="Deaktiviert"
secondary="nicht klickbar"
disabled
/>
<Box sx={{ ...tileBaseSx, bgcolor: 'primary.main', color: 'white' }}>
<Typography variant="body2" fontWeight={600}>
tileBaseSx direkt
</Typography>
</Box>
</Box>
<ComponentDocs name="Tile" />
<ComponentDocs name="tileBaseSx" />
</Box>
);
}
}
export default TilesMockPage;