245 lines
6.3 KiB
JavaScript
245 lines
6.3 KiB
JavaScript
import { Component, createRef } from 'react';
|
|
import CloseOutlinedIcon from '@mui/icons-material/CloseOutlined';
|
|
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
|
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
|
|
import Alert from '@mui/material/Alert';
|
|
import Box from '@mui/material/Box';
|
|
import CircularProgress from '@mui/material/CircularProgress';
|
|
import IconButton from '@mui/material/IconButton';
|
|
import Paper from '@mui/material/Paper';
|
|
import Typography from '@mui/material/Typography';
|
|
import { injectMuiTheme } from './injectMuiTheme';
|
|
import { enqueueSnackbar } from 'notistack';
|
|
import { backendErrorFrom } from '../../utils/backendError';
|
|
import { panelTilePaperSx, TILE_ACTION_PADDING, tileActionIconSx, tileActionSx } from './panelTileLayout';
|
|
|
|
function valuesEqual(a, b) {
|
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
}
|
|
|
|
/**
|
|
* Panel tile with centralized inline-edit chrome.
|
|
* Pass `onSave` to enable edit UI; omit it for read-only tiles (no edit icon).
|
|
*/
|
|
class EditablePanelTileBase extends Component {
|
|
constructor(props) {
|
|
super(props);
|
|
this.inputRef = createRef();
|
|
this.state = {
|
|
editing: false,
|
|
draft: null,
|
|
saving: false,
|
|
error: null,
|
|
};
|
|
}
|
|
|
|
componentDidUpdate(_prevProps, prevState) {
|
|
if (!prevState.editing && this.state.editing) {
|
|
this.inputRef.current?.focus();
|
|
}
|
|
}
|
|
|
|
getSavedValue() {
|
|
return this.props.value;
|
|
}
|
|
|
|
isDirty() {
|
|
const { draft, editing } = this.state;
|
|
if (!editing || draft == null) return false;
|
|
if (this.props.isDirty) {
|
|
return this.props.isDirty(draft, this.getSavedValue());
|
|
}
|
|
return !valuesEqual(draft, this.getSavedValue());
|
|
}
|
|
|
|
canSave() {
|
|
const { draft, saving } = this.state;
|
|
if (saving || !this.isDirty() || draft == null) return false;
|
|
if (this.props.canSave) {
|
|
return this.props.canSave(draft, this.getSavedValue());
|
|
}
|
|
return true;
|
|
}
|
|
|
|
handleStartEdit = (event) => {
|
|
event.stopPropagation();
|
|
this.setState({
|
|
editing: true,
|
|
draft: structuredClone(this.getSavedValue()),
|
|
error: null,
|
|
});
|
|
};
|
|
|
|
handleCancel = (event) => {
|
|
event?.stopPropagation();
|
|
this.setState({
|
|
editing: false,
|
|
draft: null,
|
|
error: null,
|
|
});
|
|
};
|
|
|
|
handleDraftChange = (nextDraft) => {
|
|
this.setState({ draft: nextDraft, error: null });
|
|
};
|
|
|
|
handleEditKeyDown = (event) => {
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
this.handleCancel();
|
|
return;
|
|
}
|
|
|
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (this.canSave()) {
|
|
void this.handleSave(event);
|
|
}
|
|
}
|
|
};
|
|
|
|
handleSave = async (event) => {
|
|
event?.stopPropagation();
|
|
if (!this.canSave()) return;
|
|
|
|
const { onSave } = this.props;
|
|
const { draft } = this.state;
|
|
this.setState({ saving: true, error: null });
|
|
|
|
try {
|
|
await onSave(draft);
|
|
this.setState({ editing: false, draft: null, saving: false, error: null });
|
|
enqueueSnackbar('Gespeichert.', { variant: 'success' });
|
|
} catch (err) {
|
|
const normalized = backendErrorFrom(err);
|
|
this.setState({ saving: false, error: normalized.message });
|
|
enqueueSnackbar(normalized.message, { variant: 'error' });
|
|
}
|
|
};
|
|
|
|
renderTitle() {
|
|
const { title, onSave } = this.props;
|
|
if (!title) return null;
|
|
return (
|
|
<Typography
|
|
variant="overline"
|
|
color="text.secondary"
|
|
sx={{ display: 'block', mb: 1, pr: onSave ? 9 : 0 }}
|
|
>
|
|
{title}
|
|
</Typography>
|
|
);
|
|
}
|
|
|
|
renderActions() {
|
|
const { onSave } = this.props;
|
|
if (!onSave) return null;
|
|
|
|
const { editing, saving } = this.state;
|
|
const iconSx = tileActionIconSx;
|
|
|
|
if (!editing) {
|
|
return (
|
|
<>
|
|
<IconButton
|
|
disabled
|
|
aria-hidden
|
|
tabIndex={-1}
|
|
sx={{
|
|
...tileActionSx,
|
|
right: 48,
|
|
visibility: 'hidden',
|
|
pointerEvents: 'none',
|
|
}}
|
|
>
|
|
<SaveOutlinedIcon sx={iconSx} />
|
|
</IconButton>
|
|
<IconButton
|
|
onClick={this.handleStartEdit}
|
|
aria-label="Bearbeiten"
|
|
sx={{ ...tileActionSx, right: 4 }}
|
|
>
|
|
<EditOutlinedIcon sx={iconSx} />
|
|
</IconButton>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<IconButton
|
|
onClick={this.handleSave}
|
|
disabled={!this.canSave()}
|
|
aria-label="Speichern"
|
|
color={this.canSave() ? 'primary' : 'default'}
|
|
sx={{ ...tileActionSx, right: 48 }}
|
|
>
|
|
{saving ? (
|
|
<CircularProgress size={22} color="inherit" />
|
|
) : (
|
|
<SaveOutlinedIcon sx={iconSx} />
|
|
)}
|
|
</IconButton>
|
|
<IconButton
|
|
onClick={this.handleCancel}
|
|
disabled={saving}
|
|
aria-label="Abbrechen"
|
|
sx={{ ...tileActionSx, right: 4 }}
|
|
>
|
|
<CloseOutlinedIcon sx={iconSx} />
|
|
</IconButton>
|
|
</>
|
|
);
|
|
}
|
|
|
|
render() {
|
|
const { onSave, theme, wide = 1, sx, minHeight = 120, tileKey, renderDisplay, renderEdit } =
|
|
this.props;
|
|
const { editing, draft, error } = this.state;
|
|
const savedValue = this.getSavedValue();
|
|
const pending = this.isDirty();
|
|
const actionPadding = onSave ? TILE_ACTION_PADDING : 0;
|
|
|
|
return (
|
|
<Paper
|
|
elevation={4}
|
|
sx={panelTilePaperSx({
|
|
wide,
|
|
sx,
|
|
minHeight,
|
|
editing,
|
|
pending,
|
|
tileKey,
|
|
theme,
|
|
})}
|
|
>
|
|
{this.renderActions()}
|
|
{this.renderTitle()}
|
|
<Box
|
|
sx={{
|
|
flex: 1,
|
|
minWidth: 0,
|
|
pr: actionPadding,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
}}
|
|
onKeyDown={editing ? this.handleEditKeyDown : undefined}
|
|
>
|
|
{editing && draft != null
|
|
? renderEdit(draft, this.handleDraftChange, this.inputRef)
|
|
: renderDisplay(savedValue)}
|
|
</Box>
|
|
{error ? (
|
|
<Alert severity="error" sx={{ mt: 1, py: 0 }}>
|
|
{error}
|
|
</Alert>
|
|
) : null}
|
|
</Paper>
|
|
);
|
|
}
|
|
}
|
|
|
|
export const EditablePanelTile = injectMuiTheme(EditablePanelTileBase);
|