@fayz-ai/saas 0.1.6 → 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.
- package/LICENSE +21 -0
- package/README.md +42 -0
- package/dist/crud/CrudDetailPage.d.ts.map +1 -1
- package/dist/crud/CrudFormPage.d.ts.map +1 -1
- package/dist/crud/archetypes/PersonDetailTabs.d.ts +12 -0
- package/dist/crud/archetypes/PersonDetailTabs.d.ts.map +1 -1
- package/dist/crud/fieldToColumn.d.ts.map +1 -1
- package/dist/crud/index.d.ts +1 -0
- package/dist/crud/index.d.ts.map +1 -1
- package/dist/crud/relation-field.d.ts +28 -0
- package/dist/crud/relation-field.d.ts.map +1 -0
- package/dist/hooks/useModuleNavigation.d.ts +2 -0
- package/dist/hooks/useModuleNavigation.d.ts.map +1 -1
- package/dist/index.cjs +726 -379
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +550 -204
- package/dist/index.js.map +1 -1
- package/dist/shell/components/plugins/ConnectorsHub.d.ts +6 -0
- package/dist/shell/components/plugins/ConnectorsHub.d.ts.map +1 -0
- package/dist/shell/components/plugins/PluginSettingsPanel.d.ts +4 -2
- package/dist/shell/components/plugins/PluginSettingsPanel.d.ts.map +1 -1
- package/dist/shell/createSaasApp.d.ts.map +1 -1
- package/dist/shell/hooks/useModuleNavigation.d.ts +2 -0
- package/dist/shell/hooks/useModuleNavigation.d.ts.map +1 -1
- package/dist/shell/types/crud.d.ts +12 -1
- package/dist/shell/types/crud.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/crud/CrudDetailPage.tsx +5 -0
- package/src/crud/CrudFormPage.tsx +12 -0
- package/src/crud/archetypes/PersonDetailTabs.tsx +22 -0
- package/src/crud/fieldToColumn.tsx +3 -0
- package/src/crud/index.ts +1 -0
- package/src/crud/relation-field.tsx +115 -0
- package/src/hooks/useModuleNavigation.ts +27 -1
- package/src/index.ts +1 -0
- package/src/shell/components/plugins/ConnectorsHub.tsx +163 -0
- package/src/shell/components/plugins/PluginSettingsPanel.tsx +106 -27
- package/src/shell/createSaasApp.tsx +22 -1
- package/src/shell/hooks/useModuleNavigation.ts +27 -1
- 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 —
|
|
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
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
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'),
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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 === '
|
|
181
|
-
<
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
}
|
package/src/shell/types/crud.ts
CHANGED
|
@@ -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
|