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;
|
background: #13131f;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script type="module" crossorigin src="/assets/main-Cyhj7TEH.js"></script>
|
<script type="module" crossorigin src="/assets/main-Wpp0QvRu.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-DAyIHTpH.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-xwXesvcm.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<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 acceptingSuggestion = null
|
||||||
let suggestions = []
|
let suggestions = []
|
||||||
let suggestionCount = 0
|
let suggestionCount = 0
|
||||||
|
let inboxNodes = []
|
||||||
let showCreateNode = false
|
let showCreateNode = false
|
||||||
let newNodeTitle = ''
|
let newNodeTitle = ''
|
||||||
let createInNode = null
|
let createInNode = null
|
||||||
|
|
@ -228,6 +229,7 @@
|
||||||
actions = []
|
actions = []
|
||||||
worklog = []
|
worklog = []
|
||||||
suggestions = []
|
suggestions = []
|
||||||
|
inboxNodes = []
|
||||||
showCreateNode = false
|
showCreateNode = false
|
||||||
error = ''
|
error = ''
|
||||||
todayDashboard = null
|
todayDashboard = null
|
||||||
|
|
@ -241,6 +243,8 @@
|
||||||
todayDashboard = await wailsCall('ListTodayView') || { cases: [] }
|
todayDashboard = await wailsCall('ListTodayView') || { cases: [] }
|
||||||
suggestions = await wailsCall('GetSuggestions') || []
|
suggestions = await wailsCall('GetSuggestions') || []
|
||||||
suggestionCount = suggestions.length
|
suggestionCount = suggestions.length
|
||||||
|
} else if (id === 'inbox') {
|
||||||
|
inboxNodes = await wailsCall('ListInboxNodes') || []
|
||||||
} else if (id === 'journal') {
|
} else if (id === 'journal') {
|
||||||
await loadJournal()
|
await loadJournal()
|
||||||
} else if (id === 'activity') {
|
} else if (id === 'activity') {
|
||||||
|
|
@ -251,6 +255,7 @@
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = String(e)
|
error = String(e)
|
||||||
todayDashboard = { cases: [] }
|
todayDashboard = { cases: [] }
|
||||||
|
inboxNodes = []
|
||||||
activityFeed = []
|
activityFeed = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1987,6 +1992,37 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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'}
|
{:else if selectedSection === 'journal'}
|
||||||
<div class="journal-screen">
|
<div class="journal-screen">
|
||||||
<div class="journal-header">
|
<div class="journal-header">
|
||||||
|
|
@ -2693,6 +2729,19 @@
|
||||||
.suggestion-confidence.medium { color: #60a5fa; }
|
.suggestion-confidence.medium { color: #60a5fa; }
|
||||||
.suggestion-confidence.high { color: #34d399; }
|
.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 */
|
||||||
.journal-screen { padding: 24px; overflow-y: auto; flex: 1; }
|
.journal-screen { padding: 24px; overflow-y: auto; flex: 1; }
|
||||||
.journal-header { margin-bottom: 24px; }
|
.journal-header { margin-bottom: 24px; }
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ export default {
|
||||||
'nav.createInside': 'Create inside',
|
'nav.createInside': 'Create inside',
|
||||||
'nav.createNode': 'Create element',
|
'nav.createNode': 'Create element',
|
||||||
'nav.moveToRoot': 'Move to root',
|
'nav.moveToRoot': 'Move to root',
|
||||||
|
'inbox.subtitle': 'Root items without an assigned section',
|
||||||
|
'inbox.empty': 'No unprocessed items',
|
||||||
'tab.overview': 'Overview',
|
'tab.overview': 'Overview',
|
||||||
'tab.notes': 'Notes',
|
'tab.notes': 'Notes',
|
||||||
'tab.files': 'Files',
|
'tab.files': 'Files',
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ export default {
|
||||||
'nav.createNode': 'Создать элемент',
|
'nav.createNode': 'Создать элемент',
|
||||||
'nav.moveToRoot': 'Переместить в корень',
|
'nav.moveToRoot': 'Переместить в корень',
|
||||||
|
|
||||||
|
'inbox.subtitle': 'Корневые элементы без назначенного раздела',
|
||||||
|
'inbox.empty': 'Неразобранных элементов нет',
|
||||||
|
|
||||||
'tab.overview': 'Обзор',
|
'tab.overview': 'Обзор',
|
||||||
'tab.notes': 'Заметки',
|
'tab.notes': 'Заметки',
|
||||||
'tab.files': 'Файлы',
|
'tab.files': 'Файлы',
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,10 @@ export function ListWorklog(arg1) {
|
||||||
return window['go']['main']['App']['ListWorklog'](arg1);
|
return window['go']['main']['App']['ListWorklog'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ListInboxNodes() {
|
||||||
|
return window['go']['main']['App']['ListInboxNodes']();
|
||||||
|
}
|
||||||
|
|
||||||
export function CreateWorklog(arg1, arg2, arg3) {
|
export function CreateWorklog(arg1, arg2, arg3) {
|
||||||
return window['go']['main']['App']['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)
|
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,
|
// CountChildren returns the number of non-deleted children for a parent,
|
||||||
// optionally filtered by one or more types.
|
// optionally filtered by one or more types.
|
||||||
func (r *Repository) CountChildren(parentID string, types ...string) (int, error) {
|
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 click(cdp, '.close-btn')
|
||||||
await waitForGone(cdp, '.settings-window')
|
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 clickText(cdp, '.tree-label', 'Smoke Project')
|
||||||
await waitForSelector(cdp, '.tabs')
|
await waitForSelector(cdp, '.tabs')
|
||||||
await assertText(cdp, 'Smoke Project', 'node: selected project visible')
|
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-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: {
|
notes: {
|
||||||
'node-project': [{ id: 'note-1', title: 'Smoke note', createdAt: now }],
|
'node-project': [{ id: 'note-1', title: 'Smoke note', createdAt: now }],
|
||||||
|
|
@ -671,6 +678,7 @@ function wailsMockSource() {
|
||||||
],
|
],
|
||||||
ListWorkspaceTree: async () => clone(state.nodes),
|
ListWorkspaceTree: async () => clone(state.nodes),
|
||||||
ListWorkspaceChildren: async (id) => clone(childrenOf(id)),
|
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),
|
ListEnabledTemplates: async () => clone(templates),
|
||||||
AllTemplates: async () => clone(templates),
|
AllTemplates: async () => clone(templates),
|
||||||
SetTemplateEnabled: async () => true,
|
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 })),
|
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) => {
|
CreateNodeFromTemplate: async (parentId, title, templateId) => {
|
||||||
const id = 'node-created-' + Date.now();
|
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) {
|
if (parentId) {
|
||||||
const parent = findNode(parentId);
|
|
||||||
if (parent) {
|
if (parent) {
|
||||||
parent.children = parent.children || [];
|
parent.children = parent.children || [];
|
||||||
parent.children.push(node);
|
parent.children.push(node);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue