feat: add reactive settings store for realtime updates

Settings changes can now be observed by the app without needing to re-open/reload the app
This commit is contained in:
Jacobtread 2024-06-23 18:19:58 +12:00
parent 8d2a493478
commit 3cfa89bb4b
5 changed files with 182 additions and 64 deletions

View file

@ -1,6 +1,5 @@
import { Timekeep } from "@/schema";
import { isKeepRunning } from "@/timekeep";
import { TimekeepSettings } from "@/settings";
import { App as ObsidianApp } from "obsidian";
import TimesheetStart from "@/components/TimesheetStart";
import TimesheetTable from "@/components/TimesheetTable";
@ -11,15 +10,15 @@ import React, { useState, useCallback, SetStateAction } from "react";
import TimesheetExportActions from "@/components/TimesheetExportActions";
import { AppContext } from "./contexts/use-app-context";
import { SettingsStore, useSettingsStore } from "./store/settings-store";
export type AppProps = {
app: ObsidianApp;
// Initial state loaded from the document
initialState: Timekeep;
// The timekeep settings
settings: TimekeepSettings;
settingsStore: SettingsStore;
// Function to save the timekeep data
// eslint-disable-next-line no-unused-vars
save: (timekeep: Timekeep) => Promise<boolean>;
};
@ -30,9 +29,15 @@ export type AppProps = {
* Wraps the state updates for `setTimekeep` with logic that
* saves the changes to the vault
*/
export default function App({ app, initialState, save, settings }: AppProps) {
export default function App({
app,
initialState,
save,
settingsStore,
}: AppProps) {
const [timekeep, setTimekeep] = useState(initialState);
const [saveError, setSaveError] = useState(false);
const settings = useSettingsStore(settingsStore);
const trySave = (timekeep: Timekeep) => {
save(timekeep).then((isSaved) => {

View file

@ -15,9 +15,10 @@ import {
} from "obsidian";
import { Timekeep } from "./schema";
import { SettingsStore, createSettingsStore } from "./store/settings-store";
export default class TimekeepPlugin extends Plugin {
settings: TimekeepSettings;
settingsStore: SettingsStore;
async onload(): Promise<void> {
await this.loadSettings();
@ -37,7 +38,7 @@ export default class TimekeepPlugin extends Plugin {
new TimekeepComponent(
el,
this.app,
this.settings,
this.settingsStore,
context,
loadResult
)
@ -55,23 +56,34 @@ export default class TimekeepPlugin extends Plugin {
}
async loadSettings(): Promise<void> {
this.settings = Object.assign(
const settings = Object.assign(
{},
defaultSettings,
await this.loadData()
);
this.settingsStore = createSettingsStore(settings);
}
async updateSettings(
update: (currentValue: TimekeepSettings) => TimekeepSettings
): Promise<void> {
const newValue = update(this.settingsStore.getSettings());
this.settingsStore.setSettings(newValue);
await this.saveData(newValue);
}
async saveSettings(): Promise<void> {
await this.saveData(this.settings);
await this.saveData(this.settingsStore.getSettings());
}
}
class TimekeepComponent extends MarkdownRenderChild {
// Obsidian app instance
app: ObsidianApp;
// Timekeep settings
settings: TimekeepSettings;
// Timekeep settings store
settingsStore: SettingsStore;
// Markdown context for the current markdown block
context: MarkdownPostProcessorContext;
// Timekeep load result
@ -84,13 +96,13 @@ class TimekeepComponent extends MarkdownRenderChild {
constructor(
containerEl: HTMLElement,
app: ObsidianApp,
settings: TimekeepSettings,
settingsStore: SettingsStore,
context: MarkdownPostProcessorContext,
loadResult: LoadResult
) {
super(containerEl);
this.app = app;
this.settings = settings;
this.settingsStore = settingsStore;
this.context = context;
this.loadResult = loadResult;
this.root = createRoot(containerEl);
@ -126,7 +138,7 @@ class TimekeepComponent extends MarkdownRenderChild {
React.createElement(App, {
app: this.app,
initialState: timekeep,
settings: this.settings,
settingsStore: this.settingsStore,
save: this.trySave.bind(this),
})
)

View file

@ -14,6 +14,8 @@ export class TimekeepSettingsTab extends PluginSettingTab {
this.containerEl.empty();
this.containerEl.createEl("h2", { text: "Settings" });
const settings = this.plugin.settingsStore.getSettings();
new Setting(this.containerEl)
.setName("Timestamp display format")
.setDesc(
@ -29,12 +31,17 @@ export class TimekeepSettingsTab extends PluginSettingTab {
})
)
.addText((t) => {
t.setValue(String(this.plugin.settings.timestampFormat));
t.setValue(String(settings.timestampFormat));
t.onChange(async (v) => {
this.plugin.settings.timestampFormat = v.length
// Only use a custom format if the value is not blank
const newFormat = v.length
? v
: defaultSettings.timestampFormat;
await this.plugin.saveSettings();
await this.plugin.updateSettings((currentValue) => ({
...currentValue,
timestampFormat: newFormat,
}));
});
});
@ -43,12 +50,15 @@ export class TimekeepSettingsTab extends PluginSettingTab {
.setDesc("The title to include on generated PDFs")
.addText((t) => {
t.setValue(String(this.plugin.settings.pdfTitle));
t.setValue(String(settings.pdfTitle));
t.onChange(async (v) => {
this.plugin.settings.pdfTitle = v.length
? v
: defaultSettings.pdfTitle;
await this.plugin.saveSettings();
// Only use a custom format if the value is not blank
const newPdfTitle = v.length ? v : defaultSettings.pdfTitle;
await this.plugin.updateSettings((currentValue) => ({
...currentValue,
pdfTitle: newPdfTitle,
}));
});
});
@ -57,12 +67,17 @@ export class TimekeepSettingsTab extends PluginSettingTab {
.setDesc("The footnote to include PDFs")
.addTextArea((t) => {
t.setValue(String(this.plugin.settings.pdfFootnote));
t.setValue(String(settings.pdfFootnote));
t.onChange(async (v) => {
this.plugin.settings.pdfFootnote = v.length
// Only use a custom format if the value is not blank
const newPdfFootnote = v.length
? v
: defaultSettings.pdfFootnote;
await this.plugin.saveSettings();
await this.plugin.updateSettings((currentValue) => ({
...currentValue,
pdfFootnote: newPdfFootnote,
}));
});
});
@ -78,11 +93,12 @@ export class TimekeepSettingsTab extends PluginSettingTab {
[PdfExportBehavior.OPEN_PATH]:
"Open directory containing the exported file",
});
t.setValue(String(this.plugin.settings.pdfExportBehavior));
t.setValue(String(settings.pdfExportBehavior));
t.onChange(async (v) => {
this.plugin.settings.pdfExportBehavior =
v as PdfExportBehavior;
await this.plugin.saveSettings();
await this.plugin.updateSettings((currentValue) => ({
...currentValue,
pdfExportBehavior: v as PdfExportBehavior,
}));
});
});
@ -101,12 +117,17 @@ export class TimekeepSettingsTab extends PluginSettingTab {
})
)
.addText((t) => {
t.setValue(String(this.plugin.settings.pdfDateFormat));
t.setValue(String(settings.pdfDateFormat));
t.onChange(async (v) => {
this.plugin.settings.pdfDateFormat = v.length
// Only use a custom format if the value is not blank
const newPdfDateFormat = v.length
? v
: defaultSettings.pdfDateFormat;
await this.plugin.saveSettings();
await this.plugin.updateSettings((currentValue) => ({
...currentValue,
pdfDateFormat: newPdfDateFormat,
}));
});
});
@ -125,12 +146,17 @@ export class TimekeepSettingsTab extends PluginSettingTab {
})
)
.addText((t) => {
t.setValue(String(this.plugin.settings.pdfRowDateFormat));
t.setValue(String(settings.pdfRowDateFormat));
t.onChange(async (v) => {
this.plugin.settings.pdfRowDateFormat = v.length
// Only use a custom format if the value is not blank
const newPdfRowDateFormat = v.length
? v
: defaultSettings.pdfRowDateFormat;
await this.plugin.saveSettings();
await this.plugin.updateSettings((currentValue) => ({
...currentValue,
pdfRowDateFormat: newPdfRowDateFormat,
}));
});
});
@ -140,10 +166,12 @@ export class TimekeepSettingsTab extends PluginSettingTab {
"Whether to use the first row of generated CSV as a title row"
)
.addToggle((t) => {
t.setValue(this.plugin.settings.csvTitle);
t.setValue(settings.csvTitle);
t.onChange(async (v) => {
this.plugin.settings.csvTitle = v;
await this.plugin.saveSettings();
await this.plugin.updateSettings((currentValue) => ({
...currentValue,
csvTitle: v,
}));
});
});
@ -153,37 +181,31 @@ export class TimekeepSettingsTab extends PluginSettingTab {
"The delimiter character that should be used when copying a tracker table as CSV. For example, some languages use a semicolon instead of a comma."
)
.addText((t) => {
t.setValue(String(this.plugin.settings.csvDelimiter));
t.setValue(String(settings.csvDelimiter));
t.onChange(async (v) => {
this.plugin.settings.csvDelimiter = v.length
const newCsvDelimiter = v.length
? v
: defaultSettings.csvDelimiter;
await this.plugin.saveSettings();
await this.plugin.updateSettings((currentValue) => ({
...currentValue,
csvDelimiter: newCsvDelimiter,
}));
});
});
new Setting(this.containerEl)
.setName("Timestamp durations")
.setDesc(
"Whether durations should be displayed in a timestamp format (12:15:01) rather than the default duration format (12h 15m 1s)."
)
.addToggle((t) => {
t.setValue(this.plugin.settings.timestampDurations);
t.onChange(async (v) => {
this.plugin.settings.timestampDurations = v;
await this.plugin.saveSettings();
});
});
new Setting(this.containerEl)
.setName("Show decimal hours")
.setDesc(
"Whether to show the shortened hour only durations under the current and total timers (12h 8m 39s would be shown as 12.14h)"
)
.addToggle((t) => {
t.setValue(this.plugin.settings.showDecimalHours);
t.setValue(settings.showDecimalHours);
t.onChange(async (v) => {
this.plugin.settings.showDecimalHours = v;
await this.plugin.saveSettings();
await this.plugin.updateSettings((currentValue) => ({
...currentValue,
showDecimalHours: v,
}));
});
});
@ -193,10 +215,12 @@ export class TimekeepSettingsTab extends PluginSettingTab {
"Whether older tracker segments should be displayed towards the bottom of the tracker, rather than the top."
)
.addToggle((t) => {
t.setValue(this.plugin.settings.reverseSegmentOrder);
t.setValue(settings.reverseSegmentOrder);
t.onChange(async (v) => {
this.plugin.settings.reverseSegmentOrder = v;
await this.plugin.saveSettings();
await this.plugin.updateSettings((currentValue) => ({
...currentValue,
reverseSegmentOrder: v,
}));
});
});
@ -206,10 +230,12 @@ export class TimekeepSettingsTab extends PluginSettingTab {
"Whether to limit the height of the table, will clamp the height and make the table scrollable"
)
.addToggle((t) => {
t.setValue(this.plugin.settings.limitTableSize);
t.setValue(settings.limitTableSize);
t.onChange(async (v) => {
this.plugin.settings.limitTableSize = v;
await this.plugin.saveSettings();
await this.plugin.updateSettings((currentValue) => ({
...currentValue,
limitTableSize: v,
}));
});
});
}

View file

@ -1,4 +1,3 @@
/* eslint-disable no-unused-vars */
export enum PdfExportBehavior {
// Don't do anything after exporting
NONE = "NONE",
@ -19,7 +18,6 @@ export interface TimekeepSettings {
pdfDateFormat: string;
pdfRowDateFormat: string;
reverseSegmentOrder: boolean;
timestampDurations: boolean;
timestampFormat: string;
showDecimalHours: boolean;
}
@ -36,7 +34,6 @@ export const defaultSettings: TimekeepSettings = {
csvTitle: true,
csvDelimiter: ",",
reverseSegmentOrder: false,
timestampDurations: false,
limitTableSize: true,
showDecimalHours: true,
};

View file

@ -0,0 +1,78 @@
import { useState, useEffect } from "react";
import { TimekeepSettings } from "@/settings";
/**
* Simple reactive store for storing the current settings
* and allowing the app to subscribe to changes
*/
export type SettingsStore = {
// Getter for the current value
getSettings: () => TimekeepSettings;
// Sets the current settings value and updates all subscribers
setSettings: (value: TimekeepSettings) => void;
// Allows subscribing to changes using a callback
subscribe: (callback: VoidFunction) => void;
// Allows unsubscribing from changes for the provided callback
unsubscribe: (callback: VoidFunction) => void;
};
/**
* Creates a new settings store with the provided initial value
*
* @param initialValue The initial value
* @returns The created store
*/
export function createSettingsStore(
initialValue: TimekeepSettings
): SettingsStore {
const eventTarget = new EventTarget();
const object = { settings: initialValue };
const getSettings = () => object.settings;
const setSettings = (value: TimekeepSettings) => {
object.settings = value;
eventTarget.dispatchEvent(new Event("stateChange"));
};
const subscribe = (callback: VoidFunction) =>
eventTarget.addEventListener("stateChange", callback);
const unsubscribe = (callback: VoidFunction) =>
eventTarget.removeEventListener("stateChange", callback);
return {
getSettings,
setSettings,
subscribe,
unsubscribe,
};
}
/**
* React hook to subscribe to a settings store. Will
* trigger state updates and re-render when the settings
* change
*
* @param store The settings store
* @returns The settings store value
*/
export function useSettingsStore(store: SettingsStore) {
const [state, setState] = useState(store.getSettings());
useEffect(() => {
const handleUpdate = () => {
const value = store.getSettings();
setState(value);
};
store.subscribe(handleUpdate);
return () => {
store.unsubscribe(handleUpdate);
};
}, [setState, store]);
return state;
}