Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b55de5b1d | ||
|
|
715ed23733 | ||
|
|
c7275b5c98 | ||
|
|
b9cea62d46 | ||
|
|
e3c706aefe | ||
|
|
6ea76c64c3 | ||
|
|
8804bd3030 | ||
|
|
c8b30a36ee | ||
|
|
3969d045cc | ||
|
|
09e242d7c3 | ||
|
|
0ed2d9b0f5 |
@@ -0,0 +1,75 @@
|
|||||||
|
---
|
||||||
|
name: office-tui-table
|
||||||
|
description: >-
|
||||||
|
Build and fix Bubble Tea list tables in cmd/office (DataTable, column layout,
|
||||||
|
lipgloss cell rendering, pane width). Use when changing office center-pane
|
||||||
|
tables, column widths, ANSI styling, or list toolbar alignment.
|
||||||
|
---
|
||||||
|
# office TUI table (`cmd/office/ui`)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
| Piece | File | Role |
|
||||||
|
|-------|------|------|
|
||||||
|
| `DataTable` | `table.go` | cursor, sort, viewport, header/row render |
|
||||||
|
| Column layout | `table_layout.go` | flex vs horizontal-scroll policies |
|
||||||
|
| Flex policy | `model/table_layout.go` | per-subject flex column key + min width |
|
||||||
|
| Cell render | `table_render.go` | bubbles/table pattern + ANSI-safe padding |
|
||||||
|
| Subject row style | `table_projects.go` | optional per-subject cell styles |
|
||||||
|
| Pane sizing | `scroll.go` | `paneContentWidth`, `paneLipglossWidth` |
|
||||||
|
| Scrollbar pad | `scrollbar.go` | `padANSIWidth` for styled lines |
|
||||||
|
|
||||||
|
## Layout policies
|
||||||
|
|
||||||
|
1. **Flex (all columns visible)** — register in `model.TableFlexLayoutFor`:
|
||||||
|
- fixed columns keep `TableColumn.Width`
|
||||||
|
- one `FlexColumnKey` (e.g. `title`, `email`) absorbs remainder
|
||||||
|
- `layoutFlexTable` shrinks fixed cols down to `minFixedColumnWidth` before flex min
|
||||||
|
|
||||||
|
2. **Scrolling (default)** — `layoutScrollingTable`:
|
||||||
|
- horizontal `colScroll`; subset of columns visible
|
||||||
|
- `distributeColumnWidths` fills pane width
|
||||||
|
|
||||||
|
Add a new flex subject: one entry in `TableFlexLayoutFor` only — do **not** copy `layoutProjectTable`/`layoutUserTable` per subject.
|
||||||
|
|
||||||
|
## Cell rendering (required pattern)
|
||||||
|
|
||||||
|
Never `style.Width(w).Padding(0,1).Render(text)` on styled cells — padding doubles display width and breaks `JoinHorizontal`.
|
||||||
|
|
||||||
|
Use `renderTableCell` from `table_render.go` (matches `charmbracelet/bubbles/table`):
|
||||||
|
|
||||||
|
```go
|
||||||
|
renderTableCell(outerStyle, plainText, columnWidth)
|
||||||
|
```
|
||||||
|
|
||||||
|
- truncate **plain** text with `runewidth.Truncate` inside `renderTableCell`
|
||||||
|
- inner box: `Width(innerW).Inline(true)` where `innerW = width - outer.GetHorizontalFrameSize()`
|
||||||
|
- outer style carries padding, background, foreground
|
||||||
|
|
||||||
|
## ANSI rules
|
||||||
|
|
||||||
|
- **Never** `runewidth.Truncate` / `padDisplayWidth` on strings that already contain lipgloss ANSI codes — corrupts escapes and shows garbage like lone `ID`.
|
||||||
|
- Use `padANSIWidth` (`charmbracelet/x/ansi`) for styled lines (headers, toolbar, scrollbar rows).
|
||||||
|
- Do not wrap an already full-width styled header in another `lipgloss.Width()` — pad once.
|
||||||
|
|
||||||
|
## Pane width
|
||||||
|
|
||||||
|
Lipgloss bordered panes render **2 cells wider** than `Style.Width`:
|
||||||
|
|
||||||
|
- `paneLipglossWidth(rendered) = rendered - 2` for `.Width()` on pane style
|
||||||
|
- `paneContentWidth(rendered) = rendered - 4` for table/viewport inner size (border + padding)
|
||||||
|
|
||||||
|
Pass `paneContentWidth(pw.List)` to `DataTable.SetSize` and list toolbar.
|
||||||
|
|
||||||
|
## Tests to add when touching tables
|
||||||
|
|
||||||
|
- `layoutFlexTable` width sum equals pane width
|
||||||
|
- `TestProjectTableHeaderFitsPaneWidth` / per-line width at 68, 40, 30
|
||||||
|
- `TestPadANSIWidthPreservesStyledLine`
|
||||||
|
- `TestThreePaneRenderedWidthMatchesTerminal` top border + table lines ≤ inner width
|
||||||
|
|
||||||
|
## Anti-patterns
|
||||||
|
|
||||||
|
- Per-subject duplicate layout files (`table_foo.go` with copy-pasted shrink loops)
|
||||||
|
- Skipping truncation on selected rows (causes multi-line rows in viewport)
|
||||||
|
- Using `bubbles/table` widget directly — office needs column cursor, multi-select, subject-specific row styles
|
||||||
@@ -18,10 +18,28 @@ jobs:
|
|||||||
name: GoReleaser
|
name: GoReleaser
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
# Always clone default branch first. workflow_dispatch often races with
|
||||||
|
# release-please creating the tag; fetching refs/tags/X before it exists
|
||||||
|
# fails checkout. Retry fetch, then check out the tag.
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
|
|
||||||
|
- name: Checkout release tag
|
||||||
|
env:
|
||||||
|
TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "Releasing tag: $TAG"
|
||||||
|
for i in 1 2 3 4 5 6; do
|
||||||
|
if git fetch --force origin "refs/tags/${TAG}:refs/tags/${TAG}"; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "tag $TAG not visible yet (attempt $i); sleeping…"
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
git rev-parse "refs/tags/${TAG}"
|
||||||
|
git checkout --force "refs/tags/${TAG}"
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
go: ["1.22", "stable"]
|
go: ["1.25", "stable"]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "0.6.0"
|
".": "0.8.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the
|
|||||||
- `calendar.go`, `projects.go`, `projects_files.go`, `tasks.go`, `tasks_files.go`, `users.go`, `contacts.go`, `opportunities.go`, `cases.go`, `crm_tasks.go`, `apps.go` — one file per subject (or per subject facet), each registers in `init()`.
|
- `calendar.go`, `projects.go`, `projects_files.go`, `tasks.go`, `tasks_files.go`, `users.go`, `contacts.go`, `opportunities.go`, `cases.go`, `crm_tasks.go`, `apps.go` — one file per subject (or per subject facet), each registers in `init()`.
|
||||||
- CLI-only deps (`spf13/cobra`, `joho/godotenv`) stay out of the library.
|
- CLI-only deps (`spf13/cobra`, `joho/godotenv`) stay out of the library.
|
||||||
- **TUI — `cmd/office/` as `package main`.** Bubble Tea three-pane browser (module tree, selectable list, markdown preview). Reuses `cmd/internal/bootstrap` for env/auth and the root `onlyoffice` library for all API calls. UI logic in `cmd/office/ui/`; preview/formatting in `cmd/office/preview/`; list loaders in `cmd/office/fetch/`.
|
- **TUI — `cmd/office/` as `package main`.** Bubble Tea three-pane browser (module tree, selectable list, markdown preview). Reuses `cmd/internal/bootstrap` for env/auth and the root `onlyoffice` library for all API calls. UI logic in `cmd/office/ui/`; preview/formatting in `cmd/office/preview/`; list loaders in `cmd/office/fetch/`.
|
||||||
|
- **List table (`DataTable`)** — `cmd/office/ui/table*.go`. Column layout policies live in `cmd/office/model/table_layout.go` (`TableFlexLayoutFor`); cell rendering uses the bubbles/table inline pattern in `table_render.go` (`renderTableCell`, `padANSIWidth`). See `.cursor/skills/office-tui-table/SKILL.md` before changing center-pane tables.
|
||||||
- **Shared bootstrap — `cmd/internal/bootstrap/`.** `LoadEnv()` + `NewClient(ctx)` extracted from `oo`; both binaries import it.
|
- **Shared bootstrap — `cmd/internal/bootstrap/`.** `LoadEnv()` + `NewClient(ctx)` extracted from `oo`; both binaries import it.
|
||||||
- **Applications sync — `cmd/oo/applications/`.** README→CRM bridge, CV-specific; kept under `cmd/oo/` so it's clear it's internal to the binary, not a library feature.
|
- **Applications sync — `cmd/oo/applications/`.** README→CRM bridge, CV-specific; kept under `cmd/oo/` so it's clear it's internal to the binary, not a library feature.
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,59 @@ All notable changes to this project are documented here. The format is based on
|
|||||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
||||||
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.8.2](https://github.com/eSlider/go-onlyoffice/compare/v0.8.1...v0.8.2) (2026-07-23)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **ci:** retry tag fetch in Release workflow ([#11](https://github.com/eSlider/go-onlyoffice/issues/11)) ([31b4aa0](https://github.com/eSlider/go-onlyoffice/commit/31b4aa0d90393e6d93513284e453b7da7e92c62d))
|
||||||
|
|
||||||
|
## [0.8.1](https://github.com/eSlider/go-onlyoffice/compare/v0.8.0...v0.8.1) (2026-07-23)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **oo:** assign owner and deadline on task create ([#9](https://github.com/eSlider/go-onlyoffice/issues/9)) ([f608207](https://github.com/eSlider/go-onlyoffice/commit/f60820734924284d6c40b46c79380a24573432f3))
|
||||||
|
|
||||||
|
## [0.8.0](https://github.com/eSlider/go-onlyoffice/compare/v0.7.0...v0.8.0) (2026-07-23)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **office:** users admin UI, table rendering, and user save fixes ([40b74eb](https://github.com/eSlider/go-onlyoffice/commit/40b74eb8abf4d63793a2cb9e59bd899734368b7e))
|
||||||
|
* **search:** add produktor SearXNG JSON client ([79b1a6b](https://github.com/eSlider/go-onlyoffice/commit/79b1a6b2c397c2a8f8aeef7e2d8e6d81e0a93db2))
|
||||||
|
* **search:** add produktor SearXNG JSON client ([bf144f2](https://github.com/eSlider/go-onlyoffice/commit/bf144f2bb37c44aa37ff33c6b03f59d7b5023dc7))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **oo:** skip junk dirs in applications Discover ([470ac93](https://github.com/eSlider/go-onlyoffice/commit/470ac938b1e388a17aabc5146cd62070dbb00587))
|
||||||
|
* **oo:** skip junk dirs in applications Discover ([830f0c7](https://github.com/eSlider/go-onlyoffice/commit/830f0c7208f94521a59dfe6f96d496509e6b29e9))
|
||||||
|
|
||||||
|
|
||||||
|
### Code Refactoring
|
||||||
|
|
||||||
|
* **office:** generalize DataTable layout and document TUI table skill ([38932b0](https://github.com/eSlider/go-onlyoffice/commit/38932b0e0eb9696c41a48ad74c943034ebed59e5))
|
||||||
|
|
||||||
|
## [0.7.0](https://github.com/eSlider/go-onlyoffice/compare/v0.6.0...v0.7.0) (2026-07-23)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **office:** mail preview, infinite scroll, pane resize, and scrollbars ([72844df](https://github.com/eSlider/go-onlyoffice/commit/72844dfe243e04c61ea9bc4db66c55abc08d3ae8))
|
||||||
|
* **office:** users admin UI, table rendering, and user save fixes ([40b74eb](https://github.com/eSlider/go-onlyoffice/commit/40b74eb8abf4d63793a2cb9e59bd899734368b7e))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **oo:** skip junk dirs in applications Discover ([470ac93](https://github.com/eSlider/go-onlyoffice/commit/470ac938b1e388a17aabc5146cd62070dbb00587))
|
||||||
|
* **oo:** skip junk dirs in applications Discover ([830f0c7](https://github.com/eSlider/go-onlyoffice/commit/830f0c7208f94521a59dfe6f96d496509e6b29e9))
|
||||||
|
|
||||||
|
|
||||||
|
### Code Refactoring
|
||||||
|
|
||||||
|
* **office:** generalize DataTable layout and document TUI table skill ([38932b0](https://github.com/eSlider/go-onlyoffice/commit/38932b0e0eb9696c41a48ad74c943034ebed59e5))
|
||||||
|
|
||||||
## [0.6.0](https://github.com/eSlider/go-onlyoffice/compare/v0.5.0...v0.6.0) (2026-06-24)
|
## [0.6.0](https://github.com/eSlider/go-onlyoffice/compare/v0.5.0...v0.6.0) (2026-06-24)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -751,7 +751,11 @@ oo applications sync --path ./applications/2026 --verbose
|
|||||||
oo applications sync --path ./applications/2026 --apply --verbose
|
oo applications sync --path ./applications/2026 --apply --verbose
|
||||||
```
|
```
|
||||||
|
|
||||||
Each `README.md` is parsed for company, role, email, phone, LinkedIn, etc.
|
Each dossier `README.md` is parsed for company, role, email, phone, LinkedIn, etc.
|
||||||
|
Discovery walks `--path` and **skips** junk trees (`node_modules`, `tools`, `.venv`,
|
||||||
|
`pdfs`, …). Only folders that look like CV application slugs (`source-NNN-…`, or
|
||||||
|
long hyphenated dossiers) are synced — not npm package READMEs.
|
||||||
|
|
||||||
The sync creates or finds contacts, opens a deal, adds members, and appends a
|
The sync creates or finds contacts, opens a deal, adds members, and appends a
|
||||||
history note. Re-running is safe: duplicate members and duplicate deal titles
|
history note. Re-running is safe: duplicate members and duplicate deal titles
|
||||||
are skipped when already present.
|
are skipped when already present.
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ func (l *Loader) Detail(ctx context.Context, item model.Item) (map[string]any, e
|
|||||||
return l.Client.GetTaskByID(ctx, item.ID)
|
return l.Client.GetTaskByID(ctx, item.ID)
|
||||||
case model.KindProject:
|
case model.KindProject:
|
||||||
return l.Client.GetProjectByID(ctx, item.ID)
|
return l.Client.GetProjectByID(ctx, item.ID)
|
||||||
|
case model.KindUser:
|
||||||
|
return l.Client.GetUser(ctx, item.ID)
|
||||||
default:
|
default:
|
||||||
if item.Raw != nil {
|
if item.Raw != nil {
|
||||||
return item.Raw, nil
|
return item.Raw, nil
|
||||||
@@ -362,33 +364,6 @@ func mailFolderForSubject(subject model.Subject) (int, bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Loader) listUsers(ctx context.Context) ([]model.Item, error) {
|
|
||||||
users, err := l.Client.GetUsers()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items := make([]model.Item, len(users))
|
|
||||||
for i, u := range users {
|
|
||||||
id := ""
|
|
||||||
if u.ID != nil {
|
|
||||||
id = fmt.Sprint(*u.ID)
|
|
||||||
}
|
|
||||||
title := ""
|
|
||||||
if u.DisplayName != nil {
|
|
||||||
title = *u.DisplayName
|
|
||||||
}
|
|
||||||
email := ""
|
|
||||||
if u.Email != nil {
|
|
||||||
email = *u.Email
|
|
||||||
}
|
|
||||||
items[i] = model.Item{
|
|
||||||
ID: id, Title: title, Subtitle: email, Kind: model.KindUser,
|
|
||||||
Raw: map[string]any{"id": id, "displayName": title, "email": email},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Loader) listProjectFiles(ctx context.Context, projectID string) ([]model.Item, error) {
|
func (l *Loader) listProjectFiles(ctx context.Context, projectID string) ([]model.Item, error) {
|
||||||
resp, err := l.Client.GetProjectFiles(ctx, projectID)
|
resp, err := l.Client.GetProjectFiles(ctx, projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ func (l *Loader) SaveItem(ctx context.Context, item model.Item, fields model.For
|
|||||||
}
|
}
|
||||||
switch item.Kind {
|
switch item.Kind {
|
||||||
case model.KindTask:
|
case model.KindTask:
|
||||||
return l.UpdateTask(ctx, item.ID, fields.Primary, fields.Secondary)
|
return l.UpdateTask(ctx, item.ID, fields)
|
||||||
case model.KindProject:
|
case model.KindProject:
|
||||||
return l.saveProject(ctx, item.ID, fields)
|
return l.saveProject(ctx, item.ID, fields)
|
||||||
|
case model.KindUser:
|
||||||
|
return l.SaveUser(ctx, item.ID, fields)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("save not supported for %s", item.Kind)
|
return fmt.Errorf("save not supported for %s", item.Kind)
|
||||||
}
|
}
|
||||||
@@ -60,5 +62,13 @@ func (l *Loader) DetailForm(ctx context.Context, item model.Item) (model.FormFie
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return model.FormFields{}, err
|
return model.FormFields{}, err
|
||||||
}
|
}
|
||||||
return model.FormFieldsFromRaw(item.Kind, raw), nil
|
fields := model.FormFieldsFromRaw(item.Kind, raw)
|
||||||
|
if item.Kind == model.KindTask {
|
||||||
|
choices, uerr := l.LoadUserChoices(ctx)
|
||||||
|
if uerr != nil {
|
||||||
|
return model.FormFields{}, uerr
|
||||||
|
}
|
||||||
|
fields.UserChoices = choices
|
||||||
|
}
|
||||||
|
return fields, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-15
@@ -9,8 +9,18 @@ import (
|
|||||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UpdateTask saves title and description for a project task.
|
// UpdateTask saves editable task fields via the typed JSON API.
|
||||||
func (l *Loader) UpdateTask(ctx context.Context, taskID, title, description string) error {
|
func (l *Loader) UpdateTask(ctx context.Context, taskID string, fields model.FormFields) error {
|
||||||
|
return l.updateTask(ctx, taskID, fields, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseTask saves fields and sets status to closed.
|
||||||
|
func (l *Loader) CloseTask(ctx context.Context, taskID string, fields model.FormFields) error {
|
||||||
|
fields.TaskStatus = model.TaskLifecycleClosed
|
||||||
|
return l.updateTask(ctx, taskID, fields, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Loader) updateTask(ctx context.Context, taskID string, fields model.FormFields, closing bool) error {
|
||||||
if l == nil || l.Client == nil {
|
if l == nil || l.Client == nil {
|
||||||
return fmt.Errorf("fetch: client is nil")
|
return fmt.Errorf("fetch: client is nil")
|
||||||
}
|
}
|
||||||
@@ -18,19 +28,26 @@ func (l *Loader) UpdateTask(ctx context.Context, taskID, title, description stri
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("task id %q: %w", taskID, err)
|
return fmt.Errorf("task id %q: %w", taskID, err)
|
||||||
}
|
}
|
||||||
_, err = l.Client.UpdateProjectTask(onlyoffice.ProjectTaskUpdateRequest{
|
status := onlyoffice.ProjectTaskStatus(fields.TaskStatus)
|
||||||
|
if closing {
|
||||||
|
status = onlyoffice.ProjectTaskStatusClosed
|
||||||
|
}
|
||||||
|
req := onlyoffice.ProjectTaskUpdateRequest{
|
||||||
ID: id,
|
ID: id,
|
||||||
Title: title,
|
Title: fields.Primary,
|
||||||
Description: description,
|
Description: fields.Secondary,
|
||||||
})
|
Status: status,
|
||||||
|
}
|
||||||
|
if fields.ResponsibleID != "" {
|
||||||
|
req.Responsible = []string{fields.ResponsibleID}
|
||||||
|
} else if !closing {
|
||||||
|
raw, derr := l.Detail(ctx, model.Item{ID: taskID, Kind: model.KindTask})
|
||||||
|
if derr == nil {
|
||||||
|
if rid := model.TaskResponsibleIDFromRaw(raw); rid != "" {
|
||||||
|
req.Responsible = []string{rid}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err = l.Client.UpdateProjectTask(req)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskFields loads title and description for a project task item.
|
|
||||||
func (l *Loader) TaskFields(ctx context.Context, item model.Item) (title, description string, err error) {
|
|
||||||
fields, err := l.DetailForm(ctx, item)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
return fields.Primary, fields.Secondary, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -20,27 +20,27 @@ func TestIntegrationUpdateTaskTitleDescription(t *testing.T) {
|
|||||||
t.Skip("no tasks")
|
t.Skip("no tasks")
|
||||||
}
|
}
|
||||||
item := items[0]
|
item := items[0]
|
||||||
title, desc, err := loader.TaskFields(ctx, item)
|
fields, err := loader.DetailForm(ctx, item)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
restoreTitle, restoreDesc := title, desc
|
restore := fields
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
_ = loader.UpdateTask(context.Background(), item.ID, restoreTitle, restoreDesc)
|
_ = loader.UpdateTask(context.Background(), item.ID, restore)
|
||||||
})
|
})
|
||||||
newTitle := title + " (office TUI test)"
|
fields.Primary = fields.Primary + " (office TUI test)"
|
||||||
newDesc := desc + "\n\n_edited by office integration test_"
|
fields.Secondary = fields.Secondary + "\n\n_edited by office integration test_"
|
||||||
if err := loader.UpdateTask(ctx, item.ID, newTitle, newDesc); err != nil {
|
if err := loader.UpdateTask(ctx, item.ID, fields); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
gotTitle, gotDesc, err := loader.TaskFields(ctx, item)
|
got, err := loader.DetailForm(ctx, item)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if gotTitle != newTitle {
|
if got.Primary != fields.Primary {
|
||||||
t.Fatalf("title: got %q want %q", gotTitle, newTitle)
|
t.Fatalf("title: got %q want %q", got.Primary, fields.Primary)
|
||||||
}
|
}
|
||||||
if gotDesc != newDesc {
|
if got.Secondary != fields.Secondary {
|
||||||
t.Fatalf("description mismatch")
|
t.Fatalf("description mismatch")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package fetch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (l *Loader) listUsers(ctx context.Context) ([]model.Item, error) {
|
||||||
|
users, err := l.Client.GetUsers()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]model.Item, 0, len(users))
|
||||||
|
for _, u := range users {
|
||||||
|
if u == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw := userToRaw(u)
|
||||||
|
id := strMap(raw, "id")
|
||||||
|
userName := strMap(raw, "userName")
|
||||||
|
if userName == "" {
|
||||||
|
userName = strMap(raw, "email")
|
||||||
|
}
|
||||||
|
items = append(items, model.Item{
|
||||||
|
ID: id,
|
||||||
|
Title: userName,
|
||||||
|
Kind: model.KindUser,
|
||||||
|
Raw: raw,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func userToRaw(u *onlyoffice.User) map[string]any {
|
||||||
|
b, err := json.Marshal(u)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
var raw map[string]any
|
||||||
|
if err := json.Unmarshal(b, &raw); err != nil {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func strMap(m map[string]any, key string) string {
|
||||||
|
if m == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if v, ok := m[key].(string); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
if m[key] == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprint(m[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
func userProfileUpdateBody(isAdmin bool, modules []string) map[string]any {
|
||||||
|
body := map[string]any{
|
||||||
|
"isAdmin": isAdmin,
|
||||||
|
}
|
||||||
|
if !isAdmin {
|
||||||
|
body["listAdminModules"] = modules
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveUser persists user account settings from the detail form.
|
||||||
|
func (l *Loader) SaveUser(ctx context.Context, userID string, fields model.FormFields) error {
|
||||||
|
if l == nil || l.Client == nil {
|
||||||
|
return fmt.Errorf("fetch: client is nil")
|
||||||
|
}
|
||||||
|
raw, err := l.Client.GetUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
wasEnabled := model.UserIsEnabled(raw)
|
||||||
|
|
||||||
|
if fields.UserEnabled && !wasEnabled {
|
||||||
|
if err := l.Client.ChangeUserStatus(ctx, userID, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdmin, modules := fields.UserACL.APIPayload()
|
||||||
|
body := userProfileUpdateBody(isAdmin, modules)
|
||||||
|
if _, err := l.updateUserProfile(ctx, userID, body, fields.UserEnabled); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !fields.UserEnabled && wasEnabled {
|
||||||
|
if err := l.Client.ChangeUserStatus(ctx, userID, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if fields.UserPassword != "" {
|
||||||
|
if err := l.Client.ChangeUserPassword(ctx, userID, fields.UserPassword); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Loader) updateUserProfile(ctx context.Context, userID string, body map[string]any, wantEnabled bool) (map[string]any, error) {
|
||||||
|
out, err := l.Client.UpdateUser(ctx, userID, body)
|
||||||
|
if err == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if wantEnabled && isSuspendedUserError(err) {
|
||||||
|
if actErr := l.Client.ChangeUserStatus(ctx, userID, true); actErr != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return l.Client.UpdateUser(ctx, userID, body)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSuspendedUserError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
msg := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(msg, "suspended") || strings.Contains(msg, "terminated")
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package fetch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUserProfileUpdateBodyOmitsStatus(t *testing.T) {
|
||||||
|
body := userProfileUpdateBody(true, nil)
|
||||||
|
if _, ok := body["status"]; ok {
|
||||||
|
t.Fatal("profile update must not include status")
|
||||||
|
}
|
||||||
|
if body["isAdmin"] != true {
|
||||||
|
t.Fatal("expected isAdmin true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserProfileUpdateBodyPartialAdminModules(t *testing.T) {
|
||||||
|
body := userProfileUpdateBody(false, []string{"documents", "crm"})
|
||||||
|
if body["isAdmin"] != false {
|
||||||
|
t.Fatal("expected partial admin")
|
||||||
|
}
|
||||||
|
mods, ok := body["listAdminModules"].([]string)
|
||||||
|
if !ok || len(mods) != 2 {
|
||||||
|
t.Fatalf("modules=%v", body["listAdminModules"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsSuspendedUserError(t *testing.T) {
|
||||||
|
err := fmt.Errorf(`PUT JSON /api/2.0/people/x: 500 {"error":{"message":"The user is suspended"}}`)
|
||||||
|
if !isSuspendedUserError(err) {
|
||||||
|
t.Fatal("expected suspended detection")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package fetch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoadUserChoices returns portal users for responsible pickers.
|
||||||
|
func (l *Loader) LoadUserChoices(ctx context.Context) ([]model.UserOption, error) {
|
||||||
|
if l == nil || l.Client == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
users, err := l.Client.GetUsers()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]model.UserOption, 0, len(users))
|
||||||
|
for _, u := range users {
|
||||||
|
if u == nil || u.ID == nil || *u.ID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := ""
|
||||||
|
if u.DisplayName != nil {
|
||||||
|
name = *u.DisplayName
|
||||||
|
}
|
||||||
|
if name == "" && u.Email != nil {
|
||||||
|
name = *u.Email
|
||||||
|
}
|
||||||
|
out = append(out, model.UserOption{ID: *u.ID, Name: name})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ type ActionID string
|
|||||||
const (
|
const (
|
||||||
ActionView ActionID = "view"
|
ActionView ActionID = "view"
|
||||||
ActionSave ActionID = "save"
|
ActionSave ActionID = "save"
|
||||||
|
ActionClose ActionID = "close"
|
||||||
ActionDelete ActionID = "delete"
|
ActionDelete ActionID = "delete"
|
||||||
ActionRefresh ActionID = "refresh"
|
ActionRefresh ActionID = "refresh"
|
||||||
ActionDownload ActionID = "download"
|
ActionDownload ActionID = "download"
|
||||||
@@ -29,7 +30,7 @@ func ActionsFor(kind Kind) []ItemAction {
|
|||||||
case KindTask:
|
case KindTask:
|
||||||
return []ItemAction{
|
return []ItemAction{
|
||||||
{ID: ActionSave, Label: "Save"},
|
{ID: ActionSave, Label: "Save"},
|
||||||
{ID: ActionDelete, Label: "Delete task", Danger: true},
|
{ID: ActionClose, Label: "Close"},
|
||||||
}
|
}
|
||||||
case KindCRMTask:
|
case KindCRMTask:
|
||||||
return []ItemAction{
|
return []ItemAction{
|
||||||
@@ -56,7 +57,11 @@ func ActionsFor(kind Kind) []ItemAction {
|
|||||||
{ID: ActionDownload, Label: "Download"},
|
{ID: ActionDownload, Label: "Download"},
|
||||||
{ID: ActionDelete, Label: "Delete file", Danger: true},
|
{ID: ActionDelete, Label: "Delete file", Danger: true},
|
||||||
}
|
}
|
||||||
case KindEvent, KindCalendar, KindUser:
|
case KindUser:
|
||||||
|
return []ItemAction{
|
||||||
|
{ID: ActionSave, Label: "Save"},
|
||||||
|
}
|
||||||
|
case KindEvent, KindCalendar:
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+45
-12
@@ -29,7 +29,6 @@ var subjectExtraKeys = map[Subject][]string{
|
|||||||
SubjectMailDrafts: {"to", "date"},
|
SubjectMailDrafts: {"to", "date"},
|
||||||
SubjectMailTrash: {"from", "date"},
|
SubjectMailTrash: {"from", "date"},
|
||||||
SubjectMailSpam: {"from", "date"},
|
SubjectMailSpam: {"from", "date"},
|
||||||
SubjectUsers: {"email", "displayName"},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildColumns derives table columns from the list subject and item payloads.
|
// BuildColumns derives table columns from the list subject and item payloads.
|
||||||
@@ -37,6 +36,9 @@ func BuildColumns(subject Subject, items []Item) []TableColumn {
|
|||||||
if subject == SubjectProjects {
|
if subject == SubjectProjects {
|
||||||
return buildProjectColumns(items)
|
return buildProjectColumns(items)
|
||||||
}
|
}
|
||||||
|
if subject == SubjectUsers {
|
||||||
|
return buildUserColumns(items)
|
||||||
|
}
|
||||||
if subject == SubjectTasks {
|
if subject == SubjectTasks {
|
||||||
return buildTaskColumns(items)
|
return buildTaskColumns(items)
|
||||||
}
|
}
|
||||||
@@ -102,18 +104,28 @@ func buildTaskColumns(items []Item) []TableColumn {
|
|||||||
return cols
|
return cols
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildUserColumns(items []Item) []TableColumn {
|
||||||
|
_ = items
|
||||||
|
return []TableColumn{
|
||||||
|
{Key: "_sel", Title: "✓", Width: 3},
|
||||||
|
{Key: "userName", Title: "User", Width: 14},
|
||||||
|
{Key: "registration", Title: "Registered", Width: 12},
|
||||||
|
{Key: "status", Title: "Status", Width: 10},
|
||||||
|
{Key: "email", Title: "Email", Width: 20},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func buildProjectColumns(items []Item) []TableColumn {
|
func buildProjectColumns(items []Item) []TableColumn {
|
||||||
cols := []TableColumn{
|
_ = items
|
||||||
|
return []TableColumn{
|
||||||
{Key: "_sel", Title: "✓", Width: 3},
|
{Key: "_sel", Title: "✓", Width: 3},
|
||||||
{Key: "id", Title: "ID", Width: 8},
|
{Key: "id", Title: "ID", Width: 8},
|
||||||
{Key: "status", Title: "Status", Width: 8},
|
{Key: "status", Title: "●", Width: 10},
|
||||||
{Key: "title", Title: "Title", Width: 28},
|
{Key: "title", Title: "Title", Width: 16},
|
||||||
{Key: "tasks", Title: "Tasks (open/closed)", Width: 18},
|
{Key: "tasks", Title: "Tasks", Width: 11},
|
||||||
{Key: "documents", Title: "Documents", Width: 11},
|
{Key: "documents", Title: "Docs", Width: 7},
|
||||||
{Key: "users", Title: "Users", Width: 8},
|
{Key: "users", Title: "Users", Width: 7},
|
||||||
}
|
}
|
||||||
sizeColumns(cols, items)
|
|
||||||
return cols
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CellText returns the display string for one table cell.
|
// CellText returns the display string for one table cell.
|
||||||
@@ -141,7 +153,9 @@ func CellText(it Item, key string) string {
|
|||||||
case "status":
|
case "status":
|
||||||
switch it.Kind {
|
switch it.Kind {
|
||||||
case KindProject:
|
case KindProject:
|
||||||
return ProjectStatusLabel(it.Raw)
|
return ProjectStatusCell(it.Raw)
|
||||||
|
case KindUser:
|
||||||
|
return UserStatusLabel(it.Raw)
|
||||||
case KindTask, KindCRMTask:
|
case KindTask, KindCRMTask:
|
||||||
if it.Raw == nil {
|
if it.Raw == nil {
|
||||||
return ""
|
return ""
|
||||||
@@ -157,10 +171,15 @@ func CellText(it Item, key string) string {
|
|||||||
if it.Raw == nil {
|
if it.Raw == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if rel := FormatRelativeDeadline(it.Raw["deadline"]); rel != "" {
|
// CRM tasks use camelCase deadLine; project tasks use deadline.
|
||||||
|
rawDL := it.Raw["deadline"]
|
||||||
|
if rawDL == nil || formatAny(rawDL) == "" {
|
||||||
|
rawDL = it.Raw["deadLine"]
|
||||||
|
}
|
||||||
|
if rel := FormatRelativeDeadline(rawDL); rel != "" {
|
||||||
return rel
|
return rel
|
||||||
}
|
}
|
||||||
return formatAny(it.Raw["deadline"])
|
return formatAny(rawDL)
|
||||||
case "responsible":
|
case "responsible":
|
||||||
if it.Kind == KindTask || it.Kind == KindCRMTask {
|
if it.Kind == KindTask || it.Kind == KindCRMTask {
|
||||||
return TaskResponsibleLabel(it.Raw)
|
return TaskResponsibleLabel(it.Raw)
|
||||||
@@ -171,6 +190,20 @@ func CellText(it Item, key string) string {
|
|||||||
return formatAny(it.Raw["responsible"])
|
return formatAny(it.Raw["responsible"])
|
||||||
case "subtitle":
|
case "subtitle":
|
||||||
return it.Subtitle
|
return it.Subtitle
|
||||||
|
case "userName":
|
||||||
|
if it.Raw != nil {
|
||||||
|
if u := strRaw(it.Raw, "userName"); u != "" {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return it.Title
|
||||||
|
case "registration":
|
||||||
|
return FormatUserRegistration(it.Raw)
|
||||||
|
case "email":
|
||||||
|
if it.Raw != nil {
|
||||||
|
return strRaw(it.Raw, "email")
|
||||||
|
}
|
||||||
|
return it.Subtitle
|
||||||
case "tasks":
|
case "tasks":
|
||||||
return formatProjectTasks(it.Raw)
|
return formatProjectTasks(it.Raw)
|
||||||
case "documents":
|
case "documents":
|
||||||
|
|||||||
@@ -29,13 +29,26 @@ func TestBuildProjectColumns(t *testing.T) {
|
|||||||
|
|
||||||
func TestCellTextProjectStatus(t *testing.T) {
|
func TestCellTextProjectStatus(t *testing.T) {
|
||||||
open := Item{Kind: KindProject, Raw: map[string]any{"status": 0}}
|
open := Item{Kind: KindProject, Raw: map[string]any{"status": 0}}
|
||||||
if got := CellText(open, "status"); got != "Open" {
|
if got := CellText(open, "status"); got != "🟢 Open" {
|
||||||
t.Fatalf("got %q", got)
|
t.Fatalf("got %q", got)
|
||||||
}
|
}
|
||||||
closed := Item{Kind: KindProject, Raw: map[string]any{"status": 2}}
|
closed := Item{Kind: KindProject, Raw: map[string]any{"status": 2}}
|
||||||
if got := CellText(closed, "status"); got != "Closed" {
|
if got := CellText(closed, "status"); got != "🔴 Closed" {
|
||||||
t.Fatalf("got %q", got)
|
t.Fatalf("got %q", got)
|
||||||
}
|
}
|
||||||
|
paused := Item{Kind: KindProject, Raw: map[string]any{"status": 1}}
|
||||||
|
if got := CellText(paused, "status"); got != "🟡 Paused" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildProjectColumnsStatusHeaderIsIcon(t *testing.T) {
|
||||||
|
cols := BuildColumns(SubjectProjects, nil)
|
||||||
|
for _, c := range cols {
|
||||||
|
if c.Key == "status" && c.Title != "●" {
|
||||||
|
t.Fatalf("status header=%q want ●", c.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFormatProjectTasksOpenClosed(t *testing.T) {
|
func TestFormatProjectTasksOpenClosed(t *testing.T) {
|
||||||
|
|||||||
@@ -11,7 +11,18 @@ type FormFields struct {
|
|||||||
ReadOnly bool
|
ReadOnly bool
|
||||||
HasStatus bool
|
HasStatus bool
|
||||||
Status ProjectLifecycle
|
Status ProjectLifecycle
|
||||||
|
HasTaskStatus bool
|
||||||
|
TaskStatus TaskLifecycle
|
||||||
|
HasResponsible bool
|
||||||
ResponsibleID string
|
ResponsibleID string
|
||||||
|
UserChoices []UserOption
|
||||||
|
ProjectTitle string
|
||||||
|
TimingSummary string
|
||||||
|
HasUserEdit bool
|
||||||
|
UserEnabled bool
|
||||||
|
UserACL UserACLState
|
||||||
|
GroupsText string
|
||||||
|
UserPassword string
|
||||||
}
|
}
|
||||||
|
|
||||||
// KindHeading returns a short label for the detail pane header.
|
// KindHeading returns a short label for the detail pane header.
|
||||||
@@ -62,10 +73,13 @@ func FormFieldsFromRaw(kind Kind, raw map[string]any) FormFields {
|
|||||||
ReadOnly: true,
|
ReadOnly: true,
|
||||||
}
|
}
|
||||||
case KindUser:
|
case KindUser:
|
||||||
|
acl := UserACLFromRaw(raw)
|
||||||
return FormFields{
|
return FormFields{
|
||||||
PrimaryLabel: "Name", SecondaryLabel: "Email",
|
HasUserEdit: true,
|
||||||
Primary: strRaw(raw, "displayName"), Secondary: strRaw(raw, "email"),
|
ReadOnly: false,
|
||||||
ReadOnly: true,
|
UserEnabled: UserIsEnabled(raw),
|
||||||
|
UserACL: acl,
|
||||||
|
GroupsText: UserGroupsText(raw),
|
||||||
}
|
}
|
||||||
case KindContact:
|
case KindContact:
|
||||||
name := strRaw(raw, "displayName")
|
name := strRaw(raw, "displayName")
|
||||||
@@ -98,6 +112,20 @@ func FormFieldsFromRaw(kind Kind, raw map[string]any) FormFields {
|
|||||||
Status: ProjectStatusFromAny(raw["status"]),
|
Status: ProjectStatusFromAny(raw["status"]),
|
||||||
ResponsibleID: ResponsibleIDFromRaw(raw),
|
ResponsibleID: ResponsibleIDFromRaw(raw),
|
||||||
}
|
}
|
||||||
|
case KindTask:
|
||||||
|
return FormFields{
|
||||||
|
PrimaryLabel: "Title",
|
||||||
|
SecondaryLabel: "Description",
|
||||||
|
Primary: strRaw(raw, "title"),
|
||||||
|
Secondary: strRaw(raw, "description"),
|
||||||
|
ReadOnly: false,
|
||||||
|
HasTaskStatus: true,
|
||||||
|
TaskStatus: TaskStatusFromAny(raw["status"]),
|
||||||
|
HasResponsible: true,
|
||||||
|
ResponsibleID: TaskResponsibleIDFromRaw(raw),
|
||||||
|
ProjectTitle: TaskProjectTitle(raw),
|
||||||
|
TimingSummary: TaskTimingSummary(raw),
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
title := strRaw(raw, "title")
|
title := strRaw(raw, "title")
|
||||||
if title == "" {
|
if title == "" {
|
||||||
@@ -109,11 +137,71 @@ func FormFieldsFromRaw(kind Kind, raw map[string]any) FormFields {
|
|||||||
return FormFields{
|
return FormFields{
|
||||||
PrimaryLabel: "Title", SecondaryLabel: "Description",
|
PrimaryLabel: "Title", SecondaryLabel: "Description",
|
||||||
Primary: title, Secondary: strRaw(raw, "description"),
|
Primary: title, Secondary: strRaw(raw, "description"),
|
||||||
ReadOnly: kind != KindTask && kind != KindProject,
|
ReadOnly: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TaskResponsibleIDFromRaw returns the first assignee user id from task detail.
|
||||||
|
func TaskResponsibleIDFromRaw(raw map[string]any) string {
|
||||||
|
if raw == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if ids, ok := raw["responsibleIds"].([]any); ok && len(ids) > 0 {
|
||||||
|
return strRaw(map[string]any{"id": ids[0]}, "id")
|
||||||
|
}
|
||||||
|
if list, ok := raw["responsibles"].([]any); ok && len(list) > 0 {
|
||||||
|
if m, ok := list[0].(map[string]any); ok {
|
||||||
|
return strRaw(m, "id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskProjectTitle returns the owning project title when present.
|
||||||
|
func TaskProjectTitle(raw map[string]any) string {
|
||||||
|
if raw == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if po, ok := raw["projectOwner"].(map[string]any); ok {
|
||||||
|
if t := strRaw(po, "title"); t != "" {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strRaw(raw, "projectTitle")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskTimingSummary formats start and deadline for the detail form.
|
||||||
|
func TaskTimingSummary(raw map[string]any) string {
|
||||||
|
if raw == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
start := formatTaskDateLabel(raw["startDate"])
|
||||||
|
deadline := formatTaskDateLabel(raw["deadline"])
|
||||||
|
switch {
|
||||||
|
case start != "" && deadline != "":
|
||||||
|
return start + " → " + deadline
|
||||||
|
case deadline != "":
|
||||||
|
return "Due " + deadline
|
||||||
|
case start != "":
|
||||||
|
return "From " + start
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTaskDateLabel(v any) string {
|
||||||
|
t, ok := parseDeadlineTime(v)
|
||||||
|
if !ok {
|
||||||
|
s := strRaw(map[string]any{"v": v}, "v")
|
||||||
|
if s == "" || s == "<nil>" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return t.Format("2006-01-02 15:04")
|
||||||
|
}
|
||||||
|
|
||||||
func strRaw(m map[string]any, key string) string {
|
func strRaw(m map[string]any, key string) string {
|
||||||
if m == nil {
|
if m == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestTaskStatusLifecycleCycle(t *testing.T) {
|
||||||
|
cur := TaskLifecycleOpen
|
||||||
|
cur = cur.Next()
|
||||||
|
if cur != TaskLifecycleClosed {
|
||||||
|
t.Fatalf("got %v", cur)
|
||||||
|
}
|
||||||
|
cur = cur.Prev()
|
||||||
|
if cur != TaskLifecycleOpen {
|
||||||
|
t.Fatalf("got %v", cur)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormFieldsFromRawTask(t *testing.T) {
|
||||||
|
raw := map[string]any{
|
||||||
|
"title": "Fix",
|
||||||
|
"description": "Details",
|
||||||
|
"status": 1,
|
||||||
|
"projectOwner": map[string]any{"title": "Alpha"},
|
||||||
|
"startDate": "2026-06-01T10:00:00Z",
|
||||||
|
"deadline": "2026-06-10T18:00:00Z",
|
||||||
|
"responsibles": []any{
|
||||||
|
map[string]any{"id": "u1", "displayName": "Alice"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
f := FormFieldsFromRaw(KindTask, raw)
|
||||||
|
if !f.HasTaskStatus || f.TaskStatus != TaskLifecycleOpen {
|
||||||
|
t.Fatalf("status: %+v", f)
|
||||||
|
}
|
||||||
|
if !f.HasResponsible || f.ResponsibleID != "u1" {
|
||||||
|
t.Fatalf("responsible: %+v", f)
|
||||||
|
}
|
||||||
|
if f.ProjectTitle != "Alpha" {
|
||||||
|
t.Fatalf("project: %q", f.ProjectTitle)
|
||||||
|
}
|
||||||
|
if f.TimingSummary == "" {
|
||||||
|
t.Fatal("expected timing summary")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,8 +66,7 @@ func DefaultNavTree() *NavTree {
|
|||||||
add("mail.trash", "Trash", "mail", false, &ListSpec{Subject: SubjectMailTrash})
|
add("mail.trash", "Trash", "mail", false, &ListSpec{Subject: SubjectMailTrash})
|
||||||
add("mail.spam", "Spam", "mail", false, &ListSpec{Subject: SubjectMailSpam})
|
add("mail.spam", "Spam", "mail", false, &ListSpec{Subject: SubjectMailSpam})
|
||||||
|
|
||||||
add("users", "Users", "", true, nil)
|
add("users", "Users", "", false, &ListSpec{Subject: SubjectUsers})
|
||||||
add("users.dir", "Directory", "users", false, &ListSpec{Subject: SubjectUsers})
|
|
||||||
|
|
||||||
t.rebuildVisible()
|
t.rebuildVisible()
|
||||||
return t
|
return t
|
||||||
|
|||||||
@@ -46,6 +46,27 @@ func ProjectStatusLabel(raw map[string]any) string {
|
|||||||
return ProjectStatusFromAny(raw["status"]).Label()
|
return ProjectStatusFromAny(raw["status"]).Label()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProjectStatusIcon returns the list-cell emoji for a project lifecycle state.
|
||||||
|
func (s ProjectLifecycle) Icon() string {
|
||||||
|
switch s {
|
||||||
|
case ProjectLifecycleClosed:
|
||||||
|
return "🔴"
|
||||||
|
case ProjectLifecyclePaused:
|
||||||
|
return "🟡"
|
||||||
|
default:
|
||||||
|
return "🟢"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectStatusCell returns emoji and label on one line for project table cells.
|
||||||
|
func ProjectStatusCell(raw map[string]any) string {
|
||||||
|
if raw == nil {
|
||||||
|
return ProjectLifecycleOpen.Icon() + " Open"
|
||||||
|
}
|
||||||
|
s := ProjectStatusFromAny(raw["status"])
|
||||||
|
return s.Icon() + " " + s.Label()
|
||||||
|
}
|
||||||
|
|
||||||
// ProjectIsOpen is true when the project is not closed.
|
// ProjectIsOpen is true when the project is not closed.
|
||||||
func ProjectIsOpen(raw map[string]any) bool {
|
func ProjectIsOpen(raw map[string]any) bool {
|
||||||
if raw == nil {
|
if raw == nil {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
// TableFlexLayout describes a list table where every column stays visible and
|
||||||
|
// one flex column absorbs leftover pane width.
|
||||||
|
type TableFlexLayout struct {
|
||||||
|
FlexColumnKey string
|
||||||
|
MinFlexWidth int
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableFlexLayoutFor returns a flex layout policy for subjects that show all columns.
|
||||||
|
func TableFlexLayoutFor(subject Subject) (TableFlexLayout, bool) {
|
||||||
|
switch subject {
|
||||||
|
case SubjectProjects:
|
||||||
|
return TableFlexLayout{FlexColumnKey: "title", MinFlexWidth: 12}, true
|
||||||
|
case SubjectUsers:
|
||||||
|
return TableFlexLayout{FlexColumnKey: "email", MinFlexWidth: 16}, true
|
||||||
|
default:
|
||||||
|
return TableFlexLayout{}, false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestTableFlexLayoutFor(t *testing.T) {
|
||||||
|
proj, ok := TableFlexLayoutFor(SubjectProjects)
|
||||||
|
if !ok || proj.FlexColumnKey != "title" || proj.MinFlexWidth != 12 {
|
||||||
|
t.Fatalf("projects layout=%+v ok=%v", proj, ok)
|
||||||
|
}
|
||||||
|
users, ok := TableFlexLayoutFor(SubjectUsers)
|
||||||
|
if !ok || users.FlexColumnKey != "email" {
|
||||||
|
t.Fatalf("users layout=%+v", users)
|
||||||
|
}
|
||||||
|
_, ok = TableFlexLayoutFor(SubjectTasks)
|
||||||
|
if ok {
|
||||||
|
t.Fatal("tasks should use scrolling layout")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -169,6 +169,9 @@ func personName(v any) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if name := strRaw(m, "displayName"); name != "" {
|
if name := strRaw(m, "displayName"); name != "" {
|
||||||
|
if strings.EqualFold(name, "Profile has been removed") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
first := strRaw(m, "firstName")
|
first := strRaw(m, "firstName")
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
// TaskLifecycle is the OnlyOffice project task status code.
|
||||||
|
type TaskLifecycle int
|
||||||
|
|
||||||
|
const (
|
||||||
|
TaskLifecycleNotAccept TaskLifecycle = 0
|
||||||
|
TaskLifecycleOpen TaskLifecycle = 1
|
||||||
|
TaskLifecycleClosed TaskLifecycle = 2
|
||||||
|
TaskLifecycleDisabled TaskLifecycle = 3
|
||||||
|
TaskLifecycleUnclassified TaskLifecycle = 4
|
||||||
|
TaskLifecycleNotInMilestone TaskLifecycle = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
var taskLifecycleCycle = []TaskLifecycle{
|
||||||
|
TaskLifecycleOpen,
|
||||||
|
TaskLifecycleClosed,
|
||||||
|
TaskLifecycleNotAccept,
|
||||||
|
TaskLifecycleDisabled,
|
||||||
|
TaskLifecycleUnclassified,
|
||||||
|
TaskLifecycleNotInMilestone,
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskStatusFromAny maps API status to a lifecycle value.
|
||||||
|
func TaskStatusFromAny(v any) TaskLifecycle {
|
||||||
|
if v == nil {
|
||||||
|
return TaskLifecycleOpen
|
||||||
|
}
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
switch taskStatusFromString(s) {
|
||||||
|
case "Open":
|
||||||
|
return TaskLifecycleOpen
|
||||||
|
case "Closed":
|
||||||
|
return TaskLifecycleClosed
|
||||||
|
case "Not accepted":
|
||||||
|
return TaskLifecycleNotAccept
|
||||||
|
case "Disabled":
|
||||||
|
return TaskLifecycleDisabled
|
||||||
|
case "Unclassified":
|
||||||
|
return TaskLifecycleUnclassified
|
||||||
|
case "Not in milestone":
|
||||||
|
return TaskLifecycleNotInMilestone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch n := intRawVal(map[string]any{"status": v}, "status"); n {
|
||||||
|
case 0:
|
||||||
|
return TaskLifecycleNotAccept
|
||||||
|
case 2:
|
||||||
|
return TaskLifecycleClosed
|
||||||
|
case 3:
|
||||||
|
return TaskLifecycleDisabled
|
||||||
|
case 4:
|
||||||
|
return TaskLifecycleUnclassified
|
||||||
|
case 5:
|
||||||
|
return TaskLifecycleNotInMilestone
|
||||||
|
default:
|
||||||
|
return TaskLifecycleOpen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label returns a human-readable status name.
|
||||||
|
func (s TaskLifecycle) Label() string {
|
||||||
|
return TaskStatusLabel(int(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next cycles task status for keyboard toggling.
|
||||||
|
func (s TaskLifecycle) Next() TaskLifecycle {
|
||||||
|
return cycleTaskLifecycle(s, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prev cycles task status backward.
|
||||||
|
func (s TaskLifecycle) Prev() TaskLifecycle {
|
||||||
|
return cycleTaskLifecycle(s, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cycleTaskLifecycle(cur TaskLifecycle, delta int) TaskLifecycle {
|
||||||
|
idx := 0
|
||||||
|
for i, v := range taskLifecycleCycle {
|
||||||
|
if v == cur {
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n := len(taskLifecycleCycle)
|
||||||
|
idx = (idx + delta%n + n) % n
|
||||||
|
return taskLifecycleCycle[idx]
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserACLDef is one administrator permission row in the user editor.
|
||||||
|
type UserACLDef struct {
|
||||||
|
Key string
|
||||||
|
Label string
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserACLDefs lists portal ACL rows (Full access + per-module grants).
|
||||||
|
var UserACLDefs = []UserACLDef{
|
||||||
|
{Key: "full", Label: "Full access"},
|
||||||
|
{Key: "documents", Label: "Documents"},
|
||||||
|
{Key: "projects", Label: "Projects"},
|
||||||
|
{Key: "crm", Label: "CRM"},
|
||||||
|
{Key: "community", Label: "Community"},
|
||||||
|
{Key: "people", Label: "People"},
|
||||||
|
{Key: "sample", Label: "Sample"},
|
||||||
|
{Key: "mail", Label: "Mail"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserACLState is the editable ACL snapshot for one user.
|
||||||
|
type UserACLState struct {
|
||||||
|
FullAccess bool
|
||||||
|
Modules map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserACLFromRaw parses API user detail into ACL editor state.
|
||||||
|
func UserACLFromRaw(raw map[string]any) UserACLState {
|
||||||
|
modules := make(map[string]bool, len(UserACLDefs))
|
||||||
|
for _, def := range UserACLDefs[1:] {
|
||||||
|
modules[def.Key] = false
|
||||||
|
}
|
||||||
|
if list, ok := raw["listAdminModules"].([]any); ok {
|
||||||
|
for _, v := range list {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(fmt.Sprint(v)))
|
||||||
|
if key != "" {
|
||||||
|
modules[key] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
full := boolRaw(raw, "isAdmin")
|
||||||
|
if full {
|
||||||
|
for k := range modules {
|
||||||
|
modules[k] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return UserACLState{FullAccess: full, Modules: modules}
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIPayload converts editor state to OnlyOffice update fields.
|
||||||
|
func (s UserACLState) APIPayload() (isAdmin bool, modules []string) {
|
||||||
|
if s.FullAccess {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
for _, def := range UserACLDefs[1:] {
|
||||||
|
if s.Modules[def.Key] {
|
||||||
|
modules = append(modules, def.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, modules
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACLModuleOn reports whether a module row should show as granted.
|
||||||
|
func (s UserACLState) ACLModuleOn(key string) bool {
|
||||||
|
if key == "full" {
|
||||||
|
return s.FullAccess
|
||||||
|
}
|
||||||
|
if s.FullAccess {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return s.Modules[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolRaw(m map[string]any, key string) bool {
|
||||||
|
if m == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch v := m[key].(type) {
|
||||||
|
case bool:
|
||||||
|
return v
|
||||||
|
case float64:
|
||||||
|
return v != 0
|
||||||
|
case int:
|
||||||
|
return v != 0
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestUserACLFromRawFullAccess(t *testing.T) {
|
||||||
|
raw := map[string]any{
|
||||||
|
"isAdmin": true,
|
||||||
|
"listAdminModules": []any{"crm"},
|
||||||
|
}
|
||||||
|
acl := UserACLFromRaw(raw)
|
||||||
|
if !acl.FullAccess {
|
||||||
|
t.Fatal("expected full access")
|
||||||
|
}
|
||||||
|
if !acl.ACLModuleOn("documents") {
|
||||||
|
t.Fatal("full access should show all modules on")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserACLAPIPayloadPartial(t *testing.T) {
|
||||||
|
acl := UserACLState{
|
||||||
|
FullAccess: false,
|
||||||
|
Modules: map[string]bool{
|
||||||
|
"documents": true,
|
||||||
|
"projects": true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
isAdmin, mods := acl.APIPayload()
|
||||||
|
if isAdmin {
|
||||||
|
t.Fatal("expected partial admin")
|
||||||
|
}
|
||||||
|
if len(mods) != 2 {
|
||||||
|
t.Fatalf("mods=%v", mods)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserIsEnabled(t *testing.T) {
|
||||||
|
if !UserIsEnabled(map[string]any{"status": EmployeeStatusActive}) {
|
||||||
|
t.Fatal("active user should be enabled")
|
||||||
|
}
|
||||||
|
if UserIsEnabled(map[string]any{"status": EmployeeStatusTerminated}) {
|
||||||
|
t.Fatal("terminated user should be disabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
// UserOption is one portal user for responsible pickers.
|
||||||
|
type UserOption struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const (
|
||||||
|
EmployeeStatusActive = 1
|
||||||
|
EmployeeStatusTerminated = 2
|
||||||
|
EmployeeStatusPending = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserIsEnabled reports whether the portal user account is active.
|
||||||
|
func UserIsEnabled(raw map[string]any) bool {
|
||||||
|
if raw == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if t, ok := raw["terminated"].(bool); ok && t {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch intRawVal(raw, "status") {
|
||||||
|
case EmployeeStatusTerminated:
|
||||||
|
return false
|
||||||
|
case EmployeeStatusPending:
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserStatusLabel returns a short list-cell label for account state.
|
||||||
|
func UserStatusLabel(raw map[string]any) string {
|
||||||
|
if !UserIsEnabled(raw) {
|
||||||
|
return "Disabled"
|
||||||
|
}
|
||||||
|
if intRawVal(raw, "status") == EmployeeStatusPending {
|
||||||
|
return "Pending"
|
||||||
|
}
|
||||||
|
return "Active"
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserGroupsText formats group membership for the detail form.
|
||||||
|
func UserGroupsText(raw map[string]any) string {
|
||||||
|
if raw == nil {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
list, ok := raw["groups"].([]any)
|
||||||
|
if !ok || len(list) == 0 {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(list))
|
||||||
|
for _, g := range list {
|
||||||
|
m, ok := g.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strRaw(m, "name")
|
||||||
|
if name == "" {
|
||||||
|
name = strRaw(m, "title")
|
||||||
|
}
|
||||||
|
if name != "" {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(names) == 0 {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
return stringsJoin(names, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringsJoin(parts []string, sep string) string {
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
out := parts[0]
|
||||||
|
for i := 1; i < len(parts); i++ {
|
||||||
|
out += sep + parts[i]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatUserRegistration formats registration or work-from date for list cells.
|
||||||
|
func FormatUserRegistration(raw map[string]any) string {
|
||||||
|
if raw == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, key := range []string{"registrationDate", "workFrom"} {
|
||||||
|
if s := formatUserDate(raw[key]); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatUserDate(v any) string {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case string:
|
||||||
|
if t == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if parsed, err := time.Parse("2006-01-02T15:04:05.0000000-07:00", t); err == nil {
|
||||||
|
return parsed.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
if parsed, err := time.Parse(time.RFC3339, t); err == nil {
|
||||||
|
return parsed.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
if len(t) >= 10 {
|
||||||
|
return t[:10]
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
default:
|
||||||
|
s := strRaw(map[string]any{"v": v}, "v")
|
||||||
|
if s == "" || s == "<nil>" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return formatUserDate(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package model_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNavUsersLeafReturnsListSpec(t *testing.T) {
|
||||||
|
tree := model.DefaultNavTree()
|
||||||
|
var found int = -1
|
||||||
|
for i := 0; i < tree.VisibleCount(); i++ {
|
||||||
|
n, ok := tree.NodeAtVisible(i)
|
||||||
|
if ok && n.Label == "Users" {
|
||||||
|
found = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found < 0 {
|
||||||
|
t.Fatal("Users node not found")
|
||||||
|
}
|
||||||
|
tree.SetCursor(found)
|
||||||
|
spec, ok := tree.CurrentListSpec()
|
||||||
|
if !ok || spec.Subject != model.SubjectUsers {
|
||||||
|
t.Fatalf("users spec=%v ok=%v", spec, ok)
|
||||||
|
}
|
||||||
|
if tree.IsExpandable(found) {
|
||||||
|
t.Fatal("Users should be a direct leaf, not a branch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildUserColumnsOmitsIDAndDisplayName(t *testing.T) {
|
||||||
|
items := []model.Item{{
|
||||||
|
ID: "1", Title: "jdoe", Kind: model.KindUser,
|
||||||
|
Raw: map[string]any{
|
||||||
|
"id": "1", "userName": "jdoe", "displayName": "John Doe",
|
||||||
|
"email": "j@example.com", "registrationDate": "2024-01-02T00:00:00",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
cols := model.BuildColumns(model.SubjectUsers, items)
|
||||||
|
want := []string{"_sel", "userName", "registration", "status", "email"}
|
||||||
|
if len(cols) != len(want) {
|
||||||
|
t.Fatalf("got %v", columnKeys(cols))
|
||||||
|
}
|
||||||
|
for i, key := range want {
|
||||||
|
if cols[i].Key != key {
|
||||||
|
t.Fatalf("col[%d]=%q want %q", i, cols[i].Key, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func columnKeys(cols []model.TableColumn) []string {
|
||||||
|
out := make([]string, len(cols))
|
||||||
|
for i, c := range cols {
|
||||||
|
out[i] = c.Key
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+229
-83
@@ -66,12 +66,11 @@ type Model struct {
|
|||||||
loading bool
|
loading bool
|
||||||
menuVP viewport.Model
|
menuVP viewport.Model
|
||||||
listTable DataTable
|
listTable DataTable
|
||||||
|
listToolbar ListToolbar
|
||||||
detail DetailPane
|
detail DetailPane
|
||||||
showMenu bool
|
showMenu bool
|
||||||
showList bool
|
showList bool
|
||||||
showDetail bool
|
showDetail bool
|
||||||
filterActive bool
|
|
||||||
filterSearch FilterSearch
|
|
||||||
customPaneLayout bool
|
customPaneLayout bool
|
||||||
paneSizes PaneWidths
|
paneSizes PaneWidths
|
||||||
resize paneResizeState
|
resize paneResizeState
|
||||||
@@ -86,14 +85,14 @@ func NewModel(client *onlyoffice.Client) Model {
|
|||||||
nav: model.DefaultNavTree(),
|
nav: model.DefaultNavTree(),
|
||||||
selection: model.NewSelection(),
|
selection: model.NewSelection(),
|
||||||
focus: model.FocusMenu,
|
focus: model.FocusMenu,
|
||||||
status: "Tab: pane · f: filter · row select loads detail · Ctrl+S save · q quit",
|
status: "Tab: pane · / filter · Space select row · 💾🗑 when selected · v: detail · q quit",
|
||||||
height: h,
|
height: h,
|
||||||
width: w,
|
width: w,
|
||||||
}
|
}
|
||||||
m.menuVP = viewport.New(m.paneInnerWidth(22), m.paneHeight())
|
m.menuVP = viewport.New(m.paneInnerWidth(22), m.paneHeight())
|
||||||
m.listTable = newDataTable()
|
m.listTable = newDataTable()
|
||||||
|
m.listToolbar = newListToolbar()
|
||||||
m.detail = newDetailPane()
|
m.detail = newDetailPane()
|
||||||
m.filterSearch = newFilterSearch()
|
|
||||||
m.showMenu, m.showList, m.showDetail = true, true, true
|
m.showMenu, m.showList, m.showDetail = true, true, true
|
||||||
m.menuVP.MouseWheelEnabled = true
|
m.menuVP.MouseWheelEnabled = true
|
||||||
return m
|
return m
|
||||||
@@ -117,20 +116,43 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
key := msg.String()
|
key := msg.String()
|
||||||
if key == "esc" && m.filterActive {
|
if key == "esc" && m.focus == model.FocusList && m.listToolbar.Zone() != listZoneTable {
|
||||||
m.exitFilter()
|
m.listToolbar.FocusTable()
|
||||||
|
m.syncPaneFocus()
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
if m.filterActive && m.focus == model.FocusPreview && m.showDetail {
|
if key == "esc" && m.focus == model.FocusList && m.listToolbar.Query() != "" {
|
||||||
|
m.listToolbar.ClearFilter()
|
||||||
|
m.nav.ClearFilter()
|
||||||
|
m.syncMenuContent()
|
||||||
|
m.syncListTable()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if m.focus == model.FocusList && m.listToolbar.Zone() == listZoneFilter {
|
||||||
switch key {
|
switch key {
|
||||||
case "tab", "shift+tab", "backtab":
|
case "tab", "shift+tab", "backtab":
|
||||||
// allow pane switching while filter stays active
|
// allow pane switching
|
||||||
default:
|
default:
|
||||||
cmd := m.filterSearch.Update(msg)
|
cmd := m.listToolbar.Update(msg, m.listToolbarMeta())
|
||||||
m.applyFilter()
|
m.applyFilter()
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if m.focus == model.FocusList && m.listToolbar.Zone() == listZoneActions {
|
||||||
|
meta := m.listToolbarMeta()
|
||||||
|
switch key {
|
||||||
|
case "enter":
|
||||||
|
if act, ok := m.listToolbar.SelectedAction(meta); ok && m.listToolbar.IsActionEnabled(act, meta) {
|
||||||
|
return m, m.runListToolbarAction(act)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case "tab", "shift+tab", "backtab":
|
||||||
|
// allow pane switching
|
||||||
|
default:
|
||||||
|
cmd := m.listToolbar.Update(msg, meta)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
}
|
||||||
if key == "?" {
|
if key == "?" {
|
||||||
m.status = helpText()
|
m.status = helpText()
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -138,9 +160,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if m.scrollFocusedPane(key) {
|
if m.scrollFocusedPane(key) {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
if m.filterActive && m.focus == model.FocusPreview {
|
if m.focus == model.FocusPreview {
|
||||||
// filter input owns keys except pane switching handled above
|
|
||||||
} else if m.focus == model.FocusPreview {
|
|
||||||
if cmd, handled := m.handleDetailKey(key, msg); handled {
|
if cmd, handled := m.handleDetailKey(key, msg); handled {
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
@@ -155,39 +175,60 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case ActionNextPane:
|
case ActionNextPane:
|
||||||
prev := m.focus
|
prev := m.focus
|
||||||
m.focus = NextVisibleFocus(m.focus, m.paneVis())
|
m.focus = NextVisibleFocus(m.focus, m.paneVis())
|
||||||
if m.focus == model.FocusPreview && prev != model.FocusPreview && !m.filterActive {
|
if m.focus == model.FocusPreview && prev != model.FocusPreview {
|
||||||
m.detail.FocusFirstStop()
|
m.detail.FocusFirstStop()
|
||||||
}
|
}
|
||||||
|
if m.focus == model.FocusList && prev != model.FocusList {
|
||||||
|
m.listToolbar.FocusTable()
|
||||||
|
}
|
||||||
m.syncPaneFocus()
|
m.syncPaneFocus()
|
||||||
return m, nil
|
return m, nil
|
||||||
case ActionPrevPane:
|
case ActionPrevPane:
|
||||||
prev := m.focus
|
prev := m.focus
|
||||||
m.focus = PrevVisibleFocus(m.focus, m.paneVis())
|
m.focus = PrevVisibleFocus(m.focus, m.paneVis())
|
||||||
if m.focus == model.FocusPreview && prev != model.FocusPreview && !m.filterActive {
|
if m.focus == model.FocusPreview && prev != model.FocusPreview {
|
||||||
m.detail.FocusFirstStop()
|
m.detail.FocusFirstStop()
|
||||||
}
|
}
|
||||||
|
if m.focus == model.FocusList && prev != model.FocusList {
|
||||||
|
m.listToolbar.FocusTable()
|
||||||
|
}
|
||||||
m.syncPaneFocus()
|
m.syncPaneFocus()
|
||||||
return m, nil
|
return m, nil
|
||||||
case ActionMoveUp:
|
case ActionMoveUp:
|
||||||
if m.focus == model.FocusPreview {
|
if m.focus == model.FocusPreview {
|
||||||
return m.handleDetailMove(-1)
|
return m.handleDetailMove(-1)
|
||||||
}
|
}
|
||||||
|
if m.focus == model.FocusList && m.listToolbar.Zone() != listZoneTable {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
m.moveUp()
|
m.moveUp()
|
||||||
m.syncFocusedPane()
|
m.syncFocusedPane()
|
||||||
|
if m.focus == model.FocusList {
|
||||||
return m, m.onListRowChanged()
|
return m, m.onListRowChanged()
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
case ActionMoveDown:
|
case ActionMoveDown:
|
||||||
if m.focus == model.FocusPreview {
|
if m.focus == model.FocusPreview {
|
||||||
return m.handleDetailMove(1)
|
return m.handleDetailMove(1)
|
||||||
}
|
}
|
||||||
|
if m.focus == model.FocusList && m.listToolbar.Zone() != listZoneTable {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
m.moveDown()
|
m.moveDown()
|
||||||
m.syncFocusedPane()
|
m.syncFocusedPane()
|
||||||
|
if m.focus == model.FocusList {
|
||||||
return m, m.onListRowChanged()
|
return m, m.onListRowChanged()
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
case ActionMoveLeft:
|
case ActionMoveLeft:
|
||||||
if m.focus == model.FocusPreview && m.detail.Zone() == detailZoneActions {
|
if m.focus == model.FocusPreview && m.detail.Zone() == detailZoneActions {
|
||||||
m.detail.MoveTabStop(-1)
|
m.detail.MoveTabStop(-1)
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
if m.focus == model.FocusList && m.hasList {
|
if m.focus == model.FocusMenu {
|
||||||
|
return m.handleMenuHorizontal(-1)
|
||||||
|
}
|
||||||
|
if m.focus == model.FocusList && m.hasList && m.listToolbar.Zone() == listZoneTable {
|
||||||
m.listTable.MoveCol(-1)
|
m.listTable.MoveCol(-1)
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -196,12 +237,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.detail.MoveTabStop(1)
|
m.detail.MoveTabStop(1)
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
if m.focus == model.FocusList && m.hasList {
|
if m.focus == model.FocusMenu {
|
||||||
|
return m.handleMenuHorizontal(1)
|
||||||
|
}
|
||||||
|
if m.focus == model.FocusList && m.hasList && m.listToolbar.Zone() == listZoneTable {
|
||||||
m.listTable.MoveCol(1)
|
m.listTable.MoveCol(1)
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case ActionSort:
|
case ActionSort:
|
||||||
if m.focus == model.FocusList && m.hasList {
|
if m.focus == model.FocusList && m.hasList && m.listToolbar.Zone() == listZoneTable {
|
||||||
m.listTable.ToggleSort()
|
m.listTable.ToggleSort()
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -236,7 +280,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.togglePane(3)
|
m.togglePane(3)
|
||||||
return m, nil
|
return m, nil
|
||||||
case ActionFilter:
|
case ActionFilter:
|
||||||
m.enterFilter()
|
if m.hasList {
|
||||||
|
m.focus = model.FocusList
|
||||||
|
m.listToolbar.FocusFilter()
|
||||||
|
m.syncPaneFocus()
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case ActionRefresh:
|
case ActionRefresh:
|
||||||
if m.hasList {
|
if m.hasList {
|
||||||
@@ -257,13 +305,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
m.syncMenuContent()
|
m.syncMenuContent()
|
||||||
return m, nil
|
return m, nil
|
||||||
case "right", "l":
|
|
||||||
if spec, ok := m.nav.CurrentListSpec(); ok {
|
|
||||||
return m.withList(*spec)
|
|
||||||
}
|
|
||||||
m.nav.Activate()
|
|
||||||
m.syncMenuContent()
|
|
||||||
return m, nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,10 +325,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.hasList = true
|
m.hasList = true
|
||||||
m.listHasMore = model.SubjectIsMail(msg.spec.Subject) && len(msg.items) >= fetch.MailListPageSize
|
m.listHasMore = model.SubjectIsMail(msg.spec.Subject) && len(msg.items) >= fetch.MailListPageSize
|
||||||
m.listTable.SetData(m.listSpec, m.items)
|
m.listTable.SetData(m.listSpec, m.items)
|
||||||
if m.filterActive {
|
|
||||||
m.applyFilter()
|
m.applyFilter()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return m, tea.Batch(m.onListRowChanged(), m.maybeLoadMoreList())
|
return m, tea.Batch(m.onListRowChanged(), m.maybeLoadMoreList())
|
||||||
|
|
||||||
case listMoreLoadedMsg:
|
case listMoreLoadedMsg:
|
||||||
@@ -337,8 +376,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
} else {
|
} else {
|
||||||
m.updateItemAfterSave(msg.item, msg.fields)
|
m.updateItemAfterSave(msg.item, msg.fields)
|
||||||
m.detail.LoadForm(msg.item, msg.fields)
|
m.detail.LoadForm(msg.item, msg.fields)
|
||||||
|
m.detail.form.MarkClean()
|
||||||
m.detail.SetFocused(m.focus == model.FocusPreview)
|
m.detail.SetFocused(m.focus == model.FocusPreview)
|
||||||
|
if msg.fields.HasTaskStatus && msg.fields.TaskStatus == model.TaskLifecycleClosed {
|
||||||
|
m.status = "Closed"
|
||||||
|
} else {
|
||||||
m.status = "Saved"
|
m.status = "Saved"
|
||||||
|
}
|
||||||
m.err = ""
|
m.err = ""
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -350,6 +394,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
} else {
|
} else {
|
||||||
m.status = msg.message
|
m.status = msg.message
|
||||||
m.err = ""
|
m.err = ""
|
||||||
|
m.selection.Clear()
|
||||||
if m.hasList {
|
if m.hasList {
|
||||||
m.loading = true
|
m.loading = true
|
||||||
return m, m.loadListCmd(m.listSpec)
|
return m, m.loadListCmd(m.listSpec)
|
||||||
@@ -376,11 +421,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case model.FocusMenu:
|
case model.FocusMenu:
|
||||||
m.menuVP, cmd = m.menuVP.Update(msg)
|
m.menuVP, cmd = m.menuVP.Update(msg)
|
||||||
case model.FocusList:
|
case model.FocusList:
|
||||||
|
if m.listToolbar.Zone() == listZoneTable {
|
||||||
cmd = m.listTable.Update(msg)
|
cmd = m.listTable.Update(msg)
|
||||||
case model.FocusPreview:
|
|
||||||
if !m.filterActive {
|
|
||||||
cmd = m.detail.Update(msg)
|
|
||||||
}
|
}
|
||||||
|
case model.FocusPreview:
|
||||||
|
cmd = m.detail.Update(msg)
|
||||||
}
|
}
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
@@ -392,10 +437,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case model.FocusList:
|
case model.FocusList:
|
||||||
cmd = m.listTable.Update(msg)
|
cmd = m.listTable.Update(msg)
|
||||||
case model.FocusPreview:
|
case model.FocusPreview:
|
||||||
if !m.filterActive {
|
|
||||||
cmd = m.detail.Update(msg)
|
cmd = m.detail.Update(msg)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,6 +468,9 @@ func (m *Model) handleDetailKey(key string, msg tea.KeyMsg) (tea.Cmd, bool) {
|
|||||||
if act.ID == model.ActionSave {
|
if act.ID == model.ActionSave {
|
||||||
return m.saveDetailCmd(), true
|
return m.saveDetailCmd(), true
|
||||||
}
|
}
|
||||||
|
if act.ID == model.ActionClose && m.detail.Item().Kind == model.KindTask {
|
||||||
|
return m.closeTaskCmd(), true
|
||||||
|
}
|
||||||
return m.executeActionCmd(act.ID, m.detail.Item()), true
|
return m.executeActionCmd(act.ID, m.detail.Item()), true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -435,7 +481,7 @@ func (m *Model) handleDetailKey(key string, msg tea.KeyMsg) (tea.Cmd, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) routeDetailDocumentMouse(msg tea.MouseMsg) (tea.Cmd, bool) {
|
func (m *Model) routeDetailDocumentMouse(msg tea.MouseMsg) (tea.Cmd, bool) {
|
||||||
if m.filterActive || !m.showDetail {
|
if !m.showDetail {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
if !m.detail.IsDocumentContent() && !m.detail.isReadOnlyFormContent() {
|
if !m.detail.IsDocumentContent() && !m.detail.isReadOnlyFormContent() {
|
||||||
@@ -476,7 +522,7 @@ func (m Model) View() string {
|
|||||||
h := m.paneHeight() + 2
|
h := m.paneHeight() + 2
|
||||||
var parts []string
|
var parts []string
|
||||||
if pw.Visibility.Menu {
|
if pw.Visibility.Menu {
|
||||||
menuStyle := paneStyle(m.focus == model.FocusMenu).Width(pw.Menu).Height(h)
|
menuStyle := paneStyle(m.focus == model.FocusMenu).Width(paneLipglossWidth(pw.Menu)).Height(h)
|
||||||
menuBody := ApplyVerticalScrollbar(
|
menuBody := ApplyVerticalScrollbar(
|
||||||
m.menuVP.View(),
|
m.menuVP.View(),
|
||||||
m.menuVP.Width,
|
m.menuVP.Width,
|
||||||
@@ -487,18 +533,17 @@ func (m Model) View() string {
|
|||||||
parts = append(parts, menuStyle.Render(menuBody))
|
parts = append(parts, menuStyle.Render(menuBody))
|
||||||
}
|
}
|
||||||
if pw.Visibility.List {
|
if pw.Visibility.List {
|
||||||
listStyle := paneStyle(m.focus == model.FocusList).Width(pw.List).Height(h)
|
listStyle := paneStyle(m.focus == model.FocusList).Width(paneLipglossWidth(pw.List)).Height(h)
|
||||||
parts = append(parts, listStyle.Render(m.listTable.View()))
|
meta := m.listToolbarMeta()
|
||||||
|
body := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
|
m.listToolbar.View(meta),
|
||||||
|
m.listTable.View(),
|
||||||
|
)
|
||||||
|
parts = append(parts, listStyle.Render(body))
|
||||||
}
|
}
|
||||||
if pw.Visibility.Detail {
|
if pw.Visibility.Detail {
|
||||||
prevStyle := paneStyle(m.focus == model.FocusPreview).Width(pw.Detail).Height(h)
|
prevStyle := paneStyle(m.focus == model.FocusPreview).Width(paneLipglossWidth(pw.Detail)).Height(h)
|
||||||
var detailBody string
|
parts = append(parts, prevStyle.Render(m.detail.View()))
|
||||||
if m.filterActive {
|
|
||||||
detailBody = m.filterSearch.View()
|
|
||||||
} else {
|
|
||||||
detailBody = m.detail.View()
|
|
||||||
}
|
|
||||||
parts = append(parts, prevStyle.Render(detailBody))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
status := m.status
|
status := m.status
|
||||||
@@ -526,16 +571,105 @@ func (m Model) paneLayout() PaneWidths {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) syncPaneFocus() {
|
func (m *Model) syncPaneFocus() {
|
||||||
m.listTable.SetFocused(m.focus == model.FocusList && m.showList)
|
onList := m.focus == model.FocusList && m.showList
|
||||||
if m.filterActive {
|
m.listTable.SetFocused(onList && m.listToolbar.Zone() == listZoneTable)
|
||||||
m.filterSearch.SetFocused(m.focus == model.FocusPreview && m.showDetail)
|
if onList {
|
||||||
m.detail.SetFocused(false)
|
m.listToolbar.syncInputFocus()
|
||||||
return
|
} else {
|
||||||
|
m.listToolbar.filter.SetFocused(false)
|
||||||
}
|
}
|
||||||
m.filterSearch.SetFocused(false)
|
|
||||||
m.detail.SetFocused(m.focus == model.FocusPreview && m.showDetail)
|
m.detail.SetFocused(m.focus == model.FocusPreview && m.showDetail)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m Model) listToolbarMeta() ListToolbarMeta {
|
||||||
|
meta := ListToolbarMeta{
|
||||||
|
Subject: m.listTable.SubjectLabel(),
|
||||||
|
Count: m.listTable.ItemCount(),
|
||||||
|
SortLabel: m.listTable.SortHint(),
|
||||||
|
LoadingMore: m.listTable.LoadingMore(),
|
||||||
|
SaveEnabled: m.canSaveFromList(),
|
||||||
|
DeleteEnabled: m.canDeleteFromList(),
|
||||||
|
}
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) selectedItems() []model.Item {
|
||||||
|
var out []model.Item
|
||||||
|
for _, it := range m.items {
|
||||||
|
if it.Selected {
|
||||||
|
out = append(out, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) canSaveFromList() bool {
|
||||||
|
selected := m.selectedItems()
|
||||||
|
if len(selected) != 1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, act := range model.ActionsFor(selected[0].Kind) {
|
||||||
|
if act.ID == model.ActionSave && m.detail.LoadedID() == selected[0].ID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) canDeleteFromList() bool {
|
||||||
|
selected := m.selectedItems()
|
||||||
|
if len(selected) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, it := range selected {
|
||||||
|
if !actionAvailable(model.ActionsFor(it.Kind), model.ActionDelete) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func actionAvailable(actions []model.ItemAction, id model.ActionID) bool {
|
||||||
|
for _, a := range actions {
|
||||||
|
if a.ID == id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) runListToolbarAction(action model.ActionID) tea.Cmd {
|
||||||
|
meta := m.listToolbarMeta()
|
||||||
|
if !m.listToolbar.IsActionEnabled(action, meta) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch action {
|
||||||
|
case model.ActionSave:
|
||||||
|
m.loading = true
|
||||||
|
return m.saveDetailCmd()
|
||||||
|
case model.ActionDelete:
|
||||||
|
m.loading = true
|
||||||
|
return m.deleteSelectedCmd()
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) deleteSelectedCmd() tea.Cmd {
|
||||||
|
selected := m.selectedItems()
|
||||||
|
if len(selected) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(selected) == 1 {
|
||||||
|
return m.executeActionCmd(model.ActionDelete, selected[0])
|
||||||
|
}
|
||||||
|
cmds := make([]tea.Cmd, len(selected))
|
||||||
|
for i, it := range selected {
|
||||||
|
cmds[i] = m.executeActionCmd(model.ActionDelete, it)
|
||||||
|
}
|
||||||
|
return tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Model) togglePane(which int) {
|
func (m *Model) togglePane(which int) {
|
||||||
switch which {
|
switch which {
|
||||||
case 1:
|
case 1:
|
||||||
@@ -587,11 +721,12 @@ func (m *Model) layoutViewports() {
|
|||||||
m.menuVP.Height = h
|
m.menuVP.Height = h
|
||||||
}
|
}
|
||||||
if pw.Visibility.List {
|
if pw.Visibility.List {
|
||||||
m.listTable.SetSize(m.paneInnerWidth(pw.List), h)
|
w := m.paneInnerWidth(pw.List)
|
||||||
|
m.listToolbar.SetWidth(w)
|
||||||
|
m.listTable.SetSize(w, h-listToolbarHeight)
|
||||||
}
|
}
|
||||||
if pw.Visibility.Detail {
|
if pw.Visibility.Detail {
|
||||||
m.detail.SetSize(m.paneInnerWidth(pw.Detail), h)
|
m.detail.SetSize(m.paneInnerWidth(pw.Detail), h)
|
||||||
m.filterSearch.SetSize(m.paneInnerWidth(pw.Detail), h)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -606,34 +741,14 @@ func (m *Model) syncListTable() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
items := m.items
|
items := m.items
|
||||||
if m.filterActive {
|
if q := m.listToolbar.Query(); q != "" {
|
||||||
items = model.FilterItems(m.items, m.filterSearch.Query())
|
items = model.FilterItems(m.items, q)
|
||||||
}
|
}
|
||||||
m.listTable.UpdateItems(items)
|
m.listTable.UpdateItems(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) enterFilter() {
|
|
||||||
if !m.showDetail {
|
|
||||||
m.showDetail = true
|
|
||||||
m.layoutViewports()
|
|
||||||
}
|
|
||||||
m.filterActive = true
|
|
||||||
m.focus = model.FocusPreview
|
|
||||||
m.syncPaneFocus()
|
|
||||||
m.applyFilter()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) exitFilter() {
|
|
||||||
m.filterActive = false
|
|
||||||
m.filterSearch.Clear()
|
|
||||||
m.nav.ClearFilter()
|
|
||||||
m.syncMenuContent()
|
|
||||||
m.syncListTable()
|
|
||||||
m.syncPaneFocus()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) applyFilter() {
|
func (m *Model) applyFilter() {
|
||||||
q := m.filterSearch.Query()
|
q := m.listToolbar.Query()
|
||||||
m.nav.SetFilter(q)
|
m.nav.SetFilter(q)
|
||||||
m.syncMenuContent()
|
m.syncMenuContent()
|
||||||
m.syncListTable()
|
m.syncListTable()
|
||||||
@@ -645,8 +760,8 @@ func (m Model) listTableItem() (model.Item, bool) {
|
|||||||
return model.Item{}, false
|
return model.Item{}, false
|
||||||
}
|
}
|
||||||
items := m.items
|
items := m.items
|
||||||
if m.filterActive {
|
if q := m.listToolbar.Query(); q != "" {
|
||||||
items = model.FilterItems(m.items, m.filterSearch.Query())
|
items = model.FilterItems(m.items, q)
|
||||||
}
|
}
|
||||||
if idx >= len(items) {
|
if idx >= len(items) {
|
||||||
return model.Item{}, false
|
return model.Item{}, false
|
||||||
@@ -657,7 +772,7 @@ func (m Model) listTableItem() (model.Item, bool) {
|
|||||||
func (m *Model) syncMenuContent() {
|
func (m *Model) syncMenuContent() {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
title := "Navigation"
|
title := "Navigation"
|
||||||
if m.filterActive && m.filterSearch.Query() != "" {
|
if m.listToolbar.Query() != "" {
|
||||||
title += " (filtered)"
|
title += " (filtered)"
|
||||||
}
|
}
|
||||||
b.WriteString(title + "\n\n")
|
b.WriteString(title + "\n\n")
|
||||||
@@ -705,6 +820,23 @@ func truncateRunes(s string, max int) string {
|
|||||||
return string(r[:max-1]) + "…"
|
return string(r[:max-1]) + "…"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m Model) handleMenuHorizontal(delta int) (Model, tea.Cmd) {
|
||||||
|
cur := m.nav.Cursor()
|
||||||
|
switch delta {
|
||||||
|
case 1:
|
||||||
|
if spec, ok := m.nav.CurrentListSpec(); ok {
|
||||||
|
return m.withList(*spec)
|
||||||
|
}
|
||||||
|
m.nav.Activate()
|
||||||
|
case -1:
|
||||||
|
if m.nav.IsExpandable(cur) && m.nav.IsExpanded(cur) {
|
||||||
|
m.nav.ToggleExpand(cur)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.syncMenuContent()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Model) syncFocusedPane() {
|
func (m *Model) syncFocusedPane() {
|
||||||
switch m.focus {
|
switch m.focus {
|
||||||
case model.FocusMenu:
|
case model.FocusMenu:
|
||||||
@@ -790,9 +922,6 @@ func (m *Model) loadNavProjectsCmd() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) onListRowChanged() tea.Cmd {
|
func (m *Model) onListRowChanged() tea.Cmd {
|
||||||
if m.filterActive {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !m.hasList {
|
if !m.hasList {
|
||||||
m.detail.Clear()
|
m.detail.Clear()
|
||||||
return m.maybeLoadMoreList()
|
return m.maybeLoadMoreList()
|
||||||
@@ -814,7 +943,7 @@ func (m *Model) onListRowChanged() tea.Cmd {
|
|||||||
const mailListLoadThreshold = 3
|
const mailListLoadThreshold = 3
|
||||||
|
|
||||||
func (m *Model) maybeLoadMoreList() tea.Cmd {
|
func (m *Model) maybeLoadMoreList() tea.Cmd {
|
||||||
if !m.hasList || m.listLoadingMore || !m.listHasMore || m.filterActive {
|
if !m.hasList || m.listLoadingMore || !m.listHasMore {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !model.SubjectIsMail(m.listSpec.Subject) {
|
if !model.SubjectIsMail(m.listSpec.Subject) {
|
||||||
@@ -850,6 +979,17 @@ func (m *Model) loadDetailCmd(item model.Item) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Model) closeTaskCmd() tea.Cmd {
|
||||||
|
item := m.detail.Item()
|
||||||
|
fields := m.detail.form.FormFields()
|
||||||
|
fields.TaskStatus = model.TaskLifecycleClosed
|
||||||
|
return func() tea.Msg {
|
||||||
|
ctx := context.Background()
|
||||||
|
err := m.loader.CloseTask(ctx, item.ID, fields)
|
||||||
|
return detailSavedMsg{item: item, fields: fields, err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Model) saveDetailCmd() tea.Cmd {
|
func (m *Model) saveDetailCmd() tea.Cmd {
|
||||||
item := m.detail.Item()
|
item := m.detail.Item()
|
||||||
fields := m.detail.form.FormFields()
|
fields := m.detail.form.FormFields()
|
||||||
@@ -874,6 +1014,12 @@ func (m *Model) updateItemAfterSave(item model.Item, fields model.FormFields) {
|
|||||||
if fields.HasStatus {
|
if fields.HasStatus {
|
||||||
m.items[i].Raw["status"] = statusInt(fields.Status)
|
m.items[i].Raw["status"] = statusInt(fields.Status)
|
||||||
}
|
}
|
||||||
|
if fields.HasTaskStatus {
|
||||||
|
m.items[i].Raw["status"] = int(fields.TaskStatus)
|
||||||
|
}
|
||||||
|
if fields.ResponsibleID != "" {
|
||||||
|
m.items[i].Raw["responsibleIds"] = []any{fields.ResponsibleID}
|
||||||
|
}
|
||||||
m.syncListTable()
|
m.syncListTable()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -899,5 +1045,5 @@ func paneStyle(focused bool) lipgloss.Style {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func helpText() string {
|
func helpText() string {
|
||||||
return "Alt+1/2/3: toggle panes · drag borders to resize · f: filter · Tab: fields/actions/pane · j/k: scroll mail · v: detail · Ctrl+S: save · q: quit"
|
return "Alt+1/2/3: panes · / filter · Space select · 💾🗑 when selected · v: detail · Ctrl+S: save · q: quit"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDetailTabOrderTaskForm(t *testing.T) {
|
||||||
|
d := newDetailPane()
|
||||||
|
d.SetFocused(true)
|
||||||
|
d.LoadForm(model.Item{ID: "9", Kind: model.KindTask, Title: "T"}, model.FormFields{
|
||||||
|
PrimaryLabel: "Title", SecondaryLabel: "Description",
|
||||||
|
Primary: "Alpha", Secondary: "Beta",
|
||||||
|
HasTaskStatus: true, TaskStatus: model.TaskLifecycleOpen,
|
||||||
|
HasResponsible: true, ResponsibleID: "u1",
|
||||||
|
UserChoices: []model.UserOption{{ID: "u1", Name: "Alice"}},
|
||||||
|
ProjectTitle: "Proj", TimingSummary: "2026-01-01 → 2026-02-01",
|
||||||
|
})
|
||||||
|
|
||||||
|
stops := []entityField{
|
||||||
|
entityFieldPrimary, entityFieldSecondary, entityFieldStatus, entityFieldResponsible,
|
||||||
|
}
|
||||||
|
for i, want := range stops {
|
||||||
|
if d.form.field != want {
|
||||||
|
t.Fatalf("stop %d: field=%d want %d", i, d.form.field, want)
|
||||||
|
}
|
||||||
|
if d.TabForward() {
|
||||||
|
t.Fatalf("tab from stop %d should stay in pane", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.Zone() != detailZoneActions || d.actionIdx != 0 {
|
||||||
|
t.Fatalf("expected Save action, zone=%d idx=%d", d.Zone(), d.actionIdx)
|
||||||
|
}
|
||||||
|
if d.TabForward() {
|
||||||
|
t.Fatal("tab from Save should stay")
|
||||||
|
}
|
||||||
|
if d.actionIdx != 1 {
|
||||||
|
t.Fatalf("expected Close action, idx=%d", d.actionIdx)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,8 +30,8 @@ func TestDetailPaneFormAndActions(t *testing.T) {
|
|||||||
Primary: "Hello", Secondary: "World",
|
Primary: "Hello", Secondary: "World",
|
||||||
})
|
})
|
||||||
view := d.View()
|
view := d.View()
|
||||||
if !strings.Contains(view, "Save") {
|
if !strings.Contains(view, "Save") || !strings.Contains(view, "Close") {
|
||||||
t.Fatal("expected Save action button")
|
t.Fatal("expected Save and Close action buttons")
|
||||||
}
|
}
|
||||||
if !strings.Contains(view, "Hello") {
|
if !strings.Contains(view, "Hello") {
|
||||||
t.Fatal("expected form content")
|
t.Fatal("expected form content")
|
||||||
|
|||||||
+266
-39
@@ -16,6 +16,7 @@ const (
|
|||||||
entityFieldPrimary entityField = iota
|
entityFieldPrimary entityField = iota
|
||||||
entityFieldSecondary
|
entityFieldSecondary
|
||||||
entityFieldStatus
|
entityFieldStatus
|
||||||
|
entityFieldResponsible
|
||||||
)
|
)
|
||||||
|
|
||||||
// EntityForm is the top section of the detail pane for editable entities.
|
// EntityForm is the top section of the detail pane for editable entities.
|
||||||
@@ -28,9 +29,22 @@ type EntityForm struct {
|
|||||||
readOnly bool
|
readOnly bool
|
||||||
hasStatus bool
|
hasStatus bool
|
||||||
status model.ProjectLifecycle
|
status model.ProjectLifecycle
|
||||||
|
hasTaskStatus bool
|
||||||
|
taskStatus model.TaskLifecycle
|
||||||
|
hasResponsible bool
|
||||||
responsibleID string
|
responsibleID string
|
||||||
|
userChoices []model.UserOption
|
||||||
|
responsibleIdx int
|
||||||
|
projectTitle string
|
||||||
|
timingSummary string
|
||||||
|
hasUserEdit bool
|
||||||
|
userEnabled bool
|
||||||
|
userACL model.UserACLState
|
||||||
|
groupsText string
|
||||||
|
userFieldIdx int
|
||||||
primary textinput.Model
|
primary textinput.Model
|
||||||
secondary textarea.Model
|
secondary textarea.Model
|
||||||
|
password textinput.Model
|
||||||
field entityField
|
field entityField
|
||||||
focused bool
|
focused bool
|
||||||
dirty bool
|
dirty bool
|
||||||
@@ -43,6 +57,7 @@ type entityFormStyles struct {
|
|||||||
header lipgloss.Style
|
header lipgloss.Style
|
||||||
label lipgloss.Style
|
label lipgloss.Style
|
||||||
labelAct lipgloss.Style
|
labelAct lipgloss.Style
|
||||||
|
meta lipgloss.Style
|
||||||
}
|
}
|
||||||
|
|
||||||
func newEntityFormStyles() entityFormStyles {
|
func newEntityFormStyles() entityFormStyles {
|
||||||
@@ -50,6 +65,7 @@ func newEntityFormStyles() entityFormStyles {
|
|||||||
header: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("252")),
|
header: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("252")),
|
||||||
label: lipgloss.NewStyle().Foreground(lipgloss.Color("241")),
|
label: lipgloss.NewStyle().Foreground(lipgloss.Color("241")),
|
||||||
labelAct: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("62")),
|
labelAct: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("62")),
|
||||||
|
meta: lipgloss.NewStyle().Foreground(lipgloss.Color("245")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,18 +77,30 @@ func newEntityForm() EntityForm {
|
|||||||
secondary.ShowLineNumbers = false
|
secondary.ShowLineNumbers = false
|
||||||
secondary.CharLimit = 8000
|
secondary.CharLimit = 8000
|
||||||
secondary.Prompt = "> "
|
secondary.Prompt = "> "
|
||||||
|
password := textinput.New()
|
||||||
|
password.CharLimit = 128
|
||||||
|
password.EchoMode = textinput.EchoPassword
|
||||||
|
password.EchoCharacter = '•'
|
||||||
|
password.Prompt = "> "
|
||||||
return EntityForm{
|
return EntityForm{
|
||||||
primary: primary,
|
primary: primary,
|
||||||
secondary: secondary,
|
secondary: secondary,
|
||||||
|
password: password,
|
||||||
field: entityFieldPrimary,
|
field: entityFieldPrimary,
|
||||||
styles: newEntityFormStyles(),
|
styles: newEntityFormStyles(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *EntityForm) FieldCount() int {
|
func (f *EntityForm) FieldCount() int {
|
||||||
|
if f.hasUserEdit {
|
||||||
|
return f.userFieldCount()
|
||||||
|
}
|
||||||
n := 2
|
n := 2
|
||||||
if f.hasStatus {
|
if f.hasStatus || f.hasTaskStatus {
|
||||||
n = 3
|
n++
|
||||||
|
}
|
||||||
|
if f.hasResponsible {
|
||||||
|
n++
|
||||||
}
|
}
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
@@ -86,7 +114,18 @@ func (f *EntityForm) FormFields() model.FormFields {
|
|||||||
ReadOnly: f.readOnly,
|
ReadOnly: f.readOnly,
|
||||||
HasStatus: f.hasStatus,
|
HasStatus: f.hasStatus,
|
||||||
Status: f.status,
|
Status: f.status,
|
||||||
|
HasTaskStatus: f.hasTaskStatus,
|
||||||
|
TaskStatus: f.taskStatus,
|
||||||
|
HasResponsible: f.hasResponsible,
|
||||||
ResponsibleID: f.responsibleID,
|
ResponsibleID: f.responsibleID,
|
||||||
|
UserChoices: f.userChoices,
|
||||||
|
ProjectTitle: f.projectTitle,
|
||||||
|
TimingSummary: f.timingSummary,
|
||||||
|
HasUserEdit: f.hasUserEdit,
|
||||||
|
UserEnabled: f.userEnabled,
|
||||||
|
UserACL: f.userACL,
|
||||||
|
GroupsText: f.groupsText,
|
||||||
|
UserPassword: f.password.Value(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,29 +148,66 @@ func (f *EntityForm) Load(kind model.Kind, itemID string, fields model.FormField
|
|||||||
f.readOnly = fields.ReadOnly
|
f.readOnly = fields.ReadOnly
|
||||||
f.hasStatus = fields.HasStatus
|
f.hasStatus = fields.HasStatus
|
||||||
f.status = fields.Status
|
f.status = fields.Status
|
||||||
|
f.hasTaskStatus = fields.HasTaskStatus
|
||||||
|
f.taskStatus = fields.TaskStatus
|
||||||
|
f.hasResponsible = fields.HasResponsible
|
||||||
|
f.userChoices = fields.UserChoices
|
||||||
|
f.projectTitle = fields.ProjectTitle
|
||||||
|
f.timingSummary = fields.TimingSummary
|
||||||
f.responsibleID = fields.ResponsibleID
|
f.responsibleID = fields.ResponsibleID
|
||||||
|
f.responsibleIdx = indexUserChoice(fields.UserChoices, fields.ResponsibleID)
|
||||||
|
f.hasUserEdit = fields.HasUserEdit
|
||||||
|
f.userEnabled = fields.UserEnabled
|
||||||
|
f.userACL = copyUserACLState(fields.UserACL)
|
||||||
|
f.groupsText = fields.GroupsText
|
||||||
|
f.userFieldIdx = 0
|
||||||
f.primary.SetValue(fields.Primary)
|
f.primary.SetValue(fields.Primary)
|
||||||
f.secondary.SetValue(fields.Secondary)
|
f.secondary.SetValue(fields.Secondary)
|
||||||
|
f.password.SetValue("")
|
||||||
f.dirty = false
|
f.dirty = false
|
||||||
|
if f.hasUserEdit {
|
||||||
|
f.userFieldIdx = 0
|
||||||
|
} else {
|
||||||
f.field = entityFieldPrimary
|
f.field = entityFieldPrimary
|
||||||
if f.hasStatus && f.readOnly {
|
|
||||||
f.field = entityFieldStatus
|
|
||||||
}
|
}
|
||||||
f.applyFocus()
|
f.applyFocus()
|
||||||
f.layoutFields()
|
f.layoutFields()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func indexUserChoice(choices []model.UserOption, id string) int {
|
||||||
|
for i, c := range choices {
|
||||||
|
if c.ID == id {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
func (f *EntityForm) Clear() {
|
func (f *EntityForm) Clear() {
|
||||||
f.active = false
|
f.active = false
|
||||||
f.itemID = ""
|
f.itemID = ""
|
||||||
f.hasStatus = false
|
f.hasStatus = false
|
||||||
|
f.hasTaskStatus = false
|
||||||
|
f.hasResponsible = false
|
||||||
f.status = model.ProjectLifecycleOpen
|
f.status = model.ProjectLifecycleOpen
|
||||||
|
f.taskStatus = model.TaskLifecycleOpen
|
||||||
f.responsibleID = ""
|
f.responsibleID = ""
|
||||||
|
f.userChoices = nil
|
||||||
|
f.responsibleIdx = 0
|
||||||
|
f.projectTitle = ""
|
||||||
|
f.timingSummary = ""
|
||||||
|
f.hasUserEdit = false
|
||||||
|
f.userEnabled = false
|
||||||
|
f.userACL = model.UserACLState{}
|
||||||
|
f.groupsText = ""
|
||||||
|
f.userFieldIdx = 0
|
||||||
f.primary.SetValue("")
|
f.primary.SetValue("")
|
||||||
f.secondary.SetValue("")
|
f.secondary.SetValue("")
|
||||||
|
f.password.SetValue("")
|
||||||
f.dirty = false
|
f.dirty = false
|
||||||
f.primary.Blur()
|
f.primary.Blur()
|
||||||
f.secondary.Blur()
|
f.secondary.Blur()
|
||||||
|
f.password.Blur()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *EntityForm) SetFocused(on bool) {
|
func (f *EntityForm) SetFocused(on bool) {
|
||||||
@@ -141,6 +217,7 @@ func (f *EntityForm) SetFocused(on bool) {
|
|||||||
} else {
|
} else {
|
||||||
f.primary.Blur()
|
f.primary.Blur()
|
||||||
f.secondary.Blur()
|
f.secondary.Blur()
|
||||||
|
f.password.Blur()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +242,28 @@ func (f *EntityForm) layoutFields() {
|
|||||||
inner = 8
|
inner = 8
|
||||||
}
|
}
|
||||||
f.primary.Width = inner
|
f.primary.Width = inner
|
||||||
secH := f.height - 6
|
f.password.Width = inner
|
||||||
|
if f.hasUserEdit {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
metaLines := 0
|
||||||
|
if f.projectTitle != "" {
|
||||||
|
metaLines++
|
||||||
|
}
|
||||||
|
if f.timingSummary != "" {
|
||||||
|
metaLines++
|
||||||
|
}
|
||||||
|
if metaLines > 0 {
|
||||||
|
metaLines++ // blank line before meta
|
||||||
|
}
|
||||||
|
extra := 6 + metaLines
|
||||||
|
if f.hasStatus || f.hasTaskStatus {
|
||||||
|
extra += 2
|
||||||
|
}
|
||||||
|
if f.hasResponsible {
|
||||||
|
extra += 2
|
||||||
|
}
|
||||||
|
secH := f.height - extra
|
||||||
if secH < 3 {
|
if secH < 3 {
|
||||||
secH = 3
|
secH = 3
|
||||||
}
|
}
|
||||||
@@ -173,34 +271,49 @@ func (f *EntityForm) layoutFields() {
|
|||||||
f.secondary.SetHeight(secH)
|
f.secondary.SetHeight(secH)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *EntityForm) FocusNext() {
|
func (f *EntityForm) lastField() entityField {
|
||||||
last := entityFieldSecondary
|
if f.hasResponsible {
|
||||||
if f.hasStatus {
|
return entityFieldResponsible
|
||||||
last = entityFieldStatus
|
|
||||||
}
|
}
|
||||||
if f.field < last {
|
if f.hasStatus || f.hasTaskStatus {
|
||||||
|
return entityFieldStatus
|
||||||
|
}
|
||||||
|
return entityFieldSecondary
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *EntityForm) FocusNext() {
|
||||||
|
if f.field < f.lastField() {
|
||||||
f.field++
|
f.field++
|
||||||
}
|
}
|
||||||
f.applyFocus()
|
f.applyFocus()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *EntityForm) FocusPrev() {
|
func (f *EntityForm) FocusPrev() {
|
||||||
first := entityFieldPrimary
|
if f.field > entityFieldPrimary {
|
||||||
if f.field > first {
|
|
||||||
f.field--
|
f.field--
|
||||||
}
|
}
|
||||||
f.applyFocus()
|
f.applyFocus()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *EntityForm) SetFieldIndex(i int) {
|
func (f *EntityForm) SetFieldIndex(i int) {
|
||||||
|
if f.hasUserEdit {
|
||||||
|
f.setUserFieldIndex(i)
|
||||||
|
return
|
||||||
|
}
|
||||||
switch i {
|
switch i {
|
||||||
case 0:
|
case 0:
|
||||||
f.field = entityFieldPrimary
|
f.field = entityFieldPrimary
|
||||||
case 1:
|
case 1:
|
||||||
f.field = entityFieldSecondary
|
f.field = entityFieldSecondary
|
||||||
case 2:
|
case 2:
|
||||||
if f.hasStatus {
|
if f.hasStatus || f.hasTaskStatus {
|
||||||
f.field = entityFieldStatus
|
f.field = entityFieldStatus
|
||||||
|
} else if f.hasResponsible {
|
||||||
|
f.field = entityFieldResponsible
|
||||||
|
}
|
||||||
|
case 3:
|
||||||
|
if f.hasResponsible && (f.hasStatus || f.hasTaskStatus) {
|
||||||
|
f.field = entityFieldResponsible
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return
|
return
|
||||||
@@ -209,7 +322,19 @@ func (f *EntityForm) SetFieldIndex(i int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *EntityForm) cycleStatus(delta int) {
|
func (f *EntityForm) cycleStatus(delta int) {
|
||||||
if !f.hasStatus || f.readOnly {
|
if f.readOnly {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if f.hasTaskStatus {
|
||||||
|
if delta > 0 {
|
||||||
|
f.taskStatus = f.taskStatus.Next()
|
||||||
|
} else {
|
||||||
|
f.taskStatus = f.taskStatus.Prev()
|
||||||
|
}
|
||||||
|
f.dirty = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !f.hasStatus {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if delta > 0 {
|
if delta > 0 {
|
||||||
@@ -220,7 +345,36 @@ func (f *EntityForm) cycleStatus(delta int) {
|
|||||||
f.dirty = true
|
f.dirty = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *EntityForm) cycleResponsible(delta int) {
|
||||||
|
if !f.hasResponsible || f.readOnly || len(f.userChoices) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n := len(f.userChoices)
|
||||||
|
f.responsibleIdx = (f.responsibleIdx + delta%n + n) % n
|
||||||
|
f.responsibleID = f.userChoices[f.responsibleIdx].ID
|
||||||
|
f.dirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *EntityForm) responsibleLabel() string {
|
||||||
|
if f.responsibleID == "" {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
for _, c := range f.userChoices {
|
||||||
|
if c.ID == f.responsibleID {
|
||||||
|
if c.Name != "" {
|
||||||
|
return c.Name
|
||||||
|
}
|
||||||
|
return c.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f.responsibleID
|
||||||
|
}
|
||||||
|
|
||||||
func (f *EntityForm) applyFocus() {
|
func (f *EntityForm) applyFocus() {
|
||||||
|
if f.hasUserEdit {
|
||||||
|
f.applyUserFocus()
|
||||||
|
return
|
||||||
|
}
|
||||||
if f.readOnly {
|
if f.readOnly {
|
||||||
f.primary.Blur()
|
f.primary.Blur()
|
||||||
f.secondary.Blur()
|
f.secondary.Blur()
|
||||||
@@ -243,7 +397,12 @@ func (f *EntityForm) Update(msg tea.Msg) tea.Cmd {
|
|||||||
if !f.active || !f.focused || f.readOnly {
|
if !f.active || !f.focused || f.readOnly {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if key, ok := msg.(tea.KeyMsg); ok && f.field == entityFieldStatus {
|
if f.hasUserEdit {
|
||||||
|
return f.updateUserForm(msg)
|
||||||
|
}
|
||||||
|
if key, ok := msg.(tea.KeyMsg); ok {
|
||||||
|
switch f.field {
|
||||||
|
case entityFieldStatus:
|
||||||
switch key.String() {
|
switch key.String() {
|
||||||
case "left", "h":
|
case "left", "h":
|
||||||
f.cycleStatus(-1)
|
f.cycleStatus(-1)
|
||||||
@@ -255,6 +414,19 @@ func (f *EntityForm) Update(msg tea.Msg) tea.Cmd {
|
|||||||
f.cycleStatus(1)
|
f.cycleStatus(1)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
case entityFieldResponsible:
|
||||||
|
switch key.String() {
|
||||||
|
case "left", "h":
|
||||||
|
f.cycleResponsible(-1)
|
||||||
|
return nil
|
||||||
|
case "right", "l":
|
||||||
|
f.cycleResponsible(1)
|
||||||
|
return nil
|
||||||
|
case " ":
|
||||||
|
f.cycleResponsible(1)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
switch f.field {
|
switch f.field {
|
||||||
@@ -275,6 +447,9 @@ func (f EntityForm) View() string {
|
|||||||
if !f.active {
|
if !f.active {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
if f.hasUserEdit {
|
||||||
|
return f.userView()
|
||||||
|
}
|
||||||
header := f.styles.header.Render(model.KindHeading(f.kind, f.itemID))
|
header := f.styles.header.Render(model.KindHeading(f.kind, f.itemID))
|
||||||
pLabel := f.styles.label.Render(f.primaryLabel)
|
pLabel := f.styles.label.Render(f.primaryLabel)
|
||||||
if f.focused && f.field == entityFieldPrimary && !f.readOnly {
|
if f.focused && f.field == entityFieldPrimary && !f.readOnly {
|
||||||
@@ -284,43 +459,95 @@ func (f EntityForm) View() string {
|
|||||||
if f.focused && f.field == entityFieldSecondary && !f.readOnly {
|
if f.focused && f.field == entityFieldSecondary && !f.readOnly {
|
||||||
sLabel = f.styles.labelAct.Render(f.secondaryLabel)
|
sLabel = f.styles.labelAct.Render(f.secondaryLabel)
|
||||||
}
|
}
|
||||||
statusBlock := ""
|
statusBlock := f.renderStatusBlock()
|
||||||
if f.hasStatus {
|
responsibleBlock := f.renderResponsibleBlock()
|
||||||
stLabel := f.styles.label.Render("Status")
|
metaBlock := f.renderMetaBlock()
|
||||||
if f.focused && f.field == entityFieldStatus && !f.readOnly {
|
|
||||||
stLabel = f.styles.labelAct.Render("Status")
|
|
||||||
}
|
|
||||||
valStyle := f.styles.label
|
|
||||||
if f.status == model.ProjectLifecycleClosed {
|
|
||||||
valStyle = valStyle.Foreground(lipgloss.Color("245"))
|
|
||||||
} else {
|
|
||||||
valStyle = valStyle.Foreground(lipgloss.Color("42"))
|
|
||||||
}
|
|
||||||
hint := ""
|
|
||||||
if f.focused && f.field == entityFieldStatus && !f.readOnly {
|
|
||||||
hint = f.styles.label.Render(" ←/→ toggle")
|
|
||||||
}
|
|
||||||
statusBlock = strings.Join([]string{
|
|
||||||
"",
|
|
||||||
stLabel,
|
|
||||||
valStyle.Render(f.status.Label()) + hint,
|
|
||||||
}, "\n")
|
|
||||||
}
|
|
||||||
ro := ""
|
ro := ""
|
||||||
if f.readOnly {
|
if f.readOnly {
|
||||||
ro = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(" (read-only)") + "\n"
|
ro = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(" (read-only)") + "\n"
|
||||||
}
|
}
|
||||||
return strings.Join([]string{
|
parts := []string{
|
||||||
header + ro,
|
header + ro,
|
||||||
pLabel,
|
pLabel,
|
||||||
f.primary.View(),
|
f.primary.View(),
|
||||||
"",
|
"",
|
||||||
sLabel,
|
sLabel,
|
||||||
f.secondary.View(),
|
f.secondary.View(),
|
||||||
statusBlock,
|
}
|
||||||
|
if statusBlock != "" {
|
||||||
|
parts = append(parts, statusBlock)
|
||||||
|
}
|
||||||
|
if responsibleBlock != "" {
|
||||||
|
parts = append(parts, responsibleBlock)
|
||||||
|
}
|
||||||
|
if metaBlock != "" {
|
||||||
|
parts = append(parts, metaBlock)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f EntityForm) renderStatusBlock() string {
|
||||||
|
if !f.hasStatus && !f.hasTaskStatus {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
stLabel := f.styles.label.Render("Status")
|
||||||
|
if f.focused && f.field == entityFieldStatus && !f.readOnly {
|
||||||
|
stLabel = f.styles.labelAct.Render("Status")
|
||||||
|
}
|
||||||
|
label := f.status.Label()
|
||||||
|
valStyle := f.styles.label
|
||||||
|
if f.hasTaskStatus {
|
||||||
|
label = f.taskStatus.Label()
|
||||||
|
if f.taskStatus == model.TaskLifecycleClosed {
|
||||||
|
valStyle = valStyle.Foreground(lipgloss.Color("245"))
|
||||||
|
} else if f.taskStatus == model.TaskLifecycleOpen {
|
||||||
|
valStyle = valStyle.Foreground(lipgloss.Color("42"))
|
||||||
|
}
|
||||||
|
} else if f.status == model.ProjectLifecycleClosed {
|
||||||
|
valStyle = valStyle.Foreground(lipgloss.Color("245"))
|
||||||
|
} else {
|
||||||
|
valStyle = valStyle.Foreground(lipgloss.Color("42"))
|
||||||
|
}
|
||||||
|
hint := ""
|
||||||
|
if f.focused && f.field == entityFieldStatus && !f.readOnly {
|
||||||
|
hint = f.styles.label.Render(" ←/→ cycle")
|
||||||
|
}
|
||||||
|
return strings.Join([]string{"", stLabel, valStyle.Render(label) + hint}, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f EntityForm) renderResponsibleBlock() string {
|
||||||
|
if !f.hasResponsible {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rLabel := f.styles.label.Render("Responsible")
|
||||||
|
if f.focused && f.field == entityFieldResponsible && !f.readOnly {
|
||||||
|
rLabel = f.styles.labelAct.Render("Responsible")
|
||||||
|
}
|
||||||
|
hint := ""
|
||||||
|
if f.focused && f.field == entityFieldResponsible && !f.readOnly && len(f.userChoices) > 0 {
|
||||||
|
hint = f.styles.label.Render(" ←/→ choose")
|
||||||
|
}
|
||||||
|
return strings.Join([]string{
|
||||||
|
"",
|
||||||
|
rLabel,
|
||||||
|
f.styles.meta.Render(f.responsibleLabel()) + hint,
|
||||||
}, "\n")
|
}, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f EntityForm) renderMetaBlock() string {
|
||||||
|
if f.projectTitle == "" && f.timingSummary == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
lines := []string{""}
|
||||||
|
if f.projectTitle != "" {
|
||||||
|
lines = append(lines, f.styles.label.Render("Project"), f.styles.meta.Render(f.projectTitle))
|
||||||
|
}
|
||||||
|
if f.timingSummary != "" {
|
||||||
|
lines = append(lines, f.styles.label.Render("Timing"), f.styles.meta.Render(f.timingSummary))
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
func entityFormBlinkCmd() tea.Cmd {
|
func entityFormBlinkCmd() tea.Cmd {
|
||||||
return tea.Batch(textinput.Blink, textarea.Blink)
|
return tea.Batch(textinput.Blink, textarea.Blink)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (f *EntityForm) userFieldCount() int {
|
||||||
|
return 2 + len(model.UserACLDefs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *EntityForm) userView() string {
|
||||||
|
header := f.styles.header.Render(model.KindHeading(f.kind, f.itemID))
|
||||||
|
lines := []string{
|
||||||
|
header,
|
||||||
|
f.styles.label.Render("ID"),
|
||||||
|
f.styles.meta.Render(f.itemID),
|
||||||
|
"",
|
||||||
|
f.renderUserEnabledRow(),
|
||||||
|
"",
|
||||||
|
f.renderUserPasswordRow(),
|
||||||
|
}
|
||||||
|
for i, def := range model.UserACLDefs {
|
||||||
|
lines = append(lines, f.renderUserACLRow(i, def.Label, f.userACL.ACLModuleOn(def.Key)))
|
||||||
|
}
|
||||||
|
lines = append(lines, "", f.styles.label.Render("Groups"), f.styles.meta.Render(f.groupsText))
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *EntityForm) renderUserEnabledRow() string {
|
||||||
|
label := f.styles.label.Render("Account")
|
||||||
|
if f.focused && f.userFieldIdx == 0 {
|
||||||
|
label = f.styles.labelAct.Render("Account")
|
||||||
|
}
|
||||||
|
val := "Disabled"
|
||||||
|
style := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
|
||||||
|
if f.userEnabled {
|
||||||
|
val = "Enabled"
|
||||||
|
style = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
|
||||||
|
}
|
||||||
|
hint := ""
|
||||||
|
if f.focused && f.userFieldIdx == 0 {
|
||||||
|
hint = f.styles.label.Render(" space toggle")
|
||||||
|
}
|
||||||
|
return strings.Join([]string{label, style.Render(val) + hint}, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *EntityForm) renderUserPasswordRow() string {
|
||||||
|
label := f.styles.label.Render("Password")
|
||||||
|
if f.focused && f.userFieldIdx == 1 {
|
||||||
|
label = f.styles.labelAct.Render("Password")
|
||||||
|
}
|
||||||
|
return strings.Join([]string{label, f.password.View(), f.styles.meta.Render("(leave blank to keep)")}, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *EntityForm) renderUserACLRow(idx int, title string, on bool) string {
|
||||||
|
fieldIdx := idx + 2
|
||||||
|
label := f.styles.label.Render(title)
|
||||||
|
if f.focused && f.userFieldIdx == fieldIdx {
|
||||||
|
label = f.styles.labelAct.Render(title)
|
||||||
|
}
|
||||||
|
val := "Off"
|
||||||
|
style := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
|
||||||
|
if on {
|
||||||
|
val = "On"
|
||||||
|
style = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
|
||||||
|
}
|
||||||
|
hint := ""
|
||||||
|
if f.focused && f.userFieldIdx == fieldIdx {
|
||||||
|
hint = f.styles.label.Render(" space toggle")
|
||||||
|
}
|
||||||
|
return strings.Join([]string{label, style.Render(val) + hint}, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *EntityForm) setUserFieldIndex(i int) {
|
||||||
|
if i < 0 {
|
||||||
|
i = 0
|
||||||
|
}
|
||||||
|
max := f.userFieldCount() - 1
|
||||||
|
if i > max {
|
||||||
|
i = max
|
||||||
|
}
|
||||||
|
f.userFieldIdx = i
|
||||||
|
f.applyFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *EntityForm) toggleUserEnabled() {
|
||||||
|
f.userEnabled = !f.userEnabled
|
||||||
|
f.dirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *EntityForm) toggleUserACL(idx int) {
|
||||||
|
if idx < 0 || idx >= len(model.UserACLDefs) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if idx == 0 {
|
||||||
|
f.userACL.FullAccess = !f.userACL.FullAccess
|
||||||
|
if f.userACL.FullAccess {
|
||||||
|
for k := range f.userACL.Modules {
|
||||||
|
f.userACL.Modules[k] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.dirty = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
def := model.UserACLDefs[idx]
|
||||||
|
if f.userACL.FullAccess {
|
||||||
|
f.userACL.FullAccess = false
|
||||||
|
for k := range f.userACL.Modules {
|
||||||
|
f.userACL.Modules[k] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.userACL.Modules[def.Key] = !f.userACL.Modules[def.Key]
|
||||||
|
f.dirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *EntityForm) updateUserForm(msg tea.Msg) tea.Cmd {
|
||||||
|
if key, ok := msg.(tea.KeyMsg); ok {
|
||||||
|
switch f.userFieldIdx {
|
||||||
|
case 0:
|
||||||
|
switch key.String() {
|
||||||
|
case " ", "left", "right", "l", "h":
|
||||||
|
f.toggleUserEnabled()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
case 1:
|
||||||
|
var cmd tea.Cmd
|
||||||
|
f.password, cmd = f.password.Update(msg)
|
||||||
|
f.dirty = true
|
||||||
|
return cmd
|
||||||
|
default:
|
||||||
|
switch key.String() {
|
||||||
|
case " ", "left", "right", "l", "h":
|
||||||
|
f.toggleUserACL(f.userFieldIdx - 2)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *EntityForm) applyUserFocus() {
|
||||||
|
f.primary.Blur()
|
||||||
|
f.secondary.Blur()
|
||||||
|
if f.userFieldIdx == 1 {
|
||||||
|
f.password.Focus()
|
||||||
|
} else {
|
||||||
|
f.password.Blur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyUserACLState(s model.UserACLState) model.UserACLState {
|
||||||
|
modules := make(map[string]bool, len(s.Modules))
|
||||||
|
for k, v := range s.Modules {
|
||||||
|
modules[k] = v
|
||||||
|
}
|
||||||
|
return model.UserACLState{FullAccess: s.FullAccess, Modules: modules}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUserFormViewShowsIDAndACL(t *testing.T) {
|
||||||
|
f := NewEntityFormForTest()
|
||||||
|
f.SetSize(60, 30)
|
||||||
|
f.Load(model.KindUser, "42", model.FormFields{
|
||||||
|
HasUserEdit: true,
|
||||||
|
UserEnabled: true,
|
||||||
|
UserACL: model.UserACLState{
|
||||||
|
FullAccess: false,
|
||||||
|
Modules: map[string]bool{
|
||||||
|
"documents": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
GroupsText: "Admins, Devs",
|
||||||
|
})
|
||||||
|
view := f.View()
|
||||||
|
for _, want := range []string{"User 42", "ID", "42", "Account", "Password", "Full access", "Documents", "Groups", "Admins"} {
|
||||||
|
if !strings.Contains(view, want) {
|
||||||
|
t.Fatalf("view missing %q:\n%s", want, view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserFormFieldCount(t *testing.T) {
|
||||||
|
f := NewEntityFormForTest()
|
||||||
|
f.Load(model.KindUser, "1", model.FormFields{HasUserEdit: true, UserACL: model.UserACLFromRaw(nil)})
|
||||||
|
if got := f.FieldCount(); got != 2+len(model.UserACLDefs) {
|
||||||
|
t.Fatalf("field count=%d want %d", got, len(model.UserACLDefs)+2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayoutUserTableEmailAbsorbsWidth(t *testing.T) {
|
||||||
|
cols := model.BuildColumns(model.SubjectUsers, nil)
|
||||||
|
flex, ok := model.TableFlexLayoutFor(model.SubjectUsers)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected flex layout")
|
||||||
|
}
|
||||||
|
lay := layoutFlexTable(cols, 100, flex.FlexColumnKey, flex.MinFlexWidth)
|
||||||
|
sum := 0
|
||||||
|
emailW := 0
|
||||||
|
for _, i := range lay.indices {
|
||||||
|
sum += lay.widths[i]
|
||||||
|
if cols[i].Key == "email" {
|
||||||
|
emailW = lay.widths[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sum != 100 {
|
||||||
|
t.Fatalf("sum=%d want 100", sum)
|
||||||
|
}
|
||||||
|
if emailW < 30 {
|
||||||
|
t.Fatalf("email width=%d should absorb extra space", emailW)
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-1
@@ -25,7 +25,7 @@ type filterSearchStyles struct {
|
|||||||
func newFilterSearch() FilterSearch {
|
func newFilterSearch() FilterSearch {
|
||||||
in := textinput.New()
|
in := textinput.New()
|
||||||
in.Prompt = "/ "
|
in.Prompt = "/ "
|
||||||
in.Placeholder = "Filter navigation and list…"
|
in.Placeholder = "Filter list…"
|
||||||
in.CharLimit = 256
|
in.CharLimit = 256
|
||||||
return FilterSearch{
|
return FilterSearch{
|
||||||
input: in,
|
input: in,
|
||||||
@@ -76,6 +76,17 @@ func (f *FilterSearch) Update(msg tea.Msg) tea.Cmd {
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *FilterSearch) SetInputWidth(inner int) {
|
||||||
|
if inner < 8 {
|
||||||
|
inner = 8
|
||||||
|
}
|
||||||
|
f.input.Width = inner
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FilterSearch) CompactView() string {
|
||||||
|
return f.input.View()
|
||||||
|
}
|
||||||
|
|
||||||
func (f FilterSearch) View() string {
|
func (f FilterSearch) View() string {
|
||||||
title := f.styles.title.Render("Filter")
|
title := f.styles.title.Render("Filter")
|
||||||
body := lipgloss.NewStyle().Width(f.width).Render(f.input.View())
|
body := lipgloss.NewStyle().Width(f.width).Render(f.input.View())
|
||||||
|
|||||||
@@ -39,8 +39,13 @@ func KeyAction(key string, pane model.FocusPane) Action {
|
|||||||
}
|
}
|
||||||
case "r":
|
case "r":
|
||||||
return ActionRefresh
|
return ActionRefresh
|
||||||
case "f":
|
case "f", "/":
|
||||||
|
if pane == model.FocusList {
|
||||||
return ActionFilter
|
return ActionFilter
|
||||||
|
}
|
||||||
|
if pane == model.FocusMenu {
|
||||||
|
return ActionFilter
|
||||||
|
}
|
||||||
case "up", "k":
|
case "up", "k":
|
||||||
return ActionMoveUp
|
return ActionMoveUp
|
||||||
case "down", "j":
|
case "down", "j":
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
|
"github.com/mattn/go-runewidth"
|
||||||
|
"github.com/muesli/termenv"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLayoutWidthsAllVisibleUsesFullWidth(t *testing.T) {
|
func TestLayoutWidthsAllVisibleUsesFullWidth(t *testing.T) {
|
||||||
@@ -66,6 +71,53 @@ func TestPrevVisibleFocusSkipsHidden(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestThreePaneRenderedWidthMatchesTerminal(t *testing.T) {
|
||||||
|
prev := lipgloss.ColorProfile()
|
||||||
|
lipgloss.SetColorProfile(termenv.TrueColor)
|
||||||
|
t.Cleanup(func() { lipgloss.SetColorProfile(prev) })
|
||||||
|
|
||||||
|
total := 120
|
||||||
|
pw := LayoutWidths(total, PaneVisibility{Menu: true, List: true, Detail: true})
|
||||||
|
h := 12
|
||||||
|
menu := paneStyle(false).Width(paneLipglossWidth(pw.Menu)).Height(h).Render(strings.Repeat("m", paneContentWidth(pw.Menu)))
|
||||||
|
inner := paneContentWidth(pw.List)
|
||||||
|
tbl := newDataTable()
|
||||||
|
tbl.SetSize(inner, h-listToolbarHeight)
|
||||||
|
tbl.SetData(model.ListSpec{Subject: model.SubjectProjects}, []model.Item{
|
||||||
|
{ID: "1", Title: "Alpha", Kind: model.KindProject, Raw: map[string]any{"status": 0}},
|
||||||
|
})
|
||||||
|
toolbar := newListToolbar()
|
||||||
|
toolbar.SetWidth(inner)
|
||||||
|
body := lipgloss.JoinVertical(lipgloss.Left, toolbar.View(ListToolbarMeta{Subject: "projects", Count: 1}), tbl.View())
|
||||||
|
list := paneStyle(true).Width(paneLipglossWidth(pw.List)).Height(h).Render(body)
|
||||||
|
detail := paneStyle(false).Width(paneLipglossWidth(pw.Detail)).Height(h).Render(strings.Repeat("d", paneContentWidth(pw.Detail)))
|
||||||
|
full := lipgloss.JoinHorizontal(lipgloss.Top, menu, list, detail)
|
||||||
|
re := regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||||
|
top := strings.Split(full, "\n")[0]
|
||||||
|
got := runewidth.StringWidth(re.ReplaceAllString(top, ""))
|
||||||
|
if got != total {
|
||||||
|
t.Fatalf("top border width=%d want %d", got, total)
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(tbl.View(), "\n") {
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
w := runewidth.StringWidth(re.ReplaceAllString(line, ""))
|
||||||
|
if w > inner+1 {
|
||||||
|
t.Fatalf("table line width=%d want <=%d", w, inner+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPaneContentWidth(t *testing.T) {
|
||||||
|
if got := paneContentWidth(72); got != 68 {
|
||||||
|
t.Fatalf("content width=%d want 68", got)
|
||||||
|
}
|
||||||
|
if got := paneLipglossWidth(72); got != 70 {
|
||||||
|
t.Fatalf("lipgloss width=%d want 70", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLayoutWidthsLegacyHelper(t *testing.T) {
|
func TestLayoutWidthsLegacyHelper(t *testing.T) {
|
||||||
menu, list, preview := LayoutWidthsLegacy(120)
|
menu, list, preview := LayoutWidthsLegacy(120)
|
||||||
if menu+list+preview != 120 {
|
if menu+list+preview != 120 {
|
||||||
|
|||||||
@@ -0,0 +1,352 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
|
"github.com/mattn/go-runewidth"
|
||||||
|
)
|
||||||
|
|
||||||
|
const listToolbarHeight = 2
|
||||||
|
|
||||||
|
type listZone int
|
||||||
|
|
||||||
|
const (
|
||||||
|
listZoneTable listZone = iota
|
||||||
|
listZoneFilter
|
||||||
|
listZoneActions
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListToolbarMeta is the navigation/sort summary shown above the filter row.
|
||||||
|
type ListToolbarMeta struct {
|
||||||
|
Subject string
|
||||||
|
Count int
|
||||||
|
SortLabel string
|
||||||
|
LoadingMore bool
|
||||||
|
SaveEnabled bool
|
||||||
|
DeleteEnabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListToolbar is the filter & navigation bar above the center table.
|
||||||
|
type ListToolbar struct {
|
||||||
|
filter FilterSearch
|
||||||
|
width int
|
||||||
|
zone listZone
|
||||||
|
actionIdx int
|
||||||
|
actionRects []toolbarBtnRect
|
||||||
|
btnX0 int
|
||||||
|
styles listToolbarStyles
|
||||||
|
}
|
||||||
|
|
||||||
|
type toolbarBtnRect struct {
|
||||||
|
action model.ActionID
|
||||||
|
x0, x1 int
|
||||||
|
}
|
||||||
|
|
||||||
|
type listToolbarStyles struct {
|
||||||
|
title lipgloss.Style
|
||||||
|
btn lipgloss.Style
|
||||||
|
btnOn lipgloss.Style
|
||||||
|
btnOff lipgloss.Style
|
||||||
|
btnAct lipgloss.Style
|
||||||
|
btnDn lipgloss.Style
|
||||||
|
}
|
||||||
|
|
||||||
|
func newListToolbar() ListToolbar {
|
||||||
|
return ListToolbar{
|
||||||
|
filter: newFilterSearch(),
|
||||||
|
styles: listToolbarStyles{
|
||||||
|
title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("252")),
|
||||||
|
btn: lipgloss.NewStyle().Padding(0, 1),
|
||||||
|
btnOn: lipgloss.NewStyle().Padding(0, 1).Foreground(lipgloss.Color("255")),
|
||||||
|
btnOff: lipgloss.NewStyle().Padding(0, 1).Foreground(lipgloss.Color("238")),
|
||||||
|
btnAct: lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("62")),
|
||||||
|
btnDn: lipgloss.NewStyle().Padding(0, 1).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("52")),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *ListToolbar) SetWidth(w int) {
|
||||||
|
if w < 12 {
|
||||||
|
w = 12
|
||||||
|
}
|
||||||
|
b.width = w
|
||||||
|
inner := w - 8
|
||||||
|
if inner < 12 {
|
||||||
|
inner = 12
|
||||||
|
}
|
||||||
|
b.filter.SetInputWidth(inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *ListToolbar) Zone() listZone { return b.zone }
|
||||||
|
|
||||||
|
func (b *ListToolbar) SetZone(z listZone) { b.zone = z }
|
||||||
|
|
||||||
|
func (b *ListToolbar) Query() string { return b.filter.Query() }
|
||||||
|
|
||||||
|
func (b *ListToolbar) ClearFilter() { b.filter.Clear() }
|
||||||
|
|
||||||
|
func (b *ListToolbar) syncInputFocus() {
|
||||||
|
b.filter.SetFocused(b.zone == listZoneFilter)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *ListToolbar) FocusFilter() {
|
||||||
|
b.zone = listZoneFilter
|
||||||
|
b.filter.SetFocused(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *ListToolbar) FocusTable() {
|
||||||
|
b.zone = listZoneTable
|
||||||
|
b.filter.SetFocused(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *ListToolbar) TabForward(meta ListToolbarMeta) {
|
||||||
|
switch b.zone {
|
||||||
|
case listZoneTable:
|
||||||
|
b.FocusFilter()
|
||||||
|
case listZoneFilter:
|
||||||
|
b.zone = listZoneActions
|
||||||
|
b.actionIdx = 0
|
||||||
|
b.filter.SetFocused(false)
|
||||||
|
b.clampActionIdx(meta)
|
||||||
|
case listZoneActions:
|
||||||
|
if b.nextEnabledAction(meta, 1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.FocusTable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *ListToolbar) TabBackward(meta ListToolbarMeta) {
|
||||||
|
switch b.zone {
|
||||||
|
case listZoneTable:
|
||||||
|
b.zone = listZoneActions
|
||||||
|
b.actionIdx = b.lastEnabledAction(meta)
|
||||||
|
b.filter.SetFocused(false)
|
||||||
|
case listZoneActions:
|
||||||
|
if b.nextEnabledAction(meta, -1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.FocusFilter()
|
||||||
|
case listZoneFilter:
|
||||||
|
b.FocusTable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *ListToolbar) toolbarActions(meta ListToolbarMeta) []model.ActionID {
|
||||||
|
var out []model.ActionID
|
||||||
|
if meta.SaveEnabled {
|
||||||
|
out = append(out, model.ActionSave)
|
||||||
|
}
|
||||||
|
if meta.DeleteEnabled {
|
||||||
|
out = append(out, model.ActionDelete)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *ListToolbar) clampActionIdx(meta ListToolbarMeta) {
|
||||||
|
acts := b.toolbarActions(meta)
|
||||||
|
if len(acts) == 0 {
|
||||||
|
b.actionIdx = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if b.actionIdx >= len(acts) {
|
||||||
|
b.actionIdx = len(acts) - 1
|
||||||
|
}
|
||||||
|
if b.actionIdx < 0 {
|
||||||
|
b.actionIdx = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *ListToolbar) lastEnabledAction(meta ListToolbarMeta) int {
|
||||||
|
acts := b.toolbarActions(meta)
|
||||||
|
if len(acts) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return len(acts) - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *ListToolbar) nextEnabledAction(meta ListToolbarMeta, delta int) bool {
|
||||||
|
acts := b.toolbarActions(meta)
|
||||||
|
if len(acts) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
next := b.actionIdx + delta
|
||||||
|
if next >= 0 && next < len(acts) {
|
||||||
|
b.actionIdx = next
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *ListToolbar) SelectedAction(meta ListToolbarMeta) (model.ActionID, bool) {
|
||||||
|
acts := b.toolbarActions(meta)
|
||||||
|
if b.zone != listZoneActions || len(acts) == 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
b.clampActionIdx(meta)
|
||||||
|
return acts[b.actionIdx], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *ListToolbar) MoveAction(delta int, meta ListToolbarMeta) {
|
||||||
|
if b.zone != listZoneActions {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.nextEnabledAction(meta, delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *ListToolbar) Update(msg tea.Msg, meta ListToolbarMeta) tea.Cmd {
|
||||||
|
if b.zone == listZoneFilter {
|
||||||
|
if key, ok := msg.(tea.KeyMsg); ok {
|
||||||
|
cmd := b.filter.Update(msg)
|
||||||
|
switch key.String() {
|
||||||
|
case "tab":
|
||||||
|
b.TabForward(meta)
|
||||||
|
return cmd
|
||||||
|
case "shift+tab", "backtab":
|
||||||
|
b.TabBackward(meta)
|
||||||
|
return cmd
|
||||||
|
case "enter":
|
||||||
|
b.zone = listZoneActions
|
||||||
|
b.actionIdx = 0
|
||||||
|
b.filter.SetFocused(false)
|
||||||
|
b.clampActionIdx(meta)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
return b.filter.Update(msg)
|
||||||
|
}
|
||||||
|
if b.zone == listZoneActions {
|
||||||
|
if key, ok := msg.(tea.KeyMsg); ok {
|
||||||
|
switch key.String() {
|
||||||
|
case "left", "h":
|
||||||
|
b.MoveAction(-1, meta)
|
||||||
|
case "right", "l":
|
||||||
|
b.MoveAction(1, meta)
|
||||||
|
case "tab":
|
||||||
|
b.TabForward(meta)
|
||||||
|
case "shift+tab", "backtab":
|
||||||
|
b.TabBackward(meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *ListToolbar) View(meta ListToolbarMeta) string {
|
||||||
|
title := b.styles.title.Render(meta.Subject)
|
||||||
|
if meta.Count > 0 || meta.Subject != "" {
|
||||||
|
title = b.styles.title.Render(formatListToolbarTitle(meta))
|
||||||
|
}
|
||||||
|
if meta.LoadingMore {
|
||||||
|
title += lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(" …")
|
||||||
|
}
|
||||||
|
|
||||||
|
filter := b.filter.CompactView()
|
||||||
|
buttons, rects := b.renderButtons(meta)
|
||||||
|
|
||||||
|
avail := b.width - runewidth.StringWidth(buttons) - 1
|
||||||
|
if avail < 8 {
|
||||||
|
avail = 8
|
||||||
|
}
|
||||||
|
b.btnX0 = avail + 1
|
||||||
|
for i := range rects {
|
||||||
|
rects[i].x0 += b.btnX0
|
||||||
|
rects[i].x1 += b.btnX0
|
||||||
|
}
|
||||||
|
b.actionRects = rects
|
||||||
|
|
||||||
|
filterLine := padDisplayWidth(filter, avail) + " " + buttons
|
||||||
|
if runewidth.StringWidth(filterLine) > b.width {
|
||||||
|
filterLine = runewidth.Truncate(filterLine, b.width, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := []string{
|
||||||
|
padANSIWidth(title, b.width),
|
||||||
|
padANSIWidth(filterLine, b.width),
|
||||||
|
}
|
||||||
|
return lipgloss.NewStyle().Width(b.width).MaxWidth(b.width).Render(strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatListToolbarTitle(meta ListToolbarMeta) string {
|
||||||
|
s := meta.Subject
|
||||||
|
if meta.Count > 0 {
|
||||||
|
s = fmt.Sprintf("%s (%d)", meta.Subject, meta.Count)
|
||||||
|
}
|
||||||
|
if meta.SortLabel != "" {
|
||||||
|
s += " " + meta.SortLabel
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *ListToolbar) renderButtons(meta ListToolbarMeta) (string, []toolbarBtnRect) {
|
||||||
|
type btn struct {
|
||||||
|
action model.ActionID
|
||||||
|
icon string
|
||||||
|
enabled bool
|
||||||
|
danger bool
|
||||||
|
}
|
||||||
|
defs := []btn{
|
||||||
|
{model.ActionSave, "💾", meta.SaveEnabled, false},
|
||||||
|
{model.ActionDelete, "🗑", meta.DeleteEnabled, true},
|
||||||
|
}
|
||||||
|
acts := b.toolbarActions(meta)
|
||||||
|
rects := make([]toolbarBtnRect, 0, len(defs))
|
||||||
|
parts := make([]string, 0, len(defs))
|
||||||
|
x := 0
|
||||||
|
for i, d := range defs {
|
||||||
|
active := b.zone == listZoneActions && len(acts) > 0 && b.actionIdx < len(acts) && acts[b.actionIdx] == d.action
|
||||||
|
var style lipgloss.Style
|
||||||
|
switch {
|
||||||
|
case !d.enabled:
|
||||||
|
style = b.styles.btnOff
|
||||||
|
case active && d.danger:
|
||||||
|
style = b.styles.btnDn
|
||||||
|
case active:
|
||||||
|
style = b.styles.btnAct
|
||||||
|
case d.danger:
|
||||||
|
style = b.styles.btn.Foreground(lipgloss.Color("203"))
|
||||||
|
default:
|
||||||
|
style = b.styles.btnOn
|
||||||
|
}
|
||||||
|
rendered := style.Render(d.icon)
|
||||||
|
w := runewidth.StringWidth(rendered)
|
||||||
|
rects = append(rects, toolbarBtnRect{action: d.action, x0: x, x1: x + w})
|
||||||
|
x += w
|
||||||
|
parts = append(parts, rendered)
|
||||||
|
_ = i
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " "), rects
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *ListToolbar) ActionAt(x int, meta ListToolbarMeta) (model.ActionID, bool) {
|
||||||
|
_ = meta
|
||||||
|
if x < 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
for _, r := range b.actionRects {
|
||||||
|
if x >= r.x0 && x < r.x1 {
|
||||||
|
action := r.action
|
||||||
|
if b.IsActionEnabled(action, meta) {
|
||||||
|
return action, true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *ListToolbar) IsActionEnabled(action model.ActionID, meta ListToolbarMeta) bool {
|
||||||
|
switch action {
|
||||||
|
case model.ActionSave:
|
||||||
|
return meta.SaveEnabled
|
||||||
|
case model.ActionDelete:
|
||||||
|
return meta.DeleteEnabled
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestListToolbarShowsFilterAndActions(t *testing.T) {
|
||||||
|
b := newListToolbar()
|
||||||
|
b.SetWidth(60)
|
||||||
|
view := b.View(ListToolbarMeta{
|
||||||
|
Subject: "projects",
|
||||||
|
Count: 3,
|
||||||
|
})
|
||||||
|
if !strings.Contains(view, "projects (3)") {
|
||||||
|
t.Fatalf("missing subject: %q", view)
|
||||||
|
}
|
||||||
|
if !strings.Contains(view, "💾") || !strings.Contains(view, "🗑") {
|
||||||
|
t.Fatalf("missing action icons: %q", view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListToolbarActionsEnabledFromMeta(t *testing.T) {
|
||||||
|
b := newListToolbar()
|
||||||
|
on := ListToolbarMeta{SaveEnabled: true, DeleteEnabled: true}
|
||||||
|
off := ListToolbarMeta{}
|
||||||
|
if !b.IsActionEnabled(model.ActionSave, on) {
|
||||||
|
t.Fatal("save should be enabled")
|
||||||
|
}
|
||||||
|
if b.IsActionEnabled(model.ActionSave, off) {
|
||||||
|
t.Fatal("save should be disabled")
|
||||||
|
}
|
||||||
|
if !b.IsActionEnabled(model.ActionDelete, on) {
|
||||||
|
t.Fatal("delete should be enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanSaveFromListRequiresSelection(t *testing.T) {
|
||||||
|
m := Model{
|
||||||
|
items: []model.Item{
|
||||||
|
{ID: "1", Title: "A", Kind: model.KindProject, Selected: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if m.canSaveFromList() {
|
||||||
|
t.Fatal("save should require matching loaded detail")
|
||||||
|
}
|
||||||
|
m.detail = newDetailPane()
|
||||||
|
m.detail.SetSize(40, 20)
|
||||||
|
m.detail.LoadForm(model.Item{ID: "1", Kind: model.KindProject}, model.FormFields{
|
||||||
|
PrimaryLabel: "Title", SecondaryLabel: "Description",
|
||||||
|
Primary: "A", HasStatus: true,
|
||||||
|
})
|
||||||
|
if !m.canSaveFromList() {
|
||||||
|
t.Fatal("save should be enabled with one selected row and loaded detail")
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-3
@@ -8,7 +8,11 @@ import (
|
|||||||
func (m *Model) scrollFocusedPane(key string) bool {
|
func (m *Model) scrollFocusedPane(key string) bool {
|
||||||
switch m.focus {
|
switch m.focus {
|
||||||
case model.FocusMenu:
|
case model.FocusMenu:
|
||||||
|
switch key {
|
||||||
|
case "pgdown", "pgdn", "ctrl+d", "pgup", "b", "ctrl+u", "home", "g", "end", "G":
|
||||||
return scrollViewport(&m.menuVP, key)
|
return scrollViewport(&m.menuVP, key)
|
||||||
|
}
|
||||||
|
return false
|
||||||
case model.FocusList:
|
case model.FocusList:
|
||||||
switch key {
|
switch key {
|
||||||
case "pgdown", "pgdn", "ctrl+d":
|
case "pgdown", "pgdn", "ctrl+d":
|
||||||
@@ -37,17 +41,40 @@ func (m *Model) paneHeight() int {
|
|||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) paneInnerWidth(outer int) int {
|
const (
|
||||||
if outer <= 0 {
|
paneBorderChars = 2 // left + right border drawn outside lipgloss Width
|
||||||
|
panePaddingChars = 2 // Padding(0, 1) on paneStyle
|
||||||
|
)
|
||||||
|
|
||||||
|
// paneLipglossWidth maps an on-screen pane width to lipgloss Style.Width.
|
||||||
|
// Bordered panes render two cells wider than the Width value.
|
||||||
|
func paneLipglossWidth(rendered int) int {
|
||||||
|
if rendered <= 0 {
|
||||||
return 8
|
return 8
|
||||||
}
|
}
|
||||||
w := outer - 2 // border only; content fills inner box
|
w := rendered - paneBorderChars
|
||||||
if w < 8 {
|
if w < 8 {
|
||||||
w = 8
|
w = 8
|
||||||
}
|
}
|
||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// paneContentWidth is the usable inner width for viewports and tables.
|
||||||
|
func paneContentWidth(rendered int) int {
|
||||||
|
if rendered <= 0 {
|
||||||
|
return 8
|
||||||
|
}
|
||||||
|
w := rendered - paneBorderChars - panePaddingChars
|
||||||
|
if w < 8 {
|
||||||
|
w = 8
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) paneInnerWidth(rendered int) int {
|
||||||
|
return paneContentWidth(rendered)
|
||||||
|
}
|
||||||
|
|
||||||
func scrollViewport(vp *viewport.Model, key string) bool {
|
func scrollViewport(vp *viewport.Model, key string) bool {
|
||||||
switch key {
|
switch key {
|
||||||
case "up", "k":
|
case "up", "k":
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestScrollFocusedPaneMenuUpDownMovesTree(t *testing.T) {
|
||||||
|
m := NewModel(nil)
|
||||||
|
m.focus = model.FocusMenu
|
||||||
|
m.syncMenuContent()
|
||||||
|
before := m.nav.Cursor()
|
||||||
|
|
||||||
|
if m.scrollFocusedPane("down") {
|
||||||
|
t.Fatal("down should not be consumed by menu viewport scroll")
|
||||||
|
}
|
||||||
|
m.moveDown()
|
||||||
|
m.syncFocusedPane()
|
||||||
|
if m.nav.Cursor() != before+1 {
|
||||||
|
t.Fatalf("cursor=%d want %d", m.nav.Cursor(), before+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScrollFocusedPaneMenuPageKeysScrollViewport(t *testing.T) {
|
||||||
|
m := NewModel(nil)
|
||||||
|
m.focus = model.FocusMenu
|
||||||
|
for i := 0; i < 40; i++ {
|
||||||
|
m.nav.MoveDown()
|
||||||
|
}
|
||||||
|
m.syncMenuContent()
|
||||||
|
if !m.scrollFocusedPane("pgup") {
|
||||||
|
t.Fatal("pgup should scroll menu viewport")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleMenuHorizontalRightOpensLeaf(t *testing.T) {
|
||||||
|
m := NewModel(nil)
|
||||||
|
m.focus = model.FocusMenu
|
||||||
|
// cursor starts on Projects leaf
|
||||||
|
next, cmd := m.handleMenuHorizontal(1)
|
||||||
|
if cmd == nil {
|
||||||
|
t.Fatal("expected load list command")
|
||||||
|
}
|
||||||
|
if next.listSpec.Subject != model.SubjectProjects {
|
||||||
|
t.Fatalf("expected projects list, got %+v", next.listSpec)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/mattn/go-runewidth"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ScrollbarMetrics computes thumb position for a vertical scrollbar.
|
// ScrollbarMetrics computes thumb position for a vertical scrollbar.
|
||||||
@@ -49,7 +48,7 @@ func ApplyVerticalScrollbar(view string, width, height, totalLines, yOffset int)
|
|||||||
thumb := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
|
thumb := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
|
||||||
out := make([]string, height)
|
out := make([]string, height)
|
||||||
for row := 0; row < height; row++ {
|
for row := 0; row < height; row++ {
|
||||||
line := padDisplayWidth(lines[row], contentWidth)
|
line := padANSIWidth(lines[row], contentWidth)
|
||||||
ch := "│"
|
ch := "│"
|
||||||
style := track
|
style := track
|
||||||
if row >= thumbStart && row < thumbEnd {
|
if row >= thumbStart && row < thumbEnd {
|
||||||
@@ -62,15 +61,5 @@ func ApplyVerticalScrollbar(view string, width, height, totalLines, yOffset int)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func padDisplayWidth(line string, width int) string {
|
func padDisplayWidth(line string, width int) string {
|
||||||
if width < 1 {
|
return padANSIWidth(line, width)
|
||||||
return ""
|
|
||||||
}
|
|
||||||
w := runewidth.StringWidth(line)
|
|
||||||
if w > width {
|
|
||||||
return runewidth.Truncate(line, width, "")
|
|
||||||
}
|
|
||||||
if w < width {
|
|
||||||
line += strings.Repeat(" ", width-w)
|
|
||||||
}
|
|
||||||
return line
|
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-172
@@ -9,7 +9,6 @@ import (
|
|||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
"github.com/mattn/go-runewidth"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type tableStyles struct {
|
type tableStyles struct {
|
||||||
@@ -80,7 +79,7 @@ func (t *DataTable) SetSize(w, h int) {
|
|||||||
t.width = w
|
t.width = w
|
||||||
t.height = h
|
t.height = h
|
||||||
t.viewport.Width = w
|
t.viewport.Width = w
|
||||||
t.viewport.Height = h - 2 // title + header
|
t.viewport.Height = h - 1 // column header
|
||||||
if t.viewport.Height < 1 {
|
if t.viewport.Height < 1 {
|
||||||
t.viewport.Height = 1
|
t.viewport.Height = 1
|
||||||
}
|
}
|
||||||
@@ -260,155 +259,63 @@ func (t *DataTable) Update(msg tea.Msg) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t DataTable) LoadingMore() bool { return t.loadingMore }
|
||||||
|
|
||||||
func (t DataTable) View() string {
|
func (t DataTable) View() string {
|
||||||
if !t.ready {
|
if !t.ready {
|
||||||
return "List\n\nSelect a leaf node in the tree\n(marked with •) and press Enter.\n"
|
return "\nSelect a leaf in the tree (•) and press Enter.\n"
|
||||||
}
|
|
||||||
title := t.styles.title.Render(fmt.Sprintf("%s (%d)", t.spec.Subject, len(t.items)))
|
|
||||||
if t.loadingMore {
|
|
||||||
title += t.styles.help.Render(" …")
|
|
||||||
}
|
|
||||||
sortHint := ""
|
|
||||||
if t.sortCol >= 0 && t.sortCol < len(t.columns) {
|
|
||||||
dir := "▲"
|
|
||||||
if !t.sortAsc {
|
|
||||||
dir = "▼"
|
|
||||||
}
|
|
||||||
sortHint = t.styles.help.Render(fmt.Sprintf(" sort: %s %s", t.columns[t.sortCol].Title, dir))
|
|
||||||
}
|
}
|
||||||
header := t.renderHeader()
|
header := t.renderHeader()
|
||||||
|
if t.hasVerticalScrollbar() {
|
||||||
|
header = padANSIWidth(header, t.lineContentWidth())
|
||||||
|
}
|
||||||
body := ApplyVerticalScrollbar(
|
body := ApplyVerticalScrollbar(
|
||||||
t.viewport.View(),
|
t.viewport.View(),
|
||||||
t.viewport.Width,
|
t.width,
|
||||||
t.viewport.Height,
|
t.viewport.Height,
|
||||||
t.viewport.TotalLineCount(),
|
t.viewport.TotalLineCount(),
|
||||||
t.viewport.YOffset,
|
t.viewport.YOffset,
|
||||||
)
|
)
|
||||||
content := lipgloss.JoinVertical(lipgloss.Left, title+sortHint, header, body)
|
return lipgloss.JoinVertical(lipgloss.Left, header, body)
|
||||||
return lipgloss.NewStyle().Width(t.width).Render(content)
|
}
|
||||||
|
|
||||||
|
func (t DataTable) SortHint() string {
|
||||||
|
if t.sortCol < 0 || t.sortCol >= len(t.columns) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
dir := "▲"
|
||||||
|
if !t.sortAsc {
|
||||||
|
dir = "▼"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("sort: %s %s", t.columns[t.sortCol].Title, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t DataTable) SubjectLabel() string {
|
||||||
|
if !t.ready {
|
||||||
|
return "List"
|
||||||
|
}
|
||||||
|
return string(t.spec.Subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t DataTable) ItemCount() int {
|
||||||
|
return len(t.items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t DataTable) hasVerticalScrollbar() bool {
|
||||||
|
return t.ready && t.viewport.TotalLineCount() > t.viewport.Height
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t DataTable) lineContentWidth() int {
|
||||||
|
w := t.width
|
||||||
|
if t.hasVerticalScrollbar() && w > 1 {
|
||||||
|
w--
|
||||||
|
}
|
||||||
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *DataTable) visibleLayout() (indices []int, widths map[int]int) {
|
func (t *DataTable) visibleLayout() (indices []int, widths map[int]int) {
|
||||||
widths = make(map[int]int)
|
lay := t.computeLayout()
|
||||||
if len(t.columns) == 0 || t.width <= 0 {
|
return lay.indices, lay.widths
|
||||||
return nil, widths
|
|
||||||
}
|
|
||||||
indices = t.pickVisibleColumnIndices()
|
|
||||||
if len(indices) == 0 {
|
|
||||||
return indices, widths
|
|
||||||
}
|
|
||||||
minSum := 0
|
|
||||||
for _, i := range indices {
|
|
||||||
minSum += t.columns[i].Width
|
|
||||||
}
|
|
||||||
widths = distributeColumnWidths(minSum, t.width, indices, t.columns)
|
|
||||||
return indices, widths
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *DataTable) pickVisibleColumnIndices() []int {
|
|
||||||
var out []int
|
|
||||||
used := 0
|
|
||||||
for colIdx := t.colScroll; colIdx < len(t.columns); colIdx++ {
|
|
||||||
w := t.columns[colIdx].Width
|
|
||||||
if len(out) > 0 && used+w > t.width {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
out = append(out, colIdx)
|
|
||||||
used += w
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
colIdx := t.colScroll
|
|
||||||
if colIdx < 0 || colIdx >= len(t.columns) {
|
|
||||||
colIdx = 0
|
|
||||||
}
|
|
||||||
out = []int{colIdx}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// distributeColumnWidths expands or shrinks visible columns to exactly fill total width.
|
|
||||||
func distributeColumnWidths(minSum, total int, indices []int, cols []model.TableColumn) map[int]int {
|
|
||||||
out := make(map[int]int, len(indices))
|
|
||||||
if len(indices) == 0 {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
if total < len(indices) {
|
|
||||||
total = len(indices)
|
|
||||||
}
|
|
||||||
if minSum <= 0 {
|
|
||||||
each := total / len(indices)
|
|
||||||
if each < 1 {
|
|
||||||
each = 1
|
|
||||||
}
|
|
||||||
for _, i := range indices {
|
|
||||||
out[i] = each
|
|
||||||
}
|
|
||||||
fixColumnWidthSum(out, indices, total)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
for _, i := range indices {
|
|
||||||
out[i] = cols[i].Width
|
|
||||||
}
|
|
||||||
if minSum >= total {
|
|
||||||
for _, i := range indices {
|
|
||||||
out[i] = cols[i].Width * total / minSum
|
|
||||||
if out[i] < 1 {
|
|
||||||
out[i] = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fixColumnWidthSum(out, indices, total)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
extra := total - minSum
|
|
||||||
flex := make([]int, 0, len(indices))
|
|
||||||
for _, i := range indices {
|
|
||||||
switch cols[i].Key {
|
|
||||||
case "title", "subtitle", "description", "displayName", "primaryEmail", "from", "to", "tasks", "type":
|
|
||||||
flex = append(flex, i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(flex) == 0 {
|
|
||||||
flex = append(flex, indices...)
|
|
||||||
}
|
|
||||||
flexSum := 0
|
|
||||||
for _, i := range flex {
|
|
||||||
flexSum += cols[i].Width
|
|
||||||
}
|
|
||||||
if flexSum <= 0 {
|
|
||||||
flexSum = len(flex)
|
|
||||||
}
|
|
||||||
for _, i := range flex {
|
|
||||||
out[i] += extra * cols[i].Width / flexSum
|
|
||||||
}
|
|
||||||
fixColumnWidthSum(out, indices, total)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func fixColumnWidthSum(widths map[int]int, indices []int, total int) {
|
|
||||||
if len(indices) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sum := 0
|
|
||||||
for _, i := range indices {
|
|
||||||
sum += widths[i]
|
|
||||||
}
|
|
||||||
widths[indices[len(indices)-1]] += total - sum
|
|
||||||
if widths[indices[len(indices)-1]] < 1 {
|
|
||||||
widths[indices[len(indices)-1]] = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cellHPadding = 2 // lipgloss Padding(0, 1) on each side
|
|
||||||
|
|
||||||
func truncateCellText(text string, colWidth int) string {
|
|
||||||
inner := colWidth - cellHPadding
|
|
||||||
if inner < 1 {
|
|
||||||
inner = 1
|
|
||||||
}
|
|
||||||
if runewidth.StringWidth(text) <= inner {
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
return runewidth.Truncate(text, inner, "...")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *DataTable) renderHeader() string {
|
func (t *DataTable) renderHeader() string {
|
||||||
@@ -430,7 +337,7 @@ func (t *DataTable) renderHeader() string {
|
|||||||
if t.focused && colIdx == t.cursorCol {
|
if t.focused && colIdx == t.cursorCol {
|
||||||
style = t.styles.headerSort
|
style = t.styles.headerSort
|
||||||
}
|
}
|
||||||
cells = append(cells, style.Width(w).MaxWidth(w).Render(text))
|
cells = append(cells, renderTableCell(style, text, w))
|
||||||
}
|
}
|
||||||
return lipgloss.JoinHorizontal(lipgloss.Left, cells...)
|
return lipgloss.JoinHorizontal(lipgloss.Left, cells...)
|
||||||
}
|
}
|
||||||
@@ -450,30 +357,24 @@ func (t *DataTable) refreshViewport() {
|
|||||||
func (t *DataTable) renderRow(row int) string {
|
func (t *DataTable) renderRow(row int) string {
|
||||||
item := t.items[t.order[row]]
|
item := t.items[t.order[row]]
|
||||||
selected := item.Selected
|
selected := item.Selected
|
||||||
fullRow := row == t.cursorRow || selected
|
|
||||||
indices, widths := t.visibleLayout()
|
indices, widths := t.visibleLayout()
|
||||||
cells := make([]string, 0, len(indices))
|
cells := make([]string, 0, len(indices))
|
||||||
for _, colIdx := range indices {
|
for _, colIdx := range indices {
|
||||||
col := t.columns[colIdx]
|
col := t.columns[colIdx]
|
||||||
w := widths[colIdx]
|
w := widths[colIdx]
|
||||||
raw := strings.TrimSpace(model.CellText(item, col.Key))
|
raw := strings.TrimSpace(model.CellText(item, col.Key))
|
||||||
text := raw
|
text := truncateCellText(raw, w)
|
||||||
if !fullRow {
|
cells = append(cells, t.styleCell(row, colIdx, col.Key, selected, item, text, w))
|
||||||
text = truncateCellText(raw, w)
|
|
||||||
}
|
|
||||||
cells = append(cells, t.styleCell(row, colIdx, col.Key, selected, item, text, w, fullRow))
|
|
||||||
}
|
}
|
||||||
return lipgloss.JoinHorizontal(lipgloss.Left, cells...)
|
return lipgloss.JoinHorizontal(lipgloss.Left, cells...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyCellWidth(s lipgloss.Style, width int, full bool) lipgloss.Style {
|
func (t *DataTable) styleCell(row, col int, colKey string, selected bool, item model.Item, text string, width int) string {
|
||||||
if full {
|
if t.spec.Subject == model.SubjectProjects {
|
||||||
return s
|
style := t.projectCellStyle(row, col, colKey, selected, item)
|
||||||
|
return renderTableCell(style, text, width)
|
||||||
}
|
}
|
||||||
return s.Width(width).MaxWidth(width)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *DataTable) styleCell(row, col int, colKey string, selected bool, item model.Item, text string, width int, fullWidth bool) string {
|
|
||||||
isRow := row == t.cursorRow
|
isRow := row == t.cursorRow
|
||||||
isCol := col == t.cursorCol
|
isCol := col == t.cursorCol
|
||||||
isCell := t.focused && isRow && isCol
|
isCell := t.focused && isRow && isCol
|
||||||
@@ -495,29 +396,16 @@ func (t *DataTable) styleCell(row, col int, colKey string, selected bool, item m
|
|||||||
default:
|
default:
|
||||||
base = t.styles.cell
|
base = t.styles.cell
|
||||||
}
|
}
|
||||||
base = t.applyProjectRowStyle(base, item, colKey)
|
return renderTableCell(base, text, width)
|
||||||
return applyCellWidth(base, width, fullWidth).Render(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *DataTable) applyProjectRowStyle(base lipgloss.Style, item model.Item, colKey string) lipgloss.Style {
|
|
||||||
if t.spec.Subject != model.SubjectProjects {
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
open := model.ProjectIsOpen(item.Raw)
|
|
||||||
if colKey == "status" {
|
|
||||||
if open {
|
|
||||||
return base.Foreground(lipgloss.Color("42")).Bold(true)
|
|
||||||
}
|
|
||||||
return base.Foreground(lipgloss.Color("245"))
|
|
||||||
}
|
|
||||||
if open {
|
|
||||||
return base.Background(lipgloss.Color("22")).Foreground(lipgloss.Color("255"))
|
|
||||||
}
|
|
||||||
return base.Background(lipgloss.Color("238")).Foreground(lipgloss.Color("252"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *DataTable) ensureColVisible() {
|
func (t *DataTable) ensureColVisible() {
|
||||||
vis := t.pickVisibleColumnIndices()
|
if flex, ok := model.TableFlexLayoutFor(t.spec.Subject); ok && flex.FlexColumnKey != "" {
|
||||||
|
t.colScroll = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contentW := t.lineContentWidth()
|
||||||
|
vis := pickVisibleColumnIndices(t.columns, t.colScroll, contentW)
|
||||||
if len(vis) == 0 {
|
if len(vis) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -528,7 +416,7 @@ func (t *DataTable) ensureColVisible() {
|
|||||||
if t.cursorCol > last {
|
if t.cursorCol > last {
|
||||||
t.colScroll = t.cursorCol
|
t.colScroll = t.cursorCol
|
||||||
for t.cursorCol >= 0 {
|
for t.cursorCol >= 0 {
|
||||||
vis = t.pickVisibleColumnIndices()
|
vis = pickVisibleColumnIndices(t.columns, t.colScroll, contentW)
|
||||||
if len(vis) == 0 {
|
if len(vis) == 0 {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
|
"github.com/mattn/go-runewidth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type columnLayout struct {
|
||||||
|
indices []int
|
||||||
|
widths map[int]int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *DataTable) computeLayout() columnLayout {
|
||||||
|
contentW := t.lineContentWidth()
|
||||||
|
if len(t.columns) == 0 || contentW <= 0 {
|
||||||
|
return columnLayout{widths: map[int]int{}}
|
||||||
|
}
|
||||||
|
if flex, ok := model.TableFlexLayoutFor(t.spec.Subject); ok {
|
||||||
|
return layoutFlexTable(t.columns, contentW, flex.FlexColumnKey, flex.MinFlexWidth)
|
||||||
|
}
|
||||||
|
return layoutScrollingTable(t.columns, t.colScroll, contentW)
|
||||||
|
}
|
||||||
|
|
||||||
|
const minFixedColumnWidth = 3
|
||||||
|
|
||||||
|
// layoutFlexTable shows every column; flexKey absorbs leftover width.
|
||||||
|
func layoutFlexTable(cols []model.TableColumn, totalW int, flexKey string, minFlexWidth int) columnLayout {
|
||||||
|
indices := make([]int, len(cols))
|
||||||
|
for i := range cols {
|
||||||
|
indices[i] = i
|
||||||
|
}
|
||||||
|
widths := make(map[int]int, len(cols))
|
||||||
|
|
||||||
|
flexIdx := -1
|
||||||
|
fixed := 0
|
||||||
|
for i, col := range cols {
|
||||||
|
if col.Key == flexKey {
|
||||||
|
flexIdx = i
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
widths[i] = col.Width
|
||||||
|
fixed += widths[i]
|
||||||
|
}
|
||||||
|
if flexIdx < 0 {
|
||||||
|
return layoutScrollingTable(cols, 0, totalW)
|
||||||
|
}
|
||||||
|
|
||||||
|
flexW := totalW - fixed
|
||||||
|
if flexW < minFlexWidth {
|
||||||
|
shrinkFixedColumns(widths, indices, flexIdx, fixed+minFlexWidth-totalW)
|
||||||
|
fixed = 0
|
||||||
|
for _, i := range indices {
|
||||||
|
if i != flexIdx {
|
||||||
|
fixed += widths[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flexW = totalW - fixed
|
||||||
|
if flexW < minFlexWidth {
|
||||||
|
flexW = minFlexWidth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
widths[flexIdx] = flexW
|
||||||
|
normalizeWidthSum(widths, indices, totalW, flexIdx)
|
||||||
|
return columnLayout{indices: indices, widths: widths}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shrinkFixedColumns(widths map[int]int, indices []int, flexIdx, need int) {
|
||||||
|
for need > 0 {
|
||||||
|
changed := false
|
||||||
|
for _, i := range indices {
|
||||||
|
if i == flexIdx || widths[i] <= minFixedColumnWidth {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
widths[i]--
|
||||||
|
need--
|
||||||
|
changed = true
|
||||||
|
if need == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func layoutScrollingTable(cols []model.TableColumn, colScroll, contentW int) columnLayout {
|
||||||
|
indices := pickVisibleColumnIndices(cols, colScroll, contentW)
|
||||||
|
widths := make(map[int]int, len(indices))
|
||||||
|
if len(indices) == 0 {
|
||||||
|
return columnLayout{indices: indices, widths: widths}
|
||||||
|
}
|
||||||
|
minSum := 0
|
||||||
|
for _, i := range indices {
|
||||||
|
minSum += cols[i].Width
|
||||||
|
}
|
||||||
|
widths = distributeColumnWidths(minSum, contentW, indices, cols)
|
||||||
|
return columnLayout{indices: indices, widths: widths}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickVisibleColumnIndices(cols []model.TableColumn, colScroll, contentW int) []int {
|
||||||
|
var out []int
|
||||||
|
used := 0
|
||||||
|
for colIdx := colScroll; colIdx < len(cols); colIdx++ {
|
||||||
|
w := cols[colIdx].Width
|
||||||
|
if len(out) > 0 && used+w > contentW {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out = append(out, colIdx)
|
||||||
|
used += w
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
colIdx := colScroll
|
||||||
|
if colIdx < 0 || colIdx >= len(cols) {
|
||||||
|
colIdx = 0
|
||||||
|
}
|
||||||
|
out = []int{colIdx}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// distributeColumnWidths expands or shrinks visible columns to exactly fill total width.
|
||||||
|
func distributeColumnWidths(minSum, total int, indices []int, cols []model.TableColumn) map[int]int {
|
||||||
|
out := make(map[int]int, len(indices))
|
||||||
|
if len(indices) == 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if total < len(indices) {
|
||||||
|
total = len(indices)
|
||||||
|
}
|
||||||
|
if minSum <= 0 {
|
||||||
|
each := total / len(indices)
|
||||||
|
if each < 1 {
|
||||||
|
each = 1
|
||||||
|
}
|
||||||
|
for _, i := range indices {
|
||||||
|
out[i] = each
|
||||||
|
}
|
||||||
|
normalizeWidthSum(out, indices, total, indices[len(indices)-1])
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, i := range indices {
|
||||||
|
out[i] = cols[i].Width
|
||||||
|
}
|
||||||
|
if minSum >= total {
|
||||||
|
for _, i := range indices {
|
||||||
|
out[i] = cols[i].Width * total / minSum
|
||||||
|
if out[i] < 1 {
|
||||||
|
out[i] = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalizeWidthSum(out, indices, total, flexColumnIndex(indices, cols))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
extra := total - minSum
|
||||||
|
flex := flexColumnIndices(indices, cols)
|
||||||
|
flexSum := 0
|
||||||
|
for _, i := range flex {
|
||||||
|
flexSum += cols[i].Width
|
||||||
|
}
|
||||||
|
if flexSum <= 0 {
|
||||||
|
flexSum = len(flex)
|
||||||
|
}
|
||||||
|
for _, i := range flex {
|
||||||
|
out[i] += extra * cols[i].Width / flexSum
|
||||||
|
}
|
||||||
|
normalizeWidthSum(out, indices, total, flexColumnIndex(indices, cols))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func flexColumnIndices(indices []int, cols []model.TableColumn) []int {
|
||||||
|
flex := make([]int, 0, len(indices))
|
||||||
|
for _, i := range indices {
|
||||||
|
switch cols[i].Key {
|
||||||
|
case "title", "subtitle", "description", "displayName", "primaryEmail", "from", "to", "type":
|
||||||
|
flex = append(flex, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(flex) == 0 {
|
||||||
|
flex = append(flex, indices...)
|
||||||
|
}
|
||||||
|
return flex
|
||||||
|
}
|
||||||
|
|
||||||
|
func flexColumnIndex(indices []int, cols []model.TableColumn) int {
|
||||||
|
flex := flexColumnIndices(indices, cols)
|
||||||
|
if len(flex) == 0 {
|
||||||
|
return indices[len(indices)-1]
|
||||||
|
}
|
||||||
|
for _, i := range indices {
|
||||||
|
if cols[i].Key == "title" {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return flex[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeWidthSum(widths map[int]int, indices []int, total, adjustIdx int) {
|
||||||
|
if len(indices) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sum := 0
|
||||||
|
for _, i := range indices {
|
||||||
|
sum += widths[i]
|
||||||
|
}
|
||||||
|
if adjustIdx < 0 {
|
||||||
|
adjustIdx = indices[len(indices)-1]
|
||||||
|
}
|
||||||
|
widths[adjustIdx] += total - sum
|
||||||
|
if widths[adjustIdx] < 1 {
|
||||||
|
widths[adjustIdx] = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cellHPadding = 0 // padding lives in outer lipgloss styles; column width is the full cell width
|
||||||
|
|
||||||
|
func truncateCellText(text string, colWidth int) string {
|
||||||
|
if colWidth < 1 {
|
||||||
|
colWidth = 1
|
||||||
|
}
|
||||||
|
if runewidth.StringWidth(text) <= colWidth {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
return runewidth.Truncate(text, colWidth, "…")
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLayoutFlexTableAllProjectColumnsVisible(t *testing.T) {
|
||||||
|
cols := model.BuildColumns(model.SubjectProjects, nil)
|
||||||
|
flex, ok := model.TableFlexLayoutFor(model.SubjectProjects)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected flex layout for projects")
|
||||||
|
}
|
||||||
|
lay := layoutFlexTable(cols, 80, flex.FlexColumnKey, flex.MinFlexWidth)
|
||||||
|
if len(lay.indices) != 7 {
|
||||||
|
t.Fatalf("got %d visible columns, want 7", len(lay.indices))
|
||||||
|
}
|
||||||
|
sum := 0
|
||||||
|
for _, i := range lay.indices {
|
||||||
|
sum += lay.widths[i]
|
||||||
|
}
|
||||||
|
if sum != 80 {
|
||||||
|
t.Fatalf("width sum=%d want 80", sum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayoutFlexTableUserEmailAbsorbsWidth(t *testing.T) {
|
||||||
|
cols := model.BuildColumns(model.SubjectUsers, nil)
|
||||||
|
flex, ok := model.TableFlexLayoutFor(model.SubjectUsers)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected flex layout for users")
|
||||||
|
}
|
||||||
|
lay := layoutFlexTable(cols, 100, flex.FlexColumnKey, flex.MinFlexWidth)
|
||||||
|
sum := 0
|
||||||
|
emailW := 0
|
||||||
|
for _, i := range lay.indices {
|
||||||
|
sum += lay.widths[i]
|
||||||
|
if cols[i].Key == "email" {
|
||||||
|
emailW = lay.widths[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sum != 100 {
|
||||||
|
t.Fatalf("sum=%d want 100", sum)
|
||||||
|
}
|
||||||
|
if emailW < 20 {
|
||||||
|
t.Fatalf("email width=%d should absorb remainder", emailW)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (t *DataTable) projectBaseStyle(item model.Item) lipgloss.Style {
|
||||||
|
if model.ProjectIsOpen(item.Raw) {
|
||||||
|
return lipgloss.NewStyle().
|
||||||
|
Background(lipgloss.Color("235")).
|
||||||
|
Foreground(lipgloss.Color("108"))
|
||||||
|
}
|
||||||
|
return lipgloss.NewStyle().
|
||||||
|
Background(lipgloss.Color("238")).
|
||||||
|
Foreground(lipgloss.Color("252"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *DataTable) projectCellStyle(row, col int, colKey string, selected bool, item model.Item) lipgloss.Style {
|
||||||
|
isRow := row == t.cursorRow
|
||||||
|
isCol := col == t.cursorCol
|
||||||
|
isCell := t.focused && isRow && isCol
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case selected && isCell:
|
||||||
|
return t.styles.cellSelect
|
||||||
|
case selected && isRow:
|
||||||
|
return t.styles.rowSelect
|
||||||
|
case selected:
|
||||||
|
return t.styles.rowSelect
|
||||||
|
case isCell:
|
||||||
|
return t.styles.cellActive
|
||||||
|
case t.focused && (isRow || isCol):
|
||||||
|
return t.projectStatusStyle(colKey, item, t.projectBaseStyle(item))
|
||||||
|
default:
|
||||||
|
return t.projectStatusStyle(colKey, item, t.projectBaseStyle(item))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *DataTable) projectStatusStyle(colKey string, item model.Item, base lipgloss.Style) lipgloss.Style {
|
||||||
|
if colKey != "status" {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
if model.ProjectIsOpen(item.Raw) {
|
||||||
|
return base.Foreground(lipgloss.Color("42")).Bold(true)
|
||||||
|
}
|
||||||
|
return base.Foreground(lipgloss.Color("245"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
|
"github.com/mattn/go-runewidth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// renderTableCell renders one column using the bubbles/table pattern:
|
||||||
|
// truncate plain text, constrain with an inline inner box, then apply the outer style.
|
||||||
|
func renderTableCell(outer lipgloss.Style, text string, width int) string {
|
||||||
|
if width < 1 {
|
||||||
|
width = 1
|
||||||
|
}
|
||||||
|
frame := outer.GetHorizontalFrameSize()
|
||||||
|
innerW := width - frame
|
||||||
|
if innerW < 1 {
|
||||||
|
innerW = 1
|
||||||
|
}
|
||||||
|
truncated := runewidth.Truncate(text, innerW, "…")
|
||||||
|
inner := lipgloss.NewStyle().Width(innerW).MaxWidth(innerW).Inline(true)
|
||||||
|
return outer.Render(inner.Render(truncated))
|
||||||
|
}
|
||||||
|
|
||||||
|
// padANSIWidth pads or truncates a styled line without breaking ANSI sequences.
|
||||||
|
func padANSIWidth(line string, width int) string {
|
||||||
|
if width < 1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
w := ansi.StringWidth(line)
|
||||||
|
if w > width {
|
||||||
|
return ansi.Truncate(line, width, "")
|
||||||
|
}
|
||||||
|
if w < width {
|
||||||
|
return line + repeatSpace(width-w)
|
||||||
|
}
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
func repeatSpace(n int) string {
|
||||||
|
if n <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
b := make([]byte, n)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = ' '
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
|
"github.com/mattn/go-runewidth"
|
||||||
|
"github.com/muesli/termenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProjectTableHeaderFitsPaneWidth(t *testing.T) {
|
||||||
|
prev := lipgloss.ColorProfile()
|
||||||
|
lipgloss.SetColorProfile(termenv.TrueColor)
|
||||||
|
t.Cleanup(func() { lipgloss.SetColorProfile(prev) })
|
||||||
|
|
||||||
|
for _, w := range []int{68, 50, 40, 30} {
|
||||||
|
tbl := newDataTable()
|
||||||
|
tbl.SetSize(w, 10)
|
||||||
|
tbl.SetData(model.ListSpec{Subject: model.SubjectProjects}, []model.Item{
|
||||||
|
{ID: "1", Title: "Alpha", Kind: model.KindProject, Raw: map[string]any{"status": 0}},
|
||||||
|
})
|
||||||
|
tbl.SetFocused(true)
|
||||||
|
re := regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||||
|
header := tbl.renderHeader()
|
||||||
|
got := runewidth.StringWidth(re.ReplaceAllString(header, ""))
|
||||||
|
if got != w {
|
||||||
|
t.Fatalf("width=%d header display width=%d", w, got)
|
||||||
|
}
|
||||||
|
view := tbl.View()
|
||||||
|
line0 := strings.Split(strings.TrimSuffix(view, "\n"), "\n")[0]
|
||||||
|
gotView := runewidth.StringWidth(re.ReplaceAllString(line0, ""))
|
||||||
|
if gotView < w-1 || gotView > w+1 {
|
||||||
|
t.Fatalf("width=%d view header width=%d", w, gotView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPadANSIWidthPreservesStyledLine(t *testing.T) {
|
||||||
|
prev := lipgloss.ColorProfile()
|
||||||
|
lipgloss.SetColorProfile(termenv.TrueColor)
|
||||||
|
t.Cleanup(func() { lipgloss.SetColorProfile(prev) })
|
||||||
|
|
||||||
|
line := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Render("ID")
|
||||||
|
padded := padANSIWidth(line, 10)
|
||||||
|
re := regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||||
|
if runewidth.StringWidth(re.ReplaceAllString(padded, "")) != 10 {
|
||||||
|
t.Fatalf("expected padded width 10, got %q", padded)
|
||||||
|
}
|
||||||
|
if !strings.Contains(padded, "ID") {
|
||||||
|
t.Fatalf("padding must not destroy cell text: %q", padded)
|
||||||
|
}
|
||||||
|
}
|
||||||
+147
-5
@@ -1,11 +1,14 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
"github.com/mattn/go-runewidth"
|
"github.com/mattn/go-runewidth"
|
||||||
|
"github.com/muesli/termenv"
|
||||||
)
|
)
|
||||||
|
|
||||||
func sampleItems() []model.Item {
|
func sampleItems() []model.Item {
|
||||||
@@ -89,13 +92,125 @@ func TestDistributeColumnWidthsFillsTotal(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProjectTableTitleAbsorbsWidth(t *testing.T) {
|
||||||
|
cols := model.BuildColumns(model.SubjectProjects, nil)
|
||||||
|
flex, ok := model.TableFlexLayoutFor(model.SubjectProjects)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected flex layout")
|
||||||
|
}
|
||||||
|
lay := layoutFlexTable(cols, 120, flex.FlexColumnKey, flex.MinFlexWidth)
|
||||||
|
if len(lay.indices) != len(cols) {
|
||||||
|
t.Fatalf("expected all %d columns visible, got %d", len(cols), len(lay.indices))
|
||||||
|
}
|
||||||
|
sum := 0
|
||||||
|
titleW := 0
|
||||||
|
fixedSum := 0
|
||||||
|
titleIdx := indexOfColumnKey(cols, "title")
|
||||||
|
for _, i := range lay.indices {
|
||||||
|
sum += lay.widths[i]
|
||||||
|
if i == titleIdx {
|
||||||
|
titleW = lay.widths[i]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fixedSum += lay.widths[i]
|
||||||
|
}
|
||||||
|
if sum != 120 {
|
||||||
|
t.Fatalf("sum=%d want 120", sum)
|
||||||
|
}
|
||||||
|
if titleW != 120-fixedSum {
|
||||||
|
t.Fatalf("title width=%d want remainder %d", titleW, 120-fixedSum)
|
||||||
|
}
|
||||||
|
if titleW < 40 {
|
||||||
|
t.Fatalf("title should absorb extra width, got %d", titleW)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectTableFillsPaneWidth(t *testing.T) {
|
||||||
|
tbl := newDataTable()
|
||||||
|
tbl.SetSize(80, 12)
|
||||||
|
tbl.SetData(model.ListSpec{Subject: model.SubjectProjects}, []model.Item{
|
||||||
|
{ID: "1", Title: "Alpha", Kind: model.KindProject, Raw: map[string]any{"status": 0}},
|
||||||
|
})
|
||||||
|
indices, widths := tbl.visibleLayout()
|
||||||
|
if len(indices) != len(tbl.columns) {
|
||||||
|
t.Fatalf("expected all columns visible, got %d/%d", len(indices), len(tbl.columns))
|
||||||
|
}
|
||||||
|
sum := 0
|
||||||
|
for _, i := range indices {
|
||||||
|
sum += widths[i]
|
||||||
|
}
|
||||||
|
if sum != 80 {
|
||||||
|
t.Fatalf("visible columns width sum=%d want 80", sum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectRowRenderWidth(t *testing.T) {
|
||||||
|
prev := lipgloss.ColorProfile()
|
||||||
|
lipgloss.SetColorProfile(termenv.TrueColor)
|
||||||
|
t.Cleanup(func() { lipgloss.SetColorProfile(prev) })
|
||||||
|
|
||||||
|
tbl := newDataTable()
|
||||||
|
tbl.SetSize(120, 20)
|
||||||
|
tbl.SetData(model.ListSpec{Subject: model.SubjectProjects}, []model.Item{
|
||||||
|
{ID: "abc12345", Title: "My Project Title", Kind: model.KindProject, Raw: map[string]any{"status": 0}},
|
||||||
|
})
|
||||||
|
re := regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||||
|
_, widths := tbl.visibleLayout()
|
||||||
|
want := 0
|
||||||
|
for _, w := range widths {
|
||||||
|
want += w
|
||||||
|
}
|
||||||
|
row := re.ReplaceAllString(tbl.renderRow(0), "")
|
||||||
|
got := runewidth.StringWidth(row)
|
||||||
|
if got < want-2 || got > want+2 {
|
||||||
|
t.Fatalf("row display width=%d want ~%d", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectRowStaysSingleLineWhenSelected(t *testing.T) {
|
||||||
|
prev := lipgloss.ColorProfile()
|
||||||
|
lipgloss.SetColorProfile(termenv.TrueColor)
|
||||||
|
t.Cleanup(func() { lipgloss.SetColorProfile(prev) })
|
||||||
|
|
||||||
|
long := strings.Repeat("X", 200)
|
||||||
|
tbl := newDataTable()
|
||||||
|
tbl.SetSize(120, 20)
|
||||||
|
tbl.SetData(model.ListSpec{Subject: model.SubjectProjects}, []model.Item{
|
||||||
|
{ID: "1", Title: long, Kind: model.KindProject, Raw: map[string]any{"status": 0}, Selected: true},
|
||||||
|
})
|
||||||
|
if strings.Contains(tbl.renderRow(0), "\n") {
|
||||||
|
t.Fatal("selected project row must not wrap to multiple lines")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectRowStyleUniformBackground(t *testing.T) {
|
||||||
|
prev := lipgloss.ColorProfile()
|
||||||
|
lipgloss.SetColorProfile(termenv.TrueColor)
|
||||||
|
t.Cleanup(func() { lipgloss.SetColorProfile(prev) })
|
||||||
|
|
||||||
|
tbl := newDataTable()
|
||||||
|
item := model.Item{Raw: map[string]any{"status": 0}}
|
||||||
|
base := tbl.projectBaseStyle(item)
|
||||||
|
titleStyled := tbl.projectStatusStyle("title", item, base).Render("Open Proj")
|
||||||
|
statusStyled := tbl.projectStatusStyle("status", item, base).Render(model.ProjectStatusCell(item.Raw))
|
||||||
|
if !strings.Contains(titleStyled, "\x1b[") {
|
||||||
|
t.Fatalf("title cell should be styled: %q", titleStyled)
|
||||||
|
}
|
||||||
|
if !strings.Contains(statusStyled, "\x1b[") {
|
||||||
|
t.Fatalf("status cell should be styled: %q", statusStyled)
|
||||||
|
}
|
||||||
|
if titleStyled == statusStyled {
|
||||||
|
t.Fatal("status and title cells should use different foreground styles")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTruncateCellText(t *testing.T) {
|
func TestTruncateCellText(t *testing.T) {
|
||||||
long := strings.Repeat("x", 40)
|
long := strings.Repeat("x", 40)
|
||||||
got := truncateCellText(long, 10)
|
got := truncateCellText(long, 10)
|
||||||
if runewidth.StringWidth(got) > 10 {
|
if runewidth.StringWidth(got) > 10 {
|
||||||
t.Fatalf("truncated width=%d want <=10", runewidth.StringWidth(got))
|
t.Fatalf("truncated width=%d want <=10", runewidth.StringWidth(got))
|
||||||
}
|
}
|
||||||
if !strings.HasSuffix(got, "...") {
|
if !strings.HasSuffix(got, "…") {
|
||||||
t.Fatalf("expected ellipsis suffix, got %q", got)
|
t.Fatalf("expected ellipsis suffix, got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,8 +231,26 @@ func TestDataTableTruncatesNonSelectedRows(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tbl.cursorRow = 1
|
tbl.cursorRow = 1
|
||||||
if !strings.Contains(tbl.renderRow(1), long) {
|
if !strings.Contains(tbl.renderRow(1), strings.Repeat("A", 10)) {
|
||||||
t.Fatal("cursor row should show full title")
|
t.Fatal("cursor row should show title text within column")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDataTableRowWidthMatchesColumns(t *testing.T) {
|
||||||
|
tbl := newDataTable()
|
||||||
|
tbl.SetSize(80, 12)
|
||||||
|
tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, sampleItems())
|
||||||
|
tbl.SetFocused(true)
|
||||||
|
_, widths := tbl.visibleLayout()
|
||||||
|
want := 0
|
||||||
|
for _, w := range widths {
|
||||||
|
want += w
|
||||||
|
}
|
||||||
|
re := regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||||
|
row := re.ReplaceAllString(tbl.renderRow(0), "")
|
||||||
|
got := runewidth.StringWidth(row)
|
||||||
|
if got < want-2 || got > want+2 {
|
||||||
|
t.Fatalf("row display width=%d want ~%d", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,8 +266,8 @@ func TestDataTableShowsFullTextWhenSpaceSelected(t *testing.T) {
|
|||||||
tbl.cursorRow = 0
|
tbl.cursorRow = 0
|
||||||
|
|
||||||
rowSelected := tbl.renderRow(1)
|
rowSelected := tbl.renderRow(1)
|
||||||
if !strings.Contains(rowSelected, long) {
|
if !strings.Contains(rowSelected, strings.Repeat("B", 10)) {
|
||||||
t.Fatal("space-selected row should show full title")
|
t.Fatal("space-selected row should show title text within column")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,3 +279,12 @@ func indexOfColumn(tbl DataTable, key string) int {
|
|||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func indexOfColumnKey(cols []model.TableColumn, key string) int {
|
||||||
|
for i, c := range cols {
|
||||||
|
if c.Key == key {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|||||||
+103
-3
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -17,6 +18,7 @@ func init() {
|
|||||||
rootCmd.AddCommand(crmTasksCmd)
|
rootCmd.AddCommand(crmTasksCmd)
|
||||||
crmTasksCmd.AddCommand(crmTasksListCmd())
|
crmTasksCmd.AddCommand(crmTasksListCmd())
|
||||||
crmTasksCmd.AddCommand(crmTasksCreateCmd())
|
crmTasksCmd.AddCommand(crmTasksCreateCmd())
|
||||||
|
crmTasksCmd.AddCommand(crmTasksReassignSelfCmd())
|
||||||
crmTasksCmd.AddCommand(crmTasksDeleteCmd())
|
crmTasksCmd.AddCommand(crmTasksDeleteCmd())
|
||||||
crmTasksCmd.AddCommand(crmTasksCategoriesCmd())
|
crmTasksCmd.AddCommand(crmTasksCategoriesCmd())
|
||||||
}
|
}
|
||||||
@@ -38,7 +40,7 @@ func crmTasksListCmd() *cobra.Command {
|
|||||||
if outputFormat == "table" {
|
if outputFormat == "table" {
|
||||||
fmt.Printf("total: %d (shown: %d)\n", total, len(list))
|
fmt.Printf("total: %d (shown: %d)\n", total, len(list))
|
||||||
}
|
}
|
||||||
printTable([]string{"id", "title", "deadline", "isClosed", "categoryID"}, list)
|
printTable([]string{"id", "title", "deadLine", "isClosed", "categoryID"}, list)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -53,7 +55,7 @@ func crmTasksCreateCmd() *cobra.Command {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "create TITLE",
|
Use: "create TITLE",
|
||||||
Aliases: []string{"add"},
|
Aliases: []string{"add"},
|
||||||
Short: "Create a CRM task",
|
Short: "Create a CRM task (assigns self + deadline default now+14d)",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c, err := newOO(cmd)
|
c, err := newOO(cmd)
|
||||||
@@ -68,7 +70,7 @@ func crmTasksCreateCmd() *cobra.Command {
|
|||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVar(&deadline, "deadline", "", "deadline (YYYY-MM-DD or ISO8601)")
|
cmd.Flags().StringVar(&deadline, "deadline", "", "deadline (YYYY-MM-DD or ISO8601); default now+14d")
|
||||||
cmd.Flags().IntVar(&categoryID, "category", 0, "category id (see `oo crm-tasks categories`)")
|
cmd.Flags().IntVar(&categoryID, "category", 0, "category id (see `oo crm-tasks categories`)")
|
||||||
cmd.Flags().IntVar(&contactID, "contact", 0, "contact id")
|
cmd.Flags().IntVar(&contactID, "contact", 0, "contact id")
|
||||||
cmd.Flags().StringVar(&entityType, "entity-type", "", "opportunity|case|contact")
|
cmd.Flags().StringVar(&entityType, "entity-type", "", "opportunity|case|contact")
|
||||||
@@ -77,6 +79,104 @@ func crmTasksCreateCmd() *cobra.Command {
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func crmTaskNeedsOwner(t map[string]any) (need bool, name string) {
|
||||||
|
resp, _ := t["responsible"].(map[string]any)
|
||||||
|
rid, _ := resp["id"].(string)
|
||||||
|
name, _ = resp["displayName"].(string)
|
||||||
|
need = rid == "" || strings.EqualFold(name, "Profile has been removed")
|
||||||
|
return need, name
|
||||||
|
}
|
||||||
|
|
||||||
|
func crmTaskCategoryID(t map[string]any) int {
|
||||||
|
cat, ok := t["category"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
switch v := cat["id"].(type) {
|
||||||
|
case float64:
|
||||||
|
return int(v)
|
||||||
|
case int:
|
||||||
|
return v
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func crmTasksReassignSelfCmd() *cobra.Command {
|
||||||
|
var apply bool
|
||||||
|
var count, max int
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "reassign-self",
|
||||||
|
Short: "Reassign CRM tasks with missing/removed owner to the authenticated user",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
uid, err := c.SelfUserID(cmd.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type item struct {
|
||||||
|
id, title, deadline, ownerName string
|
||||||
|
categoryID int
|
||||||
|
}
|
||||||
|
var todos []item
|
||||||
|
scanned := 0
|
||||||
|
for start := 0; ; start += count {
|
||||||
|
list, total, err := c.ListCRMTasks(cmd.Context(), count, start)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, t := range list {
|
||||||
|
scanned++
|
||||||
|
need, name := crmTaskNeedsOwner(t)
|
||||||
|
if !need {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
title, _ := t["title"].(string)
|
||||||
|
dl, _ := t["deadLine"].(string)
|
||||||
|
todos = append(todos, item{
|
||||||
|
id: fmt.Sprint(t["id"]), title: title, deadline: dl,
|
||||||
|
ownerName: name, categoryID: crmTaskCategoryID(t),
|
||||||
|
})
|
||||||
|
if max > 0 && len(todos) >= max {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (max > 0 && len(todos) >= max) || start+count >= total || len(list) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fixed := 0
|
||||||
|
for _, t := range todos {
|
||||||
|
fmt.Printf(" task %s %q owner=%q → %s\n", t.id, t.title, t.ownerName, uid)
|
||||||
|
if !apply {
|
||||||
|
fixed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := c.UpdateCRMTask(cmd.Context(), t.id, t.title, t.deadline, t.categoryID, uid); err != nil {
|
||||||
|
return fmt.Errorf("update %s: %w", t.id, err)
|
||||||
|
}
|
||||||
|
fixed++
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := "DRY-RUN"
|
||||||
|
if apply {
|
||||||
|
mode = "APPLIED"
|
||||||
|
}
|
||||||
|
fmt.Printf("%s: scanned=%d reassigned=%d (self=%s)\n", mode, scanned, fixed, uid)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().BoolVar(&apply, "apply", false, "write changes (default dry-run)")
|
||||||
|
cmd.Flags().IntVar(&count, "count", 100, "page size")
|
||||||
|
cmd.Flags().IntVar(&max, "max", 0, "stop after N candidates (0 = all)")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
func crmTasksDeleteCmd() *cobra.Command {
|
func crmTasksDeleteCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "delete TASK_ID [TASK_ID...]",
|
Use: "delete TASK_ID [TASK_ID...]",
|
||||||
|
|||||||
+1
-1
@@ -110,7 +110,7 @@ func taskCreateCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
cmd.Flags().StringVarP(&project, "project", "p", "", "project id (default $OO_PROJECT_ID)")
|
cmd.Flags().StringVarP(&project, "project", "p", "", "project id (default $OO_PROJECT_ID)")
|
||||||
cmd.Flags().StringVar(&desc, "description", "", "description")
|
cmd.Flags().StringVar(&desc, "description", "", "description")
|
||||||
cmd.Flags().StringVar(&deadline, "deadline", "", "deadline YYYY-MM-DD")
|
cmd.Flags().StringVar(&deadline, "deadline", "", "deadline YYYY-MM-DD (default now+14d; always assigned to you)")
|
||||||
cmd.Flags().StringVar(&prio, "priority", "normal", "high|normal|low")
|
cmd.Flags().StringVar(&prio, "priority", "normal", "high|normal|low")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ListContacts returns a page of CRM contacts and the total count.
|
// ListContacts returns a page of CRM contacts and the total count.
|
||||||
@@ -491,11 +492,25 @@ func (c *Client) ListCRMTasks(ctx context.Context, count, startIndex int) ([]map
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateCRMTask creates a CRM task (reminder) attached to an entity.
|
// CreateCRMTask creates a CRM task (reminder) attached to an entity.
|
||||||
|
// Empty deadline defaults to now+14d (API requires a deadline). Empty
|
||||||
|
// responsibleID falls back to the authenticated user — without this the
|
||||||
|
// portal assigns a stub "Profile has been removed" owner.
|
||||||
func (c *Client) CreateCRMTask(ctx context.Context, title, deadline string, categoryID, contactID int, entityType string, entityID int, description string) (map[string]any, error) {
|
func (c *Client) CreateCRMTask(ctx context.Context, title, deadline string, categoryID, contactID int, entityType string, entityID int, description string) (map[string]any, error) {
|
||||||
|
if deadline == "" {
|
||||||
|
deadline = time.Now().Add(14 * 24 * time.Hour).Format("2006-01-02T15:04:05")
|
||||||
|
}
|
||||||
|
if categoryID == 0 {
|
||||||
|
categoryID = 2 // Opportunity — matches applications sync
|
||||||
|
}
|
||||||
|
uid, err := c.SelfUserID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("CreateCRMTask: resolve self: %w", err)
|
||||||
|
}
|
||||||
fields := url.Values{}
|
fields := url.Values{}
|
||||||
fields.Set("title", title)
|
fields.Set("title", title)
|
||||||
fields.Set("deadline", deadline)
|
fields.Set("deadline", deadline)
|
||||||
fields.Set("categoryId", strconv.Itoa(categoryID))
|
fields.Set("categoryId", strconv.Itoa(categoryID))
|
||||||
|
fields.Set("responsibleId", uid)
|
||||||
if contactID != 0 {
|
if contactID != 0 {
|
||||||
fields.Set("contactId", strconv.Itoa(contactID))
|
fields.Set("contactId", strconv.Itoa(contactID))
|
||||||
}
|
}
|
||||||
@@ -511,6 +526,29 @@ func (c *Client) CreateCRMTask(ctx context.Context, title, deadline string, cate
|
|||||||
return c.postFormObject(ctx, "/api/2.0/crm/task.json", fields)
|
return c.postFormObject(ctx, "/api/2.0/crm/task.json", fields)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateCRMTask updates title, deadline, category, and responsible on a CRM task.
|
||||||
|
// Empty responsibleID falls back to SelfUserID; empty deadline is left unchanged
|
||||||
|
// by the API only if omitted — callers should pass deadLine when known.
|
||||||
|
func (c *Client) UpdateCRMTask(ctx context.Context, id, title, deadline string, categoryID int, responsibleID string) (map[string]any, error) {
|
||||||
|
if responsibleID == "" {
|
||||||
|
uid, err := c.SelfUserID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("UpdateCRMTask: resolve self: %w", err)
|
||||||
|
}
|
||||||
|
responsibleID = uid
|
||||||
|
}
|
||||||
|
fields := url.Values{}
|
||||||
|
fields.Set("title", title)
|
||||||
|
fields.Set("responsibleId", responsibleID)
|
||||||
|
if deadline != "" {
|
||||||
|
fields.Set("deadline", deadline)
|
||||||
|
}
|
||||||
|
if categoryID != 0 {
|
||||||
|
fields.Set("categoryId", strconv.Itoa(categoryID))
|
||||||
|
}
|
||||||
|
return c.putFormObject(ctx, fmt.Sprintf("/api/2.0/crm/task/%s.json", url.PathEscape(id)), fields)
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteCRMTask removes a CRM task by id.
|
// DeleteCRMTask removes a CRM task by id.
|
||||||
func (c *Client) DeleteCRMTask(ctx context.Context, id string) (map[string]any, error) {
|
func (c *Client) DeleteCRMTask(ctx context.Context, id string) (map[string]any, error) {
|
||||||
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/task/%s.json", url.PathEscape(id)))
|
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/task/%s.json", url.PathEscape(id)))
|
||||||
|
|||||||
@@ -8,18 +8,21 @@ require (
|
|||||||
github.com/charmbracelet/bubbletea v0.25.0
|
github.com/charmbracelet/bubbletea v0.25.0
|
||||||
github.com/charmbracelet/glamour v0.8.0
|
github.com/charmbracelet/glamour v0.8.0
|
||||||
github.com/charmbracelet/lipgloss v0.12.1
|
github.com/charmbracelet/lipgloss v0.12.1
|
||||||
|
github.com/charmbracelet/x/ansi v0.1.4
|
||||||
github.com/eslider/go-xls/v2 v2.1.0
|
github.com/eslider/go-xls/v2 v2.1.0
|
||||||
github.com/google/go-querystring v1.2.0
|
github.com/google/go-querystring v1.2.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/mattn/go-runewidth v0.0.15
|
||||||
|
github.com/muesli/termenv v0.16.0
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/JohannesKaufmann/dom v0.3.1 // indirect
|
github.com/JohannesKaufmann/dom v0.3.1 // indirect
|
||||||
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
|
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
|
||||||
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.1.4 // indirect
|
|
||||||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
|
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.0 // indirect
|
github.com/dlclark/regexp2 v1.11.0 // indirect
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
@@ -27,12 +30,10 @@ require (
|
|||||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||||
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
|
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/reflow v0.3.0 // indirect
|
github.com/muesli/reflow v0.3.0 // indirect
|
||||||
github.com/muesli/termenv v0.16.0 // indirect
|
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/spf13/pflag v1.0.9 // indirect
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
github.com/yuin/goldmark v1.8.2 // indirect
|
github.com/yuin/goldmark v1.8.2 // indirect
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46
|
|||||||
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
|
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
|
||||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||||
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||||
|
|||||||
@@ -113,7 +113,8 @@ func (c *Client) deleteObject(ctx context.Context, path string) (map[string]any,
|
|||||||
|
|
||||||
// unmarshalResponseObject extracts the "response" field from a raw OnlyOffice
|
// unmarshalResponseObject extracts the "response" field from a raw OnlyOffice
|
||||||
// envelope and decodes it into map[string]any. Returns (nil, nil) for a null
|
// envelope and decodes it into map[string]any. Returns (nil, nil) for a null
|
||||||
// response and (nil, err) when the field is missing or malformed.
|
// response, an empty array, or scalar payloads. When the API returns a list
|
||||||
|
// (for example PUT /people/status/...), the first element is returned.
|
||||||
func unmarshalResponseObject(raw json.RawMessage) (map[string]any, error) {
|
func unmarshalResponseObject(raw json.RawMessage) (map[string]any, error) {
|
||||||
resp, err := responseField(raw, "response")
|
resp, err := responseField(raw, "response")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -122,11 +123,25 @@ func unmarshalResponseObject(raw json.RawMessage) (map[string]any, error) {
|
|||||||
if len(resp) == 0 || string(resp) == "null" {
|
if len(resp) == 0 || string(resp) == "null" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
switch resp[0] {
|
||||||
|
case '{':
|
||||||
var out map[string]any
|
var out map[string]any
|
||||||
if err := json.Unmarshal(resp, &out); err != nil {
|
if err := json.Unmarshal(resp, &out); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
|
case '[':
|
||||||
|
var list []map[string]any
|
||||||
|
if err := json.Unmarshal(resp, &list); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(list) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return list[0], nil
|
||||||
|
default:
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// getJSON issues an authenticated GET and returns the raw response body.
|
// getJSON issues an authenticated GET and returns the raw response body.
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUnmarshalResponseObjectMap(t *testing.T) {
|
||||||
|
raw := json.RawMessage(`{"response":{"id":"1","isAdmin":true}}`)
|
||||||
|
out, err := unmarshalResponseObject(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out["id"] != "1" || out["isAdmin"] != true {
|
||||||
|
t.Fatalf("unexpected map: %#v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalResponseObjectArray(t *testing.T) {
|
||||||
|
raw := json.RawMessage(`{"response":[{"id":"a"},{"id":"b"}]}`)
|
||||||
|
out, err := unmarshalResponseObject(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out["id"] != "a" {
|
||||||
|
t.Fatalf("expected first array element, got %#v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalResponseObjectEmptyArray(t *testing.T) {
|
||||||
|
raw := json.RawMessage(`{"response":[]}`)
|
||||||
|
out, err := unmarshalResponseObject(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out != nil {
|
||||||
|
t.Fatalf("expected nil for empty array, got %#v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalResponseObjectNull(t *testing.T) {
|
||||||
|
raw := json.RawMessage(`{"response":null}`)
|
||||||
|
out, err := unmarshalResponseObject(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out != nil {
|
||||||
|
t.Fatalf("expected nil, got %#v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -233,17 +233,26 @@ func (c *Client) GetTaskByID(ctx context.Context, taskID string) (map[string]any
|
|||||||
// AddTask creates a task via the form-encoded endpoint (no milestone/start).
|
// AddTask creates a task via the form-encoded endpoint (no milestone/start).
|
||||||
// Prefer the typed CreateProjectTask for new code; AddTask is kept for
|
// Prefer the typed CreateProjectTask for new code; AddTask is kept for
|
||||||
// parity with the Python reference tooling.
|
// parity with the Python reference tooling.
|
||||||
|
//
|
||||||
|
// Always assigns the authenticated user as responsible (form field
|
||||||
|
// "responsibles"). Empty deadline defaults to today+14 days.
|
||||||
func (c *Client) AddTask(ctx context.Context, projectID, title, description string, priority int, deadline string) (map[string]any, error) {
|
func (c *Client) AddTask(ctx context.Context, projectID, title, description string, priority int, deadline string) (map[string]any, error) {
|
||||||
if projectID == "" {
|
if projectID == "" {
|
||||||
projectID = c.defaults.ProjectID
|
projectID = c.defaults.ProjectID
|
||||||
}
|
}
|
||||||
|
if deadline == "" {
|
||||||
|
deadline = time.Now().Add(14 * 24 * time.Hour).Format("2006-01-02")
|
||||||
|
}
|
||||||
|
uid, err := c.SelfUserID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("AddTask: resolve self: %w", err)
|
||||||
|
}
|
||||||
fields := url.Values{}
|
fields := url.Values{}
|
||||||
fields.Set("title", title)
|
fields.Set("title", title)
|
||||||
fields.Set("description", description)
|
fields.Set("description", description)
|
||||||
fields.Set("priority", strconv.Itoa(priority))
|
fields.Set("priority", strconv.Itoa(priority))
|
||||||
if deadline != "" {
|
|
||||||
fields.Set("deadline", deadline)
|
fields.Set("deadline", deadline)
|
||||||
}
|
fields.Set("responsibles", uid)
|
||||||
return c.postFormObject(ctx, fmt.Sprintf("/api/2.0/project/%s/task.json", url.PathEscape(projectID)), fields)
|
return c.postFormObject(ctx, fmt.Sprintf("/api/2.0/project/%s/task.json", url.PathEscape(projectID)), fields)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ package onlyoffice
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,6 +41,7 @@ type User struct {
|
|||||||
MailSpace *int `json:"mailSpace,omitempty"`
|
MailSpace *int `json:"mailSpace,omitempty"`
|
||||||
TalkSpace *int `json:"talkSpace,omitempty"`
|
TalkSpace *int `json:"talkSpace,omitempty"`
|
||||||
ProfileURL *string `json:"profileUrl,omitempty"`
|
ProfileURL *string `json:"profileUrl,omitempty"`
|
||||||
|
RegistrationDate *time.Time `json:"registrationDate,omitempty"`
|
||||||
Title *string `json:"title,omitempty"`
|
Title *string `json:"title,omitempty"`
|
||||||
Sex *string `json:"sex,omitempty"`
|
Sex *string `json:"sex,omitempty"`
|
||||||
Lead *string `json:"lead,omitempty"`
|
Lead *string `json:"lead,omitempty"`
|
||||||
@@ -71,6 +74,38 @@ func (c *Client) GetUsers() (list []*User, err error) {
|
|||||||
}{Response: &list})
|
}{Response: &list})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUser returns one portal user profile by ID.
|
||||||
|
func (c *Client) GetUser(ctx context.Context, userID string) (map[string]any, error) {
|
||||||
|
return c.ResponseObject(ctx, fmt.Sprintf("/api/2.0/people/%s.json", url.PathEscape(userID)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUser updates portal user profile fields (admin ACL, etc.). Do not send
|
||||||
|
// employee status here — use ChangeUserStatus instead.
|
||||||
|
func (c *Client) UpdateUser(ctx context.Context, userID string, body map[string]any) (map[string]any, error) {
|
||||||
|
return c.putJSONObject(ctx, fmt.Sprintf("/api/2.0/people/%s", url.PathEscape(userID)), body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangeUserStatus activates or terminates a user via the status API.
|
||||||
|
func (c *Client) ChangeUserStatus(ctx context.Context, userID string, active bool) error {
|
||||||
|
status := "Terminated"
|
||||||
|
if active {
|
||||||
|
status = "Active"
|
||||||
|
}
|
||||||
|
_, err := c.putJSONObject(ctx, fmt.Sprintf("/api/2.0/people/status/%s", status), map[string]any{
|
||||||
|
"userIds": []string{userID},
|
||||||
|
"resendAll": false,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangeUserPassword sets a new password for the user.
|
||||||
|
func (c *Client) ChangeUserPassword(ctx context.Context, userID, password string) error {
|
||||||
|
_, err := c.putJSONObject(ctx, fmt.Sprintf("/api/2.0/people/%s/password", url.PathEscape(userID)), map[string]string{
|
||||||
|
"password": password,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// SelfUserID returns the ID of the authenticated user (people/@self), cached.
|
// SelfUserID returns the ID of the authenticated user (people/@self), cached.
|
||||||
func (c *Client) SelfUserID(ctx context.Context) (string, error) {
|
func (c *Client) SelfUserID(ctx context.Context) (string, error) {
|
||||||
if c.selfID != "" {
|
if c.selfID != "" {
|
||||||
|
|||||||
Reference in New Issue
Block a user