Add a Dialog an ExApp page can open for a message, confirm, or prompt.
The page waits for the choice. App navigation includes the same piece.
This commit is contained in:
@@ -44,6 +44,10 @@ _Avoid_: Nextcloud middleware, HaRP ACL, admin bypass, gating the top-menu boots
|
||||
The tree of named items along the left of an ExApp page. The ExApp supplies the tree. The selected item is the page on display. It plays the same role as the navigation in Nextcloud Files. An ExApp that does not use App navigation shows its own page instead.
|
||||
_Avoid_: Top Menu, sidebar, NcAppNavigation
|
||||
|
||||
**Dialog**:
|
||||
A modal window on an ExApp page, in the same Nextcloud theme as that page. It does not require App navigation. The page waits for the user's choice; that choice is not a server request. The heading, the message, and the button word are plain text. The heading may be omitted. The fixed buttons are the English words OK and Cancel. One of three kinds: a Message (short text at info, warning, or error, dismissed with one button), a Confirm (yes or no; the agreeing button may be destructive), or a Prompt (one entered value, or cancelled). A Prompt with no value cannot be agreed. A successful Prompt value has no surrounding spaces. The caller may supply a starting value. A Prompt field has no label of its own; the message is the text above it.
|
||||
_Avoid_: popup, browser alert/confirm/prompt, Notification, toast, HTML message
|
||||
|
||||
**Top Menu visibility**:
|
||||
Whether the ExApp app icon in the Nextcloud top menu is shown to all logged-in users or to Nextcloud admins only. Configured per deploy via env `TOP_MENU_ADMIN_REQUIRED` (`0` or `1`); the ExApp passes the value to AppAPI when registering the top-menu entry on enable. Independent of route `access_level` in info.xml and of Required Groups.
|
||||
_Avoid_: route access_level, Required Groups, AppAPI group ACL
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# go-nc-exapp
|
||||
|
||||
Shared Go library for Nextcloud ExApp Services: AppAPI authentication, OCS JSON calls, per-user ExApp preferences, Notifications, Users and Groups, an optional Required Groups Access Gate, and an optional App navigation shell.
|
||||
Shared Go library for Nextcloud ExApp Services: AppAPI authentication, OCS JSON calls, per-user ExApp preferences, Notifications, Users and Groups, an optional Required Groups Access Gate, an optional App navigation shell, and a Dialog.
|
||||
|
||||
Import: `gitea.neitzel.de/konrad/go-nc-exapp` (package `gonexapp`).
|
||||
|
||||
@@ -18,7 +18,8 @@ Import: `gitea.neitzel.de/konrad/go-nc-exapp` (package `gonexapp`).
|
||||
- **Groups** — Users and Groups reads: `UserGroups`, `GroupMembers`, `ListGroups` (no search/paging). Directory calls (`GroupMembers` / `ListGroups`) run as the Credentials user and need an admin or subadmin
|
||||
- **Access Gate** — optional Required Groups enforcement (`Wrap` + `Check`), English denied HTML for browsers (200 + `frame-ancestors 'self'`), positive membership cache; default skip for lifecycle paths and **`/js/`** top-menu scripts; env helpers for `REQUIRED_GROUPS` / `REQUIRED_GROUPS_CACHE_SECONDS`
|
||||
- **Top Menu visibility** — `TopMenuAdminRequired` helper for deploy env `TOP_MENU_ADMIN_REQUIRED` (`0` / `1` for AppAPI top-menu OCS)
|
||||
- **App navigation** — optional Files-style shell (`AppNavigation.Handler`). The ExApp supplies the tree, the page for each item, and an optional header. The selected item is the `item` query parameter (override with `SelectKey`). Other query parameters, including a Visit folder, are left in place. By default the shell loads the Nextcloud theme stylesheets, paints `--image-background`, and copies the surrounding page’s `data-theme-*` markers; set `DisableTheme` to skip the stylesheets. An item may set `Icon` to a same-origin image URL. `NextcloudIcons` names the core SVGs Nextcloud already serves (`NextcloudIcons.Folder`, `NextcloudIcons.Password`, and the rest). Not mounting the handler keeps the ExApp’s own page. Lifecycle routes, HaRP startup, and Top Menu registration stay in the ExApp.
|
||||
- **App navigation** — optional Files-style shell (`AppNavigation.Handler`). The ExApp supplies the tree, the page for each item, and an optional header. The selected item is the `item` query parameter (override with `SelectKey`). Other query parameters, including a Visit folder, are left in place. By default the shell loads the Nextcloud theme stylesheets, paints `--image-background`, and copies the surrounding page’s `data-theme-*` markers; set `DisableTheme` to skip the stylesheets. An item may set `Icon` to a same-origin image URL. `NextcloudIcons` names the core SVGs Nextcloud already serves (`NextcloudIcons.Folder`, `NextcloudIcons.Password`, and the rest). The shell includes the Dialog. Not mounting the handler keeps the ExApp’s own page. Lifecycle routes, HaRP startup, and Top Menu registration stay in the ExApp.
|
||||
- **Dialog** — `DialogHTML()` is the modal for a Message, a Confirm, or a Prompt. Insert it on a page the ExApp renders itself; App navigation already includes it. The page calls `exappDialog.message`, `exappDialog.confirm`, or `exappDialog.prompt` and waits for the choice. Fixed buttons are the English words OK and Cancel. The agreeing button’s word may be replaced. The choice stays in the page.
|
||||
|
||||
**Excluded**
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package gonexapp
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"html/template"
|
||||
)
|
||||
|
||||
//go:embed dialog.js
|
||||
var dialogJS string
|
||||
|
||||
// DialogHTML is the Dialog markup and script for an ExApp page.
|
||||
// App navigation includes it. A page the ExApp renders itself inserts the
|
||||
// same fragment. The page then calls exappDialog.message, exappDialog.confirm,
|
||||
// or exappDialog.prompt and waits for the user's choice.
|
||||
func DialogHTML() template.HTML {
|
||||
return template.HTML(dialogStyle + dialogMarkup + "<script>" + dialogJS + "</script>")
|
||||
}
|
||||
|
||||
const dialogStyle = `<style>
|
||||
#exapp-dialog {
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
max-width: calc(100% - 2rem);
|
||||
}
|
||||
#exapp-dialog::backdrop { background: rgba(0, 0, 0, 0.45); }
|
||||
#exapp-dialog-panel {
|
||||
box-sizing: border-box;
|
||||
width: min(28rem, 100%);
|
||||
padding: 1.1rem 1.25rem 1rem;
|
||||
border-radius: var(--border-radius-element, 8px);
|
||||
border-top: 4px solid var(--color-primary-element, #00679e);
|
||||
background: var(--color-main-background, #fff);
|
||||
color: var(--color-main-text, #222);
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
#exapp-dialog[data-severity="warning"] #exapp-dialog-panel { border-top-color: var(--color-warning, #eca700); }
|
||||
#exapp-dialog[data-severity="error"] #exapp-dialog-panel { border-top-color: var(--color-error, #e9322d); }
|
||||
#exapp-dialog-heading { margin: 0 0 0.6rem; font-size: 1.15rem; }
|
||||
#exapp-dialog-severity {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
#exapp-dialog-message { margin: 0; white-space: pre-wrap; }
|
||||
#exapp-dialog-value {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
margin-top: 0.8rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid var(--color-border, #ccc);
|
||||
border-radius: var(--border-radius-element, 8px);
|
||||
background: var(--color-main-background, #fff);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
#exapp-dialog-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1.1rem; }
|
||||
#exapp-dialog-actions button {
|
||||
min-height: var(--default-clickable-area, 34px);
|
||||
padding: 0 0.9rem;
|
||||
border: 1px solid var(--color-border, #ccc);
|
||||
border-radius: var(--border-radius-element, 8px);
|
||||
background: var(--color-main-background, #fff);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
#exapp-dialog-agree {
|
||||
border-color: transparent;
|
||||
background: var(--color-primary-element, #00679e);
|
||||
color: var(--color-primary-element-text, #fff);
|
||||
}
|
||||
#exapp-dialog-agree.destructive {
|
||||
background: var(--color-error, #e9322d);
|
||||
color: #fff;
|
||||
}
|
||||
#exapp-dialog-agree:disabled { opacity: 0.45; cursor: default; }
|
||||
</style>`
|
||||
|
||||
const dialogMarkup = `<dialog id="exapp-dialog" aria-modal="true">
|
||||
<div id="exapp-dialog-panel">
|
||||
<h2 id="exapp-dialog-heading" hidden></h2>
|
||||
<p id="exapp-dialog-severity" hidden></p>
|
||||
<p id="exapp-dialog-message"></p>
|
||||
<input id="exapp-dialog-value" hidden autocomplete="off">
|
||||
<div id="exapp-dialog-actions">
|
||||
<button type="button" id="exapp-dialog-cancel">Cancel</button>
|
||||
<button type="button" id="exapp-dialog-agree">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>`
|
||||
@@ -0,0 +1,128 @@
|
||||
(function () {
|
||||
var dialog = document.getElementById("exapp-dialog");
|
||||
if (!dialog || dialog.getAttribute("data-ready") === "1") return;
|
||||
dialog.setAttribute("data-ready", "1");
|
||||
|
||||
var panel = document.getElementById("exapp-dialog-panel");
|
||||
var heading = document.getElementById("exapp-dialog-heading");
|
||||
var severity = document.getElementById("exapp-dialog-severity");
|
||||
var message = document.getElementById("exapp-dialog-message");
|
||||
var field = document.getElementById("exapp-dialog-value");
|
||||
var cancelBtn = document.getElementById("exapp-dialog-cancel");
|
||||
var agreeBtn = document.getElementById("exapp-dialog-agree");
|
||||
var queue = [];
|
||||
var busy = false;
|
||||
var finish = null;
|
||||
|
||||
function textOf(value) {
|
||||
return value == null ? "" : String(value);
|
||||
}
|
||||
|
||||
function trimmed() {
|
||||
return field.value.trim();
|
||||
}
|
||||
|
||||
function refreshAgree() {
|
||||
agreeBtn.disabled = !field.hidden && trimmed() === "";
|
||||
}
|
||||
|
||||
function settle(result) {
|
||||
var done = finish;
|
||||
finish = null;
|
||||
if (dialog.open) dialog.close();
|
||||
busy = false;
|
||||
if (done) done(result);
|
||||
pump();
|
||||
}
|
||||
|
||||
function decline() {
|
||||
if (!finish) return;
|
||||
var kind = dialog.getAttribute("data-kind");
|
||||
if (kind === "confirm") settle(false);
|
||||
else if (kind === "prompt") settle(null);
|
||||
else settle(undefined);
|
||||
}
|
||||
|
||||
function agree() {
|
||||
if (!finish || agreeBtn.disabled) return;
|
||||
var kind = dialog.getAttribute("data-kind");
|
||||
if (kind === "confirm") settle(true);
|
||||
else if (kind === "prompt") settle(trimmed());
|
||||
else settle(undefined);
|
||||
}
|
||||
|
||||
cancelBtn.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
decline();
|
||||
});
|
||||
agreeBtn.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
agree();
|
||||
});
|
||||
field.addEventListener("input", refreshAgree);
|
||||
dialog.addEventListener("cancel", function (e) {
|
||||
e.preventDefault();
|
||||
decline();
|
||||
});
|
||||
dialog.addEventListener("click", function (e) {
|
||||
if (e.target === dialog) decline();
|
||||
});
|
||||
dialog.addEventListener("keydown", function (e) {
|
||||
if (e.key !== "Enter") return;
|
||||
e.preventDefault();
|
||||
agree();
|
||||
});
|
||||
panel.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
function show(kind, opts, done) {
|
||||
opts = opts || {};
|
||||
finish = done;
|
||||
dialog.setAttribute("data-kind", kind);
|
||||
var head = textOf(opts.heading);
|
||||
heading.textContent = head;
|
||||
heading.hidden = head === "";
|
||||
message.textContent = textOf(opts.text);
|
||||
var sev = "";
|
||||
if (kind === "message") {
|
||||
sev = opts.severity === "warning" || opts.severity === "error" ? opts.severity : "info";
|
||||
dialog.setAttribute("data-severity", sev);
|
||||
} else {
|
||||
dialog.removeAttribute("data-severity");
|
||||
}
|
||||
severity.textContent = sev;
|
||||
severity.hidden = sev === "";
|
||||
var isPrompt = kind === "prompt";
|
||||
field.hidden = !isPrompt;
|
||||
field.value = isPrompt ? textOf(opts.value) : "";
|
||||
cancelBtn.hidden = kind === "message";
|
||||
agreeBtn.textContent = kind !== "message" && opts.agree ? textOf(opts.agree) : "OK";
|
||||
agreeBtn.classList.toggle("destructive", kind === "confirm" && !!opts.destructive);
|
||||
refreshAgree();
|
||||
if (!dialog.open) dialog.showModal();
|
||||
if (isPrompt) field.focus();
|
||||
else if (!agreeBtn.disabled) agreeBtn.focus();
|
||||
}
|
||||
|
||||
function pump() {
|
||||
if (busy || queue.length === 0) return;
|
||||
busy = true;
|
||||
queue.shift()();
|
||||
}
|
||||
|
||||
function enqueue(kind, opts) {
|
||||
return new Promise(function (resolve) {
|
||||
queue.push(function () {
|
||||
show(kind, opts, resolve);
|
||||
});
|
||||
pump();
|
||||
});
|
||||
}
|
||||
|
||||
window.exappDialog = {
|
||||
message: function (opts) { return enqueue("message", opts); },
|
||||
confirm: function (opts) { return enqueue("confirm", opts); },
|
||||
prompt: function (opts) { return enqueue("prompt", opts); }
|
||||
};
|
||||
})();
|
||||
+511
@@ -0,0 +1,511 @@
|
||||
package gonexapp_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/chromedp"
|
||||
"github.com/chromedp/chromedp/kb"
|
||||
|
||||
"gitea.neitzel.de/konrad/go-nc-exapp"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
srv := httptest.NewServer(dialogFixture())
|
||||
defer srv.Close()
|
||||
fixtureURL = srv.URL
|
||||
|
||||
path := browserExecutable()
|
||||
if path == "" {
|
||||
fmt.Fprintln(os.Stderr, "dialog tests need Chrome, Edge, or Chromium (set EXAPP_BROWSER)")
|
||||
os.Exit(1)
|
||||
}
|
||||
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||
chromedp.ExecPath(path),
|
||||
chromedp.WindowSize(1200, 800),
|
||||
)
|
||||
var cancel context.CancelFunc
|
||||
allocCtx, cancel = chromedp.NewExecAllocator(context.Background(), opts...)
|
||||
code := m.Run()
|
||||
cancel()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
var (
|
||||
fixtureURL string
|
||||
allocCtx context.Context
|
||||
)
|
||||
|
||||
func dialogFixture() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /bare", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprint(w, "<!DOCTYPE html><html><head><meta charset=utf-8><title>Dialog</title></head><body>",
|
||||
gonexapp.DialogHTML(), fixtureControls(), "</body></html>")
|
||||
})
|
||||
nav := gonexapp.AppNavigation{
|
||||
DefaultID: "home",
|
||||
Items: func(*http.Request) []gonexapp.Item {
|
||||
return []gonexapp.Item{{ID: "home", Label: "Home"}}
|
||||
},
|
||||
Page: func(*http.Request, string) (string, bool) {
|
||||
return fixtureControls(), true
|
||||
},
|
||||
}
|
||||
mux.Handle("GET /shell", nav.Handler())
|
||||
return mux
|
||||
}
|
||||
|
||||
func fixtureControls() string {
|
||||
return `<button type="button" id="open-info">Open info</button>
|
||||
<button type="button" id="open-warning">Open warning</button>
|
||||
<button type="button" id="open-error">Open error</button>
|
||||
<button type="button" id="open-plain">Open plain</button>
|
||||
<button type="button" id="open-confirm">Open confirm</button>
|
||||
<button type="button" id="open-delete">Open delete</button>
|
||||
<button type="button" id="open-html-button">Open html button</button>
|
||||
<button type="button" id="open-prompt">Open prompt</button>
|
||||
<button type="button" id="open-prefill">Open prefill</button>
|
||||
<button type="button" id="open-blank">Open blank</button>
|
||||
<button type="button" id="open-inner">Open inner</button>
|
||||
<button type="button" id="open-three">Open three</button>
|
||||
<pre id="log"></pre>
|
||||
<script>
|
||||
function log(line) { document.getElementById("log").textContent += line + "\n"; }
|
||||
document.getElementById("open-info").onclick = function () {
|
||||
exappDialog.message({ severity: "info", heading: "Notice", text: "Hello <b>x</b>", agree: "Nope" }).then(function () { log("dismissed"); });
|
||||
};
|
||||
document.getElementById("open-warning").onclick = function () {
|
||||
exappDialog.message({ severity: "warning", text: "Careful" }).then(function () { log("dismissed"); });
|
||||
};
|
||||
document.getElementById("open-error").onclick = function () {
|
||||
exappDialog.message({ severity: "error", heading: "<b>Nope</b>", text: "Failed" }).then(function () { log("dismissed"); });
|
||||
};
|
||||
document.getElementById("open-plain").onclick = function () {
|
||||
exappDialog.message({ severity: "info", text: "Only the message" }).then(function () { log("dismissed"); });
|
||||
};
|
||||
document.getElementById("open-confirm").onclick = function () {
|
||||
exappDialog.confirm({ text: "Proceed?" }).then(function (yes) { log(yes ? "yes" : "no"); });
|
||||
};
|
||||
document.getElementById("open-delete").onclick = function () {
|
||||
exappDialog.confirm({ heading: "Remove domain", text: "Gone", agree: "Delete", destructive: true }).then(function (yes) { log(yes ? "yes" : "no"); });
|
||||
};
|
||||
document.getElementById("open-html-button").onclick = function () {
|
||||
exappDialog.confirm({ text: "Go?", agree: "<i>Go</i>" }).then(function (yes) { log(yes ? "yes" : "no"); });
|
||||
};
|
||||
document.getElementById("open-prompt").onclick = function () {
|
||||
exappDialog.prompt({ heading: "New folder", text: "Folder name" }).then(function (value) {
|
||||
log(value === null ? "cancelled" : "value:" + JSON.stringify(value));
|
||||
});
|
||||
};
|
||||
document.getElementById("open-prefill").onclick = function () {
|
||||
exappDialog.prompt({ text: "Name", value: " hi " }).then(function (value) {
|
||||
log(value === null ? "cancelled" : "value:" + JSON.stringify(value));
|
||||
});
|
||||
};
|
||||
document.getElementById("open-blank").onclick = function () {
|
||||
exappDialog.prompt({ text: "Name", value: " " }).then(function (value) {
|
||||
log(value === null ? "cancelled" : "value:" + JSON.stringify(value));
|
||||
});
|
||||
};
|
||||
document.getElementById("open-inner").onclick = function () {
|
||||
exappDialog.prompt({ text: "Name", value: "a b" }).then(function (value) {
|
||||
log(value === null ? "cancelled" : "value:" + JSON.stringify(value));
|
||||
});
|
||||
};
|
||||
document.getElementById("open-three").onclick = function () {
|
||||
exappDialog.message({ severity: "info", text: "First" }).then(function () { log("1"); });
|
||||
exappDialog.message({ severity: "warning", text: "Second" }).then(function () { log("2"); });
|
||||
exappDialog.message({ severity: "error", text: "Third" }).then(function () { log("3"); });
|
||||
};
|
||||
</script>`
|
||||
}
|
||||
|
||||
func browserExecutable() string {
|
||||
if p := os.Getenv("EXAPP_BROWSER"); p != "" {
|
||||
return p
|
||||
}
|
||||
candidates := []string{
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if st, err := os.Stat(c); err == nil && !st.IsDir() {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func drive(t *testing.T, path string, actions ...chromedp.Action) {
|
||||
t.Helper()
|
||||
ctx, cancel := chromedp.NewContext(allocCtx)
|
||||
t.Cleanup(cancel)
|
||||
ctx, cancel = context.WithTimeout(ctx, 25*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
steps := append([]chromedp.Action{chromedp.Navigate(fixtureURL + path)}, actions...)
|
||||
if err := chromedp.Run(ctx, steps...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func clickButton(label string) chromedp.Action {
|
||||
expr := fmt.Sprintf(`(() => {
|
||||
const btn = [...document.querySelectorAll("#exapp-dialog button")].find((b) => b.textContent.trim() === %q && !b.hidden);
|
||||
if (!btn) return "missing";
|
||||
btn.click();
|
||||
return "clicked";
|
||||
})()`, label)
|
||||
return chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
var result string
|
||||
if err := chromedp.Evaluate(expr, &result).Do(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if result != "clicked" {
|
||||
return fmt.Errorf("button %q: %s", label, result)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func waitJS(expr, want string, contains bool) chromedp.Action {
|
||||
return chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
deadline = time.Now().Add(5 * time.Second)
|
||||
}
|
||||
var last string
|
||||
for {
|
||||
var got string
|
||||
err := chromedp.Evaluate(expr, &got).Do(ctx)
|
||||
if err == nil {
|
||||
last = got
|
||||
if contains && strings.Contains(got, want) {
|
||||
return nil
|
||||
}
|
||||
if !contains && strings.TrimSpace(got) == want {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("%s = %q, want %q", expr, last, want)
|
||||
}
|
||||
if err := chromedp.Sleep(40 * time.Millisecond).Do(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func waitDialogText(id, want string) chromedp.Action {
|
||||
return waitJS(fmt.Sprintf(`document.getElementById("%s").textContent`, id), want, false)
|
||||
}
|
||||
|
||||
func waitLog(want string) chromedp.Action {
|
||||
return waitJS(`document.getElementById("log").textContent`, want, true)
|
||||
}
|
||||
|
||||
func openShows(t *testing.T, path, button, severity, heading, message string) {
|
||||
t.Helper()
|
||||
drive(t, path,
|
||||
chromedp.WaitVisible("#"+button, chromedp.ByQuery),
|
||||
chromedp.Click("#"+button, chromedp.ByQuery),
|
||||
waitDialogText("exapp-dialog-severity", severity),
|
||||
waitDialogText("exapp-dialog-message", message),
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
var head string
|
||||
var hidden bool
|
||||
if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-heading").textContent`, &head).Do(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-heading").hidden`, &hidden).Do(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if heading == "" {
|
||||
if !hidden {
|
||||
return fmt.Errorf("heading visible %q", head)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if hidden || strings.TrimSpace(head) != heading {
|
||||
return fmt.Errorf("heading %q hidden %v, want %q", head, hidden, heading)
|
||||
}
|
||||
var bold int
|
||||
if err := chromedp.Evaluate(`document.querySelectorAll("#exapp-dialog b, #exapp-dialog i").length`, &bold).Do(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if bold != 0 {
|
||||
return fmt.Errorf("dialog interpreted HTML, %d elements", bold)
|
||||
}
|
||||
var label string
|
||||
if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-agree").textContent`, &label).Do(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(label) != "OK" {
|
||||
return fmt.Errorf("message button %q, want OK", label)
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func TestMessageOnBarePage(t *testing.T) {
|
||||
openShows(t, "/bare", "open-info", "info", "Notice", "Hello <b>x</b>")
|
||||
}
|
||||
|
||||
func TestMessageOnAppNavigation(t *testing.T) {
|
||||
openShows(t, "/shell", "open-info", "info", "Notice", "Hello <b>x</b>")
|
||||
}
|
||||
|
||||
func TestMessageSeveritiesAndDismiss(t *testing.T) {
|
||||
cases := []struct {
|
||||
button string
|
||||
severity string
|
||||
how string
|
||||
}{
|
||||
{"open-warning", "warning", "ok"},
|
||||
{"open-error", "error", "enter"},
|
||||
{"open-info", "info", "escape"},
|
||||
{"open-plain", "info", "backdrop"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.how, func(t *testing.T) {
|
||||
var dismiss chromedp.Action
|
||||
switch tc.how {
|
||||
case "ok":
|
||||
dismiss = clickButton("OK")
|
||||
case "enter":
|
||||
dismiss = chromedp.KeyEvent(kb.Enter)
|
||||
case "escape":
|
||||
dismiss = chromedp.KeyEvent(kb.Escape)
|
||||
case "backdrop":
|
||||
dismiss = chromedp.MouseClickXY(8, 8)
|
||||
}
|
||||
drive(t, "/bare",
|
||||
chromedp.Click("#"+tc.button, chromedp.ByQuery),
|
||||
waitDialogText("exapp-dialog-severity", tc.severity),
|
||||
dismiss,
|
||||
waitLog("dismissed"),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageWithoutHeading(t *testing.T) {
|
||||
openShows(t, "/bare", "open-plain", "info", "", "Only the message")
|
||||
}
|
||||
|
||||
func TestConfirmYesNoAndDestructive(t *testing.T) {
|
||||
t.Run("cancel", func(t *testing.T) {
|
||||
drive(t, "/bare",
|
||||
chromedp.Click("#open-confirm", chromedp.ByQuery),
|
||||
waitDialogText("exapp-dialog-message", "Proceed?"),
|
||||
clickButton("Cancel"),
|
||||
waitLog("no"),
|
||||
)
|
||||
})
|
||||
t.Run("escape", func(t *testing.T) {
|
||||
drive(t, "/bare",
|
||||
chromedp.Click("#open-confirm", chromedp.ByQuery),
|
||||
waitDialogText("exapp-dialog-message", "Proceed?"),
|
||||
chromedp.KeyEvent(kb.Escape),
|
||||
waitLog("no"),
|
||||
)
|
||||
})
|
||||
t.Run("backdrop", func(t *testing.T) {
|
||||
drive(t, "/bare",
|
||||
chromedp.Click("#open-confirm", chromedp.ByQuery),
|
||||
waitDialogText("exapp-dialog-message", "Proceed?"),
|
||||
chromedp.MouseClickXY(8, 8),
|
||||
waitLog("no"),
|
||||
)
|
||||
})
|
||||
t.Run("enter", func(t *testing.T) {
|
||||
drive(t, "/bare",
|
||||
chromedp.Click("#open-confirm", chromedp.ByQuery),
|
||||
waitDialogText("exapp-dialog-message", "Proceed?"),
|
||||
chromedp.KeyEvent(kb.Enter),
|
||||
waitLog("yes"),
|
||||
)
|
||||
})
|
||||
t.Run("delete", func(t *testing.T) {
|
||||
drive(t, "/bare",
|
||||
chromedp.Click("#open-delete", chromedp.ByQuery),
|
||||
waitDialogText("exapp-dialog-heading", "Remove domain"),
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
var plain, destructive string
|
||||
if err := chromedp.Evaluate(`getComputedStyle(document.getElementById("exapp-dialog-cancel")).backgroundColor`, &plain).Do(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := chromedp.Evaluate(`getComputedStyle(document.getElementById("exapp-dialog-agree")).backgroundColor`, &destructive).Do(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if plain == destructive {
|
||||
return fmt.Errorf("destructive button color %q matches Cancel", destructive)
|
||||
}
|
||||
var label string
|
||||
if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-agree").textContent`, &label).Do(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(label) != "Delete" {
|
||||
return fmt.Errorf("agree label %q", label)
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
clickButton("Delete"),
|
||||
waitLog("yes"),
|
||||
)
|
||||
})
|
||||
t.Run("button word is plain text", func(t *testing.T) {
|
||||
drive(t, "/bare",
|
||||
chromedp.Click("#open-html-button", chromedp.ByQuery),
|
||||
waitDialogText("exapp-dialog-message", "Go?"),
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
var n int
|
||||
var label string
|
||||
if err := chromedp.Evaluate(`document.querySelectorAll("#exapp-dialog i").length`, &n).Do(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if n != 0 {
|
||||
return fmt.Errorf("button word was interpreted as HTML")
|
||||
}
|
||||
if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-agree").textContent`, &label).Do(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(label) != "<i>Go</i>" {
|
||||
return fmt.Errorf("agree label %q", label)
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPromptValueAndCancel(t *testing.T) {
|
||||
t.Run("empty stays inactive", func(t *testing.T) {
|
||||
drive(t, "/bare",
|
||||
chromedp.Click("#open-prompt", chromedp.ByQuery),
|
||||
waitDialogText("exapp-dialog-message", "Folder name"),
|
||||
waitDialogText("exapp-dialog-heading", "New folder"),
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
var disabled bool
|
||||
if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-agree").disabled`, &disabled).Do(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if !disabled {
|
||||
return fmt.Errorf("agree button active on an empty Prompt")
|
||||
}
|
||||
return chromedp.KeyEvent(kb.Enter).Do(ctx)
|
||||
}),
|
||||
chromedp.Sleep(200*time.Millisecond),
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
var open bool
|
||||
if err := chromedp.Evaluate(`document.getElementById("exapp-dialog").open`, &open).Do(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if !open {
|
||||
return fmt.Errorf("Enter closed an empty Prompt")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
clickButton("Cancel"),
|
||||
waitLog("cancelled"),
|
||||
)
|
||||
})
|
||||
t.Run("spaces only stay inactive", func(t *testing.T) {
|
||||
drive(t, "/bare",
|
||||
chromedp.Click("#open-blank", chromedp.ByQuery),
|
||||
waitDialogText("exapp-dialog-message", "Name"),
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
var disabled bool
|
||||
var raw string
|
||||
if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-agree").disabled`, &disabled).Do(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-value").value`, &raw).Do(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if raw != " " {
|
||||
return fmt.Errorf("starting value %q", raw)
|
||||
}
|
||||
if !disabled {
|
||||
return fmt.Errorf("agree button active for spaces")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
})
|
||||
t.Run("prefill trims on agree", func(t *testing.T) {
|
||||
drive(t, "/bare",
|
||||
chromedp.Click("#open-prefill", chromedp.ByQuery),
|
||||
waitDialogText("exapp-dialog-message", "Name"),
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
var raw string
|
||||
if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-value").value`, &raw).Do(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if raw != " hi " {
|
||||
return fmt.Errorf("starting value %q", raw)
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
clickButton("OK"),
|
||||
waitLog(`value:"hi"`),
|
||||
)
|
||||
})
|
||||
t.Run("inner spaces stay", func(t *testing.T) {
|
||||
drive(t, "/bare",
|
||||
chromedp.Click("#open-inner", chromedp.ByQuery),
|
||||
waitDialogText("exapp-dialog-message", "Name"),
|
||||
clickButton("OK"),
|
||||
waitLog(`value:"a b"`),
|
||||
)
|
||||
})
|
||||
t.Run("escape cancels", func(t *testing.T) {
|
||||
drive(t, "/bare",
|
||||
chromedp.Click("#open-prompt", chromedp.ByQuery),
|
||||
waitDialogText("exapp-dialog-message", "Folder name"),
|
||||
chromedp.KeyEvent(kb.Escape),
|
||||
waitLog("cancelled"),
|
||||
)
|
||||
})
|
||||
t.Run("typed value", func(t *testing.T) {
|
||||
drive(t, "/bare",
|
||||
chromedp.Click("#open-prompt", chromedp.ByQuery),
|
||||
waitDialogText("exapp-dialog-message", "Folder name"),
|
||||
chromedp.SendKeys("#exapp-dialog-value", " mail ", chromedp.ByQuery),
|
||||
chromedp.KeyEvent(kb.Enter),
|
||||
waitLog(`value:"mail"`),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func TestOneDialogAtATime(t *testing.T) {
|
||||
drive(t, "/bare",
|
||||
chromedp.Click("#open-three", chromedp.ByQuery),
|
||||
waitDialogText("exapp-dialog-message", "First"),
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
var n int
|
||||
if err := chromedp.Evaluate(`document.querySelectorAll("dialog[open]").length`, &n).Do(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if n != 1 {
|
||||
return fmt.Errorf("open dialogs %d, want 1", n)
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
clickButton("OK"),
|
||||
waitDialogText("exapp-dialog-message", "Second"),
|
||||
clickButton("OK"),
|
||||
waitDialogText("exapp-dialog-message", "Third"),
|
||||
clickButton("OK"),
|
||||
waitLog("1\n2\n3"),
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package gonexapp provides AppAPI credentials, OCS JSON calls, ExApp user
|
||||
// preferences, an optional Required Groups Access Gate, and an optional
|
||||
// App navigation shell for Nextcloud ExApp Services.
|
||||
// preferences, an optional Required Groups Access Gate, an optional
|
||||
// App navigation shell, and a Dialog for Nextcloud ExApp Services.
|
||||
package gonexapp
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
module gitea.neitzel.de/konrad/go-nc-exapp
|
||||
|
||||
go 1.27.0
|
||||
|
||||
require (
|
||||
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f // indirect
|
||||
github.com/chromedp/chromedp v0.16.0 // indirect
|
||||
github.com/chromedp/sysutil v1.1.0 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.4.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f h1:0Z1zcSLEmnj2c2CmJYBqewtS6pxhB39bNWUSEUAWjgk=
|
||||
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f/go.mod h1:RwFsSODCtFExll+GhHM6R92SARHR3Z3oipaxLHj46C0=
|
||||
github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk=
|
||||
github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U=
|
||||
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
||||
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
||||
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg=
|
||||
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
@@ -105,6 +105,7 @@ func (n AppNavigation) serve(w http.ResponseWriter, r *http.Request) {
|
||||
Missing: msg,
|
||||
Title: title,
|
||||
Theme: !n.DisableTheme,
|
||||
Dialog: DialogHTML(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -135,6 +136,7 @@ type shellView struct {
|
||||
Missing string
|
||||
Title string
|
||||
Theme bool
|
||||
Dialog template.HTML
|
||||
}
|
||||
|
||||
type itemView struct {
|
||||
@@ -295,6 +297,7 @@ var shellTmpl = template.Must(template.New("shell").Parse(`<!DOCTYPE html>
|
||||
</style>
|
||||
</head>
|
||||
<body data-selected="{{.Selected}}" data-address="{{.Address}}">
|
||||
{{.Dialog}}
|
||||
<div id="app-nav">
|
||||
<div id="nav-header">{{.Header}}</div>
|
||||
<div id="app-nav-body">
|
||||
|
||||
Reference in New Issue
Block a user