Skip to content

Commit 0228991

Browse files
committed
Add DB schema versioning, make dialogs awaitable, and implement reset pipeline
- Add ScoreBookChangeReason enum to differentiate scoreBookLoaded events - Add features table with schema_version key for DB version tracking - initialize() only creates tables on fresh installs, never modifies existing DBs - handleHealth detects pre-versioning and outdated schemas, reports dbError - handleSetup allows emergency reset without auth when hasUsers() fails - Remove unused build/serve-dist.mjs - Make LoginDialog awaitable: remove callbacks, add show() with Semaphore - Make BackendSetupDialog awaitable: remove onSetupComplete, add show() - Add setStatePromise() to UIComponent for clean awaitable state transitions - Implement reset pipeline: logout → setup → confirm → login → reset → restart - Clear session on dbError before showing setup dialog - Reset pipeline uses dataModel.resetDatabase() instead of raw fetch() - Add NODE_NO_WARNINGS=1 to e2e test scripts - Make backend-config.json optional with existsSync check - Fix NotificationCenter: clearHistory also removes mainList entries - Fix NotificationCenter: add wrap prop to Label in main toast rendering - Fix ScoreLibrary toggle icon after drawer reopen (scoreBookLoaded refinement) - Suppress Node deprecation warnings in e2e tests - Remove this.props/this.state violations across 9 files - Update seed.sql with current score library data - Update readme: remove sound library manager mention - Add paths-ignore to GitHub workflows for docs/config-only pushes Signed-off-by: Mike Lischke <mike@lischke-online.de>
1 parent 64edc9b commit 0228991

21 files changed

Lines changed: 317 additions & 229 deletions

.github/copilot-instructions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ Always put a blank line after blocks (`if`/`for`/`while`/`switch`/`case`/anonymo
6767
- Blank line before `@returns`; always use `@returns` (not `@return`).
6868
- `@param` descriptions follow the tag with a single space — never column-align across entries. Wrapped continuation lines indent to where the description text starts.
6969

70-
### React
70+
### React + Preact
7171

7272
- Never use `this.props.` or `this.state.` — destructure fields into individual variables at the top of the method.
7373
- **JSX must be logic-free.** The rendering tree (everything after `return (`) must contain only markup with minimal interpolations like `{userRows}` or `{condition && <Foo />}`. No inline `.map()`, no ternaries with more than one line per branch, no IIFEs, no `Array.from().find()`.

build/seed.sql

Lines changed: 10 additions & 25 deletions
Large diffs are not rendered by default.

src/App.tsx

Lines changed: 77 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,9 @@ export class App extends UIComponent<{}, IAppState> {
194194
}
195195

196196
public override componentDidUpdate(_prevProps: {}, prevState: IAppState): void {
197-
if (prevState.phase !== AppPhase.Running && this.state.phase === AppPhase.Running) {
197+
const { phase } = this.state;
198+
199+
if (prevState.phase !== AppPhase.Running && phase === AppPhase.Running) {
198200
this.updateStatsItem();
199201
}
200202
}
@@ -213,7 +215,8 @@ export class App extends UIComponent<{}, IAppState> {
213215
}
214216

215217
public render() {
216-
const { phase, displayMode, sidebarOpen, headerPinned, instrumentEditorEnabled } = this.state;
218+
const { phase, displayMode, sidebarOpen, headerPinned, instrumentEditorEnabled, printing,
219+
printOptions } = this.state;
217220
const isRunning = phase === AppPhase.Running;
218221

219222
let splashContent: ComponentChild;
@@ -225,9 +228,6 @@ export class App extends UIComponent<{}, IAppState> {
225228
splashContent = (
226229
<BackendSetupDialog
227230
ref={this.backendSetupDialogRef}
228-
onSetupComplete={() => {
229-
void this.handleBackendSetupComplete();
230-
}}
231231
/>
232232
);
233233

@@ -249,8 +249,6 @@ export class App extends UIComponent<{}, IAppState> {
249249
<LoginDialog
250250
ref={this.loginDialogRef}
251251
dataModel={this.dataModel}
252-
onLoginSuccess={this.handleLoginSuccess}
253-
onContinueAnonymous={this.handleContinueAnonymous}
254252
/>
255253
);
256254

@@ -500,14 +498,9 @@ export class App extends UIComponent<{}, IAppState> {
500498
<LoginDialog
501499
ref={this.loginDialogRef}
502500
dataModel={this.dataModel}
503-
onLoginSuccess={this.handleLoginSuccess}
504-
onContinueAnonymous={this.handleContinueAnonymous}
505501
/>
506502
<BackendSetupDialog
507503
ref={this.backendSetupDialogRef}
508-
onSetupComplete={() => {
509-
void this.handleBackendSetupComplete();
510-
}}
511504
/>
512505
<PrintDialog ref={this.printDialogRef} onAccept={this.handlePrintAccept} />
513506
<UserGroupEditor
@@ -524,11 +517,11 @@ export class App extends UIComponent<{}, IAppState> {
524517
}}
525518
/>
526519
{
527-
this.state.printing && this.dataModel.arrangement && this.state.printOptions
520+
printing && this.dataModel.arrangement && printOptions
528521
&& this.arrangementPlayer && this.undoManager && (
529522
<PrintView
530523
arrangement={this.dataModel.arrangement as Arrangement}
531-
options={this.state.printOptions}
524+
options={printOptions}
532525
dataModel={this.dataModel}
533526
arrangementPlayer={this.arrangementPlayer}
534527
services={this.services}
@@ -567,6 +560,8 @@ export class App extends UIComponent<{}, IAppState> {
567560
/**
568561
* Checks if the backend is reachable and initialised. If not, opens the setup dialog.
569562
* Once the backend is ready, proceeds with data model initialisation.
563+
*
564+
* @returns A promise that resolves when the check is complete.
570565
*/
571566
private async checkBackendThenInitialize(): Promise<void> {
572567
let health: {
@@ -588,7 +583,7 @@ export class App extends UIComponent<{}, IAppState> {
588583

589584
if (!health.configLoaded) {
590585
this.setState({ phase: AppPhase.Setup }, () => {
591-
this.backendSetupDialogRef.current?.open({
586+
void this.backendSetupDialogRef.current?.show({
592587
mode: "fatal",
593588
configError: health.configError,
594589
});
@@ -599,7 +594,7 @@ export class App extends UIComponent<{}, IAppState> {
599594

600595
if (!health.initialized) {
601596
this.setState({ phase: AppPhase.Setup }, () => {
602-
this.backendSetupDialogRef.current?.open({
597+
void this.backendSetupDialogRef.current?.show({
603598
mode: "initial",
604599
dbError: health.dbError,
605600
});
@@ -608,6 +603,50 @@ export class App extends UIComponent<{}, IAppState> {
608603
return;
609604
}
610605

606+
if (health.dbError) {
607+
// Pipeline: logout → setup dialog → confirmation → login → reset.
608+
await this.dataModel.logout();
609+
await this.setStatePromise({ phase: AppPhase.Setup });
610+
611+
const setupResult = await this.backendSetupDialogRef.current?.show({
612+
mode: "admin",
613+
dbError: health.dbError,
614+
});
615+
616+
if (setupResult !== "reset") {
617+
return;
618+
}
619+
620+
const confirmed = await this.confirmDialogRef.current?.show(
621+
"This will delete all scores, folders, users and groups.\n"
622+
+ "The database tables will be recreated from scratch.",
623+
{ accept: "Reset Database", refuse: "Cancel" },
624+
"Reset Database",
625+
["This cannot be undone. Make sure to export your scores if you want to keep them."],
626+
);
627+
628+
if (confirmed !== DialogResponseClosure.Accept) {
629+
return;
630+
}
631+
632+
await this.setStatePromise({ phase: AppPhase.Login });
633+
const loggedIn = await this.loginDialogRef.current?.show(true);
634+
635+
if (!loggedIn) {
636+
return;
637+
}
638+
639+
const ok = await this.dataModel.resetDatabase();
640+
641+
if (!ok) {
642+
// Reset failed — restart the health check so the setup dialog can show the error.
643+
return this.checkBackendThenInitialize();
644+
}
645+
646+
// Restart the health check — the backend is now fresh.
647+
return this.checkBackendThenInitialize();
648+
}
649+
611650
if (!health.hasUsers) {
612651
this.setState({ phase: AppPhase.AdminSetup }, () => {
613652
this.adminSetupDialogRef.current?.open();
@@ -655,7 +694,7 @@ export class App extends UIComponent<{}, IAppState> {
655694
}
656695

657696
this.setState({ phase: AppPhase.Login }, () => {
658-
this.loginDialogRef.current?.open();
697+
void this.loginDialogRef.current?.show().then(this.handleLoginDialogResult);
659698
});
660699
}
661700

@@ -671,9 +710,11 @@ export class App extends UIComponent<{}, IAppState> {
671710
};
672711

673712
private handleAuthChanged = (): Promise<boolean> => {
674-
if (!this.dataModel.authenticated && this.state.phase === AppPhase.Running) {
713+
const { phase } = this.state;
714+
715+
if (!this.dataModel.authenticated && phase === AppPhase.Running) {
675716
this.setState({ phase: AppPhase.Login }, () => {
676-
this.loginDialogRef.current?.open();
717+
void this.loginDialogRef.current?.show().then(this.handleLoginDialogResult);
677718
});
678719
} else {
679720
this.forceUpdate();
@@ -682,6 +723,19 @@ export class App extends UIComponent<{}, IAppState> {
682723
return Promise.resolve(true);
683724
};
684725

726+
/**
727+
* Handles the result of a login dialog show() call for non-pipeline paths.
728+
*
729+
* @param loggedIn Whether the user logged in successfully.
730+
*/
731+
private handleLoginDialogResult = (loggedIn: boolean): void => {
732+
if (loggedIn) {
733+
this.handleLoginSuccess();
734+
} else {
735+
this.handleContinueAnonymous();
736+
}
737+
};
738+
685739
private handleLoginSuccess = (): void => {
686740
this.signInFromRunning = false;
687741
void this.initializeApp().then(() => {
@@ -747,7 +801,7 @@ export class App extends UIComponent<{}, IAppState> {
747801
}
748802

749803
this.setState({ phase: AppPhase.Login }, () => {
750-
this.loginDialogRef.current?.open();
804+
void this.loginDialogRef.current?.show().then(this.handleLoginDialogResult);
751805
});
752806

753807
return;
@@ -826,7 +880,7 @@ export class App extends UIComponent<{}, IAppState> {
826880
private handleSignInClick = () => {
827881
this.signInFromRunning = true;
828882
this.setState({ phase: AppPhase.Login }, () => {
829-
this.loginDialogRef.current?.open();
883+
void this.loginDialogRef.current?.show().then(this.handleLoginDialogResult);
830884
});
831885
};
832886

@@ -848,7 +902,7 @@ export class App extends UIComponent<{}, IAppState> {
848902
this.notificationItem = undefined;
849903

850904
this.setState({ phase: AppPhase.Login }, () => {
851-
this.loginDialogRef.current?.open();
905+
void this.loginDialogRef.current?.show().then(this.handleLoginDialogResult);
852906
});
853907
};
854908

@@ -880,7 +934,7 @@ export class App extends UIComponent<{}, IAppState> {
880934
label: "Reset Backend",
881935
icon: <Icon src={Codicon.Server} />,
882936
onClick: () => {
883-
this.backendSetupDialogRef.current?.open({ mode: "admin" });
937+
void this.backendSetupDialogRef.current?.show({ mode: "admin" });
884938
},
885939
});
886940
} else if (user) {

src/components/ui/Arrangement/ArrangementViewer.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@ export class ArrangementViewer extends UIComponent<IArrangementViewerProps, IArr
294294
private autoFollow = (realTime: RealTime) => {
295295
if (this.viewerRef.current && this.playBeamRef.current && this.viewerContentHostRef.current) {
296296
const { arrangementPlayer } = this.props;
297+
const { trackViewMode } = this.state;
297298

298299
const viewer = this.viewerRef.current;
299300
const contentHost = this.viewerContentHostRef.current;
@@ -303,7 +304,7 @@ export class ArrangementViewer extends UIComponent<IArrangementViewerProps, IArr
303304
const maxScroll = Math.max(0, contentWidth - clientWidth);
304305

305306
const metrics = arrangementPlayer.scoreMetrics;
306-
const prefixWidthPixels = this.state.trackViewMode === "staff" ? this.measureStaffPrefixWidthPx() : 0;
307+
const prefixWidthPixels = trackViewMode === "staff" ? this.measureStaffPrefixWidthPx() : 0;
307308
const musicalContentWidth = Math.max(0, contentWidth - prefixWidthPixels);
308309
const barWidthPixels = metrics.bars > 0 ? musicalContentWidth / metrics.bars : 0;
309310
const stepWidthPixels = barWidthPixels / metrics.stepsPerBar;

src/components/ui/Overlay.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,9 @@ export class Overlay extends UIComponent<IOverlayProps, IOverlayState> {
109109
};
110110

111111
private handleOverlayVisibilityChanged = (data: { name: string; visible: boolean; }): Promise<boolean> => {
112-
if (data.name !== this.props.name) {
112+
const { name } = this.props;
113+
114+
if (data.name !== name) {
113115
return Promise.resolve(false);
114116
}
115117

src/components/ui/Print/PrintDialog.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,8 +217,10 @@ export class PrintDialog extends UIComponent<IPrintDialogProps, IPrintDialogStat
217217

218218
private handleClose = (returnValue: string): void => {
219219
const { onAccept } = this.props;
220+
const { options } = this.state;
221+
220222
if (returnValue === "print") {
221-
onAccept?.(this.state.options);
223+
onAccept?.(options);
222224
}
223225
};
224226
}

src/components/ui/composites/ConfirmDialog.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export class ConfirmDialog extends UIComponent<{}, IConfirmDialogState> {
5858
}
5959

6060
public render(): ComponentChild {
61-
const { title, message, buttons, description } = this.state;
61+
const { title, message, buttons, description, closeOnBackdropClick } = this.state;
6262

6363
const className = this.generateFinalClassName(["confirmDialog"]);
6464
let dialogContent = null;
@@ -114,7 +114,7 @@ export class ConfirmDialog extends UIComponent<{}, IConfirmDialogState> {
114114
<Dialog
115115
ref={this.dialogRef}
116116
className={className}
117-
closeOnBackdropClick={this.state.closeOnBackdropClick}
117+
closeOnBackdropClick={closeOnBackdropClick}
118118
caption={
119119
<>
120120
<Icon src={Codicon.Question} />

src/components/ui/composites/StatusDialog.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,11 @@ export class StatusDialog extends UIComponent<IStatusDialogProperties> {
120120
* Closes the dialog programmatically.
121121
*/
122122
public dismiss(): void {
123+
const { onClose } = this.props;
124+
123125
this.closingIntentionally = true;
124126
this.valueDialogRef.current?.dismiss();
125-
this.props.onClose?.();
127+
onClose?.();
126128
}
127129

128130
public render(): ComponentChild {

src/components/ui/framework/DrawerSidebar.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,10 @@ export class DrawerSidebar extends UIComponent<IDrawerSidebarProps, IDrawerSideb
3636
}
3737

3838
public override componentDidUpdate(prevProps: IDrawerSidebarProps, prevState: IDrawerSidebarState): void {
39-
4039
const { open, alwaysOpen } = this.props;
40+
const { everOpened } = this.state;
4141

42-
if (!this.state.everOpened && (open || alwaysOpen === true)) {
42+
if (!everOpened && (open || alwaysOpen === true)) {
4343
this.setState({ everOpened: true });
4444
}
4545
}

src/components/ui/framework/Menu/Menu.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export class Menu extends UIComponent<IMenuProperties, IMenuState> {
5151
}
5252

5353
public render(): ComponentChild {
54-
const { id, caption, icon, items, style } = this.props;
54+
const { id, caption, icon, items, style, onItemClick } = this.props;
5555
const triggerShown = (caption ?? icon) !== undefined;
5656
const className = this.generateFinalClassName(["menuHost"]);
5757

@@ -88,7 +88,7 @@ export class Menu extends UIComponent<IMenuProperties, IMenuState> {
8888
onClick={(e) => {
8989
e.stopPropagation();
9090
if (!item.disabled && item.label !== "-") {
91-
this.props.onItemClick?.(item.id);
91+
onItemClick?.(item.id);
9292
this.close();
9393
}
9494
}}

0 commit comments

Comments
 (0)