Merge pull request #95 from kristianwind/fladen-foelger-udgaven
feat(udgaver): fladen følger udgaven, og søgningen holder opgaverne inde
This commit is contained in:
@@ -44,6 +44,11 @@ node_modules.nosync/
|
|||||||
# End-to-end runs: the throwaway instance the suite starts, the saved session it
|
# End-to-end runs: the throwaway instance the suite starts, the saved session it
|
||||||
# signs in with, and whatever a failure left behind.
|
# signs in with, and whatever a failure left behind.
|
||||||
/web/.playwright-data/
|
/web/.playwright-data/
|
||||||
|
# Noter-udgavens egen instans i prøvekørslen. Den har sin egen database OG sin
|
||||||
|
# egen secret.key, og den nåede at blive committet én gang: et mønster, der
|
||||||
|
# nævner den ene mappe ved navn, dækker ikke den anden, selv om de er to kopier
|
||||||
|
# af det samme.
|
||||||
|
/web/.playwright-data-notes/
|
||||||
/web/e2e/.auth/
|
/web/e2e/.auth/
|
||||||
/web/test-results/
|
/web/test-results/
|
||||||
/web/playwright-report/
|
/web/playwright-report/
|
||||||
|
|||||||
+11
-1
@@ -53,7 +53,7 @@ paths:
|
|||||||
/api/v1/auth/setup:
|
/api/v1/auth/setup:
|
||||||
get:
|
get:
|
||||||
tags: [auth]
|
tags: [auth]
|
||||||
summary: Whether this instance still needs its first account.
|
summary: What this instance is, and whether it still needs its first account.
|
||||||
security: []
|
security: []
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
@@ -64,6 +64,16 @@ paths:
|
|||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
needs_setup: { type: boolean }
|
needs_setup: { type: boolean }
|
||||||
|
edition:
|
||||||
|
type: string
|
||||||
|
enum: [full, notes]
|
||||||
|
description: >
|
||||||
|
`full` is tasks, projects and notes together. `notes` is
|
||||||
|
the notes half: the task routes are not mounted at all, so
|
||||||
|
an instance that is not a task manager cannot be asked to
|
||||||
|
be one through its API. The interface reads this before
|
||||||
|
anybody is signed in, because it decides what there is to
|
||||||
|
show rather than what this person may see.
|
||||||
post:
|
post:
|
||||||
tags: [auth]
|
tags: [auth]
|
||||||
summary: Create the first account, which becomes the administrator.
|
summary: Create the first account, which becomes the administrator.
|
||||||
|
|||||||
@@ -492,7 +492,14 @@ func (s *Server) handleSetupState(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.internal(w, r, "count users", err)
|
s.internal(w, r, "count users", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"needs_setup": n == 0})
|
// Udgaven hører til her og ikke på /auth/me: den er en egenskab ved
|
||||||
|
// instansen, ikke ved den, der er logget ind, og fladen skal kende den, før
|
||||||
|
// nogen er. Det her er i forvejen kaldet, der spørger "hvad er det her for en
|
||||||
|
// instans", så den følger med uden en ekstra tur.
|
||||||
|
writeJSON(w, http.StatusOK, struct {
|
||||||
|
NeedsSetup bool `json:"needs_setup"`
|
||||||
|
Edition string `json:"edition"`
|
||||||
|
}{n == 0, s.edition()})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, user *store.User) {
|
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, user *store.User) {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import "github.com/kristianwind/verdande/internal/config"
|
||||||
|
|
||||||
|
// edition is what this instance tells the world it is.
|
||||||
|
//
|
||||||
|
// Never the empty string, even when the config was built by hand in a test and
|
||||||
|
// never went through config.Load: a flade that reads "" cannot tell "this is the
|
||||||
|
// full program" from "this build forgot to say", and the first of those is the
|
||||||
|
// answer that has always been true.
|
||||||
|
func (s *Server) edition() string {
|
||||||
|
if s.cfg.NotesOnly() {
|
||||||
|
return config.EditionNotes
|
||||||
|
}
|
||||||
|
return config.EditionFull
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -158,3 +159,72 @@ func routesOf(t *testing.T, edition string) map[string]bool {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The interface cannot gate itself on something it is not told, and it has to be
|
||||||
|
// told before anybody is signed in — an instance's shape is not a permission.
|
||||||
|
func TestSetupStateSaysWhichEdition(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ edition, want string }{
|
||||||
|
{config.EditionFull, "full"},
|
||||||
|
{config.EditionNotes, "notes"},
|
||||||
|
{"", "full"}, // a config built by hand is the program as it has always been
|
||||||
|
} {
|
||||||
|
ts := newTestServerWith(t, func(c *config.Config) { c.Edition = tc.edition })
|
||||||
|
|
||||||
|
_, body := ts.do(t, "GET", "/api/v1/auth/setup", nil)
|
||||||
|
if got := body["edition"]; got != tc.want {
|
||||||
|
t.Errorf("edition %q reported as %v, want %q", tc.edition, got, tc.want)
|
||||||
|
}
|
||||||
|
// And the field it has always had is still there: adding one must not
|
||||||
|
// quietly replace the answer the sign-in screen depends on.
|
||||||
|
if _, ok := body["needs_setup"]; !ok {
|
||||||
|
t.Errorf("edition %q: needs_setup is gone from the answer", tc.edition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search is mounted in both editions — notes and notebooks have to be findable —
|
||||||
|
// so it is the one door that could still open onto the half the router just shut.
|
||||||
|
//
|
||||||
|
// Not a theoretical case. An instance converted from a full one still has the
|
||||||
|
// tasks in its database, and ⌘K would be the only place they turned up.
|
||||||
|
func TestNotesEditionSearchFindsNoTasks(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
edition string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{config.EditionFull, 1},
|
||||||
|
{config.EditionNotes, 0},
|
||||||
|
} {
|
||||||
|
ts := newTestServerWith(t, func(c *config.Config) { c.Edition = tc.edition })
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, me := ts.do(t, "GET", "/api/v1/auth/me", nil)
|
||||||
|
userID, _ := me["id"].(string)
|
||||||
|
if userID == "" {
|
||||||
|
t.Fatalf("no user: %v", me)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Created through the store rather than the API: in the notes edition there
|
||||||
|
// is no route to create one, and the row this is about is the row that is
|
||||||
|
// already there.
|
||||||
|
inbox, err := ts.db.InboxID(context.Background(), userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
task := &store.Task{ProjectID: inbox, Content: "Kvartalsregnskabet", CreatedBy: userID}
|
||||||
|
if err := ts.db.CreateTask(context.Background(), task, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, body := ts.do(t, "GET", "/api/v1/search?q=Kvartalsregnskabet", nil)
|
||||||
|
found, _ := body["tasks"].([]any)
|
||||||
|
if len(found) != tc.want {
|
||||||
|
t.Errorf("edition %q: search returned %d tasks, want %d", tc.edition, len(found), tc.want)
|
||||||
|
}
|
||||||
|
// The full edition's number is the control: a search that finds nothing at
|
||||||
|
// all would satisfy the notes case for the wrong reason.
|
||||||
|
if tc.edition == config.EditionFull && len(found) == 0 {
|
||||||
|
t.Errorf("the full edition found no tasks either — this proves nothing about the notes one")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -206,13 +206,22 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
// Søgningen er monteret i begge udgaver, fordi man også skal kunne finde sine
|
||||||
Search: query,
|
// noter og notesbøger. Men opgaverne må ikke komme med i noter-udgaven, og det
|
||||||
// Completed tasks are included: "what was that thing I did last week" is
|
// er ikke en teoretisk sag: en instans lavet om fra en fuld har opgaverne
|
||||||
// a search, and excluding them makes the box feel broken.
|
// liggende i databasen endnu, og så ville ⌘K være den ene dør, der stod åben
|
||||||
IncludeCompleted: true,
|
// ind til den halvdel, resten af programmet lige har lukket.
|
||||||
Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100),
|
var tasks []store.Task
|
||||||
})
|
var err error
|
||||||
|
if !s.cfg.NotesOnly() {
|
||||||
|
tasks, err = s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
||||||
|
Search: query,
|
||||||
|
// Completed tasks are included: "what was that thing I did last week" is
|
||||||
|
// a search, and excluding them makes the box feel broken.
|
||||||
|
IncludeCompleted: true,
|
||||||
|
Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100),
|
||||||
|
})
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, r, "search tasks", err)
|
s.internal(w, r, "search tasks", err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Noter-udgaven, kørt som sit eget program.
|
||||||
|
*
|
||||||
|
* Egen server på sin egen port med `VERDANDE_EDITION=notes`, egen database og
|
||||||
|
* ingen lånt session: den her instans har aldrig set den konto, den fulde udgaves
|
||||||
|
* opsætning lavede, så den laver sin egen.
|
||||||
|
*
|
||||||
|
* Og det er hele grunden til at prøven findes som en separat server frem for som
|
||||||
|
* en indstilling, en prøve skruer på. En skakt, der kun er prøvet ved at kalde en
|
||||||
|
* funktion, er prøvet på det, nogen mente — det her måler programmet, som en
|
||||||
|
* bruger møder det.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const KONTO = {
|
||||||
|
email: 'noter@example.dk',
|
||||||
|
name: 'Noteskriver',
|
||||||
|
password: 'et langt kodeord til test'
|
||||||
|
};
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
// Første konto, hvis den ikke er lavet endnu. Prøverne i filen deler instans,
|
||||||
|
// så den anden gang er det en almindelig login.
|
||||||
|
const opret = page.getByRole('button', { name: 'Opret konto' });
|
||||||
|
const login = page.getByRole('button', { name: 'Log ind' });
|
||||||
|
// Ventet på, at EN af de to er der, før der spørges hvilken. Spurgt før
|
||||||
|
// tegningen svarer begge nej, og så faldt prøven i den forkerte gren og ventede
|
||||||
|
// tredive sekunder på en knap, der aldrig kom.
|
||||||
|
await expect(opret.or(login)).toBeVisible();
|
||||||
|
|
||||||
|
if (await opret.isVisible()) {
|
||||||
|
await page.getByLabel(/Navn/).fill(KONTO.name);
|
||||||
|
await page.getByLabel(/E-mail/).fill(KONTO.email);
|
||||||
|
await page.getByLabel(/Adgangskode/).fill(KONTO.password);
|
||||||
|
await opret.click();
|
||||||
|
// Beaconens besked står øverst første gang; den er ikke det her prøvens
|
||||||
|
// ærinde, men den dækker for det, der er.
|
||||||
|
const besked = page.getByRole('region', { name: /melder, at den findes/ });
|
||||||
|
if (await besked.isVisible().catch(() => false)) {
|
||||||
|
await besked.getByRole('button', { name: 'Behold den' }).click();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await page.getByLabel(/E-mail/).fill(KONTO.email);
|
||||||
|
await page.getByLabel(/Adgangskode/).fill(KONTO.password);
|
||||||
|
await page.getByRole('button', { name: 'Log ind' }).click();
|
||||||
|
}
|
||||||
|
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('instansen siger selv, at den er noter-udgaven', async ({ page }) => {
|
||||||
|
// Fladen kan ikke lukke af for noget, den ikke får at vide, og den skal have
|
||||||
|
// det at vide, før nogen er logget ind.
|
||||||
|
const svar = await page.request.get('/api/v1/auth/setup');
|
||||||
|
expect((await svar.json()).edition).toBe('notes');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('der er ingen vej til opgaverne i menuen', async ({ page }) => {
|
||||||
|
const menu = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||||
|
await expect(menu).toBeVisible();
|
||||||
|
|
||||||
|
// Udeladt, ikke gråtonet: ruterne bag dem er ikke monteret på serveren.
|
||||||
|
for (const navn of ['I dag', 'Kommende', 'Kalender', 'Uddelegeret']) {
|
||||||
|
await expect(menu.getByRole('link', { name: navn })).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Og det, udgaven ER til, står der. En menu uden opgaver OG uden noter ville
|
||||||
|
// også bestå prøven ovenfor, og den ville være en sletning frem for en skakt.
|
||||||
|
await expect(menu.getByRole('link', { name: 'Noter', exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forsiden er noterne, ikke en tom opgaveliste', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page).toHaveURL(/\/noter$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('en gemt adresse til en opgaveside fører til noterne i stedet for et blindt spor', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
// Nogen, der har gemt et link fra en fuld instans, skal lande et sted, der
|
||||||
|
// giver mening. Der er ikke sket noget forkert — adressen hører bare til et
|
||||||
|
// andet program end det, der kører her.
|
||||||
|
for (const sti of ['/upcoming', '/kalender', '/uddelegeret', '/faerdige']) {
|
||||||
|
await page.goto(sti);
|
||||||
|
await expect(page).toHaveURL(/\/noter$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('⌘K finder noter og ikke opgaver', async ({ page }) => {
|
||||||
|
await page.goto('/noter');
|
||||||
|
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||||
|
const felt = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||||
|
await expect(felt).toHaveText('');
|
||||||
|
await felt.click();
|
||||||
|
await page.keyboard.type('Kvartalsregnskabet');
|
||||||
|
await felt.blur();
|
||||||
|
await expect(page.locator('.notes button.row strong').filter({ hasText: /^Kvartalsregnskabet$/ })).toHaveCount(1);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Søg/ }).click();
|
||||||
|
const palette = page.getByRole('dialog');
|
||||||
|
await palette.getByRole('textbox').fill('Kvartalsregnskabet');
|
||||||
|
|
||||||
|
// Noten findes — ellers beviser det næste ingenting.
|
||||||
|
await expect(palette.locator('li button', { hasText: 'Kvartalsregnskabet' })).toBeVisible();
|
||||||
|
// Og søgningen på serveren svarer uden opgaver overhovedet.
|
||||||
|
const svar = await page.request.get('/api/v1/search?q=Kvartalsregnskabet');
|
||||||
|
expect((await svar.json()).tasks ?? []).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opgaveruterne findes slet ikke på serveren', async ({ page }) => {
|
||||||
|
// Det her er forskellen på skjult og fraværende, målt udefra.
|
||||||
|
for (const sti of ['/api/v1/tasks', '/api/v1/labels', '/api/v1/upcoming', '/api/v1/filters']) {
|
||||||
|
const svar = await page.request.get(sti);
|
||||||
|
expect(svar.status(), `${sti} svarede ${svar.status()}`).toBe(404);
|
||||||
|
}
|
||||||
|
// Kontrollen: en rute, der ER monteret, svarer ikke 404 — ellers ville
|
||||||
|
// ovenstående bestå på en server, der var helt væk.
|
||||||
|
expect((await page.request.get('/api/v1/notes')).status()).toBe(200);
|
||||||
|
});
|
||||||
+83
-36
@@ -20,6 +20,7 @@ import { dirname, join } from 'node:path';
|
|||||||
const here = dirname(fileURLToPath(import.meta.url));
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
const repo = join(here, '..');
|
const repo = join(here, '..');
|
||||||
const dataDir = join(here, '.playwright-data');
|
const dataDir = join(here, '.playwright-data');
|
||||||
|
const notesDataDir = join(here, '.playwright-data-notes');
|
||||||
|
|
||||||
// A clean instance every run: the first test creates the first account, which only
|
// A clean instance every run: the first test creates the first account, which only
|
||||||
// works on an empty database.
|
// works on an empty database.
|
||||||
@@ -31,9 +32,14 @@ const dataDir = join(here, '.playwright-data');
|
|||||||
// connection fail, with SQLITE_CANTOPEN. It looks exactly like a flaky auth bug.
|
// connection fail, with SQLITE_CANTOPEN. It looks exactly like a flaky auth bug.
|
||||||
if (process.env.TEST_WORKER_INDEX === undefined) {
|
if (process.env.TEST_WORKER_INDEX === undefined) {
|
||||||
rmSync(dataDir, { recursive: true, force: true });
|
rmSync(dataDir, { recursive: true, force: true });
|
||||||
|
rmSync(notesDataDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const PORT = 8097;
|
const PORT = 8097;
|
||||||
|
// Noter-udgaven kører som sin egen server ved siden af den fulde. Den kan ikke
|
||||||
|
// dele port, og den må ikke dele database: de to er to instanser, og en prøve der
|
||||||
|
// lod dem skrive i den samme ville måle en tilstand, ingen af dem ejer.
|
||||||
|
const NOTES_PORT = 8098;
|
||||||
// `localhost`, not `127.0.0.1`. WebAuthn refuses an IP address as a relying party
|
// `localhost`, not `127.0.0.1`. WebAuthn refuses an IP address as a relying party
|
||||||
// id — `SecurityError: This is an invalid domain` — and localhost is the exception
|
// id — `SecurityError: This is an invalid domain` — and localhost is the exception
|
||||||
// every browser makes. The passkey test cannot run against a loopback address.
|
// every browser makes. The passkey test cannot run against a loopback address.
|
||||||
@@ -72,6 +78,10 @@ export default defineConfig({
|
|||||||
{ name: 'setup', testMatch: /.*\.setup\.js/ },
|
{ name: 'setup', testMatch: /.*\.setup\.js/ },
|
||||||
{
|
{
|
||||||
name: 'chromium',
|
name: 'chromium',
|
||||||
|
// Alt undtagen noter-udgaven, som har sin egen server på sin egen port.
|
||||||
|
// Uden den her linje kørte den prøve også her — mod den FULDE udgave, hvor
|
||||||
|
// den naturligvis fejler, og hvor fejlen læser som om skakten ikke virker.
|
||||||
|
testIgnore: /edition\.spec\.js/,
|
||||||
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/user.json' },
|
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/user.json' },
|
||||||
dependencies: ['setup']
|
dependencies: ['setup']
|
||||||
},
|
},
|
||||||
@@ -95,6 +105,21 @@ export default defineConfig({
|
|||||||
*
|
*
|
||||||
* WebKit here is the same engine Safari is, which is the whole point.
|
* WebKit here is the same engine Safari is, which is the whole point.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Noter-udgaven, som sit eget program.
|
||||||
|
*
|
||||||
|
* Egen server, egen port, egen database, og ingen `storageState`: den her
|
||||||
|
* instans har aldrig set den konto, den fulde udgaves opsætning lavede, og
|
||||||
|
* at låne dens cookie ville prøve to instanser som var de én.
|
||||||
|
*
|
||||||
|
* Den deler ikke `setup` af samme grund — den laver sin egen første konto.
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
name: 'notes-edition',
|
||||||
|
testMatch: /edition\.spec\.js/,
|
||||||
|
use: { ...devices['Desktop Chrome'], baseURL: `http://127.0.0.1:${NOTES_PORT}` }
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: 'webkit-forms',
|
name: 'webkit-forms',
|
||||||
grep: /@forms/,
|
grep: /@forms/,
|
||||||
@@ -103,42 +128,64 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
||||||
webServer: {
|
webServer: [
|
||||||
// The whole chain, every run.
|
{
|
||||||
//
|
// The whole chain, every run.
|
||||||
// The frontend is embedded in the binary at compile time, from a copy under
|
|
||||||
// cmd/verdande/webbuild — so a suite that merely started the server would
|
|
||||||
// test whatever frontend was copied there last, which during development is
|
|
||||||
// nearly always the wrong one. Testing a stale build is worse than not
|
|
||||||
// testing: it reports green on code that is not running.
|
|
||||||
command: [
|
|
||||||
'npm run build',
|
|
||||||
'rm -rf ../cmd/verdande/webbuild',
|
|
||||||
'cp -r build ../cmd/verdande/webbuild',
|
|
||||||
'cd .. && go run -tags embedweb ./cmd/verdande'
|
|
||||||
].join(' && '),
|
|
||||||
cwd: here,
|
|
||||||
url: `${baseURL}/healthz`,
|
|
||||||
reuseExistingServer: false,
|
|
||||||
// The first run compiles the binary and builds nothing else; a cold Go
|
|
||||||
// build on a CI runner is comfortably under two minutes.
|
|
||||||
timeout: 180_000,
|
|
||||||
stdout: 'pipe',
|
|
||||||
stderr: 'pipe',
|
|
||||||
env: {
|
|
||||||
VERDANDE_ADDR: `:${PORT}`,
|
|
||||||
// `localhost`, while the suite itself drives `127.0.0.1`. These are two
|
|
||||||
// different things: the suite's address is where the browser goes, and
|
|
||||||
// this one is what the server calls itself — which is now also the
|
|
||||||
// WebAuthn relying party id, and WebAuthn refuses an IP address.
|
|
||||||
//
|
//
|
||||||
// The passkey test navigates to localhost explicitly so its origin
|
// The frontend is embedded in the binary at compile time, from a copy under
|
||||||
// matches. Everything else stays on the loopback address, where it has
|
// cmd/verdande/webbuild — so a suite that merely started the server would
|
||||||
// always been.
|
// test whatever frontend was copied there last, which during development is
|
||||||
VERDANDE_BASE_URL: `http://localhost:${PORT}`,
|
// nearly always the wrong one. Testing a stale build is worse than not
|
||||||
VERDANDE_DATA_DIR: dataDir,
|
// testing: it reports green on code that is not running.
|
||||||
// Off: an update check would reach out to GitHub from a test run.
|
command: [
|
||||||
VERDANDE_UPDATE_CHECK: 'false'
|
'npm run build',
|
||||||
|
'rm -rf ../cmd/verdande/webbuild',
|
||||||
|
'cp -r build ../cmd/verdande/webbuild',
|
||||||
|
'cd .. && go run -tags embedweb ./cmd/verdande'
|
||||||
|
].join(' && '),
|
||||||
|
cwd: here,
|
||||||
|
url: `${baseURL}/healthz`,
|
||||||
|
reuseExistingServer: false,
|
||||||
|
// The first run compiles the binary and builds nothing else; a cold Go
|
||||||
|
// build on a CI runner is comfortably under two minutes.
|
||||||
|
timeout: 180_000,
|
||||||
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
|
env: {
|
||||||
|
VERDANDE_ADDR: `:${PORT}`,
|
||||||
|
// `localhost`, while the suite itself drives `127.0.0.1`. These are two
|
||||||
|
// different things: the suite's address is where the browser goes, and
|
||||||
|
// this one is what the server calls itself — which is now also the
|
||||||
|
// WebAuthn relying party id, and WebAuthn refuses an IP address.
|
||||||
|
//
|
||||||
|
// The passkey test navigates to localhost explicitly so its origin
|
||||||
|
// matches. Everything else stays on the loopback address, where it has
|
||||||
|
// always been.
|
||||||
|
VERDANDE_BASE_URL: `http://localhost:${PORT}`,
|
||||||
|
VERDANDE_DATA_DIR: dataDir,
|
||||||
|
// Off: an update check would reach out to GitHub from a test run.
|
||||||
|
VERDANDE_UPDATE_CHECK: 'false'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Den samme binær en gang til, med den anden udgave slået til. Bygningen er
|
||||||
|
// allerede lavet af serveren ovenfor — `go run` genbruger byggecachen — så
|
||||||
|
// det her koster en opstart og ikke en oversættelse.
|
||||||
|
{
|
||||||
|
command: 'cd .. && go run -tags embedweb ./cmd/verdande',
|
||||||
|
cwd: here,
|
||||||
|
url: `http://127.0.0.1:${NOTES_PORT}/healthz`,
|
||||||
|
reuseExistingServer: false,
|
||||||
|
timeout: 180_000,
|
||||||
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
|
env: {
|
||||||
|
VERDANDE_ADDR: `:${NOTES_PORT}`,
|
||||||
|
VERDANDE_BASE_URL: `http://localhost:${NOTES_PORT}`,
|
||||||
|
VERDANDE_DATA_DIR: notesDataDir,
|
||||||
|
VERDANDE_EDITION: 'notes',
|
||||||
|
VERDANDE_UPDATE_CHECK: 'false'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -85,7 +85,11 @@
|
|||||||
...settings.map((s) => ({ kind: 'setting', id: s.href, label: `${s.tab} · ${s.title}` })),
|
...settings.map((s) => ({ kind: 'setting', id: s.href, label: `${s.tab} · ${s.title}` })),
|
||||||
...projects.map((p) => ({ kind: 'project', id: p.id, label: p.name })),
|
...projects.map((p) => ({ kind: 'project', id: p.id, label: p.name })),
|
||||||
...notes.map((n) => ({ kind: 'note', id: n.id, label: n.title || n.body.slice(0, 60) })),
|
...notes.map((n) => ({ kind: 'note', id: n.id, label: n.title || n.body.slice(0, 60) })),
|
||||||
...tasks.map((t) => ({
|
// Serveren sender ingen opgaver i noter-udgaven, så listen er alligevel tom.
|
||||||
|
// Spurgt her også, fordi en tom liste af den rigtige grund og en tom liste
|
||||||
|
// ved et tilfælde ser ens ud — og den dag nogen mounter søgningen anderledes,
|
||||||
|
// skal ⌘K ikke være stedet, det viser sig.
|
||||||
|
...(app.notesOnly ? [] : tasks).map((t) => ({
|
||||||
kind: 'task',
|
kind: 'task',
|
||||||
id: t.id,
|
id: t.id,
|
||||||
label: t.content,
|
label: t.content,
|
||||||
|
|||||||
@@ -155,9 +155,12 @@
|
|||||||
// entry is a question the sidebar keeps asking and answering "no".
|
// entry is a question the sidebar keeps asking and answering "no".
|
||||||
let navItems = $derived(
|
let navItems = $derived(
|
||||||
[
|
[
|
||||||
{ key: 'today', href: '/', label: 'nav.today' },
|
// Opgavernes egne indgange findes ikke i noter-udgaven. Ikke gråtonet og
|
||||||
{ key: 'upcoming', href: '/upcoming', label: 'nav.upcoming' },
|
// ikke skjult med CSS — udeladt, fordi ruterne bag dem ikke er monteret
|
||||||
app.projects.some((p) => p.shared)
|
// på serveren. Et link til noget, der ikke findes, er ikke en skønhedsfejl.
|
||||||
|
app.notesOnly ? null : { key: 'today', href: '/', label: 'nav.today' },
|
||||||
|
app.notesOnly ? null : { key: 'upcoming', href: '/upcoming', label: 'nav.upcoming' },
|
||||||
|
!app.notesOnly && app.projects.some((p) => p.shared)
|
||||||
? { key: 'delegated', href: '/uddelegeret', label: 'nav.delegated' }
|
? { key: 'delegated', href: '/uddelegeret', label: 'nav.delegated' }
|
||||||
: null,
|
: null,
|
||||||
// No href: the inbox is a project and is drawn by projectRow, which knows
|
// No href: the inbox is a project and is drawn by projectRow, which knows
|
||||||
@@ -185,7 +188,7 @@
|
|||||||
// Sidst i rækken, fordi `navOrder` føjer en ukendt nøgle til bagest: står
|
// Sidst i rækken, fordi `navOrder` føjer en ukendt nøgle til bagest: står
|
||||||
// den et andet sted her, ser en ny konto én rækkefølge og alle
|
// den et andet sted her, ser en ny konto én rækkefølge og alle
|
||||||
// eksisterende en anden.
|
// eksisterende en anden.
|
||||||
{ key: 'calendar', href: '/kalender', label: 'nav.calendar' }
|
app.notesOnly ? null : { key: 'calendar', href: '/kalender', label: 'nav.calendar' }
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,27 @@ class AppState {
|
|||||||
*/
|
*/
|
||||||
unreachable = $state(false);
|
unreachable = $state(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hvilken udgave af programmet den her instans er.
|
||||||
|
*
|
||||||
|
* `full` er opgaver, projekter og noter sammen. `notes` er notehalvdelen, hvor
|
||||||
|
* opgaveruterne slet ikke er monteret på serveren — så fladen må ikke tilbyde
|
||||||
|
* en vej til dem. Et link til en rute, der ikke findes, er ikke en skønhedsfejl:
|
||||||
|
* det er en knap, der fører et sted hen, hvor der ikke er noget.
|
||||||
|
*
|
||||||
|
* Instansens form, ikke personens rettigheder. Derfor hentes den ved siden af
|
||||||
|
* kontoen frem for efter den: den skal også være kendt, når ingen er logget ind.
|
||||||
|
*
|
||||||
|
* `full` som udgangspunkt, fordi det er det, programmet altid har været — en
|
||||||
|
* instans, der ikke når at svare, skal ikke pludselig se ud som et andet
|
||||||
|
* program.
|
||||||
|
*/
|
||||||
|
edition = $state('full');
|
||||||
|
|
||||||
|
get notesOnly() {
|
||||||
|
return this.edition === 'notes';
|
||||||
|
}
|
||||||
|
|
||||||
/** Transient messages: a failed save, a rolled-back change. */
|
/** Transient messages: a failed save, a rolled-back change. */
|
||||||
toasts = $state([]);
|
toasts = $state([]);
|
||||||
connected = $state(false);
|
connected = $state(false);
|
||||||
@@ -108,6 +129,17 @@ class AppState {
|
|||||||
|
|
||||||
async load() {
|
async load() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
||||||
|
// Formen hentes for sig, og en fejl på den tier. En 401 fra `me` må ikke
|
||||||
|
// tage udgaven med sig — login-skærmen er også en flade i en udgave — og
|
||||||
|
// en instans, der slet ikke svarer, melder `me` ordentligt nedenfor.
|
||||||
|
const shape = api
|
||||||
|
.setupState()
|
||||||
|
.then((state) => {
|
||||||
|
if (state?.edition) this.edition = state.edition;
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.user = await api.me();
|
this.user = await api.me();
|
||||||
this.unreachable = false;
|
this.unreachable = false;
|
||||||
@@ -131,6 +163,10 @@ class AppState {
|
|||||||
// arriving. A 401 is the server answering, and that is a real sign-out.
|
// arriving. A 401 is the server answering, and that is a real sign-out.
|
||||||
this.unreachable = e?.code === 'offline';
|
this.unreachable = e?.code === 'offline';
|
||||||
} finally {
|
} finally {
|
||||||
|
// Afventet her, så fladen aldrig tegnes, før den ved hvilket program den
|
||||||
|
// er. Tegnet først og rettet bagefter ville vise opgaver i et sekund på
|
||||||
|
// en instans, der ikke har nogen.
|
||||||
|
await shape;
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,39 @@
|
|||||||
app.load();
|
app.load();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opgavernes sider findes ikke i noter-udgaven.
|
||||||
|
*
|
||||||
|
* Ét sted frem for en vagt i hver rute: listen ER beslutningen, og en vagt pr.
|
||||||
|
* fil er syv steder at glemme den ottende. Skrevet som præfikser, fordi
|
||||||
|
* `/opgave/<id>` og `/filter/<id>` er sider under dem.
|
||||||
|
*
|
||||||
|
* Sendt til noterne og ikke til en fejlside: der er ikke sket noget forkert.
|
||||||
|
* Adressen hører bare til et andet program end det, der kører her — og nogen,
|
||||||
|
* der har gemt et link fra en fuld instans, skal lande et sted, der giver
|
||||||
|
* mening frem for i en blindgyde.
|
||||||
|
*
|
||||||
|
* `replaceState`, så Tilbage ikke fører hen til den side, man lige blev sendt
|
||||||
|
* væk fra, og derfra tilbage igen.
|
||||||
|
*/
|
||||||
|
const TASK_PAGES = [
|
||||||
|
'/',
|
||||||
|
'/upcoming',
|
||||||
|
'/uddelegeret',
|
||||||
|
'/kalender',
|
||||||
|
'/faerdige',
|
||||||
|
'/opgave',
|
||||||
|
'/filter',
|
||||||
|
'/etiket'
|
||||||
|
];
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!app.notesOnly) return;
|
||||||
|
const here = $page.url.pathname;
|
||||||
|
const taskPage = TASK_PAGES.some((p) => here === p || (p !== '/' && here.startsWith(p + '/')));
|
||||||
|
if (taskPage) goto('/noter', { replaceState: true });
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Links ud af programmet, åbnet i den browser, maskinen er sat op til.
|
* Links ud af programmet, åbnet i den browser, maskinen er sat op til.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user