feat: add interactive inbox view
This commit is contained in:
parent
02d68ca3f4
commit
035f877280
|
|
@ -0,0 +1,20 @@
|
|||
package main
|
||||
|
||||
func (a *App) ListInboxNodes() ([]NodeDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list, err := a.nodes.ListInboxRoots(false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dtos := toNodeDTOs(list)
|
||||
for i := range dtos {
|
||||
n, err := a.nodes.CountChildren(dtos[i].ID, "case", "client", "project", "folder", "document", "recipe")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dtos[i].HasChildren = n > 0
|
||||
}
|
||||
return dtos, nil
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -16,8 +16,8 @@
|
|||
background: #13131f;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/main-Cyhj7TEH.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-DAyIHTpH.css">
|
||||
<script type="module" crossorigin src="/assets/main-Wpp0QvRu.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-xwXesvcm.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestListInboxNodesReturnsOnlyUnassignedRoots(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
unassigned, err := app.CreateNodeFromTemplate("", "Unassigned Root", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create unassigned root: %v", err)
|
||||
}
|
||||
inbox, err := app.CreateNodeFromTemplate("", "Inbox Root", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create inbox root: %v", err)
|
||||
}
|
||||
assigned, err := app.CreateNodeFromTemplate("", "Assigned Root", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create assigned root: %v", err)
|
||||
}
|
||||
child, err := app.CreateNodeFromTemplate(unassigned.ID, "Nested Child", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create child: %v", err)
|
||||
}
|
||||
if _, err := app.db.Exec(`UPDATE nodes SET section = 'inbox' WHERE id = ?`, inbox.ID); err != nil {
|
||||
t.Fatalf("mark inbox: %v", err)
|
||||
}
|
||||
if _, err := app.db.Exec(`UPDATE nodes SET section = 'projects' WHERE id = ?`, assigned.ID); err != nil {
|
||||
t.Fatalf("mark assigned: %v", err)
|
||||
}
|
||||
|
||||
list, err := app.ListInboxNodes()
|
||||
if err != nil {
|
||||
t.Fatalf("ListInboxNodes: %v", err)
|
||||
}
|
||||
|
||||
got := map[string]bool{}
|
||||
for _, item := range list {
|
||||
got[item.ID] = true
|
||||
}
|
||||
if !got[unassigned.ID] {
|
||||
t.Fatal("unassigned root missing from inbox")
|
||||
}
|
||||
if !got[inbox.ID] {
|
||||
t.Fatal("section=inbox root missing from inbox")
|
||||
}
|
||||
if got[assigned.ID] {
|
||||
t.Fatal("assigned root should not be in inbox")
|
||||
}
|
||||
if got[child.ID] {
|
||||
t.Fatal("nested child should not be in inbox")
|
||||
}
|
||||
}
|
||||
|
|
@ -91,6 +91,7 @@
|
|||
let acceptingSuggestion = null
|
||||
let suggestions = []
|
||||
let suggestionCount = 0
|
||||
let inboxNodes = []
|
||||
let showCreateNode = false
|
||||
let newNodeTitle = ''
|
||||
let createInNode = null
|
||||
|
|
@ -228,6 +229,7 @@
|
|||
actions = []
|
||||
worklog = []
|
||||
suggestions = []
|
||||
inboxNodes = []
|
||||
showCreateNode = false
|
||||
error = ''
|
||||
todayDashboard = null
|
||||
|
|
@ -241,6 +243,8 @@
|
|||
todayDashboard = await wailsCall('ListTodayView') || { cases: [] }
|
||||
suggestions = await wailsCall('GetSuggestions') || []
|
||||
suggestionCount = suggestions.length
|
||||
} else if (id === 'inbox') {
|
||||
inboxNodes = await wailsCall('ListInboxNodes') || []
|
||||
} else if (id === 'journal') {
|
||||
await loadJournal()
|
||||
} else if (id === 'activity') {
|
||||
|
|
@ -251,6 +255,7 @@
|
|||
} catch (e) {
|
||||
error = String(e)
|
||||
todayDashboard = { cases: [] }
|
||||
inboxNodes = []
|
||||
activityFeed = []
|
||||
}
|
||||
}
|
||||
|
|
@ -1987,6 +1992,37 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
{:else if selectedSection === 'inbox'}
|
||||
<div class="inbox-screen">
|
||||
<div class="inbox-header">
|
||||
<div>
|
||||
<h2>{t('nav.inbox')}</h2>
|
||||
<p>{t('inbox.subtitle')}</p>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" on:click={openCreateRoot}>+ {t('nav.createNode')}</button>
|
||||
</div>
|
||||
{#if inboxNodes.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>{t('inbox.empty')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="inbox-list">
|
||||
{#each inboxNodes as item}
|
||||
<div class="inbox-item" role="button" tabindex="0" on:click={() => openNodeById(item.id)} on:keydown={(e) => e.key === 'Enter' && openNodeById(item.id)}>
|
||||
<div class="inbox-item-main">
|
||||
<span class="inbox-item-title">{item.title}</span>
|
||||
<span class="inbox-item-meta">{nodeKindLabel(item.type)} · {formatDate(item.createdAt)}</span>
|
||||
</div>
|
||||
<div class="inbox-item-actions">
|
||||
<button class="btn btn-sm" on:click|stopPropagation={() => openNodeById(item.id)}>{t('common.open')}</button>
|
||||
<button class="btn btn-sm" on:click|stopPropagation={() => openNodeFolder(item)}>{t('file.showInExplorer')}</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else if selectedSection === 'journal'}
|
||||
<div class="journal-screen">
|
||||
<div class="journal-header">
|
||||
|
|
@ -2693,6 +2729,19 @@
|
|||
.suggestion-confidence.medium { color: #60a5fa; }
|
||||
.suggestion-confidence.high { color: #34d399; }
|
||||
|
||||
/* Inbox screen */
|
||||
.inbox-screen { padding: 24px; overflow-y: auto; flex: 1; }
|
||||
.inbox-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
|
||||
.inbox-header h2 { margin: 0 0 6px; }
|
||||
.inbox-header p { margin: 0; color: #a0a0b8; font-size: 13px; }
|
||||
.inbox-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.inbox-item { display: flex; align-items: center; gap: 12px; padding: 12px; border: 1px solid #2a2a3c; border-radius: 8px; background: #1a1a28; cursor: pointer; }
|
||||
.inbox-item:hover { border-color: #3a3a5c; background: #1e1e32; }
|
||||
.inbox-item-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
||||
.inbox-item-title { color: #e4e4ef; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.inbox-item-meta { color: #8888a0; font-size: 12px; }
|
||||
.inbox-item-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
|
||||
/* Journal screen */
|
||||
.journal-screen { padding: 24px; overflow-y: auto; flex: 1; }
|
||||
.journal-header { margin-bottom: 24px; }
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ export default {
|
|||
'nav.createInside': 'Create inside',
|
||||
'nav.createNode': 'Create element',
|
||||
'nav.moveToRoot': 'Move to root',
|
||||
'inbox.subtitle': 'Root items without an assigned section',
|
||||
'inbox.empty': 'No unprocessed items',
|
||||
'tab.overview': 'Overview',
|
||||
'tab.notes': 'Notes',
|
||||
'tab.files': 'Files',
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ export default {
|
|||
'nav.createNode': 'Создать элемент',
|
||||
'nav.moveToRoot': 'Переместить в корень',
|
||||
|
||||
'inbox.subtitle': 'Корневые элементы без назначенного раздела',
|
||||
'inbox.empty': 'Неразобранных элементов нет',
|
||||
|
||||
'tab.overview': 'Обзор',
|
||||
'tab.notes': 'Заметки',
|
||||
'tab.files': 'Файлы',
|
||||
|
|
|
|||
|
|
@ -78,6 +78,10 @@ export function ListWorklog(arg1) {
|
|||
return window['go']['main']['App']['ListWorklog'](arg1);
|
||||
}
|
||||
|
||||
export function ListInboxNodes() {
|
||||
return window['go']['main']['App']['ListInboxNodes']();
|
||||
}
|
||||
|
||||
export function CreateWorklog(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['CreateWorklog'](arg1, arg2, arg3);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,6 +169,23 @@ func (r *Repository) ListRoots(includeDeleted bool) ([]Node, error) {
|
|||
return scanNodes(rows)
|
||||
}
|
||||
|
||||
// ListInboxRoots returns active root nodes that are not assigned to a specific section.
|
||||
func (r *Repository) ListInboxRoots(includeDeleted bool) ([]Node, error) {
|
||||
q := `SELECT ` + nodeColumns + ` FROM nodes
|
||||
WHERE parent_id IS NULL AND COALESCE(section, '') IN ('', 'inbox')`
|
||||
if !includeDeleted {
|
||||
q += " AND deleted_at IS NULL"
|
||||
}
|
||||
q += " ORDER BY updated_at DESC, sort_order, title"
|
||||
|
||||
rows, err := r.db.Query(q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanNodes(rows)
|
||||
}
|
||||
|
||||
// CountChildren returns the number of non-deleted children for a parent,
|
||||
// optionally filtered by one or more types.
|
||||
func (r *Repository) CountChildren(parentID string, types ...string) (int, error) {
|
||||
|
|
|
|||
|
|
@ -133,6 +133,12 @@ async function runReadyScenario(cdp, url) {
|
|||
await click(cdp, '.close-btn')
|
||||
await waitForGone(cdp, '.settings-window')
|
||||
|
||||
await clickText(cdp, '.nav-item', 'Неразобранное')
|
||||
await assertText(cdp, 'Неразобранное', 'inbox: system view opens')
|
||||
await assertText(cdp, 'Inbox Smoke Item', 'inbox: unassigned item visible')
|
||||
await clickText(cdp, '.inbox-item-actions .btn', 'Открыть')
|
||||
await assertText(cdp, 'Inbox Smoke Item', 'inbox: item opens from list')
|
||||
|
||||
await clickText(cdp, '.tree-label', 'Smoke Project')
|
||||
await waitForSelector(cdp, '.tabs')
|
||||
await assertText(cdp, 'Smoke Project', 'node: selected project visible')
|
||||
|
|
@ -565,6 +571,7 @@ function wailsMockSource() {
|
|||
],
|
||||
},
|
||||
{ id: 'node-client', title: 'Smoke Client', type: 'client', section: 'clients', createdAt: now, has_children: false, children: [] },
|
||||
{ id: 'node-inbox', title: 'Inbox Smoke Item', type: 'folder', section: '', createdAt: now, has_children: false, children: [] },
|
||||
],
|
||||
notes: {
|
||||
'node-project': [{ id: 'note-1', title: 'Smoke note', createdAt: now }],
|
||||
|
|
@ -671,6 +678,7 @@ function wailsMockSource() {
|
|||
],
|
||||
ListWorkspaceTree: async () => clone(state.nodes),
|
||||
ListWorkspaceChildren: async (id) => clone(childrenOf(id)),
|
||||
ListInboxNodes: async () => clone(state.nodes.filter((node) => !node.parent_id && (!node.section || node.section === 'inbox'))),
|
||||
ListEnabledTemplates: async () => clone(templates),
|
||||
AllTemplates: async () => clone(templates),
|
||||
SetTemplateEnabled: async () => true,
|
||||
|
|
@ -679,9 +687,9 @@ function wailsMockSource() {
|
|||
SearchNodes: async (query) => allNodes().filter((node) => node.title.toLowerCase().includes(String(query || '').toLowerCase())).map((node) => ({ id: node.id, title: node.title, path: '/Smoke/' + node.title })),
|
||||
CreateNodeFromTemplate: async (parentId, title, templateId) => {
|
||||
const id = 'node-created-' + Date.now();
|
||||
const node = { id, title, type: templateId?.split('.')[0] || 'folder', section: 'projects', parent_id: parentId || '', createdAt: now, has_children: false, children: [] };
|
||||
const parent = parentId ? findNode(parentId) : null;
|
||||
const node = { id, title, type: templateId?.split('.')[0] || 'folder', section: parent?.section || '', parent_id: parentId || '', createdAt: now, has_children: false, children: [] };
|
||||
if (parentId) {
|
||||
const parent = findNode(parentId);
|
||||
if (parent) {
|
||||
parent.children = parent.children || [];
|
||||
parent.children.push(node);
|
||||
|
|
|
|||
Loading…
Reference in New Issue