feat: add scoped shortcuts
This commit is contained in:
@@ -11,7 +11,7 @@ import type {
|
||||
TextInputProps,
|
||||
} from "react-native";
|
||||
import { useTerminalDimensions } from "@opentui/react";
|
||||
import { RGBA } from "@opentui/core";
|
||||
import { BorderSides, RGBA } from "@opentui/core";
|
||||
import { platform } from "node:os";
|
||||
import { exec } from "node:child_process";
|
||||
|
||||
@@ -66,6 +66,27 @@ export function View({ children, style }: ViewProps) {
|
||||
: undefined;
|
||||
|
||||
const padding = attr(style, "padding", "number");
|
||||
const paddingTop = attr(style, "paddingTop", "number");
|
||||
const paddingLeft = attr(style, "paddingLeft", "number");
|
||||
const paddingBottom = attr(style, "paddingBottom", "number");
|
||||
const paddingRight = attr(style, "paddingRight", "number");
|
||||
const gap = attr(style, "gap", "number");
|
||||
|
||||
const borderBottomWidth = attr(style, "borderBottomWidth", "number");
|
||||
const borderTopWidth = attr(style, "borderTopWidth", "number");
|
||||
const borderLeftWidth = attr(style, "borderLeftWidth", "number");
|
||||
const borderRightWidth = attr(style, "borderRightWidth", "number");
|
||||
|
||||
const borderBottomColor = attr(style, "borderBottomColor", "string");
|
||||
const borderTopColor = attr(style, "borderTopColor", "string");
|
||||
const borderLeftColor = attr(style, "borderLeftColor", "string");
|
||||
const borderRightColor = attr(style, "borderRightColor", "string");
|
||||
|
||||
const borderColor = attr(style, "borderColor", "string");
|
||||
|
||||
const top = attr(style, "top", "number");
|
||||
|
||||
const width = attr(style, "width", "number");
|
||||
|
||||
const props = {
|
||||
overflow: attr(style, "overflow", "string"),
|
||||
@@ -75,7 +96,6 @@ export function View({ children, style }: ViewProps) {
|
||||
justifyContent: attr(style, "justifyContent", "string"),
|
||||
flexShrink: attr(style, "flexShrink", "number"),
|
||||
flexDirection: attr(style, "flexDirection", "string"),
|
||||
top: attr(style, "top", "number"),
|
||||
zIndex: attr(style, "zIndex", "number"),
|
||||
left: attr(style, "left", "number"),
|
||||
right: attr(style, "right", "number"),
|
||||
@@ -84,13 +104,40 @@ export function View({ children, style }: ViewProps) {
|
||||
attr(style, "flex", "number") || attr(style, "flexGrow", "number"),
|
||||
};
|
||||
|
||||
const border = (() => {
|
||||
const sides: BorderSides[] = [];
|
||||
if (borderBottomWidth) sides.push("bottom");
|
||||
if (borderTopWidth) sides.push("top");
|
||||
if (borderLeftWidth) sides.push("left");
|
||||
if (borderRightWidth) sides.push("right");
|
||||
if (!sides.length) return undefined;
|
||||
return sides;
|
||||
})();
|
||||
|
||||
return (
|
||||
<box
|
||||
backgroundColor={bg}
|
||||
paddingTop={padding && Math.round(padding / RATIO_HEIGHT)}
|
||||
paddingBottom={padding && Math.round(padding / RATIO_HEIGHT)}
|
||||
paddingLeft={padding && Math.round(padding / RATIO_WIDTH)}
|
||||
paddingRight={padding && Math.round(padding / RATIO_WIDTH)}
|
||||
paddingTop={
|
||||
(paddingTop && Math.round(paddingTop / RATIO_HEIGHT)) ||
|
||||
(padding && Math.round(padding / RATIO_HEIGHT))
|
||||
}
|
||||
paddingBottom={
|
||||
(paddingBottom && Math.round(paddingBottom / RATIO_HEIGHT)) ||
|
||||
(padding && Math.round(padding / RATIO_HEIGHT))
|
||||
}
|
||||
paddingLeft={
|
||||
(paddingLeft && Math.round(paddingLeft / RATIO_WIDTH)) ||
|
||||
(padding && Math.round(padding / RATIO_WIDTH))
|
||||
}
|
||||
paddingRight={
|
||||
(paddingRight && Math.round(paddingRight / RATIO_WIDTH)) ||
|
||||
(padding && Math.round(padding / RATIO_WIDTH))
|
||||
}
|
||||
gap={gap && Math.round(gap / RATIO_HEIGHT)}
|
||||
border={border}
|
||||
borderColor={borderColor}
|
||||
width={width && Math.round(width / RATIO_WIDTH)}
|
||||
top={top && Math.round(top / RATIO_HEIGHT)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -234,7 +281,8 @@ export function TextInput({
|
||||
}: TextInputProps) {
|
||||
return (
|
||||
<input
|
||||
width={20}
|
||||
minWidth={20}
|
||||
minHeight={1}
|
||||
backgroundColor="white"
|
||||
textColor="black"
|
||||
focused={true}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Transaction } from "@rocicorp/zero";
|
||||
import { authDataSchema, type AuthData } from "./auth";
|
||||
import { type Schema } from "./zero-schema.gen";
|
||||
import { type Category, type Schema } from "./zero-schema.gen";
|
||||
import { isLoggedIn } from "./zql";
|
||||
|
||||
type Tx = Transaction<Schema>;
|
||||
@@ -8,10 +8,10 @@ type Tx = Transaction<Schema>;
|
||||
export function createMutators(authData: AuthData | null) {
|
||||
return {
|
||||
link: {
|
||||
async create() { },
|
||||
async get(tx: Tx, { link_token }: { link_token: string }) { },
|
||||
async updateTransactions() { },
|
||||
async updateBalences() { },
|
||||
async create() {},
|
||||
async get(tx: Tx, { link_token }: { link_token: string }) {},
|
||||
async updateTransactions() {},
|
||||
async updateBalences() {},
|
||||
async deleteAccounts(tx: Tx, { accountIds }: { accountIds: string[] }) {
|
||||
isLoggedIn(authData);
|
||||
for (const id of accountIds) {
|
||||
@@ -74,15 +74,29 @@ export function createMutators(authData: AuthData | null) {
|
||||
id,
|
||||
budgetId,
|
||||
order,
|
||||
}: { id: string; budgetId: string; order?: number },
|
||||
}: { id: string; budgetId: string; order: number },
|
||||
) {
|
||||
isLoggedIn(authData);
|
||||
|
||||
if (order != undefined) {
|
||||
const after = await tx.query.category
|
||||
.where("budgetId", "=", budgetId)
|
||||
.where("order", ">", order);
|
||||
|
||||
after.forEach((item) => {
|
||||
tx.mutate.category.update({
|
||||
id: item.id,
|
||||
order: item.order + 1,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
tx.mutate.category.insert({
|
||||
id,
|
||||
budgetId,
|
||||
amount: 0,
|
||||
every: "week",
|
||||
order: order || 0,
|
||||
order: order + 1,
|
||||
label: "My category",
|
||||
color: "#f06",
|
||||
createdBy: authData.user.id,
|
||||
@@ -90,20 +104,46 @@ export function createMutators(authData: AuthData | null) {
|
||||
},
|
||||
async deleteCategory(tx: Tx, { id }: { id: string }) {
|
||||
isLoggedIn(authData);
|
||||
const item = await tx.query.category.where("id", "=", id).one();
|
||||
if (!item) throw Error("Item does not exist");
|
||||
tx.mutate.category.update({
|
||||
id,
|
||||
removedAt: new Date().getTime(),
|
||||
removedBy: authData.user.id,
|
||||
});
|
||||
const after = await tx.query.category
|
||||
.where("budgetId", "=", item.budgetId)
|
||||
.where("order", ">", item.order)
|
||||
.run();
|
||||
for (const item of after) {
|
||||
tx.mutate.category.update({ id: item.id, order: item.order - 1 });
|
||||
}
|
||||
// after.forEach((item) => {
|
||||
// });
|
||||
},
|
||||
async updateCategory(
|
||||
tx: Tx,
|
||||
{ id, label }: { id: string; label: string },
|
||||
{
|
||||
id,
|
||||
label,
|
||||
order,
|
||||
amount,
|
||||
every,
|
||||
}: {
|
||||
id: string;
|
||||
label?: string;
|
||||
order?: number;
|
||||
amount?: number;
|
||||
every?: Category["every"];
|
||||
},
|
||||
) {
|
||||
isLoggedIn(authData);
|
||||
tx.mutate.category.update({
|
||||
id,
|
||||
label,
|
||||
order,
|
||||
amount,
|
||||
every,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -67,7 +67,7 @@ export const queries = {
|
||||
isLoggedIn(authData);
|
||||
return builder.budget
|
||||
.related("categories", (q) =>
|
||||
q.where("removedAt", "IS", null).orderBy("order", "desc"),
|
||||
q.where("removedAt", "IS", null).orderBy("order", "asc"),
|
||||
)
|
||||
.limit(10);
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface DialogState {
|
||||
close?: () => void;
|
||||
}
|
||||
export const Context = createContext<DialogState>({
|
||||
close: () => { },
|
||||
close: () => {},
|
||||
});
|
||||
|
||||
interface ProviderProps {
|
||||
@@ -21,7 +21,7 @@ export function Provider({ children, visible, close }: ProviderProps) {
|
||||
{/* <Pressable onPress={() => close && close()} style={{ justifyContent: 'center', alignItems: 'center', flex: 1, backgroundColor: 'rgba(0,0,0,0.2)', }}> */}
|
||||
<View
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
// justifyContent: "center",
|
||||
alignItems: "center",
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0,0,0,0.2)",
|
||||
@@ -39,12 +39,10 @@ interface ContentProps {
|
||||
}
|
||||
export function Content({ children }: ContentProps) {
|
||||
const { close } = use(Context);
|
||||
useShortcut("escape", () => close?.());
|
||||
useShortcut("escape", () => close?.(), "dialog");
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{ backgroundColor: "white", padding: 12, alignItems: "center" }}
|
||||
>
|
||||
<View style={{ backgroundColor: "white", alignItems: "center", top: 120 }}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import {
|
||||
createContext,
|
||||
use,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createContext, use, useEffect, useState, type ReactNode } from "react";
|
||||
import { View, Text } from "react-native";
|
||||
import type { KeyEvent } from "@opentui/core";
|
||||
import { useShortcut } from "../lib/shortcuts/hooks";
|
||||
import type { Key } from "../lib/shortcuts";
|
||||
|
||||
const HEADER_COLOR = "#7158e2";
|
||||
const TABLE_COLORS = ["#ddd", "#eee"];
|
||||
const SELECTED_COLOR = "#f7b730";
|
||||
|
||||
const COLORS = {
|
||||
focused: "#ddd",
|
||||
selected: "#eaebf6",
|
||||
focused_selected: "#d5d7ef",
|
||||
};
|
||||
|
||||
const EXTRA = 5;
|
||||
|
||||
@@ -24,7 +20,7 @@ interface TableState {
|
||||
columns: Column[];
|
||||
columnMap: Map<string, number>;
|
||||
idx: number;
|
||||
selectedFrom: number | undefined;
|
||||
selectedIdx: Set<number>;
|
||||
}
|
||||
|
||||
const INITAL_STATE = {
|
||||
@@ -32,7 +28,7 @@ const INITAL_STATE = {
|
||||
columns: [],
|
||||
columnMap: new Map(),
|
||||
idx: 0,
|
||||
selectedFrom: undefined,
|
||||
selectedIdx: new Set(),
|
||||
} satisfies TableState;
|
||||
|
||||
export const Context = createContext<TableState>(INITAL_STATE);
|
||||
@@ -69,7 +65,7 @@ export function Provider<T extends ValidRecord>({
|
||||
shortcuts,
|
||||
}: ProviderProps<T>) {
|
||||
const [idx, setIdx] = useState(0);
|
||||
const [selectedFrom, setSelectedFrom] = useState<number>();
|
||||
const [selectedIdx, setSelectedIdx] = useState(new Set<number>());
|
||||
|
||||
useShortcut("j", () => {
|
||||
setIdx((prev) => Math.min(prev + 1, data.length - 1));
|
||||
@@ -84,6 +80,17 @@ export function Provider<T extends ValidRecord>({
|
||||
setIdx((prev) => Math.max(prev - 1, 0));
|
||||
});
|
||||
|
||||
useShortcut("escape", () => {
|
||||
setSelectedIdx(new Set());
|
||||
});
|
||||
useShortcut("x", () => {
|
||||
setSelectedIdx((last) => {
|
||||
const newSelected = new Set(last);
|
||||
newSelected.add(idx);
|
||||
return newSelected;
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setIdx((prev) => Math.max(Math.min(prev, data.length - 1), 0));
|
||||
}, [data]);
|
||||
@@ -91,9 +98,9 @@ export function Provider<T extends ValidRecord>({
|
||||
if (shortcuts) {
|
||||
for (const shortcut of shortcuts) {
|
||||
useShortcut(shortcut.key, () => {
|
||||
const from = selectedFrom ? Math.min(idx, selectedFrom) : idx;
|
||||
const to = selectedFrom ? Math.max(idx, selectedFrom) : idx;
|
||||
const selected = data.slice(from, to + 1);
|
||||
const selected = data.filter(
|
||||
(_, index) => idx == index || selectedIdx.has(index),
|
||||
);
|
||||
shortcut.handler({ selected, index: idx });
|
||||
});
|
||||
}
|
||||
@@ -112,14 +119,14 @@ export function Provider<T extends ValidRecord>({
|
||||
);
|
||||
|
||||
return (
|
||||
<Context.Provider value={{ data, columns, columnMap, idx, selectedFrom }}>
|
||||
<Context.Provider value={{ data, columns, columnMap, idx, selectedIdx }}>
|
||||
{children}
|
||||
</Context.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function Body() {
|
||||
const { columns, data, columnMap, idx, selectedFrom } = use(Context);
|
||||
const { columns, data, columnMap, idx, selectedIdx } = use(Context);
|
||||
return (
|
||||
<View>
|
||||
<View style={{ backgroundColor: HEADER_COLOR, flexDirection: "row" }}>
|
||||
@@ -136,19 +143,21 @@ export function Body() {
|
||||
))}
|
||||
</View>
|
||||
{data.map((row, index) => {
|
||||
const isSelected =
|
||||
index == idx ||
|
||||
(selectedFrom != undefined &&
|
||||
((selectedFrom <= index && index <= idx) ||
|
||||
(idx <= index && index <= selectedFrom)));
|
||||
const isSelected = selectedIdx.has(index);
|
||||
const isFocused = index == idx;
|
||||
|
||||
return (
|
||||
<View
|
||||
key={index}
|
||||
style={{
|
||||
backgroundColor: isSelected
|
||||
? SELECTED_COLOR
|
||||
: TABLE_COLORS[index % 2],
|
||||
backgroundColor:
|
||||
isSelected && isFocused
|
||||
? COLORS.focused_selected
|
||||
: isFocused
|
||||
? COLORS.focused
|
||||
: isSelected
|
||||
? COLORS.selected
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<TableRow
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { View, Text } from "react-native";
|
||||
import { keysStore } from "./store";
|
||||
import { keysStore, type ScopeKeys } from "./store";
|
||||
|
||||
export function ShortcutDebug() {
|
||||
const entries = useSyncExternalStore(
|
||||
@@ -19,14 +19,23 @@ export function ShortcutDebug() {
|
||||
padding: 10,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "red", fontFamily: "mono" }}>Registered:</Text>
|
||||
<Text style={{ color: "red", fontFamily: "mono" }}>Scopes:</Text>
|
||||
{entries.map(([scope, keys]) => (
|
||||
<ScopeView key={scope} scope={scope} keys={keys} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopeView({ scope, keys }: { scope: string; keys: ScopeKeys }) {
|
||||
return (
|
||||
<Text style={{ color: "red", fontFamily: "mono", textAlign: "right" }}>
|
||||
{entries
|
||||
.values()
|
||||
{scope}:
|
||||
{keys
|
||||
.entries()
|
||||
.map(([key, _]) => key)
|
||||
.toArray()
|
||||
.join(",")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,16 +3,20 @@ import { keysStore } from "./store";
|
||||
import type { Key } from "./types";
|
||||
import { enforceKeyOptions } from "./util";
|
||||
|
||||
export const useShortcut = (key: Key, handler: () => void) => {
|
||||
export const useShortcut = (
|
||||
key: Key,
|
||||
handler: () => void,
|
||||
scope: string = "global",
|
||||
) => {
|
||||
const keyOptions = enforceKeyOptions(key);
|
||||
const keyName = keyOptions.name;
|
||||
const ref = useRef(handler);
|
||||
ref.current = handler;
|
||||
|
||||
useEffect(() => {
|
||||
keysStore.register(keyName, ref);
|
||||
keysStore.register(keyName, ref, scope);
|
||||
return () => {
|
||||
keysStore.deregister(keyName);
|
||||
keysStore.deregister(keyName, scope);
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { type RefObject } from "react";
|
||||
|
||||
// internal map
|
||||
const keys = new Map<string, RefObject<() => void>>();
|
||||
export type ScopeKeys = Map<string, RefObject<() => void>>;
|
||||
|
||||
// cached snapshot (stable reference)
|
||||
let snapshot: [string, RefObject<() => void>][] = [];
|
||||
// outer reactive container
|
||||
const scopes = new Map<string, ScopeKeys>();
|
||||
|
||||
let listeners = new Set<() => void>();
|
||||
// stable snapshot for subscribers
|
||||
let snapshot: [string, ScopeKeys][] = [];
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit() {
|
||||
// refresh snapshot ONLY when keys actually change
|
||||
snapshot = Array.from(keys.entries());
|
||||
// replace identity so subscribers re-render
|
||||
snapshot = Array.from(scopes.entries());
|
||||
for (const fn of listeners) fn();
|
||||
}
|
||||
|
||||
@@ -21,20 +23,36 @@ export const keysStore = {
|
||||
},
|
||||
|
||||
getSnapshot() {
|
||||
return snapshot; // stable unless emit() ran
|
||||
return snapshot;
|
||||
},
|
||||
|
||||
register(key: string, ref: RefObject<() => void>) {
|
||||
keys.set(key, ref);
|
||||
register(key: string, ref: RefObject<() => void>, scope: string) {
|
||||
const prev = scopes.get(scope);
|
||||
const next = new Map(prev); // <-- important: new identity
|
||||
next.set(key, ref);
|
||||
|
||||
scopes.set(scope, next); // <-- outer identity also changes
|
||||
emit();
|
||||
},
|
||||
|
||||
deregister(key: string) {
|
||||
keys.delete(key);
|
||||
deregister(key: string, scope: string) {
|
||||
const prev = scopes.get(scope);
|
||||
if (!prev) return;
|
||||
|
||||
const next = new Map(prev);
|
||||
next.delete(key);
|
||||
|
||||
if (next.size === 0) {
|
||||
scopes.delete(scope);
|
||||
} else {
|
||||
scopes.set(scope, next);
|
||||
}
|
||||
emit();
|
||||
},
|
||||
|
||||
getHandler(key: string) {
|
||||
return keys.get(key)?.current;
|
||||
// last scope wins — clarify this logic as needed
|
||||
const last = Array.from(scopes.values()).at(-1);
|
||||
return last?.get(key)?.current;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -10,7 +10,12 @@ import {
|
||||
import { useQuery, useZero } from "@rocicorp/zero/react";
|
||||
import * as Table from "../components/Table";
|
||||
import { Button } from "../components/Button";
|
||||
import * as Dialog from "../components/Dialog";
|
||||
import { RenameCategoryDialog } from "./budget/RenameCategoryDialog";
|
||||
import {
|
||||
UpdateCategoryAmountDialog,
|
||||
type CategoryWithComputed,
|
||||
type Updating,
|
||||
} from "./budget/UpdateCategoryAmountDialog";
|
||||
|
||||
const COLUMNS: Table.Column[] = [
|
||||
{ name: "label", label: "Name" },
|
||||
@@ -24,9 +29,9 @@ export function Budget() {
|
||||
const { auth } = use(RouterContext);
|
||||
const [budgets] = useQuery(queries.getBudgets(auth));
|
||||
const [renaming, setRenaming] = useState<Category>();
|
||||
const [editCategoryAmount, setEditCategoryAmount] = useState<Updating>();
|
||||
|
||||
const z = useZero<Schema, Mutators>();
|
||||
const refText = useRef("");
|
||||
|
||||
const newBudget = () => {
|
||||
const id = new Date().getTime().toString();
|
||||
@@ -60,8 +65,8 @@ export function Budget() {
|
||||
|
||||
const data = budget.categories.slice().map((category) => {
|
||||
const { amount } = category;
|
||||
const week = amount;
|
||||
const month = amount * 4;
|
||||
const week = amount / 4;
|
||||
const month = amount;
|
||||
const year = amount * 12;
|
||||
|
||||
return {
|
||||
@@ -79,7 +84,7 @@ export function Budget() {
|
||||
z.mutate.budget.createCategory({
|
||||
id,
|
||||
budgetId: budget.id,
|
||||
order: index - 1,
|
||||
order: index,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -95,38 +100,37 @@ export function Budget() {
|
||||
}
|
||||
};
|
||||
|
||||
const onEditCategoryYearly = ({
|
||||
selected,
|
||||
}: { selected: CategoryWithComputed[] }) => {
|
||||
for (const category of selected) {
|
||||
setEditCategoryAmount({ category, every: "year" });
|
||||
}
|
||||
};
|
||||
|
||||
const onEditCategoryMonthly = ({
|
||||
selected,
|
||||
}: { selected: CategoryWithComputed[] }) => {
|
||||
for (const category of selected) {
|
||||
setEditCategoryAmount({ category, every: "month" });
|
||||
}
|
||||
};
|
||||
|
||||
const onEditCategoryWeekly = ({
|
||||
selected,
|
||||
}: { selected: CategoryWithComputed[] }) => {
|
||||
for (const category of selected) {
|
||||
setEditCategoryAmount({ category, every: "week" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog.Provider
|
||||
visible={renaming != undefined}
|
||||
close={() => setRenaming(undefined)}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<Text style={{ fontFamily: "mono" }}>Edit Category</Text>
|
||||
|
||||
<TextInput
|
||||
style={{ fontFamily: "mono" }}
|
||||
autoFocus
|
||||
selectTextOnFocus
|
||||
defaultValue={renaming?.label}
|
||||
onChangeText={(t) => {
|
||||
refText.current = t;
|
||||
}}
|
||||
onKeyPress={(e) => {
|
||||
if (!renaming) return;
|
||||
if (e.nativeEvent.key == "Enter") {
|
||||
z.mutate.budget.updateCategory({
|
||||
id: renaming.id,
|
||||
label: refText.current,
|
||||
});
|
||||
setRenaming(undefined);
|
||||
} else if (e.nativeEvent.key == "Escape") {
|
||||
setRenaming(undefined);
|
||||
}
|
||||
}}
|
||||
<RenameCategoryDialog renaming={renaming} setRenaming={setRenaming} />
|
||||
<UpdateCategoryAmountDialog
|
||||
updating={editCategoryAmount}
|
||||
setUpdating={setEditCategoryAmount}
|
||||
/>
|
||||
</Dialog.Content>
|
||||
</Dialog.Provider>
|
||||
|
||||
<View style={{ alignItems: "flex-start" }}>
|
||||
<Text style={{ fontFamily: "mono", textAlign: "left" }}>
|
||||
@@ -140,6 +144,9 @@ export function Budget() {
|
||||
{ key: "i", handler: newCategory },
|
||||
{ key: "d", handler: deleteCategory },
|
||||
{ key: "r", handler: renameCategory },
|
||||
{ key: "y", handler: onEditCategoryYearly },
|
||||
{ key: "m", handler: onEditCategoryMonthly },
|
||||
{ key: "w", handler: onEditCategoryWeekly },
|
||||
]}
|
||||
>
|
||||
<View style={{ flex: 1 }}>
|
||||
|
||||
77
packages/ui/src/budget/RenameCategoryDialog.tsx
Normal file
77
packages/ui/src/budget/RenameCategoryDialog.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useRef, useState } from "react";
|
||||
import * as Dialog from "../../components/Dialog";
|
||||
import { View, Text, TextInput } from "react-native";
|
||||
import { type Category, type Mutators, type Schema } from "@money/shared";
|
||||
import { useZero } from "@rocicorp/zero/react";
|
||||
|
||||
interface RenameCategoryDialogProps {
|
||||
renaming: Category | undefined;
|
||||
setRenaming: (v: Category | undefined) => void;
|
||||
}
|
||||
export function RenameCategoryDialog({
|
||||
renaming,
|
||||
setRenaming,
|
||||
}: RenameCategoryDialogProps) {
|
||||
const refText = useRef("");
|
||||
const [renamingText, setRenamingText] = useState("");
|
||||
|
||||
const z = useZero<Schema, Mutators>();
|
||||
|
||||
return (
|
||||
<Dialog.Provider
|
||||
visible={renaming != undefined}
|
||||
close={() => setRenaming(undefined)}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<View style={{ width: 400 }}>
|
||||
<View
|
||||
style={{
|
||||
borderBottomWidth: 1,
|
||||
paddingTop: 12,
|
||||
paddingLeft: 12,
|
||||
paddingRight: 12,
|
||||
}}
|
||||
>
|
||||
<TextInput
|
||||
style={{
|
||||
fontFamily: "mono",
|
||||
// @ts-ignore
|
||||
outline: "none",
|
||||
}}
|
||||
autoFocus
|
||||
selectTextOnFocus
|
||||
defaultValue={renaming?.label}
|
||||
onChangeText={(t) => {
|
||||
refText.current = t;
|
||||
setRenamingText(t);
|
||||
}}
|
||||
onKeyPress={(e) => {
|
||||
if (!renaming) return;
|
||||
if (e.nativeEvent.key == "Enter") {
|
||||
if (refText.current.trim() == "")
|
||||
return setRenaming(undefined);
|
||||
z.mutate.budget.updateCategory({
|
||||
id: renaming.id,
|
||||
label: refText.current,
|
||||
});
|
||||
setRenaming(undefined);
|
||||
} else if (e.nativeEvent.key == "Escape") {
|
||||
setRenaming(undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={{ paddingLeft: 12, paddingRight: 12, paddingBottom: 12 }}
|
||||
>
|
||||
<Text style={{ fontFamily: "mono" }}>
|
||||
→ Rename category to: {renamingText || renaming?.label}
|
||||
</Text>
|
||||
<Text style={{ fontFamily: "mono" }}>→ Cancel</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Dialog.Content>
|
||||
</Dialog.Provider>
|
||||
);
|
||||
}
|
||||
107
packages/ui/src/budget/UpdateCategoryAmountDialog.tsx
Normal file
107
packages/ui/src/budget/UpdateCategoryAmountDialog.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useRef, useState } from "react";
|
||||
import * as Dialog from "../../components/Dialog";
|
||||
import { View, Text, TextInput } from "react-native";
|
||||
import { type Category, type Mutators, type Schema } from "@money/shared";
|
||||
import { useZero } from "@rocicorp/zero/react";
|
||||
|
||||
export type Updating = {
|
||||
category: CategoryWithComputed;
|
||||
every: Category["every"];
|
||||
};
|
||||
|
||||
export type CategoryWithComputed = Category & {
|
||||
month: number;
|
||||
year: number;
|
||||
};
|
||||
|
||||
interface UpdateCategoryAmountDialogProps {
|
||||
updating: Updating | undefined;
|
||||
setUpdating: (v: Updating | undefined) => void;
|
||||
}
|
||||
export function UpdateCategoryAmountDialog({
|
||||
updating,
|
||||
setUpdating,
|
||||
}: UpdateCategoryAmountDialogProps) {
|
||||
const category = updating?.category;
|
||||
const every = updating?.every;
|
||||
|
||||
const refText = useRef("");
|
||||
const [amountText, setAmountText] = useState("");
|
||||
|
||||
const z = useZero<Schema, Mutators>();
|
||||
|
||||
return (
|
||||
<Dialog.Provider
|
||||
visible={category != undefined}
|
||||
close={() => setUpdating(undefined)}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<View style={{ width: 400 }}>
|
||||
<View
|
||||
style={{
|
||||
borderBottomWidth: 1,
|
||||
paddingTop: 12,
|
||||
paddingLeft: 12,
|
||||
paddingRight: 12,
|
||||
}}
|
||||
>
|
||||
<TextInput
|
||||
style={{
|
||||
fontFamily: "mono",
|
||||
// @ts-ignore
|
||||
outline: "none",
|
||||
}}
|
||||
autoFocus
|
||||
selectTextOnFocus
|
||||
defaultValue={category?.month.toString()}
|
||||
onChangeText={(t) => {
|
||||
refText.current = t;
|
||||
setAmountText(t);
|
||||
}}
|
||||
onKeyPress={(e) => {
|
||||
if (!category) return;
|
||||
if (e.nativeEvent.key == "Enter") {
|
||||
if (refText.current.trim() == "")
|
||||
return setUpdating(undefined);
|
||||
|
||||
try {
|
||||
const parsed = parseFloat(refText.current);
|
||||
|
||||
const amount = (function () {
|
||||
switch (every) {
|
||||
case "year":
|
||||
return parsed / 12;
|
||||
case "month":
|
||||
return parsed;
|
||||
case "week":
|
||||
return parsed * 4;
|
||||
}
|
||||
})();
|
||||
|
||||
z.mutate.budget.updateCategory({
|
||||
id: category.id,
|
||||
amount,
|
||||
every,
|
||||
});
|
||||
setUpdating(undefined);
|
||||
} catch (e) {}
|
||||
} else if (e.nativeEvent.key == "Escape") {
|
||||
setUpdating(undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={{ paddingLeft: 12, paddingRight: 12, paddingBottom: 12 }}
|
||||
>
|
||||
<Text style={{ fontFamily: "mono" }}>
|
||||
→ Update monthly amount to: {amountText || category?.month}
|
||||
</Text>
|
||||
<Text style={{ fontFamily: "mono" }}>→ Cancel</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Dialog.Content>
|
||||
</Dialog.Provider>
|
||||
);
|
||||
}
|
||||
@@ -60,7 +60,7 @@ interface RouterContextType {
|
||||
export const RouterContext = createContext<RouterContextType>({
|
||||
auth: null,
|
||||
route: "/",
|
||||
setRoute: () => { },
|
||||
setRoute: () => {},
|
||||
});
|
||||
|
||||
type AppProps = {
|
||||
|
||||
@@ -40,13 +40,11 @@ export function Transactions() {
|
||||
<Table.Provider
|
||||
data={items}
|
||||
columns={COLUMNS}
|
||||
onKey={(key) => {
|
||||
if (key.name == "r" && key.shift) {
|
||||
z.mutate.link.updateTransactions();
|
||||
}
|
||||
}}
|
||||
shortcuts={[
|
||||
{ key: "r", handler: () => z.mutate.link.updateTransactions() },
|
||||
]}
|
||||
>
|
||||
<View style={{ flex: 1 }}>
|
||||
<View style={{ padding: 10, flex: 1 }}>
|
||||
<View style={{ flexShrink: 0 }}>
|
||||
<Table.Body />
|
||||
</View>
|
||||
@@ -59,18 +57,16 @@ export function Transactions() {
|
||||
}
|
||||
|
||||
function Selected() {
|
||||
const { data, idx, selectedFrom } = use(Table.Context);
|
||||
const { data, selectedIdx } = use(Table.Context);
|
||||
|
||||
if (selectedFrom == undefined)
|
||||
if (selectedIdx.size == 0)
|
||||
return (
|
||||
<View style={{ backgroundColor: "#ddd" }}>
|
||||
<Text style={{ fontFamily: "mono" }}>No items selected</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const from = Math.min(idx, selectedFrom);
|
||||
const to = Math.max(idx, selectedFrom);
|
||||
const selected = data.slice(from, to + 1) as Transaction[];
|
||||
const selected = data.filter((_, i) => selectedIdx.has(i)) as Transaction[];
|
||||
const count = selected.length;
|
||||
const sum = selected.reduce((prev, curr) => prev + curr.amount, 0);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user