Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c151d6c98c | ||
|
|
bd5e86dd03 | ||
|
|
bb25b94f96 | ||
|
|
dab1827c14 | ||
|
|
fe7ce5c853 |
@@ -585,6 +585,15 @@ func inboxName(locale string) string {
|
||||
// sync that has been failing for a week is invisible otherwise. The background job
|
||||
// writes a log line the next restart erases, and nobody is watching the one screen
|
||||
// that would have said so.
|
||||
// StatusUpstreamRefused is what this server answers when a service it depends on
|
||||
// says no. Not 502, though that is what it means: a proxy is entitled to replace
|
||||
// the body of an origin 5xx with a page of its own, and Cloudflare does. That
|
||||
// swallowed every explanation this server produced — "gmail: 403 Forbidden",
|
||||
// "the panel refused: HTTP 401" — and left the interface with nothing to show but
|
||||
// "something went wrong". A 4xx passes through untouched, and the truth reaching
|
||||
// the person who has to act on it beats the tidier status code.
|
||||
const StatusUpstreamRefused = http.StatusUnprocessableEntity
|
||||
|
||||
func (s *Server) upstream(w http.ResponseWriter, r *http.Request, code, what string, err error) {
|
||||
s.log.Warn(what, "err", err)
|
||||
|
||||
@@ -593,12 +602,12 @@ func (s *Server) upstream(w http.ResponseWriter, r *http.Request, code, what str
|
||||
userID = u.ID
|
||||
}
|
||||
s.db.RecordError(r.Context(), store.ServerError{
|
||||
Method: r.Method, Path: r.URL.Path, Status: http.StatusBadGateway,
|
||||
Method: r.Method, Path: r.URL.Path, Status: StatusUpstreamRefused,
|
||||
What: what, Message: err.Error(), UserID: userID,
|
||||
RequestID: middleware.GetReqID(r.Context()),
|
||||
})
|
||||
|
||||
writeError(w, http.StatusBadGateway, code, err.Error())
|
||||
writeError(w, StatusUpstreamRefused, code, err.Error())
|
||||
}
|
||||
|
||||
func (s *Server) internal(w http.ResponseWriter, r *http.Request, what string, err error) {
|
||||
|
||||
@@ -45,7 +45,7 @@ func TestAGmailFailureSaysWhatGmailSaidAndIsRecorded(t *testing.T) {
|
||||
}
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/gmail/sync", nil)
|
||||
if resp.StatusCode != http.StatusBadGateway {
|
||||
if resp.StatusCode != StatusUpstreamRefused {
|
||||
t.Fatalf("sync: status %d, want 502 — this is Gmail saying no, not this server breaking",
|
||||
resp.StatusCode)
|
||||
}
|
||||
@@ -68,7 +68,7 @@ func TestAGmailFailureSaysWhatGmailSaidAndIsRecorded(t *testing.T) {
|
||||
if row["what"] != "sync gmail" {
|
||||
t.Errorf("what = %v, want \"sync gmail\"", row["what"])
|
||||
}
|
||||
if row["status"] != float64(http.StatusBadGateway) {
|
||||
if row["status"] != float64(StatusUpstreamRefused) {
|
||||
t.Errorf("status = %v, want 502", row["status"])
|
||||
}
|
||||
}
|
||||
@@ -143,7 +143,7 @@ func TestASlowMailboxDoesNotHangTheRequest(t *testing.T) {
|
||||
t.Errorf("the sync took %s; the budget is not being applied",
|
||||
took.Round(time.Second))
|
||||
}
|
||||
if resp.StatusCode >= 500 && resp.StatusCode != http.StatusBadGateway {
|
||||
if resp.StatusCode >= 500 && resp.StatusCode != StatusUpstreamRefused {
|
||||
t.Errorf("status %d — a slow mailbox is not this server breaking", resp.StatusCode)
|
||||
}
|
||||
t.Logf("came back in %s with %d", took.Round(time.Millisecond), resp.StatusCode)
|
||||
|
||||
@@ -288,7 +288,7 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
|
||||
subtasks, err := ai.New(cfg).SplitIntoSubtasks(ctx, task.Content, task.Description, user.Locale)
|
||||
if err != nil {
|
||||
s.log.Warn("ai split", "err", err)
|
||||
writeError(w, http.StatusBadGateway, CodeInternal, err.Error())
|
||||
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -352,7 +352,7 @@ func (s *Server) handleAISummary(w http.ResponseWriter, r *http.Request) {
|
||||
summary, err := ai.New(cfg).WeeklySummary(ctx, lines, user.Locale)
|
||||
if err != nil {
|
||||
s.log.Warn("ai summary", "err", err)
|
||||
writeError(w, http.StatusBadGateway, CodeInternal, err.Error())
|
||||
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"summary": summary})
|
||||
|
||||
@@ -96,7 +96,7 @@ func (s *Server) handleRestartFromPanel(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
s.log.Error("panel refused the restart", "status", resp.StatusCode)
|
||||
writeError(w, http.StatusBadGateway, CodeInternal,
|
||||
writeError(w, StatusUpstreamRefused, CodeInternal,
|
||||
fmt.Sprintf("the panel refused: HTTP %d — check VERDANDE_PANEL_TOKEN has control of this server", resp.StatusCode))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,129 +3,75 @@ package httpapi
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
)
|
||||
|
||||
// Restarting is administrators only, and sessions only. A leaked API token that
|
||||
// could restart the server is a denial of service in one request.
|
||||
func TestRestartingIsAdminsOnlyAndSessionsOnly(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
// A panel that never answers must not become this server's problem. The container
|
||||
// is being asked to stop, so the reply may legitimately never arrive — but the
|
||||
// request has to end either way, or whatever sits in front answers for it.
|
||||
func TestAPanelThatNeverAnswersStillEndsTheRequest(t *testing.T) {
|
||||
panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer panel.Close()
|
||||
|
||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||
cfg.PanelURL = panel.URL
|
||||
cfg.PanelToken = "test-token"
|
||||
cfg.PanelServerID = "server-1"
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||
|
||||
for _, path := range []string{"/api/v1/panel", "/api/v1/panel/restart"} {
|
||||
method := "GET"
|
||||
if path != "/api/v1/panel" {
|
||||
method = "POST"
|
||||
}
|
||||
if resp, _ := other.do(t, method, path, nil); resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("%s as an ordinary user: status %d, want 403", path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
admin, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := ts.apiToken(t, admin.ID)
|
||||
// Its own client: the shared one gives up at ten seconds, which is the very
|
||||
// budget under test, so it could not tell a pass from a failure.
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/panel/restart", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
t.Fatalf("the request never came back (%v after %s)", err, time.Since(started).Round(time.Second))
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("restart with an admin's API token: status %d, want 403", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
took := time.Since(started)
|
||||
|
||||
// Unconfigured, it says which of the three settings are missing rather than
|
||||
// "not configured" — an operator should not have to go and find out which.
|
||||
func TestAnUnconfiguredPanelSaysWhatIsMissing(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, status := ts.do(t, "GET", "/api/v1/panel", nil)
|
||||
if status["configured"] != false {
|
||||
t.Fatalf("configured = %v on an instance with no panel settings", status["configured"])
|
||||
if took > 20*time.Second {
|
||||
t.Errorf("the restart took %s; that is long enough for a proxy to answer first",
|
||||
took.Round(time.Second))
|
||||
}
|
||||
missing, _ := status["missing"].([]any)
|
||||
if len(missing) != 3 {
|
||||
t.Errorf("missing = %v, want all three settings named", missing)
|
||||
}
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/panel/restart", nil)
|
||||
if resp.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Errorf("restart with nothing configured: status %d, want 503", resp.StatusCode)
|
||||
}
|
||||
if body["error"] == nil {
|
||||
t.Error("the refusal says nothing about why")
|
||||
}
|
||||
}
|
||||
|
||||
// Configured, it calls the panel with a bearer token and reports what came back.
|
||||
func TestARestartCallsThePanelWithItsToken(t *testing.T) {
|
||||
var gotPath, gotAuth string
|
||||
panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath, gotAuth = r.URL.Path, r.Header.Get("Authorization")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer panel.Close()
|
||||
|
||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||
cfg.PanelURL = panel.URL
|
||||
cfg.PanelToken = "hemmelig"
|
||||
cfg.PanelServerID = "abc-123"
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, status := ts.do(t, "GET", "/api/v1/panel", nil)
|
||||
if status["configured"] != true {
|
||||
t.Fatalf("configured = %v with all three set", status["configured"])
|
||||
}
|
||||
// The token is a credential and must never reach a browser.
|
||||
for key, value := range status {
|
||||
if s, ok := value.(string); ok && s == "hemmelig" {
|
||||
t.Errorf("the panel token was sent to the client in %q", key)
|
||||
}
|
||||
}
|
||||
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/panel/restart", nil)
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
t.Fatalf("restart: status %d, want 202", resp.StatusCode)
|
||||
}
|
||||
if gotPath != "/api/servers/abc-123/restart" {
|
||||
t.Errorf("called %q", gotPath)
|
||||
}
|
||||
if gotAuth != "Bearer hemmelig" {
|
||||
t.Errorf("Authorization = %q", gotAuth)
|
||||
t.Errorf("status %d, want 202 — a panel that went quiet is the expected case, "+
|
||||
"not a failure", resp.StatusCode)
|
||||
}
|
||||
t.Logf("came back in %s with %d", took.Round(time.Millisecond), resp.StatusCode)
|
||||
}
|
||||
|
||||
// A panel that refuses is reported as the panel refusing, not as this server
|
||||
// breaking: the fix is a token with control of that server, and saying "internal
|
||||
// error" would send somebody looking in the wrong place.
|
||||
func TestAPanelThatRefusesIsReportedAsSuch(t *testing.T) {
|
||||
// And a panel that says no must say so in this server's own words, not in a proxy's.
|
||||
func TestAPanelThatRefusesSaysSoAsJSON(t *testing.T) {
|
||||
panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}))
|
||||
defer panel.Close()
|
||||
|
||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||
cfg.PanelURL = panel.URL
|
||||
cfg.PanelToken = "forkert"
|
||||
cfg.PanelServerID = "abc-123"
|
||||
cfg.PanelToken = "wrong-token"
|
||||
cfg.PanelServerID = "server-1"
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/panel/restart", nil)
|
||||
if resp.StatusCode != http.StatusBadGateway {
|
||||
t.Errorf("a refusing panel: status %d, want 502", resp.StatusCode)
|
||||
if resp.StatusCode != StatusUpstreamRefused {
|
||||
t.Errorf("status %d, want 502: %s", resp.StatusCode, body)
|
||||
}
|
||||
if msg, _ := body["error"].(string); msg == "" {
|
||||
t.Error("the refusal does not say what to check")
|
||||
if msg, _ := body["error"].(string); !strings.Contains(msg, "403") {
|
||||
t.Errorf("the reply does not say what the panel answered: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,11 +28,13 @@ import (
|
||||
// the app, which is the worst possible set to leave in one language, because it is
|
||||
// the text somebody reads in the half-second before they agree to lose something.
|
||||
func TestNoDanishProseInDialogsAndToasts(t *testing.T) {
|
||||
// The argument of one of the three, quoted or backticked. Deliberately narrow:
|
||||
// a general scan of every string literal in a Svelte script block would drown
|
||||
// in class names, keys and query fragments.
|
||||
call := regexp.MustCompile(`(?:confirm|alert|app\.toast)\(\s*['` + "`" + `]([^'` + "`" + `]{4,})['` + "`" + `]`)
|
||||
danish := regexp.MustCompile(`(?i)\b(og|er|ikke|det|den|der|til|kan|som|har|med|af|en|et|du|din|dine|vises|ingen|nyere|fundet|ude|valgt|slettet|gemt|opgave|projekt|indstillinger|fjern|opret|vælg|luk|tilføj|ryd|gem|slet|omdøb|bliver|holder|alle)\b`)
|
||||
// Every string literal in the argument list of one of the three, not just one
|
||||
// sitting flush against the paren. A toast written as a ternary over two lines
|
||||
// hid Danish from the earlier version of this guard for a whole release.
|
||||
// Deliberately still narrow: a general scan of every literal in a script block
|
||||
// would drown in class names, keys and query fragments.
|
||||
call := regexp.MustCompile(`(?:confirm|alert|app\.toast)\(`)
|
||||
danish := regexp.MustCompile(`(?i)\b(og|er|ikke|det|den|der|til|kan|som|har|med|af|en|et|du|din|dine|vises|ingen|nyere|fundet|ude|valgt|slettet|gemt|gemmes|hentet|hentes|beskeder|opgave|opgaver|projekt|indstillinger|fjern|opret|vælg|luk|tilføj|ryd|gem|slet|omdøb|bliver|holder|alle)\b`)
|
||||
|
||||
var found []string
|
||||
root := filepath.Join("..", "..", "web", "src")
|
||||
@@ -51,9 +53,12 @@ func TestNoDanishProseInDialogsAndToasts(t *testing.T) {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
for _, m := range call.FindAllStringSubmatch(string(body), -1) {
|
||||
if danish.MatchString(m[1]) {
|
||||
found = append(found, rel+": "+m[1])
|
||||
src := string(body)
|
||||
for _, loc := range call.FindAllStringIndex(src, -1) {
|
||||
for _, lit := range literalsInCall(src[loc[1]:]) {
|
||||
if danish.MatchString(lit) {
|
||||
found = append(found, rel+": "+lit)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -70,6 +75,34 @@ func TestNoDanishProseInDialogsAndToasts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// literalsInCall returns the string literals in one argument list, given the text
|
||||
// just past its opening paren. It stops at the matching close paren so the next
|
||||
// statement's strings are somebody else's problem.
|
||||
func literalsInCall(src string) []string {
|
||||
var out []string
|
||||
depth := 1
|
||||
for i := 0; i < len(src); i++ {
|
||||
switch c := src[i]; c {
|
||||
case '(':
|
||||
depth++
|
||||
case ')':
|
||||
if depth--; depth == 0 {
|
||||
return out
|
||||
}
|
||||
case '\'', '"', '`':
|
||||
end := strings.IndexByte(src[i+1:], c)
|
||||
if end < 0 {
|
||||
return out
|
||||
}
|
||||
if lit := src[i+1 : i+1+end]; len(lit) >= 4 {
|
||||
out = append(out, lit)
|
||||
}
|
||||
i += end + 1
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestNoDanishProseOutsideTheDictionaries(t *testing.T) {
|
||||
// Text nodes of three characters or more, and the three attributes a person
|
||||
// reads. Anything inside {…} is already an expression — usually a t() call —
|
||||
|
||||
+42
-22
@@ -302,26 +302,41 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
||||
await expect(heading).toBeVisible();
|
||||
await expect(sidebar.getByText('Træk et projekt herop')).toBeVisible();
|
||||
|
||||
// The words in the heading must not sit on top of each other.
|
||||
// A group's name must be readable in full, and must start where a project's
|
||||
// name starts.
|
||||
//
|
||||
// Everything else here clicks by accessible name, which is exactly why this
|
||||
// needs saying out loud: "Omdøb" and "Slet" once got the 20px square meant for
|
||||
// the "+" icon beside "Projekter", overflowed it, and were drawn overlapping.
|
||||
// Both buttons still worked, so every assertion in this file passed.
|
||||
const boxes = await sidebar.locator('.folder-head').evaluate((el) =>
|
||||
// Links as well as buttons: the group's name is a link to its page now, and
|
||||
// a measurement that skipped it would miss the widest thing in the row.
|
||||
[...el.querySelectorAll('button, a')].map((b) => {
|
||||
const r = b.getBoundingClientRect();
|
||||
return { text: b.textContent.trim(), left: r.left, right: r.right };
|
||||
})
|
||||
);
|
||||
for (let i = 1; i < boxes.length; i++) {
|
||||
expect(
|
||||
boxes[i].left,
|
||||
`"${boxes[i - 1].text}" og "${boxes[i].text}" overlapper i gruppehovedet`
|
||||
).toBeGreaterThanOrEqual(boxes[i - 1].right);
|
||||
}
|
||||
// Both halves are here because both were once broken at the same time and for
|
||||
// different reasons. "Omdøb" and "Slet" sat in the flow reserving their width
|
||||
// even while invisible, so ARBEJDE was drawn as "ARBE"; and the fold chevron
|
||||
// sat before the name, pushing a group 22px further in than the projects it
|
||||
// contains — so a group read as if it were filed under them. Every assertion
|
||||
// in this file passed through both, because both buttons still worked.
|
||||
const shape = await sidebar.locator('.folder-head').evaluate((el) => {
|
||||
const link = el.querySelector('h2 a');
|
||||
const name = { ...link.querySelector('.dot').getBoundingClientRect().toJSON() };
|
||||
// The last child of the link is the name itself; the dot is a span before it.
|
||||
return {
|
||||
clipped: link.scrollWidth > link.clientWidth + 1,
|
||||
left: name.left,
|
||||
right: link.getBoundingClientRect().right,
|
||||
count: el.querySelector('.count')?.getBoundingClientRect().left ?? Infinity
|
||||
};
|
||||
});
|
||||
|
||||
expect(shape.clipped, 'gruppens navn bliver klippet').toBe(false);
|
||||
expect(shape.count, 'navnet og tallet overlapper').toBeGreaterThanOrEqual(shape.right);
|
||||
|
||||
// Measured dot to dot, not box to box: both rows begin with the same coloured
|
||||
// mark, and it is the only thing in them that means the same on both sides.
|
||||
// Comparing the group's inner link with the project's outer box reported the
|
||||
// row's own padding as a misalignment.
|
||||
const projectLeft = await sidebar
|
||||
.getByRole('link', { name: 'Regnskab' })
|
||||
.evaluate((el) => el.querySelector('.dot').getBoundingClientRect().left);
|
||||
expect(
|
||||
Math.abs(shape.left - projectLeft),
|
||||
`gruppen starter ${Math.round(shape.left - projectLeft)}px fra projekterne`
|
||||
).toBeLessThanOrEqual(2);
|
||||
|
||||
// Dragged in, then dragged back out onto "Projekter" — which is the reason
|
||||
// that heading is a drop target at all: with every project filed away there
|
||||
@@ -348,7 +363,9 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
||||
// Colour, which is chosen while renaming rather than from the row. Both are
|
||||
// edits to the same thing, and a heading carrying four controls crowded the
|
||||
// name it exists to show.
|
||||
await sidebar.getByRole('button', { name: 'Omdøb' }).click();
|
||||
// Double-click on the row opens it; the two words that used to sit on top of
|
||||
// the name are gone from the sidebar entirely.
|
||||
await sidebar.getByRole('link', { name: 'Arbejde', exact: true }).dblclick();
|
||||
await sidebar.getByRole('button', { name: 'Petrol' }).click();
|
||||
// Still open: picking a colour must not close the form under the pointer, which
|
||||
// is what a plain blur-to-cancel does in the two browsers that do not focus a
|
||||
@@ -392,7 +409,7 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
||||
|
||||
// Renaming writes one row rather than one per project, which is the reason a
|
||||
// group is a table and not a string repeated on every project.
|
||||
await sidebar.getByRole('button', { name: 'Omdøb' }).click();
|
||||
await sidebar.getByRole('link', { name: 'Arbejde', exact: true }).dblclick();
|
||||
const name = sidebar.getByLabel('Gruppens navn');
|
||||
await name.fill('Kontoret');
|
||||
await name.press('Enter');
|
||||
@@ -401,8 +418,11 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
||||
// Deleting the heading must not take the project filed under it. It is
|
||||
// ungrouped here, which is the case that would look identical either way if
|
||||
// the assertion were only "the group is gone".
|
||||
// Deleting is on the group's own page now, where the projects it will not take
|
||||
// with it are on the screen above the button.
|
||||
await sidebar.getByRole('link', { name: 'Kontoret', exact: true }).click();
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await sidebar.getByRole('button', { name: 'Slet' }).click();
|
||||
await page.getByRole('button', { name: 'Slet' }).click();
|
||||
await expect(sidebar.getByRole('link', { name: 'Kontoret', exact: true })).toBeHidden();
|
||||
await expect(sidebar.getByRole('link', { name: 'Regnskab' })).toBeVisible();
|
||||
|
||||
|
||||
@@ -105,17 +105,18 @@
|
||||
await app.renameGroup(group.id, name);
|
||||
}
|
||||
|
||||
async function removeGroup(group) {
|
||||
const count = inGroup(group.id).length;
|
||||
// Says what happens to the projects, because "slet gruppen" reads like it
|
||||
// might take them with it. It does not: only the heading goes.
|
||||
const kept =
|
||||
count === 1 ? t('sidebar.deleteGroupOne') : t('sidebar.deleteGroupMany', { n: count });
|
||||
const question = t('sidebar.deleteGroup', { name: group.name });
|
||||
if (!confirm(count ? `${question} ${kept}` : question)) return;
|
||||
await app.deleteGroup(group.id);
|
||||
/** Double-click, or F2 with the row focused. The colour is chosen in the same
|
||||
* form, because it is the same edit to the same thing. */
|
||||
/** Cleared by a second click, which means the double was a rename. */
|
||||
let navTimer;
|
||||
|
||||
function startRename(group) {
|
||||
clearTimeout(navTimer);
|
||||
renamingGroup = group.id;
|
||||
groupName = group.name;
|
||||
}
|
||||
|
||||
|
||||
// --- resizing -------------------------------------------------------------------
|
||||
|
||||
let resizing = $state(false);
|
||||
@@ -547,6 +548,13 @@
|
||||
overId = null;
|
||||
}}
|
||||
ondrop={(e) => onGroupDrop(e, group)}
|
||||
ondblclick={() => startRename(group)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'F2') {
|
||||
e.preventDefault();
|
||||
startRename(group);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if renamingGroup === group.id}
|
||||
<!-- Renaming is where the colour is chosen too.
|
||||
@@ -618,7 +626,24 @@
|
||||
</svg>
|
||||
</button>
|
||||
<h2 class="fold">
|
||||
<a href="/gruppe/{group.id}" onclick={onnavigate}>
|
||||
<!-- Navigation waits two tenths of a second for a second click. A
|
||||
link cannot know a double is coming without pausing, and the
|
||||
alternative was worse than the pause: the first click opened the
|
||||
page while the rename form was appearing, and the colour swatches
|
||||
moved out from under the pointer. Two tenths is under the double
|
||||
click threshold and over the threshold of noticing. -->
|
||||
<a
|
||||
href="/gruppe/{group.id}"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
clearTimeout(navTimer);
|
||||
if (e.detail > 1) return;
|
||||
navTimer = setTimeout(() => {
|
||||
onnavigate();
|
||||
goto(`/gruppe/${group.id}`);
|
||||
}, 200);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
class="dot group-dot"
|
||||
style="background: {colorVar(group.color)}"
|
||||
@@ -628,14 +653,6 @@
|
||||
</a>
|
||||
</h2>
|
||||
<span class="count">{inside.length}</span>
|
||||
<button
|
||||
class="group-action"
|
||||
onclick={() => {
|
||||
renamingGroup = group.id;
|
||||
groupName = group.name;
|
||||
}}>{t('sidebar.rename')}</button
|
||||
>
|
||||
<button class="group-action remove" onclick={() => removeGroup(group)}>{t('sidebar.delete')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -939,14 +956,30 @@
|
||||
/* The chevron on its own, so the name beside it can be a link to the group's
|
||||
page. Sized as a target rather than as a glyph: 11px of arrow is something
|
||||
you miss. */
|
||||
/* The chevron sits on top of the group's dot rather than before it. In the flow
|
||||
it pushed the name 22px further in than a project's, so groups read as if
|
||||
they were filed under the projects above them — the opposite of what they
|
||||
are. Out of the flow, a group's name starts exactly where a project's does.
|
||||
The dot is the resting state; the chevron takes over on hover or focus, and
|
||||
stays put while the group is folded, so a folded group still shows the way
|
||||
back open. */
|
||||
.chevron-button {
|
||||
flex: none;
|
||||
position: absolute;
|
||||
left: calc(var(--s2) - 5px);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius-sm);
|
||||
color: inherit;
|
||||
opacity: 0;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.folder-head:hover .chevron-button,
|
||||
.chevron-button:focus-visible,
|
||||
.chevron-button[aria-expanded='false'] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chevron-button:hover {
|
||||
@@ -997,30 +1030,34 @@
|
||||
|
||||
/* Hidden until the heading is hovered, like the section actions on a project
|
||||
page: two buttons permanently beside a label stop it reading as a label. */
|
||||
.group-action {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
padding: 0 var(--s1);
|
||||
flex: none;
|
||||
opacity: 0;
|
||||
transition: opacity var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.folder-head:hover .group-action,
|
||||
.group-action:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.group-action:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.group-action.remove:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.folder-head .count {
|
||||
margin-left: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Without min-width a flex item refuses to shrink below its content, so a long
|
||||
group name pushed the count off the row instead of ending in an ellipsis. */
|
||||
.folder-head h2 {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* No left padding of its own: the row already has the same padding as a project
|
||||
row, and the anchor's would be added on top — which is where the last eight
|
||||
pixels of the old indent were hiding. Vertical padding stays, so the heading
|
||||
keeps its height. */
|
||||
.folder-head h2 a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
min-width: 0;
|
||||
padding: var(--s2) 0;
|
||||
}
|
||||
|
||||
.folder-head h2 a :global(*:last-child),
|
||||
.folder-head h2 {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The group's own mark, between the chevron and the name — inside the fold
|
||||
|
||||
@@ -497,6 +497,9 @@ export const da = {
|
||||
'int.server': 'Server',
|
||||
'int.caldavAuth':
|
||||
'Brugernavn er din e-mail. Adgangskoden er en API-token — ikke din rigtige adgangskode.',
|
||||
'int.gmailFetchedOne': '{n} opgave hentet.',
|
||||
'int.gmailFetchedMany': '{n} opgaver hentet.',
|
||||
'int.gmailNothingNew': 'Ingen nye beskeder.',
|
||||
'int.gmailConnected': 'Gmail er forbundet.',
|
||||
'int.gmailState':
|
||||
'Svaret fra Google passede ikke til det forsøg, der blev startet her. Prøv igen.',
|
||||
|
||||
@@ -493,6 +493,9 @@ export const en = {
|
||||
'int.server': 'Server',
|
||||
'int.caldavAuth':
|
||||
'The username is your email. The password is an API token — not your real password.',
|
||||
'int.gmailFetchedOne': '{n} task fetched.',
|
||||
'int.gmailFetchedMany': '{n} tasks fetched.',
|
||||
'int.gmailNothingNew': 'No new messages.',
|
||||
'int.gmailConnected': 'Gmail is connected.',
|
||||
'int.gmailState': 'Google’s reply did not match the attempt that started here. Try again.',
|
||||
'int.gmailExpired': 'The attempt expired on the way. Try again.',
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* projects answers is what is left.
|
||||
*/
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app, projectName } from '$lib/stores.svelte.js';
|
||||
import { colorVar } from '$lib/colors.js';
|
||||
@@ -29,6 +30,18 @@
|
||||
load(id);
|
||||
});
|
||||
|
||||
/** Says what happens to the projects, because "delete the group" reads like it
|
||||
* might take them with it. It does not: only the heading goes. */
|
||||
async function removeGroup() {
|
||||
const count = projects.length;
|
||||
const kept =
|
||||
count === 1 ? t('sidebar.deleteGroupOne') : t('sidebar.deleteGroupMany', { n: count });
|
||||
const question = t('sidebar.deleteGroup', { name: group.name });
|
||||
if (!confirm(count ? `${question} ${kept}` : question)) return;
|
||||
await app.deleteGroup(group.id);
|
||||
goto('/');
|
||||
}
|
||||
|
||||
async function load(groupId) {
|
||||
if (!groupId) return;
|
||||
status = 'loading';
|
||||
@@ -208,6 +221,14 @@
|
||||
<span>{uploading ? t('group.uploading') : t('group.addFile')}</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<!-- Deleting lives here rather than in the sidebar, where two words sat on
|
||||
top of the group's name and where you could press them without having
|
||||
seen what the group holds. Here the projects are on the screen above
|
||||
it, which is the whole argument for the button being here. -->
|
||||
<section class="danger">
|
||||
<button class="remove" onclick={removeGroup}>{t('sidebar.delete')}</button>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -230,6 +251,22 @@
|
||||
gap: var(--s3);
|
||||
}
|
||||
|
||||
.danger {
|
||||
margin-top: var(--s4);
|
||||
padding-top: var(--s4);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.danger .remove {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-faint);
|
||||
padding: var(--s2) 0;
|
||||
}
|
||||
|
||||
.danger .remove:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-2xl);
|
||||
min-width: 0;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { page } from '$app/stores';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { t, plural } from '$lib/i18n.svelte.js';
|
||||
|
||||
// --- Gmail ------------------------------------------------------------------
|
||||
|
||||
@@ -115,7 +115,9 @@
|
||||
try {
|
||||
const result = await api.syncGmail();
|
||||
app.toast(
|
||||
result?.created ? `${result.created} opgave(r) hentet.` : 'Ingen nye beskeder.'
|
||||
result?.created
|
||||
? plural(result.created, 'int.gmailFetchedOne', 'int.gmailFetchedMany')
|
||||
: t('int.gmailNothingNew')
|
||||
);
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
|
||||
Reference in New Issue
Block a user