diff options
| -rw-r--r-- | Makefile | 5 | ||||
| -rw-r--r-- | clients/drafts.c | 249 | ||||
| -rw-r--r-- | clients/drafts.h | 49 | ||||
| -rw-r--r-- | clients/screens_invoices.c | 465 | ||||
| -rw-r--r-- | clients/tui.c | 189 | ||||
| -rw-r--r-- | clients/tui.h | 23 | ||||
| -rw-r--r-- | docs/DECISIONS.md | 5 | ||||
| -rw-r--r-- | docs/STATE.md | 27 | ||||
| -rw-r--r-- | docs/TUI-GUIDELINES.md | 35 | ||||
| -rwxr-xr-x | scripts/tui-golden.py | 59 | ||||
| -rw-r--r-- | tests/test_tui.c | 95 |
11 files changed, 1077 insertions, 124 deletions
@@ -58,7 +58,8 @@ TUI_SCREEN_OBJ = $(patsubst %.c,$(BUILD)/%.o,$(TUI_SCREENS)) TUI_SCREEN_DEP = $(TUI_SCREEN_OBJ:.o=.d) $(BUILD)/bokftui: $(BUILD)/clients/bokftui.o $(BUILD)/clients/ui.o \ - $(TUI_SCREEN_OBJ) $(BUILD)/clients/tui.o \ + $(BUILD)/clients/drafts.o $(TUI_SCREEN_OBJ) \ + $(BUILD)/clients/tui.o \ $(BUILD)/clients/client.o \ $(BUILD)/src/util.o $(BUILD)/src/log.o $(BUILD)/src/formula.o \ $(BUILD)/vendor/yyjson.o $(BUILD)/vendor/sha256.o @@ -69,6 +70,7 @@ $(BUILD)/test_core: $(BUILD)/tests/test_core.o $(BUILD)/clients/client.o \ $(CC) $(CFLAGS) -o $@ $^ -lm $(SSL_LIBS) $(BUILD)/test_tui: $(BUILD)/tests/test_tui.o $(BUILD)/clients/tui.o \ + $(BUILD)/clients/drafts.o \ $(BUILD)/src/util.o $(BUILD)/src/log.o $(BUILD)/vendor/yyjson.o \ $(BUILD)/vendor/sha256.o $(CC) $(CFLAGS) -o $@ $^ -lm -lncursesw @@ -156,6 +158,7 @@ $(BUILD)/tests/%.o: tests/%.c $(BUILD)/clients/bokfctl.d $(BUILD)/clients/bokftui.d \ $(BUILD)/clients/ui.d $(TUI_SCREEN_DEP) \ $(BUILD)/clients/tui.d $(BUILD)/clients/client.d \ + $(BUILD)/clients/drafts.d \ $(BUILD)/tests/test_core.d $(BUILD)/tests/test_tui.d \ $(BUILD)/tests/pdf_check.d $(BUILD)/tests/invoice_check.d \ $(BUILD)/tests/smtp_check.d \ diff --git a/clients/drafts.c b/clients/drafts.c new file mode 100644 index 0000000..f71e85c --- /dev/null +++ b/clients/drafts.c @@ -0,0 +1,249 @@ +#include "drafts.h" + +#include <fcntl.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/stat.h> +#include <time.h> +#include <unistd.h> + +#include "util.h" +#include "yyjson.h" + +static void mkdirs_for(const char *path) +{ + char tmp[640]; + + if (snprintf(tmp, sizeof tmp, "%s", path) >= (int)sizeof tmp) + return; + for (char *p = tmp + 1; *p; p++) { + if (*p != '/') + continue; + *p = '\0'; + mkdir(tmp, 0700); + *p = '/'; + } +} + +static void cache_path(char *buf, size_t n) +{ + const char *cache = getenv("XDG_CACHE_HOME"); + + if (cache && *cache) { + snprintf(buf, n, "%s/bokf/drafts.json", cache); + return; + } + const char *home = getenv("HOME"); + snprintf(buf, n, "%s/.cache/bokf/drafts.json", + home && *home ? home : "."); +} + +static void rec_clear(struct draft *r) +{ + free(r->entity); + free(r->id); + free(r->fields); +} + +void drafts_free(struct drafts *d) +{ + if (!d) + return; + for (size_t i = 0; i < d->n; i++) + rec_clear(&d->v[i]); + free(d->v); + d->v = NULL; + d->n = d->cap = 0; +} + +static struct draft *rec_find(const struct drafts *d, int64_t org, + const char *entity, const char *id) +{ + for (size_t i = 0; i < d->n; i++) { + struct draft *r = &d->v[i]; + + if (r->org == org && strcmp(r->entity, entity) == 0 && + strcmp(r->id, id) == 0) + return r; + } + return NULL; +} + +static void rec_put(struct drafts *d, int64_t org, const char *entity, + const char *id, const char *fields) +{ + struct draft *r = rec_find(d, org, entity, id); + + if (!r) { + if (d->n == d->cap) { + d->cap = d->cap ? d->cap * 2 : 8; + d->v = xrealloc(d->v, d->cap * sizeof *d->v); + } + r = &d->v[d->n++]; + memset(r, 0, sizeof *r); + r->org = org; + r->entity = xstrdup(entity); + r->id = xstrdup(id); + r->fields = xstrdup(fields && *fields ? fields : "{}"); + return; + } + free(r->fields); + r->fields = xstrdup(fields && *fields ? fields : "{}"); +} + +static void drafts_write(const struct drafts *d) +{ + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root = yyjson_mut_obj(doc); + + yyjson_mut_doc_set_root(doc, root); + yyjson_mut_obj_add_int(doc, root, "version", 1); + yyjson_mut_val *arr = yyjson_mut_arr(doc); + for (size_t i = 0; i < d->n; i++) { + const struct draft *r = &d->v[i]; + yyjson_mut_val *o = yyjson_mut_arr_add_obj(doc, arr); + yyjson_mut_obj_add_int(doc, o, "org", r->org); + yyjson_mut_obj_add_strcpy(doc, o, "entity", r->entity); + yyjson_mut_obj_add_strcpy(doc, o, "id", r->id); + yyjson_mut_val *f = yyjson_mut_rawcpy(doc, r->fields); + yyjson_mut_obj_add_val(doc, o, "fields", f ? f : yyjson_mut_obj(doc)); + } + yyjson_mut_obj_add_val(doc, root, "drafts", arr); + char *json = yyjson_mut_write(doc, YYJSON_WRITE_PRETTY, NULL); + yyjson_mut_doc_free(doc); + if (!json) + return; + + mkdirs_for(d->path); + char tmp[640]; + if (snprintf(tmp, sizeof tmp, "%s.tmp", d->path) >= (int)sizeof tmp) { + free(json); + return; + } + int fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (fd >= 0) { + FILE *f = fdopen(fd, "w"); + if (f) { + fputs(json, f); + fclose(f); + rename(tmp, d->path); + } else { + close(fd); + } + } + free(json); +} + +static void drafts_load(struct drafts *d) +{ + FILE *f = fopen(d->path, "rb"); + if (!f) + return; + struct buf b; + char tmp[8192]; + size_t k; + buf_init(&b); + while ((k = fread(tmp, 1, sizeof tmp, f)) > 0) + buf_append(&b, tmp, k); + fclose(f); + yyjson_doc *doc = yyjson_read((const char *)b.p, b.len, 0); + buf_free(&b); + if (!doc) + return; + yyjson_val *arr = yyjson_obj_get(yyjson_doc_get_root(doc), "drafts"); + if (yyjson_is_arr(arr)) { + size_t idx, max; + yyjson_val *it; + yyjson_arr_foreach(arr, idx, max, it) { + yyjson_val *org = yyjson_obj_get(it, "org"); + yyjson_val *entity = yyjson_obj_get(it, "entity"); + yyjson_val *id = yyjson_obj_get(it, "id"); + yyjson_val *fields = yyjson_obj_get(it, "fields"); + if (!yyjson_is_int(org) || !yyjson_is_str(entity) || + !yyjson_is_str(id) || !yyjson_is_obj(fields)) + continue; + char *ft = yyjson_val_write(fields, 0, NULL); + rec_put(d, yyjson_get_sint(org), yyjson_get_str(entity), + yyjson_get_str(id), ft ? ft : "{}"); + free(ft); + } + } + yyjson_doc_free(doc); +} + +void drafts_init_path(struct drafts *d, const char *path) +{ + memset(d, 0, sizeof *d); + snprintf(d->path, sizeof d->path, "%s", path); + drafts_load(d); +} + +void drafts_init(struct drafts *d) +{ + char path[600]; + cache_path(path, sizeof path); + drafts_init_path(d, path); +} + +const char *drafts_get(const struct drafts *d, int64_t org, + const char *entity, const char *id) +{ + const struct draft *r = rec_find(d, org, entity, id); + return r ? r->fields : NULL; +} + +int drafts_have(const struct drafts *d, int64_t org, const char *entity, + const char *id) +{ + return rec_find(d, org, entity, id) != NULL; +} + +void drafts_put(struct drafts *d, int64_t org, const char *entity, + const char *id, const char *fields) +{ + rec_put(d, org, entity, id, fields); + drafts_write(d); +} + +void drafts_del(struct drafts *d, int64_t org, const char *entity, + const char *id) +{ + struct draft *r = rec_find(d, org, entity, id); + + if (!r) + return; + size_t i = (size_t)(r - d->v); + rec_clear(r); + memmove(&d->v[i], &d->v[i + 1], (d->n - i - 1) * sizeof *d->v); + d->n--; + drafts_write(d); +} + +size_t drafts_count(const struct drafts *d, int64_t org, const char *entity) +{ + size_t n = 0; + + for (size_t i = 0; i < d->n; i++) + if (d->v[i].org == org && strcmp(d->v[i].entity, entity) == 0) + n++; + return n; +} + +void drafts_foreach(const struct drafts *d, int64_t org, const char *entity, + void (*cb)(void *ud, const char *id, const char *fields), + void *ud) +{ + for (size_t i = 0; i < d->n; i++) + if (d->v[i].org == org && strcmp(d->v[i].entity, entity) == 0) + cb(ud, d->v[i].id, d->v[i].fields); +} + +char *drafts_new_id(void) +{ + static unsigned seq; + char buf[64]; + + snprintf(buf, sizeof buf, "ny-%lld-%u", (long long)time(NULL), ++seq); + return xstrdup(buf); +} diff --git a/clients/drafts.h b/clients/drafts.h new file mode 100644 index 0000000..2cc67db --- /dev/null +++ b/clients/drafts.h @@ -0,0 +1,49 @@ +#ifndef BOKF_DRAFTS_H +#define BOKF_DRAFTS_H + +#include <stddef.h> +#include <stdint.h> + +/* Client-local edit drafts: every non-committed edit is mirrored to + $XDG_CACHE_HOME/bokf/drafts.json (mode 0600, atomic replace) so a reload, + a crash or Ctrl+C never loses work. A record is keyed by + (org, entity, id); a new entity uses a temporary id from + drafts_new_id(). See docs/TUI-GUIDELINES.md "Interaction model". */ + +struct draft { + int64_t org; + char *entity; + char *id; + char *fields; /* JSON object text */ +}; + +struct drafts { + struct draft *v; + size_t n, cap; + char path[600]; +}; + +void drafts_init(struct drafts *d); +/* Explicit path, for tests. */ +void drafts_init_path(struct drafts *d, const char *path); +void drafts_free(struct drafts *d); + +/* The draft's fields JSON, or NULL; the store owns the string. */ +const char *drafts_get(const struct drafts *d, int64_t org, + const char *entity, const char *id); +int drafts_have(const struct drafts *d, int64_t org, const char *entity, + const char *id); +/* Inserts or replaces the draft and persists the file. */ +void drafts_put(struct drafts *d, int64_t org, const char *entity, + const char *id, const char *fields); +void drafts_del(struct drafts *d, int64_t org, const char *entity, + const char *id); +size_t drafts_count(const struct drafts *d, int64_t org, const char *entity); +void drafts_foreach(const struct drafts *d, int64_t org, const char *entity, + void (*cb)(void *ud, const char *id, const char *fields), + void *ud); + +/* "ny-<time>-<seq>", unique within the process. Caller frees. */ +char *drafts_new_id(void); + +#endif diff --git a/clients/screens_invoices.c b/clients/screens_invoices.c index 352c184..712a8a8 100644 --- a/clients/screens_invoices.c +++ b/clients/screens_invoices.c @@ -14,6 +14,7 @@ #include <unistd.h> #include "client.h" +#include "drafts.h" #include "tui.h" #include "formula.h" #include "util.h" @@ -219,12 +220,128 @@ static int customer_save(struct app *a, int64_t id, const char *name, return ok; } -static void customer_form(struct app *a, int64_t id) +/* Drafts for the customer register: new entities exist only as drafts + until Spara writes them (docs/TUI-GUIDELINES.md "Interaction model"). */ + +static struct drafts g_drafts; +static int g_drafts_ready; + +static struct drafts *kdrafts(void) +{ + if (!g_drafts_ready) { + drafts_init(&g_drafts); + g_drafts_ready = 1; + } + return &g_drafts; +} + +struct cform_ctx { + struct app *a; + int64_t id; + const char *draft_id; + int deleted; + char name[256]; + char address[512]; + char postal[32]; + char city[128]; + char vat[64]; + char email[256]; + char your_ref[128]; + char payment[24]; + char notes[2048]; +}; + +static char *customer_fields_json(const struct cform_ctx *c) +{ + yyjson_mut_doc *d = yyjson_mut_doc_new(NULL); + yyjson_mut_val *o = yyjson_mut_obj(d); + + yyjson_mut_doc_set_root(d, o); + yyjson_mut_obj_add_strcpy(d, o, "name", c->name); + yyjson_mut_obj_add_strcpy(d, o, "address", c->address); + yyjson_mut_obj_add_strcpy(d, o, "postal_code", c->postal); + yyjson_mut_obj_add_strcpy(d, o, "city", c->city); + yyjson_mut_obj_add_strcpy(d, o, "vat_nr", c->vat); + yyjson_mut_obj_add_strcpy(d, o, "email", c->email); + yyjson_mut_obj_add_strcpy(d, o, "your_ref", c->your_ref); + yyjson_mut_obj_add_int(d, o, "payment_days", + strtoll(c->payment, NULL, 10)); + yyjson_mut_obj_add_strcpy(d, o, "notes", c->notes); + char *s = yyjson_mut_write(d, 0, NULL); + yyjson_mut_doc_free(d); + return s; +} + +static void customer_load_json(const char *json, struct cform_ctx *c) { - char name[256] = "", address[512] = "", postal[32] = "", city[128] = ""; - char vat[64] = "", email[256] = "", your_ref[128] = ""; - char payment[16] = "30", notes[2048] = ""; - if (id > 0) { + yyjson_doc *doc = yyjson_read(json, strlen(json), 0); + + if (!doc) + return; + yyjson_val *o = yyjson_doc_get_root(doc); + const struct { + const char *key; + char *dst; + size_t cap; + } fields[] = { + { "name", c->name, sizeof c->name }, + { "address", c->address, sizeof c->address }, + { "postal_code", c->postal, sizeof c->postal }, + { "city", c->city, sizeof c->city }, + { "vat_nr", c->vat, sizeof c->vat }, + { "email", c->email, sizeof c->email }, + { "your_ref", c->your_ref, sizeof c->your_ref }, + { "notes", c->notes, sizeof c->notes }, + }; + for (size_t i = 0; i < sizeof fields / sizeof fields[0]; i++) + snprintf(fields[i].dst, fields[i].cap, "%s", + vstr(o, fields[i].key)); + int64_t days = vint(o, "payment_days"); + if (days > 0) + snprintf(c->payment, sizeof c->payment, "%lld", (long long)days); + yyjson_doc_free(doc); +} + +static int cform_key(void *ud, int ch) +{ + struct cform_ctx *c = ud; + + if (ch != KEY_F(2)) + return TUI_HOOK_NONE; + int have = drafts_have(kdrafts(), c->a->org, "customer", c->draft_id); + struct tui_action acts[] = { + { "customer.save", "Spara", 0, 1, NULL }, + { "customer.draft.delete", "Radera utkast", 0, have, "inget utkast" }, + }; + static int afocus; + int r = tui_action_menu("Åtgärder", acts, 2, &afocus); + if (r == 0) + return TUI_HOOK_SUBMIT; + if (r == 1) { + if (tui_confirm("Kund", "Radera utkastet?")) { + drafts_del(kdrafts(), c->a->org, "customer", c->draft_id); + c->deleted = 1; + return TUI_HOOK_BACK; + } + return TUI_HOOK_STAY; + } + return TUI_HOOK_STAY; +} + +static void customer_form(struct app *a, int64_t id, const char *draft_id) +{ + struct cform_ctx c; + + memset(&c, 0, sizeof c); + c.a = a; + c.id = id; + c.draft_id = draft_id; + snprintf(c.payment, sizeof c.payment, "30"); + + const char *fields = drafts_get(kdrafts(), a->org, "customer", draft_id); + if (fields) { + customer_load_json(fields, &c); + } else if (id > 0) { char args[64]; snprintf(args, sizeof args, "{\"id\":%lld}", (long long)id); char *resp = @@ -234,43 +351,46 @@ static void customer_form(struct app *a, int64_t id) free(resp); return; } - struct { + const struct { const char *key; char *dst; size_t cap; - } fields[] = { - { "name", name, sizeof name }, - { "address", address, sizeof address }, - { "postal_code", postal, sizeof postal }, - { "city", city, sizeof city }, - { "vat_nr", vat, sizeof vat }, - { "email", email, sizeof email }, - { "your_ref", your_ref, sizeof your_ref }, - { "notes", notes, sizeof notes }, + } f[] = { + { "name", c.name, sizeof c.name }, + { "address", c.address, sizeof c.address }, + { "postal_code", c.postal, sizeof c.postal }, + { "city", c.city, sizeof c.city }, + { "vat_nr", c.vat, sizeof c.vat }, + { "email", c.email, sizeof c.email }, + { "your_ref", c.your_ref, sizeof c.your_ref }, + { "notes", c.notes, sizeof c.notes }, }; - for (size_t i = 0; i < sizeof fields / sizeof fields[0]; i++) { - char path[64], *v; - snprintf(path, sizeof path, "result.%s", fields[i].key); + for (size_t i = 0; i < sizeof f / sizeof f[0]; i++) { + char path[64]; + char *v; + snprintf(path, sizeof path, "result.%s", f[i].key); v = jstr_dup(resp, path); if (v) { - snprintf(fields[i].dst, fields[i].cap, "%s", v); + snprintf(f[i].dst, f[i].cap, "%s", v); free(v); } } - snprintf(payment, sizeof payment, "%lld", + snprintf(c.payment, sizeof c.payment, "%lld", (long long)jint_val(resp, "result.payment_days", 30)); free(resp); } + const char *labels[] = { "Namn", "Adress", "Postnummer", "Ort", "Momsreg.nr", "E-post", "Er referens", "Betalningsdagar", "Anteckningar" }; - char *vals[] = { name, address, postal, city, vat, - email, your_ref, payment, notes }; - size_t caps[] = { sizeof name, sizeof address, sizeof postal, - sizeof city, sizeof vat, sizeof email, - sizeof your_ref, sizeof payment, sizeof notes }; + char *vals[] = { c.name, c.address, c.postal, c.city, + c.vat, c.email, c.your_ref, c.payment, + c.notes }; + size_t caps[] = { sizeof c.name, sizeof c.address, sizeof c.postal, + sizeof c.city, sizeof c.vat, sizeof c.email, + sizeof c.your_ref, sizeof c.payment, sizeof c.notes }; struct tui_form_field ff[10]; memset(ff, 0, sizeof ff); for (int i = 0; i < 9; i++) { @@ -282,22 +402,47 @@ static void customer_form(struct app *a, int64_t id) ff[9].label = ""; ff[9].value = "Spara"; ff[9].kind = TUI_F_ACTION; + static int sel = 0; + tui_form_hint_extra("F2 = åtgärder"); for (;;) { - int r = tui_form_run(id > 0 ? "Kund" : "Ny kund", ff, 10, 1, &sel); + char title[64]; + snprintf(title, sizeof title, "%s%s", id > 0 ? "Kund" : "Ny kund", + drafts_have(kdrafts(), a->org, "customer", draft_id) + ? " <UTKAST>" + : ""); + int r = tui_form_run_hook(title, ff, 10, 1, cform_key, &c, &sel); + if (c.deleted) + break; + if (r >= 0) { + char *json = customer_fields_json(&c); + if (json) { + drafts_put(kdrafts(), a->org, "customer", draft_id, json); + free(json); + } + } if (r == TUI_FORM_BACK) - return; + break; if (r == TUI_FORM_REFRESH) { - customer_save(a, id, name, address, postal, city, vat, email, - your_ref, payment, notes, 1); + customer_save(a, id, c.name, c.address, c.postal, c.city, c.vat, + c.email, c.your_ref, c.payment, c.notes, 1); continue; } if (r == TUI_FORM_SUBMIT || r == 9) { - if (customer_save(a, id, name, address, postal, city, vat, email, - your_ref, payment, notes, 0)) - return; + if (customer_save(a, id, c.name, c.address, c.postal, c.city, + c.vat, c.email, c.your_ref, c.payment, c.notes, + 0)) { + drafts_del(kdrafts(), a->org, "customer", draft_id); + break; + } + char *json = customer_fields_json(&c); + if (json) { + drafts_put(kdrafts(), a->org, "customer", draft_id, json); + free(json); + } } } + tui_form_hint_extra(NULL); } static void customer_archive_toggle(struct app *a, int64_t id, const char *name, @@ -319,59 +464,170 @@ static void customer_archive_toggle(struct app *a, int64_t id, const char *name, free(r); } +struct clist { + struct app *a; + int *cur; + char **items; + char **names; + char **dkeys; + int64_t *ids; + unsigned char *active; + size_t nserver; + size_t nrows; /* server rows + temp drafts; the add row is at nrows */ +}; + +static void clist_clear(struct clist *l) +{ + if (l->items) { + for (size_t i = 0; i <= l->nrows; i++) { + free(l->items[i]); + free(l->names[i]); + free(l->dkeys[i]); + } + } + free(l->items); + free(l->names); + free(l->dkeys); + free(l->ids); + free(l->active); + l->items = NULL; + l->names = NULL; + l->dkeys = NULL; + l->ids = NULL; + l->active = NULL; + l->nserver = l->nrows = 0; +} + +static int clist_is_server_id(const struct clist *l, const char *id) +{ + long long v = strtoll(id, NULL, 10); + + if (v <= 0) + return 0; + for (size_t i = 0; i < l->nserver; i++) + if (l->ids[i] == v) + return 1; + return 0; +} + +static void clist_collect_temp(void *ud, const char *id, const char *fields) +{ + struct clist *l = ud; + char nbuf[256], line[768]; + const char *name = "(namnlös)"; + yyjson_doc *doc; + + if (clist_is_server_id(l, id)) + return; + doc = yyjson_read(fields, strlen(fields), 0); + if (doc) { + yyjson_val *o = yyjson_doc_get_root(doc); + const char *n = vstr(o, "name"); + if (*n) + name = n; + size_t i = l->nrows; + l->names[i] = xstrdup(name); + snprintf(nbuf, sizeof nbuf, "<UTKAST> %s", name); + snprintf(line, sizeof line, "%s", nbuf); + l->items[i] = xstrdup(line); + l->dkeys[i] = xstrdup(id); + l->ids[i] = 0; + l->active[i] = 1; + l->nrows++; + yyjson_doc_free(doc); + return; + } + size_t i = l->nrows; + l->names[i] = xstrdup(name); + snprintf(line, sizeof line, "<UTKAST> %s", name); + l->items[i] = xstrdup(line); + l->dkeys[i] = xstrdup(id); + l->ids[i] = 0; + l->active[i] = 1; + l->nrows++; +} + +static int clist_key(void *ud, int ch) +{ + struct clist *l = ud; + static int afocus; + + if (ch != KEY_F(2) || !l->cur) + return TUI_HOOK_NONE; + int i = *l->cur; + if (i < 0 || (size_t)i >= l->nrows) + return TUI_HOOK_STAY; + struct tui_action acts[] = { + { "customer.open", "Öppna", 0, 1, NULL }, + { "customer.draft.delete", "Radera utkast", 0, l->dkeys[i] != NULL, + "inget utkast" }, + { "customer.archive", l->active[i] ? "Arkivera" : "Återaktivera", 0, + l->ids[i] != 0, "gäller inte utkast" }, + }; + int r = tui_action_menu("Åtgärder", acts, 3, &afocus); + if (r == 0) { + char idbuf[32]; + const char *dk = l->dkeys[i]; + if (!dk) { + snprintf(idbuf, sizeof idbuf, "%lld", (long long)l->ids[i]); + dk = idbuf; + } + customer_form(l->a, l->ids[i], dk); + return TUI_HOOK_REFRESH; + } + if (r == 1) { + if (tui_confirm("Kund", "Radera utkastet?")) + drafts_del(kdrafts(), l->a->org, "customer", l->dkeys[i]); + return TUI_HOOK_REFRESH; + } + if (r == 2) { + customer_archive_toggle(l->a, l->ids[i], l->names[i], + l->active[i] != 0); + return TUI_HOOK_REFRESH; + } + return TUI_HOOK_STAY; +} + void customers_screen(struct app *a) { - char **items = NULL; - char **names = NULL; - int64_t *ids = NULL; - unsigned char *active = NULL; - size_t n = 0; + struct clist l; + memset(&l, 0, sizeof l); + l.a = a; + int cur = 0; + l.cur = &cur; int fetch = 1; for (;;) { if (g_quit) { - for (size_t i = 0; i < n; i++) { - free(items[i]); - free(names[i]); - } - free(items); - free(names); - free(ids); - free(active); + clist_clear(&l); return; } if (fetch) { - for (size_t i = 0; i < n; i++) { - free(items[i]); - free(names[i]); - } - free(items); - free(names); - free(ids); - free(active); - items = NULL; - names = NULL; - ids = NULL; - active = NULL; - n = 0; + clist_clear(&l); fetch = 0; char *resp = client_rpc(&a->conn, "customer.list", a->session, a->org, "{}"); if (!resp || !client_ok(resp)) { show_error("Kunder", resp); free(resp); + clist_clear(&l); return; } - n = jarr_size(resp, "result.items"); - items = xcalloc(n + 1, sizeof(char *)); - names = xcalloc(n + 1, sizeof(char *)); - ids = xcalloc(n ? n : 1, sizeof(int64_t)); - active = xcalloc(n + 1, 1); + size_t n = jarr_size(resp, "result.items"); + size_t ndraft = drafts_count(kdrafts(), a->org, "customer"); + size_t cap = n + ndraft + 2; + l.items = xcalloc(cap, sizeof(char *)); + l.names = xcalloc(cap, sizeof(char *)); + l.dkeys = xcalloc(cap, sizeof(char *)); + l.ids = xcalloc(cap, sizeof(int64_t)); + l.active = xcalloc(cap, 1); + l.nserver = n; + l.nrows = n; for (size_t i = 0; i < n; i++) { - char path[64], line[768]; + char path[64], line[768], idbuf[32]; snprintf(path, sizeof path, "result.items.%zu.id", i); - ids[i] = jint_val(resp, path, 0); + l.ids[i] = jint_val(resp, path, 0); snprintf(path, sizeof path, "result.items.%zu.active", i); - active[i] = (unsigned char)jbool_val(resp, path, 1); + l.active[i] = (unsigned char)jbool_val(resp, path, 1); snprintf(path, sizeof path, "result.items.%zu.name", i); char *name = jstr_dup(resp, path); snprintf(path, sizeof path, "result.items.%zu.postal_code", i); @@ -390,60 +646,81 @@ void customers_screen(struct app *a) tui_pad_field(pbuf, sizeof pbuf, 30); snprintf(vbuf, sizeof vbuf, "%s", vat ? vat : ""); tui_pad_field(vbuf, sizeof vbuf, 18); - snprintf(line, sizeof line, "%s %s %s %3lld dagar %s", nbuf, - pbuf, vbuf, (long long)days, - active[i] ? "" : "(arkiverad)"); - items[i] = xstrdup(line); - names[i] = xstrdup(name ? name : ""); + snprintf(idbuf, sizeof idbuf, "%lld", (long long)l.ids[i]); + int isdraft = + drafts_have(kdrafts(), a->org, "customer", idbuf); + if (isdraft) + l.dkeys[i] = xstrdup(idbuf); + snprintf(line, sizeof line, "%s%s %s %s %3lld dagar %s", + isdraft ? "<UTKAST> " : "", nbuf, pbuf, vbuf, + (long long)days, + l.active[i] ? "" : "(arkiverad)"); + l.items[i] = xstrdup(line); + l.names[i] = xstrdup(name ? name : ""); free(name); free(postal); free(city); free(vat); } free(resp); - items[n] = xstrdup("+ Ny kund (^N)"); + drafts_foreach(kdrafts(), a->org, "customer", clist_collect_temp, + &l); + l.items[l.nrows] = xstrdup("+ Ny kund (^N)"); + l.names[l.nrows] = xstrdup(""); + l.ids[l.nrows] = 0; + l.active[l.nrows] = 1; } int start = 0; if (a->customer_sel) { - for (size_t i = 0; i < n; i++) - if (ids[i] == a->customer_sel) { + for (size_t i = 0; i < l.nserver; i++) + if (l.ids[i] == a->customer_sel) { start = (int)i; break; } } - int cur = start; - int sel = tui_select_list("Kunder", items, (int)n + 1, start, 1, &cur, - 1, "arkivera/återaktivera", 0); - if (cur >= 0 && (size_t)cur < n) - a->customer_sel = ids[cur]; + cur = start; + tui_list_hint_extra("F2 = åtgärder"); + int sel = tui_select_list_hook("Kunder", l.items, + (int)l.nrows + 1, start, 1, &cur, 1, + "arkivera/återaktivera", 0, clist_key, + &l); + tui_list_hint_extra(NULL); + if (cur >= 0 && (size_t)cur < l.nserver) + a->customer_sel = l.ids[cur]; if (sel == -1) { - for (size_t i = 0; i < n; i++) { - free(items[i]); - free(names[i]); - } - free(items); - free(names); - free(ids); - free(active); + clist_clear(&l); return; } if (sel == -2) { fetch = 1; continue; } - if (sel == -6 && cur >= 0 && (size_t)cur < n) { - customer_archive_toggle(a, ids[cur], names[cur], - active[cur] != 0); + if (sel == -6 && cur >= 0 && (size_t)cur < l.nrows) { + if (l.ids[cur] == 0) { + tui_message("Kund", "Utkast raderas med F2."); + } else { + customer_archive_toggle(a, l.ids[cur], l.names[cur], + l.active[cur] != 0); + } fetch = 1; continue; } - if (sel == -4 || (sel >= 0 && (size_t)sel == n)) { - customer_form(a, 0); + if (sel == -4 || (cur >= 0 && (size_t)cur == l.nrows)) { + char *tid = drafts_new_id(); + drafts_put(kdrafts(), a->org, "customer", tid, "{}"); + customer_form(a, 0, tid); + free(tid); fetch = 1; continue; } - if (sel >= 0 && (size_t)sel < n) { - customer_form(a, ids[sel]); + if (sel >= 0 && (size_t)sel < l.nrows) { + char idbuf[32]; + const char *dk = l.dkeys[sel]; + if (!dk) { + snprintf(idbuf, sizeof idbuf, "%lld", (long long)l.ids[sel]); + dk = idbuf; + } + customer_form(a, l.ids[sel], dk); fetch = 1; continue; } diff --git a/clients/tui.c b/clients/tui.c index db5599d..13da593 100644 --- a/clients/tui.c +++ b/clients/tui.c @@ -17,6 +17,12 @@ static int (*g_in)(void) = getch; static void (*g_frame)(const char *title); static void (*g_hints)(const char *hints); static void (*g_quit)(void); +static const char *g_list_hint_extra; + +void tui_list_hint_extra(const char *extra) +{ + g_list_hint_extra = extra; +} void tui_set_input(int (*fn)(void)) { @@ -1082,6 +1088,10 @@ int tui_select_list_hook(const char *title, char **items, int n, int start, allow_refresh ? " F5 = uppdatera" : "", allow_new ? " ^N = ny" : "", dbuf, allow_toggle ? " ^A = öppna/stäng" : ""); + if (g_list_hint_extra && *g_list_hint_extra) { + size_t hl = strlen(hint); + snprintf(hint + hl, sizeof hint - hl, " %s", g_list_hint_extra); + } list_draw(title, items, n, &v, hint); int ch = in_key(); int r = tui_nav_key(&v, ch, allow_new, archive_action != NULL, @@ -1190,6 +1200,7 @@ int tui_menu(const char *title, const char *const *items, int n, /* ------------------------------------------------------------------ */ static const char *g_form_hint; +static const char *g_form_hint_extra; static char g_status[512]; static char g_right[160]; @@ -1198,6 +1209,11 @@ void tui_form_hint(const char *hint) g_form_hint = hint; } +void tui_form_hint_extra(const char *extra) +{ + g_form_hint_extra = extra; +} + void tui_set_status(const char *status, const char *right) { snprintf(g_status, sizeof g_status, "%s", status ? status : ""); @@ -1531,6 +1547,174 @@ void tui_form_act_label(const struct tui_form_action *a, char *buf, size_t n) } } +static void action_key_name(int key, char *buf, size_t n) +{ + if (key >= KEY_F(1) && key <= KEY_F(12)) + snprintf(buf, n, "F%d", key - KEY_F(0)); + else if (key == TUI_KEY_CTRL_N) + snprintf(buf, n, "^N"); + else if (key == TUI_KEY_CTRL_X) + snprintf(buf, n, "^X"); + else if (key == 27) + snprintf(buf, n, "Esc"); + else if (key == '\n' || key == '\r' || key == KEY_ENTER) + snprintf(buf, n, "Enter"); + else if (key > 0 && key < 27) + snprintf(buf, n, "^%c", 'A' + key - 1); + else + snprintf(buf, n, "?"); +} + +void tui_action_label(const struct tui_action *a, char *buf, size_t n) +{ + const char *label = a && a->label ? a->label : ""; + + if (a && a->enabled == 0) { + const char *reason = + a->reason && *a->reason ? a->reason : "otillgänglig"; + snprintf(buf, n, "%s (%s)", label, reason); + } else { + snprintf(buf, n, "%s", label); + } +} + +void tui_action_hint(const struct tui_action *acts, int n, int max, char *buf, + size_t cap) +{ + size_t o = 0; + int shown = 0; + + if (!buf || cap == 0) + return; + buf[0] = '\0'; + for (int i = 0; i < n && (max <= 0 || shown < max); i++) { + char key[16], item[200]; + size_t len; + + if (acts[i].enabled != 1 || !acts[i].key) + continue; + action_key_name(acts[i].key, key, sizeof key); + snprintf(item, sizeof item, "%s%s = %s", shown ? " " : "", key, + acts[i].label ? acts[i].label : ""); + len = strlen(item); + if (o + len + 1 > cap) + break; + memcpy(buf + o, item, len + 1); + o += len; + shown++; + } + const char *more = "F2 = fler"; + size_t ml = strlen(more); + if (o + ml + (o ? 3 : 0) + 1 <= cap) + snprintf(buf + o, cap - o, "%s%s", o ? " " : "", more); +} + +int tui_action_menu(const char *title, const struct tui_action *acts, int n, + int *focus) +{ + struct tui_list_nav v; + unsigned char *ok; + char **items; + int ret = -1; + + if (!acts || n <= 0) + return -1; + memset(&v, 0, sizeof v); + v.n = n; + ok = xcalloc((size_t)n, 1); + items = xcalloc((size_t)n, sizeof(char *)); + for (int i = 0; i < n; i++) { + char line[192]; + + tui_action_label(&acts[i], line, sizeof line); + items[i] = xstrdup(line); + ok[i] = acts[i].enabled != -1; + } + v.selectable = ok; + v.sel = focus && *focus >= 0 && *focus < n ? *focus : 0; + if (!ok[v.sel]) + v.sel = nav_snap(&v, v.sel, 1); + v.numw = tui_num_width(n); + if (v.numw < 2) + v.numw = 2; + v.view = LINES - 4; + snprintf(v.gotolabel, sizeof v.gotolabel, "Gå till nummer: "); + for (;;) { + int num = 0; + + if (v.sel < 0 || v.sel >= n) { + v.sel = nav_snap(&v, 0, 1); + if (v.sel < 0) + break; + } + if (focus) + *focus = v.sel; + list_view_sync(&v); + if (g_frame) + g_frame(title); + for (int i = 0; i < v.top && i < n; i++) + if (ok[i]) + num++; + for (int idx = v.top; idx < n && idx < v.top + v.view; idx++) { + char line[832]; + if (!ok[idx]) { + snprintf(line, sizeof line, "%*s %s", v.numw, "", + items[idx]); + attron(tui_style_attrs(TUI_DIM, g_colours, 0)); + mvaddnstr(3 + (idx - v.top), 4, line, COLS - 6); + attroff(tui_style_attrs(TUI_DIM, g_colours, 0)); + continue; + } + num++; + snprintf(line, sizeof line, "%*d. %s", v.numw, num, items[idx]); + if (acts[idx].enabled != 1) + attron(tui_style_attrs(TUI_DIM, g_colours, 0)); + if (idx == v.sel) + attron(A_REVERSE); + mvaddnstr(3 + (idx - v.top), 4, line, COLS - 6); + if (idx == v.sel) + attroff(A_REVERSE); + if (acts[idx].enabled != 1) + attroff(tui_style_attrs(TUI_DIM, g_colours, 0)); + } + if (v.goto_active) { + move(LINES - 2, 2); + tui_style(TUI_ACCENT); + printw("%s%s", v.gotolabel, v.gotobuf); + tui_style_reset(); + clrtoeol(); + } else { + move(LINES - 2, 2); + clrtoeol(); + } + if (g_hints) + g_hints("upp/ned, 1-9 = hoppa, g = gå till, PgUp/PgDn, Home/End," + " Enter = utför Esc/q = avbryt"); + refresh(); + int ch = in_key(); + if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) { + if (!ok[v.sel]) + continue; + if (acts[v.sel].enabled != 1) { + tui_message(title, "%s", + acts[v.sel].reason && *acts[v.sel].reason + ? acts[v.sel].reason + : "Otillgänglig."); + continue; + } + ret = v.sel; + break; + } + if (tui_nav_key(&v, ch, 0, 0, 0, 0) == -1) + break; + } + for (int i = 0; i < n; i++) + free(items[i]); + free(items); + free(ok); + return ret; +} + /* Action rows sit under the fields, one blank line below them. */ static void form_draw_actions(const struct tui_form_action *acts, int na, int nf, int sel) @@ -1628,6 +1812,11 @@ static int form_run(const char *title, struct tui_form_field *f, int nf, : "upp/ned/Home/End F5 = uppdatera" " Esc/q = tillbaka ^C = avsluta" " (endast behöriga kan ändra)"); + char hbuf[640]; + if (g_form_hint_extra && *g_form_hint_extra && + snprintf(hbuf, sizeof hbuf, "%s %s", h, g_form_hint_extra) < + (int)sizeof hbuf) + h = hbuf; tui_hints(h); g_form_hint = NULL; refresh(); diff --git a/clients/tui.h b/clients/tui.h index 6df27fb..1785a32 100644 --- a/clients/tui.h +++ b/clients/tui.h @@ -191,10 +191,33 @@ int tui_form_next(int sel, int n, int ch); int tui_form_run(const char *title, struct tui_form_field *f, int n, int can_edit, int *focus); void tui_form_hint(const char *hint); /* overrides the footer for the next run */ +/* Extra text appended to the form/list footer until changed (NULL clears). */ +void tui_form_hint_extra(const char *extra); +void tui_list_hint_extra(const char *extra); int tui_form_run_hook(const char *title, struct tui_form_field *f, int n, int can_edit, int (*key)(void *ud, int ch), void *ud, int *focus); +/* --- action registry (docs/TUI-GUIDELINES.md "Interaction model") --- */ +struct tui_action { + const char *id; /* stable, e.g. "customer.archive" */ + const char *label; /* Swedish UI text */ + int key; /* accelerator; 0 = menu only */ + int enabled; /* 1 runnable, 0 dim + reason, -1 heading */ + const char *reason; /* why a disabled action is dim */ +}; + +/* "label", or "label (reason)" when the action is disabled. */ +void tui_action_label(const struct tui_action *a, char *buf, size_t n); +/* Builds "F9 = spara F2 = fler", showing at most max keyed actions. */ +void tui_action_hint(const struct tui_action *acts, int n, int max, + char *buf, size_t cap); +/* Modal action list: arrows/Tab/Home/End/PgUp/PgDn/1-9/g navigate, Enter + runs (a disabled action shows its reason), Esc cancels. Returns the + chosen index or -1. *focus remembers the row. */ +int tui_action_menu(const char *title, const struct tui_action *acts, int n, + int *focus); + /* --- action list in a form (tui_form_run_actions) --- */ /* enabled: 1 = selectable and runs; 0 = dimmed but selectable (Enter shows disabled_reason in a message); -1 = non-selectable heading/status row, diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 1522842..a0c613b 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -256,7 +256,10 @@ kept verbatim from the STATE.md they were pruned from (2026-09-21). error keeps the draft. Screens declare actions in one ordered `struct tui_action` list that drives the `F2` menu, the accelerator keys and the footer hints; `F2` only, no `§`; `Ctrl+Enter` is dropped as a - commit key (`F9` and the save action row remain). Pilots on Kunder. + commit key (`F9` and the save action row remain). **Kunder pilot + implemented 2026-09-22** (`clients/drafts.[ch]`, `<UTKAST>`, the `Spara` + row, the `F2` menu, delete from list and editor); the other screens + follow. ## Completed work formerly listed under "Pending decisions" diff --git a/docs/STATE.md b/docs/STATE.md index 3077b12..b034a9a 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -54,13 +54,17 @@ unit tests and the docs consistency check. employer name there too (it previously used the Comfortaa wordmark). The invoice's `FAKTURA` outline is unchanged; the `MAKANDRA AB` outlines in `src/wordmark.h` are now unused at runtime. -- **TUI interaction model (2026-09-22, design, not implemented)**: the UX - session settled two focus modes, three entity lifecycles, explicit - `Spara` in every savable form and `<UTKAST>` drafts persisted to - `$XDG_CACHE_HOME/bokf/drafts.json` (deletable from both the list and the - editor), plus one `tui_action` registry per screen behind `F2` (no `§`). - Spec in `TUI-GUIDELINES.md` "Interaction model"; decisions in - `DECISIONS.md` #28. Rollout is backlog item 17. +- **TUI interaction model (2026-09-22)**: the UX session settled two focus + modes, three entity lifecycles, explicit `Spara` in every savable form and + `<UTKAST>` drafts persisted to `$XDG_CACHE_HOME/bokf/drafts.json` + (deletable from both the list and the editor), plus one `tui_action` + registry per screen behind `F2` (no `§`). The **Kunder pilot is + implemented** (`clients/drafts.[ch]`, `tui_action_menu()`, + `tui_action_hint()`, `<UTKAST>`, `Spara` row, F2 menu, delete draft) with + unit tests and the pty scenarios `customer-draft`/`customer-draft-save`; + the other screens are unchanged. Spec in `TUI-GUIDELINES.md` "Interaction + model"; decisions in `DECISIONS.md` #28. Rollout continues in backlog + item 17. - **Mail configuration**: Makandra AB (org 2) has **no** `smtp_*` settings in bokf, so `invoice.send` there is `SMTP_NOT_CONFIGURED` (fine if invoices are sent elsewhere — set them up when wanted). Mock AB (org 1) @@ -130,10 +134,11 @@ None open. Completed items that used to be listed here are archived in (`tests/core_<domain>.c`) so `--only` stops cascading; pilot with one domain. 17. Interaction-model rollout (spec: `TUI-GUIDELINES.md` "Interaction - model", decisions #28): `struct tui_action` + `F2` menu in the widget - layer, `clients/drafts.[ch]`, the Kunder pilot, then the other - registers; settings get `Spara`; pty scenarios for draft - create/save/delete/reload. + model", decisions #28): **Kunder done** 2026-09-22 (widget layer + `tui_action`/`F2`, `clients/drafts.[ch]`, drafts/`<UTKAST>`/`Spara`, + pty scenarios). Remaining: the other register screens, explicit + `Spara` in the settings forms (replacing per-field autosave), and + `tui_rt` action menus. Original entries for the struck items are in `docs/DECISIONS.md`. diff --git a/docs/TUI-GUIDELINES.md b/docs/TUI-GUIDELINES.md index fbd3b75..1c201aa 100644 --- a/docs/TUI-GUIDELINES.md +++ b/docs/TUI-GUIDELINES.md @@ -5,9 +5,10 @@ copy the behaviour of the voucher list / voucher form; they are the reference implementations. Inspired by Midnight Commander, htop, mutt and calcurse. The **Interaction model** section is the agreed target (settled 2026-09-22, -`DECISIONS.md` #28) and has not been implemented yet. The sections after it -describe today's widget behaviour and stay authoritative until the widget -layer and the screens are migrated; the interaction model wins where they +`DECISIONS.md` #28). The **Kunder** pilot is implemented: drafts in +`$XDG_CACHE_HOME/bokf/drafts.json`, `<UTKAST>` marking, the explicit `Spara` +row, the `F2` action menu and draft deletion work there. Every other screen +still follows the sections below; the interaction model wins where they conflict as each screen moves over. ## Interaction model (target, 2026-09-22) @@ -105,21 +106,21 @@ struct tui_action { commit key (`Ctrl+Enter` is dropped) and `F2` only — no `§` binding (it is not reliably encodable across terminals). -### Deltas to implement +### Implementation status -1. `struct tui_action` + `tui_action_menu()` in `clients/tui.[ch]` - (`tui_form_action` is generalized); the pure ordering/dimming/hint logic - is unit-tested in `tests/test_tui.c`. -2. Action lists on `tui_select_list` and `tui_rt`, replacing the per-screen - key branches; hints and dispatch read the same list. -3. `clients/drafts.[ch]`: JSON store, atomic write, dirty tracking, - temporary ids, `<UTKAST>` marking and the delete action. -4. Registers get the draft/`Spara` model first — pilot on **Kunder** — then - the other register screens; settings drop their per-field autosave and - get a `Spara` row. -5. pty scenarios: a new empty entity is a visible `<UTKAST>`; fill + `Spara` - commits and clears it; delete from the list and from the editor; a draft - survives `Ctrl+R`. +1. Done: `struct tui_action`, `tui_action_menu()` and `tui_action_hint()` in + `clients/tui.[ch]`, unit-tested in `tests/test_tui.c`. +2. Partly done: the Kunder list builds its `F2` actions in a key hook and + appends `F2 = åtgärder` to the footer via `tui_list_hint_extra()`; other + lists have no actions yet, and `tui_rt` still uses its own key branches. +3. Done for Kunder: `clients/drafts.[ch]` (JSON store, atomic 0600 write, + temporary ids, `<UTKAST>` marking, delete action), unit-tested. +4. Done for Kunder: draft/`Spara` model with the `Spara` action row, + `F2` menu and draft deletion from both the list and the editor. The + other register screens and the settings forms (explicit `Spara`, no + per-field autosave) are next. +5. Done for Kunder: pty scenarios `customer-draft` and + `customer-draft-save` cover create, edit, `<UTKAST>`, delete and save. ## Session start diff --git a/scripts/tui-golden.py b/scripts/tui-golden.py index b339f2d..4fee4df 100755 --- a/scripts/tui-golden.py +++ b/scripts/tui-golden.py @@ -60,6 +60,7 @@ KEYS = { "ctrlenter-kitty": "\x1b[13;5u", "f5": "\x1b[15~", "f9": "\x1b[20~", + "f2": "\x1bOQ", } # {org_name} {org_nr} {fy_label} {fy_start} {fy_end} are substituted at run @@ -258,6 +259,64 @@ SCENARIOS = [ "expect": ["Kunder", "Testkund AB"], }, { + "name": "customer-draft", + "screen": "customers", + "expect": ["Kunder", "Testkund AB"], + "steps": [ + { + "keys": ["ctrln"], + "expect": ["Ny kund", "<UTKAST>"], + }, + { + "keys": ["enter", "Utkastkund", "enter"], + "expect": ["Ny kund", "<UTKAST>", "Utkastkund"], + }, + { + "keys": ["esc"], + "expect": ["Kunder", "<UTKAST> Utkastkund"], + }, + { + "keys": ["end", "up"], + "expect": ["<UTKAST> Utkastkund"], + }, + { + "keys": ["f2"], + "expect": ["Åtgärder", "Öppna", "Radera utkast"], + }, + { + "keys": ["down", "enter"], + "expect": ["Radera utkastet?"], + }, + { + "keys": ["enter"], + "expect": ["Kunder", "Testkund AB"], + }, + ], + }, + { + "name": "customer-draft-save", + "screen": "customers", + "expect": ["Kunder", "Testkund AB"], + "steps": [ + { + "keys": ["ctrln"], + "expect": ["Ny kund", "<UTKAST>"], + }, + { + "keys": ["enter", "Sparad kund", "enter"], + "expect": ["Sparad kund", "<UTKAST>"], + }, + { + "keys": ["f9"], + "expect": ["Sparat."], + }, + { + "keys": ["enter"], + "expect": ["Kunder", "Sparad kund"], + }, + ], + }, + { "name": "invoice-form", "screen": "invoices", "steps": [ diff --git a/tests/test_tui.c b/tests/test_tui.c index 84e610e..aa062fc 100644 --- a/tests/test_tui.c +++ b/tests/test_tui.c @@ -1,10 +1,13 @@ /* Unit tests for the TUI widget layer (clients/tui.c). Only the pure parts are exercised here; drawing is smoke-tested over a pty. */ #include <stdio.h> +#include <stdlib.h> #include <string.h> +#include <unistd.h> #include <ncursesw/ncurses.h> +#include "drafts.h" #include "tui.h" #include "util.h" @@ -712,6 +715,96 @@ static void test_rt_footer(void) CHECK(f[0] == '\0'); } +static void test_actions(void) +{ + char buf[256]; + struct tui_action acts[] = { + { "save", "Spara", KEY_F(9), 1, NULL }, + { "del", "Radera utkast", 0, 0, "inget utkast" }, + { "sec", "Sektion", 0, -1, NULL }, + { "arch", "Arkivera", KEY_F(2), 1, NULL }, + }; + + tui_action_label(&acts[0], buf, sizeof buf); + CHECK(strcmp(buf, "Spara") == 0); + tui_action_label(&acts[1], buf, sizeof buf); + CHECK(strcmp(buf, "Radera utkast (inget utkast)") == 0); + + tui_action_hint(acts, 4, 1, buf, sizeof buf); + CHECK(strstr(buf, "F9 = Spara") != NULL); + CHECK(strstr(buf, "F2 = fler") != NULL); + CHECK(strstr(buf, "Arkivera") == NULL); + + tui_action_hint(acts, 4, 0, buf, sizeof buf); + CHECK(strstr(buf, "F9 = Spara") != NULL); + CHECK(strstr(buf, "F2 = Arkivera F2 = fler") != NULL); + CHECK(strstr(buf, "Radera utkast") == NULL); + + tui_action_hint(NULL, 0, 1, buf, sizeof buf); + CHECK(strcmp(buf, "F2 = fler") == 0); +} + +static void test_drafts(void) +{ + char path[128]; + struct drafts d, r; + + snprintf(path, sizeof path, "/tmp/bokf-drafts-test-%d.json", + (int)getpid()); + unlink(path); + drafts_init_path(&d, path); + CHECK(drafts_count(&d, 1, "customer") == 0); + CHECK(drafts_get(&d, 1, "customer", "ny-1") == NULL); + + drafts_put(&d, 1, "customer", "ny-1", "{\"name\":\"A\"}"); + CHECK(drafts_have(&d, 1, "customer", "ny-1")); + CHECK(drafts_count(&d, 1, "customer") == 1); + CHECK(strcmp(drafts_get(&d, 1, "customer", "ny-1"), + "{\"name\":\"A\"}") == 0); + drafts_put(&d, 1, "customer", "ny-1", "{\"name\":\"B\"}"); + CHECK(strcmp(drafts_get(&d, 1, "customer", "ny-1"), + "{\"name\":\"B\"}") == 0); + drafts_put(&d, 2, "customer", "7", "{\"name\":\"C\"}"); + drafts_put(&d, 1, "employee", "3", "{\"name\":\"D\"}"); + CHECK(drafts_count(&d, 1, "customer") == 1); + CHECK(drafts_count(&d, 2, "customer") == 1); + + drafts_init_path(&r, path); + CHECK(drafts_count(&r, 1, "customer") == 1); + CHECK(strcmp(drafts_get(&r, 1, "customer", "ny-1"), + "{\"name\":\"B\"}") == 0); + CHECK(strcmp(drafts_get(&r, 2, "customer", "7"), + "{\"name\":\"C\"}") == 0); + CHECK(strcmp(drafts_get(&r, 1, "employee", "3"), + "{\"name\":\"D\"}") == 0); + drafts_del(&r, 1, "customer", "ny-1"); + CHECK(drafts_count(&r, 1, "customer") == 0); + drafts_free(&r); + + drafts_init_path(&r, path); + CHECK(drafts_count(&r, 1, "customer") == 0); + CHECK(drafts_count(&r, 2, "customer") == 1); + drafts_free(&r); + drafts_free(&d); + unlink(path); + + FILE *f = fopen(path, "w"); + if (f) { + fputs("inte json", f); + fclose(f); + } + drafts_init_path(&r, path); + CHECK(drafts_count(&r, 1, "customer") == 0); + drafts_free(&r); + unlink(path); + + char *a = drafts_new_id(); + char *b = drafts_new_id(); + CHECK(a && b && strncmp(a, "ny-", 3) == 0 && strcmp(a, b) != 0); + free(a); + free(b); +} + int main(void) { test_disp_width(); @@ -733,6 +826,8 @@ int main(void) test_rt_fields(); test_rt_focus(); test_rt_footer(); + test_actions(); + test_drafts(); printf("test_tui: %d checks, %d failures\n", checks, failures); return failures ? 1 : 0; } |
