Skip to content

Commit 9e9d3fb

Browse files
committed
feat: implement folder management for tables
1 parent 24f41cf commit 9e9d3fb

5 files changed

Lines changed: 921 additions & 102 deletions

File tree

apps/desktop/src/entities/database/store/helpers.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,3 +148,144 @@ export function setChatPosition(id: string, position: typeof databaseStoreType.i
148148
},
149149
} satisfies typeof state))
150150
}
151+
152+
export function toggleFolder(id: string, schema: string, folder: string) {
153+
const store = databaseStore(id)
154+
store.setState((state) => {
155+
const key = `${schema}:${folder}`
156+
const isOpen = state.tablesTreeOpenedFolders.some(f => `${f.schema}:${f.folder}` === key)
157+
158+
return {
159+
...state,
160+
tablesTreeOpenedFolders: isOpen
161+
? state.tablesTreeOpenedFolders.filter(f => `${f.schema}:${f.folder}` !== key)
162+
: [...state.tablesTreeOpenedFolders, { schema, folder }],
163+
} satisfies typeof state
164+
})
165+
}
166+
167+
export function addTableToGroup(id: string, schema: string, folder: string, table: string) {
168+
const store = databaseStore(id)
169+
store.setState((state) => {
170+
const existingGroup = state.tableGroups.find(g => g.schema === schema && g.folder === folder)
171+
172+
if (existingGroup) {
173+
if (existingGroup.tables.includes(table)) {
174+
return state
175+
}
176+
177+
return {
178+
...state,
179+
tableGroups: state.tableGroups.map(g =>
180+
g.schema === schema && g.folder === folder
181+
? { ...g, tables: [...g.tables, table] }
182+
: g,
183+
),
184+
} satisfies typeof state
185+
}
186+
187+
return {
188+
...state,
189+
tableGroups: [...state.tableGroups, { schema, folder, tables: [table] }],
190+
} satisfies typeof state
191+
})
192+
}
193+
194+
export function removeTableFromGroup(id: string, schema: string, folder: string, table: string) {
195+
const store = databaseStore(id)
196+
store.setState(state => ({
197+
...state,
198+
tableGroups: state.tableGroups
199+
.map(g =>
200+
g.schema === schema && g.folder === folder
201+
? { ...g, tables: g.tables.filter(t => t !== table) }
202+
: g,
203+
)
204+
.filter(g => g.tables.length > 0),
205+
} satisfies typeof state))
206+
}
207+
208+
export function renameGroup(id: string, schema: string, oldFolder: string, newFolder: string) {
209+
const store = databaseStore(id)
210+
store.setState(state => ({
211+
...state,
212+
tableGroups: state.tableGroups.map(g =>
213+
g.schema === schema && g.folder === oldFolder
214+
? { ...g, folder: newFolder }
215+
: g,
216+
),
217+
tablesTreeOpenedFolders: state.tablesTreeOpenedFolders.map(f =>
218+
f.schema === schema && f.folder === oldFolder
219+
? { schema, folder: newFolder }
220+
: f,
221+
),
222+
} satisfies typeof state))
223+
}
224+
225+
export function deleteGroup(id: string, schema: string, folder: string) {
226+
const store = databaseStore(id)
227+
store.setState(state => ({
228+
...state,
229+
tableGroups: state.tableGroups.filter(g => !(g.schema === schema && g.folder === folder)),
230+
tablesTreeOpenedFolders: state.tablesTreeOpenedFolders.filter(f => !(f.schema === schema && f.folder === folder)),
231+
} satisfies typeof state))
232+
}
233+
234+
export function toggleTableSelection(id: string, schema: string, table: string) {
235+
const store = databaseStore(id)
236+
store.setState((state) => {
237+
const key = `${schema}:${table}`
238+
const isSelected = state.selectedTables.some(t => `${t.schema}:${t.table}` === key)
239+
240+
return {
241+
...state,
242+
selectedTables: isSelected
243+
? state.selectedTables.filter(t => `${t.schema}:${t.table}` !== key)
244+
: [...state.selectedTables, { schema, table }],
245+
} satisfies typeof state
246+
})
247+
}
248+
249+
export function clearTableSelection(id: string) {
250+
const store = databaseStore(id)
251+
store.setState(state => ({
252+
...state,
253+
selectedTables: [],
254+
} satisfies typeof state))
255+
}
256+
257+
export function selectMultipleTables(id: string, tables: { schema: string, table: string }[]) {
258+
const store = databaseStore(id)
259+
store.setState(state => ({
260+
...state,
261+
selectedTables: tables,
262+
} satisfies typeof state))
263+
}
264+
265+
export function addMultipleTablesToGroup(id: string, schema: string, folder: string, tables: string[]) {
266+
const store = databaseStore(id)
267+
store.setState((state) => {
268+
const existingGroup = state.tableGroups.find(g => g.schema === schema && g.folder === folder)
269+
270+
if (existingGroup) {
271+
const newTables = tables.filter(t => !existingGroup.tables.includes(t))
272+
if (newTables.length === 0) {
273+
return state
274+
}
275+
276+
return {
277+
...state,
278+
tableGroups: state.tableGroups.map(g =>
279+
g.schema === schema && g.folder === folder
280+
? { ...g, tables: [...g.tables, ...newTables] }
281+
: g,
282+
),
283+
} satisfies typeof state
284+
}
285+
286+
return {
287+
...state,
288+
tableGroups: [...state.tableGroups, { schema, folder, tables }],
289+
} satisfies typeof state
290+
})
291+
}

apps/desktop/src/entities/database/store/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,19 @@ export const databaseStoreType = type({
4444
tabs: tabType.array(),
4545
tablesSearch: 'string',
4646
tablesTreeOpenedSchemas: 'string[] | null',
47+
tablesTreeOpenedFolders: type({
48+
schema: 'string',
49+
folder: 'string',
50+
}).array(),
51+
tableGroups: type({
52+
schema: 'string',
53+
folder: 'string',
54+
tables: 'string[]',
55+
}).array(),
56+
selectedTables: type({
57+
schema: 'string',
58+
table: 'string',
59+
}).array(),
4760
pinnedTables: type({
4861
schema: 'string',
4962
table: 'string',
@@ -80,6 +93,9 @@ const defaultState: typeof databaseStoreType.infer = {
8093
tabs: [],
8194
tablesSearch: '',
8295
tablesTreeOpenedSchemas: null,
96+
tablesTreeOpenedFolders: [],
97+
tableGroups: [],
98+
selectedTables: [],
8399
pinnedTables: [],
84100
layout: {
85101
chatVisible: true,
@@ -133,6 +149,9 @@ export function databaseStore(id: string) {
133149
tabs: currentVal.tabs,
134150
tablesSearch: currentVal.tablesSearch,
135151
tablesTreeOpenedSchemas: currentVal.tablesTreeOpenedSchemas,
152+
tablesTreeOpenedFolders: currentVal.tablesTreeOpenedFolders,
153+
tableGroups: currentVal.tableGroups,
154+
selectedTables: currentVal.selectedTables,
136155
pinnedTables: currentVal.pinnedTables,
137156
layout: currentVal.layout,
138157
} satisfies Omit<typeof currentVal, 'queriesToRun' | 'files' | 'editorQueries'>))
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import type { databases } from '~/drizzle'
2+
import { Button } from '@conar/ui/components/button'
3+
import { ScrollArea } from '@conar/ui/components/custom/scroll-area'
4+
import {
5+
Dialog,
6+
DialogContent,
7+
DialogHeader,
8+
DialogTitle,
9+
} from '@conar/ui/components/dialog'
10+
import { Input } from '@conar/ui/components/input'
11+
import { Label } from '@conar/ui/components/label'
12+
import { cn } from '@conar/ui/lib/utils'
13+
import { RiAddLine, RiFolderLine } from '@remixicon/react'
14+
import { useStore } from '@tanstack/react-store'
15+
import { useImperativeHandle, useState } from 'react'
16+
import { toast } from 'sonner'
17+
import { addTableToGroup, databaseStore } from '~/entities/database/store'
18+
19+
interface AddToFolderDialogProps {
20+
ref: React.RefObject<{
21+
addToFolder: (schema: string, table: string) => void
22+
} | null>
23+
database: typeof databases.$inferSelect
24+
}
25+
26+
export function AddToFolderDialog({ ref, database }: AddToFolderDialogProps) {
27+
const [schema, setSchema] = useState('')
28+
const [table, setTable] = useState('')
29+
const [open, setOpen] = useState(false)
30+
const [newFolderName, setNewFolderName] = useState('')
31+
const [showNewFolder, setShowNewFolder] = useState(false)
32+
33+
const store = databaseStore(database.id)
34+
const tableGroups = useStore(store, state => state.tableGroups)
35+
36+
useImperativeHandle(ref, () => ({
37+
addToFolder: (schema: string, table: string) => {
38+
setSchema(schema)
39+
setTable(table)
40+
setNewFolderName('')
41+
setShowNewFolder(false)
42+
setOpen(true)
43+
},
44+
}))
45+
46+
const existingFolders = tableGroups
47+
.filter(g => g.schema === schema)
48+
.map(g => g.folder)
49+
50+
const handleAddToExistingFolder = (folder: string) => {
51+
addTableToGroup(database.id, schema, folder, table)
52+
toast.success(`Table "${table}" added to folder "${folder}"`)
53+
setOpen(false)
54+
}
55+
56+
const handleCreateNewFolder = () => {
57+
if (!newFolderName.trim()) {
58+
toast.error('Folder name cannot be empty')
59+
return
60+
}
61+
62+
if (existingFolders.includes(newFolderName.trim())) {
63+
toast.error('A folder with this name already exists')
64+
return
65+
}
66+
67+
addTableToGroup(database.id, schema, newFolderName.trim(), table)
68+
toast.success(`Table "${table}" added to new folder "${newFolderName.trim()}"`)
69+
setOpen(false)
70+
}
71+
72+
return (
73+
<Dialog open={open} onOpenChange={setOpen}>
74+
<DialogContent>
75+
<DialogHeader>
76+
<DialogTitle>
77+
Add "
78+
{table}
79+
" to Folder
80+
</DialogTitle>
81+
</DialogHeader>
82+
<div className="space-y-4">
83+
{existingFolders.length > 0 && (
84+
<div className="space-y-2">
85+
<Label>Select Existing Folder</Label>
86+
<ScrollArea className="max-h-50 rounded-md border">
87+
<div className="space-y-1 p-2">
88+
{existingFolders.map(folder => (
89+
<button
90+
key={folder}
91+
type="button"
92+
onClick={() => handleAddToExistingFolder(folder)}
93+
className={cn(
94+
`
95+
flex w-full items-center gap-2 rounded-md px-3 py-2
96+
text-left text-sm transition-colors
97+
hover:bg-accent
98+
`,
99+
)}
100+
>
101+
<RiFolderLine className="size-4 text-muted-foreground" />
102+
<span>{folder}</span>
103+
</button>
104+
))}
105+
</div>
106+
</ScrollArea>
107+
</div>
108+
)}
109+
110+
<div className="space-y-2">
111+
{!showNewFolder
112+
? (
113+
<Button
114+
type="button"
115+
variant="outline"
116+
className="w-full"
117+
onClick={() => setShowNewFolder(true)}
118+
>
119+
<RiAddLine className="size-4" />
120+
Create New Folder
121+
</Button>
122+
)
123+
: (
124+
<div className="space-y-2">
125+
<Label htmlFor="new-folder-name">New Folder Name</Label>
126+
<Input
127+
id="new-folder-name"
128+
placeholder="Enter folder name..."
129+
value={newFolderName}
130+
onChange={e => setNewFolderName(e.target.value)}
131+
onKeyDown={(e) => {
132+
if (e.key === 'Enter') {
133+
e.preventDefault()
134+
handleCreateNewFolder()
135+
}
136+
}}
137+
autoFocus
138+
/>
139+
<div className="flex gap-2">
140+
<Button
141+
type="button"
142+
onClick={handleCreateNewFolder}
143+
disabled={!newFolderName.trim()}
144+
className="flex-1"
145+
>
146+
Create & Add
147+
</Button>
148+
</div>
149+
</div>
150+
)}
151+
</div>
152+
</div>
153+
</DialogContent>
154+
</Dialog>
155+
)
156+
}

0 commit comments

Comments
 (0)