@startsimpli/ui 0.4.36 → 0.4.37
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.
- package/package.json +1 -1
- package/src/components/schema/RelationshipBuilder.tsx +167 -0
- package/src/components/schema/__tests__/RelationshipBuilder.test.tsx +34 -0
- package/src/components/schema/__tests__/schema-builder-core.test.ts +13 -0
- package/src/components/schema/index.ts +7 -0
- package/src/components/schema/schema-builder-core.ts +7 -0
package/package.json
CHANGED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { Button } from '../ui/button'
|
|
5
|
+
import { Input } from '../ui/input'
|
|
6
|
+
import { Label } from '../ui/label'
|
|
7
|
+
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'
|
|
8
|
+
import {
|
|
9
|
+
Select,
|
|
10
|
+
SelectTrigger,
|
|
11
|
+
SelectValue,
|
|
12
|
+
SelectContent,
|
|
13
|
+
SelectItem,
|
|
14
|
+
} from '../ui/select'
|
|
15
|
+
import { notify as defaultNotify } from '../toast'
|
|
16
|
+
import { cn } from '../../lib/utils'
|
|
17
|
+
import { suggestRelationshipKey, type SchemaId } from './schema-builder-core'
|
|
18
|
+
import type { SchemaTypeBuilderNotifier } from './SchemaTypeBuilder'
|
|
19
|
+
|
|
20
|
+
/** A type option the edge can connect (id + its key/label for display). */
|
|
21
|
+
export interface RelationshipTypeOption {
|
|
22
|
+
id: SchemaId
|
|
23
|
+
key: string
|
|
24
|
+
label: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RelationshipBuilderClassNames {
|
|
28
|
+
root?: string
|
|
29
|
+
card?: string
|
|
30
|
+
submit?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RelationshipBuilderProps {
|
|
34
|
+
/** The org's entity types — the selectable endpoints of the edge. */
|
|
35
|
+
types: RelationshipTypeOption[]
|
|
36
|
+
/** Persist a relationship definition (typed edge source_type -> target_type). */
|
|
37
|
+
createRelationship: (input: {
|
|
38
|
+
key: string
|
|
39
|
+
sourceType: SchemaId
|
|
40
|
+
targetType: SchemaId
|
|
41
|
+
}) => Promise<unknown>
|
|
42
|
+
/** Fired after the relationship def is created (refetch here). */
|
|
43
|
+
onCreated?: (created: { key: string }) => void
|
|
44
|
+
notify?: SchemaTypeBuilderNotifier
|
|
45
|
+
classNames?: RelationshipBuilderClassNames
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* No-code relationship builder (768w.10.4 S5): connect two entity types with a
|
|
50
|
+
* typed edge. The persistence layer is injected via `createRelationship`; the
|
|
51
|
+
* consuming app supplies the type list + handles refetch in `onCreated`.
|
|
52
|
+
*/
|
|
53
|
+
export function RelationshipBuilder({
|
|
54
|
+
types,
|
|
55
|
+
createRelationship,
|
|
56
|
+
onCreated,
|
|
57
|
+
notify = defaultNotify,
|
|
58
|
+
classNames,
|
|
59
|
+
}: RelationshipBuilderProps) {
|
|
60
|
+
const [sourceId, setSourceId] = useState('')
|
|
61
|
+
const [targetId, setTargetId] = useState('')
|
|
62
|
+
const [keyOverride, setKeyOverride] = useState('')
|
|
63
|
+
const [saving, setSaving] = useState(false)
|
|
64
|
+
|
|
65
|
+
const sourceKey = types.find((t) => String(t.id) === sourceId)?.key ?? ''
|
|
66
|
+
const targetKey = types.find((t) => String(t.id) === targetId)?.key ?? ''
|
|
67
|
+
const effectiveKey = keyOverride.trim() || suggestRelationshipKey(sourceKey, targetKey)
|
|
68
|
+
|
|
69
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
70
|
+
e.preventDefault()
|
|
71
|
+
if (!sourceId || !targetId) {
|
|
72
|
+
notify.error?.('Pick a source and a target type.')
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
if (!effectiveKey) {
|
|
76
|
+
notify.error?.('Give the relationship a name.')
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
setSaving(true)
|
|
80
|
+
try {
|
|
81
|
+
await createRelationship({ key: effectiveKey, sourceType: sourceId, targetType: targetId })
|
|
82
|
+
notify.success?.(`Connected ${sourceKey} → ${targetKey}.`)
|
|
83
|
+
onCreated?.({ key: effectiveKey })
|
|
84
|
+
setKeyOverride('')
|
|
85
|
+
} catch (err) {
|
|
86
|
+
notify.error?.(err instanceof Error ? err.message : 'Could not create the relationship.')
|
|
87
|
+
} finally {
|
|
88
|
+
setSaving(false)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<form onSubmit={handleSubmit} className={cn('space-y-6', classNames?.root)}>
|
|
94
|
+
<Card className={classNames?.card}>
|
|
95
|
+
<CardHeader>
|
|
96
|
+
<CardTitle>Connect two types</CardTitle>
|
|
97
|
+
</CardHeader>
|
|
98
|
+
<CardContent className="space-y-4">
|
|
99
|
+
{types.length === 0 ? (
|
|
100
|
+
<p className="text-sm text-gray-500">
|
|
101
|
+
Create at least one entity type first, then connect them.
|
|
102
|
+
</p>
|
|
103
|
+
) : (
|
|
104
|
+
<>
|
|
105
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
106
|
+
<div className="space-y-1.5">
|
|
107
|
+
<Label>From (source)</Label>
|
|
108
|
+
<Select value={sourceId} onValueChange={setSourceId}>
|
|
109
|
+
<SelectTrigger>
|
|
110
|
+
<SelectValue placeholder="Pick a type" />
|
|
111
|
+
</SelectTrigger>
|
|
112
|
+
<SelectContent>
|
|
113
|
+
{types.map((t) => (
|
|
114
|
+
<SelectItem key={String(t.id)} value={String(t.id)}>
|
|
115
|
+
{t.label}
|
|
116
|
+
</SelectItem>
|
|
117
|
+
))}
|
|
118
|
+
</SelectContent>
|
|
119
|
+
</Select>
|
|
120
|
+
</div>
|
|
121
|
+
<div className="space-y-1.5">
|
|
122
|
+
<Label>To (target)</Label>
|
|
123
|
+
<Select value={targetId} onValueChange={setTargetId}>
|
|
124
|
+
<SelectTrigger>
|
|
125
|
+
<SelectValue placeholder="Pick a type" />
|
|
126
|
+
</SelectTrigger>
|
|
127
|
+
<SelectContent>
|
|
128
|
+
{types.map((t) => (
|
|
129
|
+
<SelectItem key={String(t.id)} value={String(t.id)}>
|
|
130
|
+
{t.label}
|
|
131
|
+
</SelectItem>
|
|
132
|
+
))}
|
|
133
|
+
</SelectContent>
|
|
134
|
+
</Select>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<div className="space-y-1.5">
|
|
139
|
+
<Label htmlFor="rel-key">Name (optional)</Label>
|
|
140
|
+
<Input
|
|
141
|
+
id="rel-key"
|
|
142
|
+
value={keyOverride}
|
|
143
|
+
onChange={(e) => setKeyOverride(e.target.value)}
|
|
144
|
+
placeholder={effectiveKey || 'auto-generated from the two types'}
|
|
145
|
+
/>
|
|
146
|
+
<p className="text-xs text-gray-500">
|
|
147
|
+
Stable identifier for this edge. Defaults to{' '}
|
|
148
|
+
<span className="font-mono">{effectiveKey || '—'}</span>.
|
|
149
|
+
</p>
|
|
150
|
+
</div>
|
|
151
|
+
</>
|
|
152
|
+
)}
|
|
153
|
+
</CardContent>
|
|
154
|
+
</Card>
|
|
155
|
+
|
|
156
|
+
<div className="flex justify-end">
|
|
157
|
+
<Button
|
|
158
|
+
type="submit"
|
|
159
|
+
disabled={saving || types.length === 0}
|
|
160
|
+
className={classNames?.submit}
|
|
161
|
+
>
|
|
162
|
+
{saving ? 'Connecting…' : 'Define relationship'}
|
|
163
|
+
</Button>
|
|
164
|
+
</div>
|
|
165
|
+
</form>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { RelationshipBuilder } from '../RelationshipBuilder'
|
|
4
|
+
|
|
5
|
+
const types = [
|
|
6
|
+
{ id: 'uuid-artist', key: 'artist', label: 'Artist' },
|
|
7
|
+
{ id: 'uuid-release', key: 'release', label: 'Release' },
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
describe('RelationshipBuilder', () => {
|
|
11
|
+
it('renders source + target pickers and a define button', () => {
|
|
12
|
+
render(<RelationshipBuilder types={types} createRelationship={jest.fn()} />)
|
|
13
|
+
expect(screen.getByText('From (source)')).toBeInTheDocument()
|
|
14
|
+
expect(screen.getByText('To (target)')).toBeInTheDocument()
|
|
15
|
+
expect(screen.getByRole('button', { name: /define relationship/i })).toBeInTheDocument()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('warns and does not persist when no endpoints are chosen', async () => {
|
|
19
|
+
const createRelationship = jest.fn().mockResolvedValue({})
|
|
20
|
+
const notify = { success: jest.fn(), error: jest.fn() }
|
|
21
|
+
render(
|
|
22
|
+
<RelationshipBuilder types={types} createRelationship={createRelationship} notify={notify} />,
|
|
23
|
+
)
|
|
24
|
+
await userEvent.setup().click(screen.getByRole('button', { name: /define relationship/i }))
|
|
25
|
+
expect(createRelationship).not.toHaveBeenCalled()
|
|
26
|
+
expect(notify.error).toHaveBeenCalled()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('prompts to create types first when none exist (submit disabled)', () => {
|
|
30
|
+
render(<RelationshipBuilder types={[]} createRelationship={jest.fn()} />)
|
|
31
|
+
expect(screen.getByText(/create at least one entity type first/i)).toBeInTheDocument()
|
|
32
|
+
expect(screen.getByRole('button', { name: /define relationship/i })).toBeDisabled()
|
|
33
|
+
})
|
|
34
|
+
})
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
draftToAttributePayloads,
|
|
5
5
|
attributeToDraft,
|
|
6
6
|
diffAttributes,
|
|
7
|
+
suggestRelationshipKey,
|
|
7
8
|
DATA_TYPES,
|
|
8
9
|
type DraftAttribute,
|
|
9
10
|
type ExistingAttribute,
|
|
@@ -195,6 +196,18 @@ describe('diffAttributes (create/update/delete reconciliation)', () => {
|
|
|
195
196
|
})
|
|
196
197
|
})
|
|
197
198
|
|
|
199
|
+
describe('suggestRelationshipKey', () => {
|
|
200
|
+
it('joins source + target into a slugified edge key', () => {
|
|
201
|
+
expect(suggestRelationshipKey('artist', 'release')).toBe('artist_release')
|
|
202
|
+
expect(suggestRelationshipKey('Purchase Order', 'Line Item')).toBe('purchase_order_line_item')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('is empty until both ends are chosen', () => {
|
|
206
|
+
expect(suggestRelationshipKey('', 'release')).toBe('')
|
|
207
|
+
expect(suggestRelationshipKey('artist', '')).toBe('')
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
198
211
|
describe('DATA_TYPES', () => {
|
|
199
212
|
it('exposes the eight backend data types as {value,label} options', () => {
|
|
200
213
|
expect(DATA_TYPES.map((d) => d.value)).toEqual([
|
|
@@ -6,10 +6,17 @@ export type {
|
|
|
6
6
|
CreatedType,
|
|
7
7
|
ExistingType,
|
|
8
8
|
} from './SchemaTypeBuilder'
|
|
9
|
+
export { RelationshipBuilder } from './RelationshipBuilder'
|
|
10
|
+
export type {
|
|
11
|
+
RelationshipBuilderProps,
|
|
12
|
+
RelationshipBuilderClassNames,
|
|
13
|
+
RelationshipTypeOption,
|
|
14
|
+
} from './RelationshipBuilder'
|
|
9
15
|
export {
|
|
10
16
|
DATA_TYPES,
|
|
11
17
|
blankAttribute,
|
|
12
18
|
toKey,
|
|
19
|
+
suggestRelationshipKey,
|
|
13
20
|
draftToPayload,
|
|
14
21
|
draftToAttributePayloads,
|
|
15
22
|
attributeToDraft,
|
|
@@ -58,6 +58,13 @@ export function toKey(label: string): string {
|
|
|
58
58
|
.replace(/^_+|_+$/g, '')
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/** Suggest a relationship (edge) key from its endpoint type names. Empty until
|
|
62
|
+
* both ends are chosen. */
|
|
63
|
+
export function suggestRelationshipKey(source: string, target: string): string {
|
|
64
|
+
if (!source.trim() || !target.trim()) return ''
|
|
65
|
+
return toKey(`${source}_${target}`)
|
|
66
|
+
}
|
|
67
|
+
|
|
61
68
|
/** Server-assigned ids are UUID strings; numbers are tolerated for tests/legacy. */
|
|
62
69
|
export type SchemaId = string | number
|
|
63
70
|
|