@orsetra/shared-ui 1.5.9 → 1.5.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -92,7 +92,7 @@ export function UserPanel({ main_base_url }: UserPanelProps) {
92
92
  </button>
93
93
  {currentProject && organization && (
94
94
  <a
95
- href={`${main_base_url}/projects/${currentProject.id}`}
95
+ href={`${main_base_url}/business-units/${currentProject.id}`}
96
96
  onClick={close}
97
97
  className="text-xs text-[#0f62fe] hover:underline"
98
98
  >
@@ -0,0 +1,62 @@
1
+ "use client"
2
+
3
+ import { FileCode, X } from "lucide-react"
4
+
5
+ export interface FileChipProps {
6
+ file: { name: string; id?: string }
7
+ isActive: boolean
8
+ isEditing: boolean
9
+ disabled?: boolean
10
+ onSelect: () => void
11
+ onDelete: (e: React.MouseEvent) => void
12
+ onNameChange: (name: string) => void
13
+ onEditStart: () => void
14
+ onEditEnd: () => void
15
+ }
16
+
17
+ export function FileChip({ file, isActive, isEditing, disabled, onSelect, onDelete, onNameChange, onEditStart, onEditEnd }: FileChipProps) {
18
+ const showInput = isEditing || file.name === ""
19
+
20
+ return (
21
+ <div
22
+ onClick={onSelect}
23
+ className={`flex items-center gap-1 px-2 py-0.5 text-xs cursor-pointer border transition-colors shrink-0 ${
24
+ isActive
25
+ ? "border-ibm-blue-60 bg-white text-ibm-blue-60"
26
+ : "border-ibm-gray-20 bg-white text-ibm-gray-70 hover:border-ibm-gray-40"
27
+ }`}
28
+ >
29
+ <FileCode className="w-3 h-3 shrink-0 flex-none" />
30
+
31
+ {showInput ? (
32
+ <input
33
+ autoFocus
34
+ type="text"
35
+ value={file.name}
36
+ onChange={(e) => onNameChange(e.target.value)}
37
+ onBlur={onEditEnd}
38
+ onClick={(e) => e.stopPropagation()}
39
+ placeholder="filename.yaml"
40
+ className="w-24 text-xs outline-none border-b border-ibm-blue-40 bg-transparent placeholder:text-ibm-gray-40 text-ibm-gray-100"
41
+ />
42
+ ) : (
43
+ <span
44
+ className="truncate max-w-[120px]"
45
+ onDoubleClick={(e) => { e.stopPropagation(); if (isActive) onEditStart() }}
46
+ >
47
+ {file.name}
48
+ </span>
49
+ )}
50
+
51
+ {!disabled && (
52
+ <button
53
+ type="button"
54
+ onClick={(e) => { e.stopPropagation(); onDelete(e) }}
55
+ className="ml-0.5 hover:text-red-500 transition-colors flex-none"
56
+ >
57
+ <X className="w-3 h-3" />
58
+ </button>
59
+ )}
60
+ </div>
61
+ )
62
+ }
@@ -0,0 +1,158 @@
1
+ "use client"
2
+
3
+ import { useState, useEffect } from "react"
4
+ import { Button } from "./button"
5
+ import { Plus } from "lucide-react"
6
+ import { SecretInput } from "./secret-input"
7
+ import { FileChip } from "./file-chip"
8
+
9
+ interface ResourcesInputProps {
10
+ id?: string
11
+ value?: string[]
12
+ onChange?: (value: string[]) => void
13
+ disabled?: boolean
14
+ [key: string]: any
15
+ }
16
+
17
+ const SECRET_PREFIX = "secret:"
18
+ const BASE_PATH = "/etc"
19
+
20
+ function parseItem(item: string): { name: string; path: string } {
21
+ const raw = item.startsWith(SECRET_PREFIX) ? item.slice(SECRET_PREFIX.length) : item
22
+ const at = raw.indexOf("@")
23
+ if (at === -1) return { name: raw, path: BASE_PATH }
24
+ return { name: raw.slice(0, at), path: raw.slice(at + 1) }
25
+ }
26
+
27
+ function buildItem(name: string, path: string): string {
28
+ return `${SECRET_PREFIX}${name}@${path}`
29
+ }
30
+
31
+ function pathSuffix(path: string): string {
32
+ return path.startsWith(BASE_PATH) ? path.slice(BASE_PATH.length) : path
33
+ }
34
+
35
+ export function ResourcesInput({ id, value = [], onChange, disabled = false, ...rest }: ResourcesInputProps) {
36
+ const [items, setItems] = useState<string[]>(value.filter(Boolean))
37
+ const [draft, setDraft] = useState("")
38
+ const [editingNameIndex, setEditingNameIndex] = useState<number | null>(null)
39
+ const [editingPathIndex, setEditingPathIndex] = useState<number | null>(null)
40
+ const [editingPathSuffix, setEditingPathSuffix] = useState("")
41
+
42
+ useEffect(() => {
43
+ const filtered = value.filter(Boolean)
44
+ if (JSON.stringify(filtered) !== JSON.stringify(items)) {
45
+ setItems(filtered)
46
+ }
47
+ }, [value])
48
+
49
+ const handleAdd = () => {
50
+ if (!draft || items.includes(draft)) return
51
+ const next = [...items, draft]
52
+ setItems(next)
53
+ onChange?.(next)
54
+ setDraft("")
55
+ }
56
+
57
+ const handleRemove = (index: number) => {
58
+ const next = items.filter((_, i) => i !== index)
59
+ setItems(next)
60
+ onChange?.(next)
61
+ }
62
+
63
+ const handleNameChange = (index: number, newName: string) => {
64
+ const { path } = parseItem(items[index])
65
+ const next = items.map((item, i) => i === index ? buildItem(newName, path) : item)
66
+ setItems(next)
67
+ }
68
+
69
+ const handleNameEditEnd = (index: number) => {
70
+ setEditingNameIndex(null)
71
+ onChange?.(items)
72
+ }
73
+
74
+ const startPathEditing = (index: number, path: string) => {
75
+ if (disabled) return
76
+ setEditingPathIndex(index)
77
+ setEditingPathSuffix(pathSuffix(path))
78
+ }
79
+
80
+ const confirmPathEdit = (index: number) => {
81
+ const { name } = parseItem(items[index])
82
+ const fullPath = `${BASE_PATH}${editingPathSuffix}`
83
+ const next = items.map((item, i) => i === index ? buildItem(name, fullPath) : item)
84
+ setItems(next)
85
+ onChange?.(next)
86
+ setEditingPathIndex(null)
87
+ }
88
+
89
+ return (
90
+ <div className="space-y-2">
91
+ {items.map((item, index) => {
92
+ const { name, path } = parseItem(item)
93
+ const suffix = pathSuffix(path)
94
+ return (
95
+ <div key={index} className="flex items-center gap-2">
96
+ <FileChip
97
+ file={{ name, id: String(index) }}
98
+ isActive={editingNameIndex === index}
99
+ isEditing={editingNameIndex === index}
100
+ disabled={disabled}
101
+ onSelect={() => {}}
102
+ onDelete={(e) => handleRemove(index)}
103
+ onNameChange={(newName) => handleNameChange(index, newName)}
104
+ onEditStart={() => setEditingNameIndex(index)}
105
+ onEditEnd={() => handleNameEditEnd(index)}
106
+ />
107
+
108
+ {editingPathIndex === index ? (
109
+ <div className="flex flex-1 items-center border border-ibm-blue-60 bg-white min-w-0 h-10">
110
+ <span className="px-2 text-sm font-mono text-ibm-gray-40 shrink-0 select-none">{BASE_PATH}</span>
111
+ <input
112
+ autoFocus
113
+ value={editingPathSuffix}
114
+ onChange={(e) => setEditingPathSuffix(e.target.value)}
115
+ onBlur={() => confirmPathEdit(index)}
116
+ onKeyDown={(e) => {
117
+ if (e.key === "Enter") confirmPathEdit(index)
118
+ if (e.key === "Escape") setEditingPathIndex(null)
119
+ }}
120
+ className="flex-1 text-sm font-mono text-ibm-gray-100 bg-transparent focus:outline-none pr-2 min-w-0"
121
+ placeholder="/myapp"
122
+ />
123
+ </div>
124
+ ) : (
125
+ <span
126
+ onDoubleClick={() => startPathEditing(index, path)}
127
+ title={disabled ? undefined : "Double-click to edit"}
128
+ className={`flex flex-1 items-center border border-ibm-gray-20 bg-white h-10 px-3 font-mono text-sm truncate min-w-0 ${
129
+ disabled ? "text-ibm-gray-40" : "cursor-text hover:border-ibm-gray-40"
130
+ }`}
131
+ >
132
+ <span className="text-ibm-gray-40 shrink-0">{BASE_PATH}</span>
133
+ <span className={suffix ? "text-ibm-gray-70" : "text-ibm-gray-40 italic"}>
134
+ {suffix || "/…"}
135
+ </span>
136
+ </span>
137
+ )}
138
+ </div>
139
+ )
140
+ })}
141
+
142
+ <div className="flex gap-2">
143
+ <div className="flex-1">
144
+ <SecretInput value={draft} onChange={setDraft} disabled={disabled} type={id} />
145
+ </div>
146
+ <Button
147
+ type="button"
148
+ variant="secondary"
149
+ onClick={handleAdd}
150
+ disabled={disabled || !draft}
151
+ className="rounded-none h-10 w-10 p-0 shrink-0"
152
+ >
153
+ <Plus className="h-3.5 w-3.5" />
154
+ </Button>
155
+ </div>
156
+ </div>
157
+ )
158
+ }
@@ -6,23 +6,28 @@ import type { ReactNode } from "react"
6
6
  export interface SecretEntry {
7
7
  name: string
8
8
  description?: string
9
+ labels?: string[]
10
+ template?: string
9
11
  }
10
12
 
11
13
  interface SecretContextValue {
12
14
  secretsMap: Map<string, SecretEntry[]>
15
+ loading: boolean
13
16
  }
14
17
 
15
18
  const SecretContext = createContext<SecretContextValue | null>(null)
16
19
 
17
20
  export function SecretContextProvider({
18
21
  children,
22
+ loading = false,
19
23
  secretsMap,
20
24
  }: {
21
25
  children: ReactNode
26
+ loading?: boolean
22
27
  secretsMap: Map<string, SecretEntry[]>
23
28
  }) {
24
29
  return (
25
- <SecretContext.Provider value={{ secretsMap }}>
30
+ <SecretContext.Provider value={{ secretsMap, loading }}>
26
31
  {children}
27
32
  </SecretContext.Provider>
28
33
  )
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { SelectInput } from "./select-input"
4
4
  import { useSecretContext } from "./secret-context"
5
+ import { Loader2 } from "lucide-react"
5
6
 
6
7
  interface SecretInputProps {
7
8
  id?: string
@@ -31,16 +32,20 @@ export function SecretInput({
31
32
  </div>
32
33
  )
33
34
  }
35
+
34
36
  const secrets = ctx.secretsMap.get(type || 'configs') || []
35
37
  return (
36
- <SelectInput
37
- id={id}
38
- value={value}
39
- onChange={onChange}
40
- disabled={disabled}
41
- className={className}
42
- placeholder={placeholder}
43
- options={secrets.map((s) => ({ label: s.description || s.name, value: `secret:${s.name}` }))}
44
- />
38
+ <div className="flex items-center gap-2">
39
+ <SelectInput
40
+ id={id}
41
+ value={value}
42
+ onChange={onChange}
43
+ disabled={disabled || ctx.loading}
44
+ className={className}
45
+ placeholder={ctx.loading ? "Loading…" : placeholder}
46
+ options={secrets.map((s) => ({ label: s.description || s.name, value: s.name }))}
47
+ />
48
+ {ctx.loading && <Loader2 className="h-4 w-4 animate-spin text-ibm-blue-60 flex-shrink-0" />}
49
+ </div>
45
50
  )
46
51
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orsetra/shared-ui",
3
- "version": "1.5.9",
3
+ "version": "1.5.11",
4
4
  "description": "Shared UI components for Orsetra platform",
5
5
  "main": "./index.ts",
6
6
  "types": "./index.ts",
@@ -1,274 +0,0 @@
1
- "use client"
2
-
3
- import { useState, useEffect } from "react"
4
- import { Button } from "./button"
5
- import { Card, CardContent, CardHeader, CardTitle } from "./card"
6
- import { Key, Plus, Trash2, Folder, File, ChevronRight } from "lucide-react"
7
- import { useToast } from "../../hooks/use-toast"
8
-
9
- import type { Secrets } from "@/app/secrets/models"
10
-
11
- interface SecretExplorerProps {
12
- currentPath: string
13
- onPathChange: (path: string) => void
14
- getSecrets: (path: string) => Promise<Secrets>
15
- deleteSecret: (path: string) => Promise<boolean>
16
- selectedSecret: string | null
17
- onSecretSelect: (secret: string | null) => void
18
- onCreateClick: () => void
19
- onRefresh?: () => void
20
- }
21
-
22
- export function SecretExplorer({
23
- currentPath,
24
- onPathChange,
25
- getSecrets,
26
- deleteSecret,
27
- selectedSecret,
28
- onSecretSelect,
29
- onCreateClick,
30
- onRefresh
31
- }: SecretExplorerProps) {
32
- const { toast } = useToast()
33
- const [secretPaths, setSecretPaths] = useState<string[]>([])
34
- const [loading, setLoading] = useState(true)
35
- const [showDeleteDialog, setShowDeleteDialog] = useState(false)
36
- const [selectedSecretForAction, setSelectedSecretForAction] = useState<string | null>(null)
37
-
38
- useEffect(() => {
39
- loadSecrets()
40
- }, [currentPath])
41
-
42
- const loadSecrets = async () => {
43
- try {
44
- const secretsResponse: Secrets = await getSecrets(currentPath || "/")
45
- const paths = secretsResponse.data?.keys || []
46
- setSecretPaths(paths)
47
- } catch (error) {
48
- // Navigate up if current path is invalid
49
- if (currentPath) {
50
- const pathParts = currentPath.split('/').filter(Boolean)
51
- pathParts.pop()
52
- const newPath = pathParts.length > 0 ? pathParts.join('/') + '/' : ""
53
- onPathChange(newPath)
54
- }
55
- toast({
56
- variant: "destructive",
57
- title: "Error loading secrets",
58
- description: "Failed to fetch configuration secrets."
59
- })
60
- } finally {
61
- setLoading(false)
62
- }
63
- }
64
-
65
- const handleItemClick = async (path: string) => {
66
- if (path.endsWith('/')) {
67
- // It's a folder, navigate into it
68
- const newPath = currentPath ? `${currentPath}${path}` : path
69
- onPathChange(newPath)
70
- onSecretSelect(null)
71
- } else {
72
- // It's a secret, show details
73
- onSecretSelect(path)
74
- }
75
- }
76
-
77
- const getBreadcrumbs = () => {
78
- if (!currentPath) return [{ name: "Root", path: "" }]
79
-
80
- const parts = currentPath.split('/').filter(Boolean)
81
- const breadcrumbs = [{ name: "Root", path: "" }]
82
-
83
- let accumulatedPath = ""
84
- parts.forEach((part, index) => {
85
- accumulatedPath += part + "/"
86
- breadcrumbs.push({
87
- name: part,
88
- path: accumulatedPath
89
- })
90
- })
91
-
92
- return breadcrumbs
93
- }
94
-
95
- const handleDeleteSecret = async () => {
96
- if (!selectedSecretForAction) return
97
-
98
- try {
99
- const fullPath = currentPath ? `${currentPath}${selectedSecretForAction}` : selectedSecretForAction
100
- await deleteSecret(fullPath)
101
-
102
- toast({
103
- variant: "success",
104
- title: "Secret deleted successfully",
105
- description: `Configuration "${selectedSecretForAction}" has been deleted.`
106
- })
107
-
108
- setShowDeleteDialog(false)
109
- setSelectedSecretForAction(null)
110
- loadSecrets()
111
-
112
- // Clear details if this was the selected secret
113
- if (selectedSecret === selectedSecretForAction) {
114
- onSecretSelect(null)
115
- }
116
- } catch (error) {
117
- console.error("Error deleting secret:", error)
118
- toast({
119
- variant: "destructive",
120
- title: "Error deleting secret",
121
- description: "Failed to delete configuration secret."
122
- })
123
- }
124
- }
125
-
126
- if (loading) {
127
- return (
128
- <Card className="border border-ibm-gray-20">
129
- <CardHeader className="pb-3">
130
- <div className="animate-pulse">
131
- <div className="h-6 bg-ibm-gray-20 mb-2"></div>
132
- <div className="h-4 bg-ibm-gray-20"></div>
133
- </div>
134
- </CardHeader>
135
- <CardContent className="p-0">
136
- <div className="animate-pulse">
137
- <div className="h-32 bg-ibm-gray-20"></div>
138
- </div>
139
- </CardContent>
140
- </Card>
141
- )
142
- }
143
-
144
- return (
145
- <>
146
- <Card className="border border-ibm-gray-20">
147
- <CardHeader className="pb-3">
148
- <div className="flex items-center justify-between">
149
- <CardTitle className="text-lg font-medium">Secrets Explorer</CardTitle>
150
- <Button variant="ghost" size="sm" onClick={onCreateClick}>
151
- <Plus className="w-4 h-4" />
152
- </Button>
153
- </div>
154
-
155
- {/* Breadcrumbs */}
156
- <div className="flex items-center gap-1 text-xs text-ibm-gray-70 mt-2">
157
- {getBreadcrumbs().map((crumb, index) => (
158
- <div key={crumb.path} className="flex items-center gap-1">
159
- {index > 0 && <ChevronRight className="w-3 h-3" />}
160
- <button
161
- onClick={() => {
162
- onPathChange(crumb.path)
163
- onSecretSelect(null)
164
- }}
165
- className={`hover:text-ibm-blue-60 ${
166
- crumb.path === currentPath ? "text-ibm-blue-60 font-medium" : ""
167
- }`}
168
- >
169
- {crumb.name}
170
- </button>
171
- </div>
172
- ))}
173
- </div>
174
- </CardHeader>
175
-
176
- <CardContent className="p-0">
177
- {/* File List */}
178
- <div className="border-t border-ibm-gray-20">
179
- {secretPaths.length === 0 ? (
180
- <div className="p-8 text-center">
181
- <Key className="w-8 h-8 text-ibm-gray-30 mx-auto mb-2" />
182
- <p className="text-sm text-ibm-gray-70">
183
- Empty folder
184
- </p>
185
- </div>
186
- ) : (
187
- <div className="divide-y divide-ibm-gray-20">
188
- {secretPaths.map((path) => {
189
- const isFolder = path.endsWith('/')
190
- const displayName = isFolder ? path.slice(0, -1) : path
191
- const isSelected = selectedSecret === path
192
-
193
- return (
194
- <div
195
- key={path}
196
- className={`group flex items-center gap-3 px-4 py-3 hover:bg-ibm-gray-10 cursor-pointer transition-colors ${
197
- isSelected ? "bg-ibm-blue-10 border-r-2 border-ibm-blue-60" : ""
198
- }`}
199
- onClick={() => handleItemClick(path)}
200
- >
201
- <div className="flex-shrink-0">
202
- {isFolder ? (
203
- <Folder className="w-4 h-4 text-ibm-yellow-60" />
204
- ) : (
205
- <File className="w-4 h-4 text-ibm-blue-60" />
206
- )}
207
- </div>
208
- <span className="text-sm text-ibm-gray-100 flex-1 truncate">
209
- {displayName}
210
- </span>
211
-
212
- {!isFolder && (
213
- <Button
214
- variant="ghost"
215
- size="xs"
216
- leftIcon={<Trash2 className="w-3 h-3" />}
217
- onClick={(e) => {
218
- e.stopPropagation()
219
- setSelectedSecretForAction(path)
220
- setShowDeleteDialog(true)
221
- }}
222
- className="opacity-0 group-hover:opacity-100 transition-opacity text-ibm-red-60 hover:text-ibm-red-70 hover:bg-ibm-red-10"
223
- >
224
- </Button>
225
- )}
226
- </div>
227
- )
228
- })}
229
- </div>
230
- )}
231
- </div>
232
- </CardContent>
233
- </Card>
234
-
235
- {/* Delete Confirmation Dialog */}
236
- <div
237
- className={`fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 ${
238
- showDeleteDialog ? 'block' : 'hidden'
239
- }`}
240
- onClick={() => setShowDeleteDialog(false)}
241
- >
242
- <div
243
- className="bg-white p-6 rounded-lg max-w-md w-full mx-4"
244
- onClick={(e) => e.stopPropagation()}
245
- >
246
- <h3 className="text-lg font-medium text-ibm-gray-100 mb-2">Delete Secret</h3>
247
- <p className="text-sm text-ibm-gray-70 mb-6">
248
- Are you sure you want to delete the secret "{selectedSecretForAction}"?
249
- This action cannot be undone and will permanently remove all secret data.
250
- </p>
251
- <div className="flex justify-end gap-3">
252
- <Button
253
- variant="secondary"
254
- size="sm"
255
- style={{ borderRadius: 0 }}
256
- onClick={() => setShowDeleteDialog(false)}
257
- >
258
- Cancel
259
- </Button>
260
- <Button
261
- variant="danger"
262
- style={{ borderRadius: 0 }}
263
- leftIcon={<Trash2 className="w-3 h-3" />}
264
- size="sm"
265
- onClick={handleDeleteSecret}
266
- >
267
- Delete Secret
268
- </Button>
269
- </div>
270
- </div>
271
- </div>
272
- </>
273
- )
274
- }