@fayz-ai/saas 0.1.7 → 0.2.0

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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/dist/crud/CrudDetailPage.d.ts.map +1 -1
  3. package/dist/crud/CrudFormPage.d.ts.map +1 -1
  4. package/dist/crud/archetypes/PersonDetailTabs.d.ts +12 -0
  5. package/dist/crud/archetypes/PersonDetailTabs.d.ts.map +1 -1
  6. package/dist/crud/fieldToColumn.d.ts.map +1 -1
  7. package/dist/crud/index.d.ts +1 -0
  8. package/dist/crud/index.d.ts.map +1 -1
  9. package/dist/crud/relation-field.d.ts +28 -0
  10. package/dist/crud/relation-field.d.ts.map +1 -0
  11. package/dist/hooks/useModuleNavigation.d.ts +2 -0
  12. package/dist/hooks/useModuleNavigation.d.ts.map +1 -1
  13. package/dist/index.cjs +726 -379
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +550 -204
  18. package/dist/index.js.map +1 -1
  19. package/dist/shell/components/plugins/ConnectorsHub.d.ts +6 -0
  20. package/dist/shell/components/plugins/ConnectorsHub.d.ts.map +1 -0
  21. package/dist/shell/components/plugins/PluginSettingsPanel.d.ts +4 -2
  22. package/dist/shell/components/plugins/PluginSettingsPanel.d.ts.map +1 -1
  23. package/dist/shell/createSaasApp.d.ts.map +1 -1
  24. package/dist/shell/hooks/useModuleNavigation.d.ts +2 -0
  25. package/dist/shell/hooks/useModuleNavigation.d.ts.map +1 -1
  26. package/dist/shell/types/crud.d.ts +12 -1
  27. package/dist/shell/types/crud.d.ts.map +1 -1
  28. package/package.json +4 -4
  29. package/src/crud/CrudDetailPage.tsx +5 -0
  30. package/src/crud/CrudFormPage.tsx +12 -0
  31. package/src/crud/archetypes/PersonDetailTabs.tsx +22 -0
  32. package/src/crud/fieldToColumn.tsx +3 -0
  33. package/src/crud/index.ts +1 -0
  34. package/src/crud/relation-field.tsx +115 -0
  35. package/src/hooks/useModuleNavigation.ts +27 -1
  36. package/src/index.ts +1 -0
  37. package/src/shell/components/plugins/ConnectorsHub.tsx +163 -0
  38. package/src/shell/components/plugins/PluginSettingsPanel.tsx +106 -27
  39. package/src/shell/createSaasApp.tsx +22 -1
  40. package/src/shell/hooks/useModuleNavigation.ts +27 -1
  41. package/src/shell/types/crud.ts +13 -0
@@ -1,13 +1,14 @@
1
1
  import React, { useState, useMemo } from 'react'
2
- import { ChevronLeft, Settings } from 'lucide-react'
2
+ import { ChevronLeft, Settings, Plug, TableProperties } from 'lucide-react'
3
3
  import { cn } from '../../lib/cn'
4
4
  import { useTranslation } from '../../hooks/useTranslation'
5
5
  import { ICON_MAP } from '@fayz-ai/ui'
6
6
  import type { PluginRegistryDef } from '../../types/plugins'
7
7
  import type { EntityDef } from '../../types/crud'
8
8
  import { createCrudStore } from '../../../stores/createCrudStore'
9
- import { resolveDataProvider } from '@fayz-ai/core'
9
+ import { resolveDataProvider, useConnectorsForPlugin } from '@fayz-ai/core'
10
10
  import { CrudPage } from '../../../crud/CrudPage'
11
+ import { ConnectorsHub } from './ConnectorsHub'
11
12
 
12
13
  // ---------------------------------------------------------------------------
13
14
  // Settings tab definition — General, custom tabs, and registry tabs all unified
@@ -42,26 +43,102 @@ function RegistryCrudView({ registry, basePath }: { registry: PluginRegistryDef;
42
43
  )
43
44
  }
44
45
 
46
+ // ---------------------------------------------------------------------------
47
+ // Properties tab — the plugin's CRUD registries as an inner (pill) tab strip.
48
+ // Sub-routes at `${routeBase}/_properties/<registryId>`; also accepts the legacy
49
+ // flat `${routeBase}/<registryId>` deep links.
50
+ // ---------------------------------------------------------------------------
51
+
52
+ function PropertiesTab({ registries, routeBase, regLabel }: {
53
+ registries: PluginRegistryDef[]
54
+ routeBase: string
55
+ regLabel: (id: string, fallback: string) => string
56
+ }) {
57
+ function getActive(): string {
58
+ const hash = window.location.hash.slice(1)
59
+ const nested = `${routeBase}/_properties/`
60
+ if (hash.startsWith(nested)) {
61
+ const id = hash.slice(nested.length).split('/')[0]
62
+ if (registries.find((r) => r.id === id)) return id
63
+ }
64
+ const flat = `${routeBase}/`
65
+ if (hash.startsWith(flat)) {
66
+ const id = hash.slice(flat.length).split('/')[0]
67
+ if (registries.find((r) => r.id === id)) return id // legacy deep link
68
+ }
69
+ return registries[0]?.id ?? ''
70
+ }
71
+
72
+ const [active, setActive] = useState(getActive)
73
+ React.useEffect(() => {
74
+ const handler = () => setActive(getActive())
75
+ window.addEventListener('hashchange', handler)
76
+ return () => window.removeEventListener('hashchange', handler)
77
+ }, [routeBase, registries])
78
+
79
+ function select(id: string) {
80
+ setActive(id)
81
+ window.location.hash = `${routeBase}/_properties/${id}`
82
+ }
83
+
84
+ const activeReg = registries.find((r) => r.id === active) ?? registries[0]
85
+
86
+ return (
87
+ <div className="space-y-3">
88
+ <div className="flex gap-1.5 flex-wrap">
89
+ {registries.map((r) => {
90
+ const Icon = r.icon ? (ICON_MAP[r.icon] ?? null) : null
91
+ const on = r.id === activeReg?.id
92
+ return (
93
+ <button
94
+ key={r.id}
95
+ onClick={() => select(r.id)}
96
+ className={cn(
97
+ 'inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition-colors',
98
+ on ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:bg-muted/70',
99
+ )}
100
+ >
101
+ {Icon && <Icon className="h-3.5 w-3.5" />}
102
+ {regLabel(r.id, r.entity.name)}
103
+ </button>
104
+ )
105
+ })}
106
+ </div>
107
+ {activeReg?.description && (
108
+ <p className="text-xs text-muted-foreground">{regLabel(activeReg.id + '.description', activeReg.description)}</p>
109
+ )}
110
+ {activeReg && (
111
+ <RegistryCrudView key={activeReg.id} registry={activeReg} basePath={`${routeBase}/_properties/${activeReg.id}`} />
112
+ )}
113
+ </div>
114
+ )
115
+ }
116
+
45
117
  // ---------------------------------------------------------------------------
46
118
  // Plugin settings panel
47
119
  // ---------------------------------------------------------------------------
48
120
 
49
- export function PluginSettingsPanel({ title, subtitle, generalSettings, registries, customTabs, routeBase, onClose }: {
121
+ export function PluginSettingsPanel({ title, subtitle, generalSettings, registries, customTabs, routeBase, hostPluginId, onClose }: {
50
122
  title: string
51
123
  subtitle?: string
52
124
  /** General preferences component */
53
125
  generalSettings?: React.ReactNode
54
- /** CRUD registries — each becomes its own tab */
126
+ /** CRUD registries — grouped under a "Properties" tab */
55
127
  registries?: PluginRegistryDef[]
56
128
  /** Additional custom tabs (e.g., Pipeline config for CRM) */
57
129
  customTabs?: SettingsPanelTab[]
58
130
  /** Hash route base for CRUD sub-routing */
59
131
  routeBase: string
132
+ /** This plugin's id — used to surface connectors addons registered for it in an Integrations tab. */
133
+ hostPluginId?: string
60
134
  /** Called when back button is clicked. If not provided, back button is hidden. */
61
135
  onClose?: () => void
62
136
  }) {
63
137
  const { t } = useTranslation()
64
138
 
139
+ // Connectors that addon plugins registered against this host plugin.
140
+ const connectors = useConnectorsForPlugin(hostPluginId ?? '')
141
+
65
142
  // Translate registry entity name: try t('registry.{id}'), fallback to raw name
66
143
  const regLabel = (id: string, fallback: string) => {
67
144
  const key = `registry.${id}`
@@ -69,28 +146,33 @@ export function PluginSettingsPanel({ title, subtitle, generalSettings, registri
69
146
  return translated === key ? fallback : translated
70
147
  }
71
148
 
72
- // Build unified tab list: General → custom tabs → registry tabs
73
- const tabs = useMemo(() => {
74
- const list: { id: string; label: string; icon?: string; type: 'general' | 'custom' | 'registry'; registryDef?: PluginRegistryDef }[] = []
149
+ // Standard tab list: General → custom tabs → Integrations → Properties.
150
+ // Standard tabs carry their lucide component directly (IconComp) so they render
151
+ // regardless of ICON_MAP; custom tabs still resolve their string icon via ICON_MAP.
152
+ type Tab = {
153
+ id: string; label: string; type: 'general' | 'custom' | 'integrations' | 'properties'
154
+ icon?: string; IconComp?: React.ComponentType<{ className?: string }>
155
+ }
156
+ const tabs = useMemo<Tab[]>(() => {
157
+ const list: Tab[] = []
75
158
 
76
159
  if (generalSettings) {
77
- list.push({ id: '_general', label: t('settings.general'), icon: 'Settings', type: 'general' })
160
+ list.push({ id: '_general', label: t('settings.general'), IconComp: Settings, type: 'general' })
78
161
  }
79
-
80
162
  if (customTabs) {
81
163
  for (const tab of customTabs) {
82
164
  list.push({ id: `_custom_${tab.id}`, label: tab.label, icon: tab.icon, type: 'custom' })
83
165
  }
84
166
  }
85
-
86
- if (registries) {
87
- for (const reg of registries) {
88
- list.push({ id: reg.id, label: regLabel(reg.id, reg.entity.name), icon: reg.icon, type: 'registry', registryDef: reg })
89
- }
167
+ if (connectors.length > 0) {
168
+ list.push({ id: '_integrations', label: t('connectors.title'), IconComp: Plug, type: 'integrations' })
169
+ }
170
+ if (registries && registries.length > 0) {
171
+ list.push({ id: '_properties', label: t('settings.properties'), IconComp: TableProperties, type: 'properties' })
90
172
  }
91
173
 
92
174
  return list
93
- }, [generalSettings, customTabs, registries, t])
175
+ }, [generalSettings, customTabs, connectors.length, registries, t])
94
176
 
95
177
  // Always use hash routing — the app router now prefix-matches /settings/*
96
178
  const useHashRouting = true
@@ -102,6 +184,8 @@ export function PluginSettingsPanel({ title, subtitle, generalSettings, registri
102
184
  const rest = hash.slice(routeBase.length + 1).split('/')[0]
103
185
  const match = tabs.find((t) => t.id === rest)
104
186
  if (match) return match.id
187
+ // Legacy/nested: a registry id (flat or under _properties) → Properties tab.
188
+ if (registries?.some((r) => r.id === rest) && tabs.some((t) => t.id === '_properties')) return '_properties'
105
189
  }
106
190
  return tabs[0]?.id ?? ''
107
191
  }
@@ -143,7 +227,7 @@ export function PluginSettingsPanel({ title, subtitle, generalSettings, registri
143
227
  {/* Unified tabs — underline style */}
144
228
  <div className="flex gap-0.5 overflow-x-auto border-b scrollbar-none" style={{ scrollbarWidth: 'none' }}>
145
229
  {tabs.map((tab) => {
146
- const Icon = tab.icon ? (ICON_MAP[tab.icon] ?? null) : null
230
+ const Icon = tab.IconComp ?? (tab.icon ? (ICON_MAP[tab.icon] ?? null) : null)
147
231
  const active = tab.id === activeTab
148
232
  return (
149
233
  <button
@@ -163,11 +247,6 @@ export function PluginSettingsPanel({ title, subtitle, generalSettings, registri
163
247
  })}
164
248
  </div>
165
249
 
166
- {/* Description for registry tabs */}
167
- {activeTabDef?.type === 'registry' && activeTabDef.registryDef?.description && (
168
- <p className="text-xs text-muted-foreground">{regLabel(activeTabDef.id + '.description', activeTabDef.registryDef.description)}</p>
169
- )}
170
-
171
250
  {/* Content */}
172
251
  {activeTabDef?.type === 'general' && generalSettings && (
173
252
  <div>{generalSettings}</div>
@@ -177,12 +256,12 @@ export function PluginSettingsPanel({ title, subtitle, generalSettings, registri
177
256
  <div>{customTabContent.content}</div>
178
257
  )}
179
258
 
180
- {activeTabDef?.type === 'registry' && activeTabDef.registryDef && (
181
- <RegistryCrudView
182
- key={activeTabDef.id}
183
- registry={activeTabDef.registryDef}
184
- basePath={`${routeBase}/${activeTabDef.id}`}
185
- />
259
+ {activeTabDef?.type === 'integrations' && (
260
+ <ConnectorsHub connectors={connectors} />
261
+ )}
262
+
263
+ {activeTabDef?.type === 'properties' && registries && registries.length > 0 && (
264
+ <PropertiesTab registries={registries} routeBase={routeBase} regLabel={regLabel} />
186
265
  )}
187
266
  </div>
188
267
  )
@@ -60,7 +60,7 @@ import { usePermission } from './hooks/usePermission'
60
60
  import { useTenantPlugins } from './hooks/useTenantPlugins'
61
61
  import { I18nProvider, builtInLocales } from './lib/i18n'
62
62
  import { useLocaleStore } from './stores/locale.store'
63
- import { setDefaultCurrency, setCurrentLocale, registerTranslations } from '@fayz-ai/core'
63
+ import { setDefaultCurrency, setCurrentLocale, registerTranslations, recordNavigation } from '@fayz-ai/core'
64
64
  import { useTranslation } from './hooks/useTranslation'
65
65
 
66
66
  // ---------------------------------------------------------------------------
@@ -567,6 +567,16 @@ export function createSaasApp(config: SaasAppConfig): React.FC {
567
567
  if (toAdd.length > 0) store.setFeatures([...store.features, ...toAdd])
568
568
  }, [])
569
569
 
570
+ // Record every route change into the global navigation history so shared
571
+ // headers (SubpageHeader / Breadcrumb) can render a context-aware back link
572
+ // that returns to the actual previous page across module boundaries. The
573
+ // label resolver is filled in below once `navigation` is built; we read it
574
+ // via a ref so this effect can live before the early return.
575
+ const navLabelRef = React.useRef<(path: string) => string>(() => '')
576
+ React.useEffect(() => {
577
+ recordNavigation(route, navLabelRef.current(route))
578
+ }, [route])
579
+
570
580
  // If we're on the login route, render login page
571
581
  if (route === '/login') {
572
582
  return React.createElement(LoginPageWrapper)
@@ -634,6 +644,17 @@ export function createSaasApp(config: SaasAppConfig): React.FC {
634
644
  })
635
645
  }
636
646
 
647
+ // Module-level label for a route, used by the global navigation history.
648
+ // Exact nav match first, then prefix match (e.g. '/clients/{id}' → 'Clientes').
649
+ navLabelRef.current = (path: string): string => {
650
+ const exact = navigation.find((n) => n.route === path)?.label
651
+ if (exact) return exact
652
+ for (const n of navigation) {
653
+ if (n.route !== '/' && path.startsWith(n.route + '/')) return n.label
654
+ }
655
+ return ''
656
+ }
657
+
637
658
  // Resolve page: exact match first, then prefix match for CRUD sub-routes and plugin routes
638
659
  let matchedPath = route
639
660
  let routeEntry = routes.get(route)
@@ -60,6 +60,21 @@ export function useModuleNavigation(hashBase: string, depthMap?: DepthMap, homeV
60
60
  const [direction, setDirection] = useState<NavDirection>('forward')
61
61
  const prevDepthRef = useRef(getDepth(getInitialView()))
62
62
 
63
+ // Track recent in-module views so detail/subpages can render a context-aware
64
+ // back link ("Back to {where you came from}") and return to the actual
65
+ // previous page rather than a hard-coded parent route. Records every distinct
66
+ // view (regardless of how it was reached: navigate(), topbar, browser back).
67
+ const historyRef = useRef<string[]>([getInitialView()])
68
+ const [previousView, setPreviousView] = useState<string | null>(null)
69
+ useEffect(() => {
70
+ const stack = historyRef.current
71
+ if (stack[stack.length - 1] !== view) {
72
+ stack.push(view)
73
+ if (stack.length > 30) stack.shift()
74
+ }
75
+ setPreviousView(stack.length >= 2 ? stack[stack.length - 2] : null)
76
+ }, [view])
77
+
63
78
  // Sync view from external hash changes (e.g., topbar nav clicking the module link)
64
79
  useEffect(() => {
65
80
  function handler() {
@@ -118,7 +133,18 @@ export function useModuleNavigation(hashBase: string, depthMap?: DepthMap, homeV
118
133
  }
119
134
  }, [hashBase])
120
135
 
136
+ // Return to the actual previous page. Uses the browser history when there's
137
+ // in-app history to pop (preserving the prior page); otherwise navigates to a
138
+ // sensible fallback (deep links / direct loads have nowhere to go back to).
139
+ const back = useCallback((fallbackView?: string) => {
140
+ if (historyRef.current.length >= 2) {
141
+ window.history.back()
142
+ } else {
143
+ navigate(fallbackView ?? homeView)
144
+ }
145
+ }, [navigate, homeView])
146
+
121
147
  const animationClass = direction === 'forward' ? 'saas-nav-forward' : 'saas-nav-back'
122
148
 
123
- return { view, direction, animationClass, navigate }
149
+ return { view, direction, animationClass, navigate, previousView, back }
124
150
  }
@@ -5,10 +5,21 @@ export type FieldType =
5
5
  | 'text' | 'email' | 'phone' | 'url' | 'image'
6
6
  | 'number' | 'currency'
7
7
  | 'select' | 'multiselect' | 'segmented'
8
+ | 'relation'
8
9
  | 'date' | 'datetime' | 'time'
9
10
  | 'boolean' | 'textarea'
10
11
  | 'color' | 'computed'
11
12
 
13
+ /** Foreign-key source for a `relation` field (options loaded from a table). */
14
+ export interface FieldRelation {
15
+ table: string
16
+ valueField?: string
17
+ labelField?: string
18
+ tenantScoped?: boolean
19
+ schema?: string
20
+ filter?: Record<string, unknown>
21
+ }
22
+
12
23
  /** Read-only display produced by a `computed` field's `compute()`. */
13
24
  export interface ComputedFieldValue {
14
25
  display: string
@@ -24,6 +35,8 @@ export interface FieldDef {
24
35
  /** Small helper text shown under the field input. */
25
36
  hint?: string
26
37
  options?: string[] | { label: string; value: string; description?: string }[]
38
+ /** Required for `relation` fields — where to load the option list from. */
39
+ relation?: FieldRelation
27
40
  min?: number
28
41
  max?: number
29
42
  currency?: string