@startsimpli/ui 0.4.36 → 0.4.38

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.36",
3
+ "version": "0.4.38",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -33,8 +33,8 @@
33
33
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
34
34
  "react": "^18.0.0 || ^19.0.0",
35
35
  "react-dom": "^18.0.0 || ^19.0.0",
36
- "@startsimpli/auth": "0.4.28",
37
- "@startsimpli/api": "0.5.35"
36
+ "@startsimpli/api": "0.5.36",
37
+ "@startsimpli/auth": "0.4.29"
38
38
  },
39
39
  "peerDependenciesMeta": {
40
40
  "@startsimpli/api": {
@@ -12,3 +12,8 @@ export { ArticleCard, type ArticleCardProps } from './ArticleCard'
12
12
  export { BlogIndex, type BlogIndexProps } from './BlogIndex'
13
13
  export { BlogPost, type BlogPostProps } from './BlogPost'
14
14
  export { TableOfContents, extractToc, type TableOfContentsProps, type TocItem } from './TableOfContents'
15
+ export {
16
+ resolveArticleOrFallback,
17
+ type ArticleSlugFetcher,
18
+ type ResolveArticleOptions,
19
+ } from './resolveArticle'
@@ -0,0 +1,51 @@
1
+ import { notFound, redirect } from 'next/navigation'
2
+
3
+ /**
4
+ * Minimal shape of a blog client's slug lookup. Matches both the shared
5
+ * `@startsimpli/api/blog` client (`createBlogClient().fetchPostBySlug`) and the
6
+ * legacy per-app `blog-api-server` modules, so callers can pass whichever they
7
+ * already have without migrating their data layer first.
8
+ */
9
+ export type ArticleSlugFetcher<TPost> = (slug: string) => Promise<TPost | null | undefined>
10
+
11
+ export interface ResolveArticleOptions {
12
+ /**
13
+ * List/index route a missing article falls back to, e.g. `/resources` or
14
+ * `/articles`. Dead, stale, or mistyped article links land here.
15
+ */
16
+ indexPath: string
17
+ /**
18
+ * Behaviour when no post matches the slug.
19
+ * - `'redirect'` (default): 307 to `indexPath` — the smart landing that keeps
20
+ * visitors (and crawlers following old links) inside the content instead of
21
+ * on a bare 404.
22
+ * - `'notFound'`: trigger the nearest `not-found.tsx` (404 status). Use for
23
+ * routes where preserving the 404 signal matters more than the soft landing.
24
+ */
25
+ onMissing?: 'redirect' | 'notFound'
26
+ }
27
+
28
+ /**
29
+ * Resolve a blog post by slug, applying the shared missing-article default.
30
+ *
31
+ * Every marketing-site article route (`/resources/[slug]`, `/articles/[slug]`,
32
+ * `/guides/[guide]`, …) should call this instead of hand-rolling
33
+ * `if (!post) notFound()` — so a non-existent or removed article URL
34
+ * smart-redirects to its list page by default, consistently across every app
35
+ * that shares the blog layer.
36
+ *
37
+ * Never returns a nullish value: it either returns the resolved post or throws
38
+ * the redirect / not-found control-flow signal that Next.js expects. Call it at
39
+ * the top level of a Server Component (do not wrap in try/catch, or the signal
40
+ * will be swallowed).
41
+ */
42
+ export async function resolveArticleOrFallback<TPost>(
43
+ fetchPostBySlug: ArticleSlugFetcher<TPost>,
44
+ slug: string,
45
+ { indexPath, onMissing = 'redirect' }: ResolveArticleOptions,
46
+ ): Promise<TPost> {
47
+ const post = await fetchPostBySlug(slug)
48
+ if (post) return post
49
+ if (onMissing === 'notFound') notFound()
50
+ redirect(indexPath)
51
+ }
@@ -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