@postxl/generators 1.11.7 → 1.12.1
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/dist/backend-view/model-view-service.generator.js +4 -1
- package/dist/backend-view/model-view-service.generator.js.map +1 -1
- package/dist/backend-view/template/query.utils.test.ts +34 -0
- package/dist/backend-view/template/query.utils.ts +12 -0
- package/dist/decoders/decoders.generator.js +19 -5
- package/dist/decoders/decoders.generator.js.map +1 -1
- package/dist/decoders/discriminated-union.decoder.generator.js +61 -37
- package/dist/decoders/discriminated-union.decoder.generator.js.map +1 -1
- package/dist/decoders/excel-field-decoder.helper.d.ts +1 -0
- package/dist/decoders/excel-field-decoder.helper.js +6 -5
- package/dist/decoders/excel-field-decoder.helper.js.map +1 -1
- package/dist/decoders/model-decoder.generator.js +4 -3
- package/dist/decoders/model-decoder.generator.js.map +1 -1
- package/dist/frontend-admin/admin.generator.d.ts +9 -0
- package/dist/frontend-admin/admin.generator.js +19 -0
- package/dist/frontend-admin/admin.generator.js.map +1 -1
- package/dist/frontend-admin/generators/admin-sidebar.generator.js +1 -1
- package/dist/frontend-admin/generators/audit-log-sidebar.generator.js +107 -113
- package/dist/frontend-admin/generators/audit-log-sidebar.generator.js.map +1 -1
- package/dist/frontend-admin/generators/comment-sidebar.generator.d.ts +9 -0
- package/dist/frontend-admin/generators/comment-sidebar.generator.js +246 -0
- package/dist/frontend-admin/generators/comment-sidebar.generator.js.map +1 -0
- package/dist/frontend-admin/generators/detail-sidebar.generator.d.ts +9 -0
- package/dist/frontend-admin/generators/detail-sidebar.generator.js +148 -0
- package/dist/frontend-admin/generators/detail-sidebar.generator.js.map +1 -0
- package/dist/frontend-admin/generators/model-admin-page.generator.js +40 -6
- package/dist/frontend-admin/generators/model-admin-page.generator.js.map +1 -1
- package/dist/frontend-core/frontend.generator.js +2 -1
- package/dist/frontend-core/frontend.generator.js.map +1 -1
- package/dist/frontend-core/template/docs/LOGIN_PROCESS.d2 +372 -0
- package/dist/frontend-core/template/docs/LOGIN_PROCESS.md +214 -0
- package/dist/frontend-core/template/docs/LOGIN_PROCESS.svg +914 -0
- package/dist/frontend-core/template/src/components/admin/table-filter.tsx +1 -1
- package/dist/frontend-core/template/src/components/admin/table-view-panel.tsx +159 -0
- package/dist/frontend-core/template/src/context-providers/auth-context-provider.tsx +54 -43
- package/dist/frontend-core/template/src/hooks/use-table-view-config.tsx +249 -0
- package/dist/frontend-core/template/src/pages/dashboard/dashboard.page.tsx +5 -5
- package/dist/frontend-core/template/src/pages/error/auth-error.page.tsx +37 -0
- package/dist/frontend-core/template/src/pages/error/default-error.page.tsx +24 -40
- package/dist/frontend-core/template/src/pages/error/not-found-error.page.tsx +8 -10
- package/dist/frontend-core/template/src/pages/login/login.page.tsx +39 -13
- package/dist/frontend-core/template/src/pages/unauthorized/unauthorized.page.tsx +2 -2
- package/dist/frontend-core/template/src/routes/_auth-routes.tsx +21 -8
- package/dist/frontend-core/template/src/routes/auth-error.tsx +19 -0
- package/dist/frontend-core/template/vite.config.ts +5 -0
- package/dist/frontend-core/types/hook.d.ts +1 -1
- package/dist/frontend-tables/generators/model-table.generator.js +30 -5
- package/dist/frontend-tables/generators/model-table.generator.js.map +1 -1
- package/dist/seed-data/generator/excel-template.generator.js +3 -3
- package/dist/seed-data/generator/excel-template.generator.js.map +1 -1
- package/package.json +3 -3
|
@@ -566,7 +566,7 @@ export const TableFilter = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
566
566
|
options.length === 0 ? (
|
|
567
567
|
<div className="py-6 text-center text-sm">No options available.</div>
|
|
568
568
|
) : (
|
|
569
|
-
<div className="px-1 py-1 max-h-[
|
|
569
|
+
<div className="px-1 py-1 max-h-[200px] overflow-auto">
|
|
570
570
|
{options.map((option) => (
|
|
571
571
|
<SlicerHierarchyItem
|
|
572
572
|
key={option.value}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { Table } from '@tanstack/react-table'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef } from 'react'
|
|
4
|
+
import { toast } from 'sonner'
|
|
5
|
+
|
|
6
|
+
import { useTableViewConfig, type TableViewState } from '@hooks/use-table-view-config'
|
|
7
|
+
import { DataGridViewMenu } from '@postxl/ui-components'
|
|
8
|
+
|
|
9
|
+
export type TableViewPanelProps<TData> = {
|
|
10
|
+
/** The model/table name to scope views to (e.g. 'user', 'publication'). */
|
|
11
|
+
model: string
|
|
12
|
+
/** The TanStack Table instance (from useDataGrid). */
|
|
13
|
+
table: Table<TData>
|
|
14
|
+
/** The current user's ID. */
|
|
15
|
+
currentUserId?: string
|
|
16
|
+
/** Whether the current user has admin privileges. */
|
|
17
|
+
isAdmin?: boolean
|
|
18
|
+
/** Current filter state — included when saving a view. */
|
|
19
|
+
filters?: Record<string, unknown>
|
|
20
|
+
/** Current sort state — included when saving a view. */
|
|
21
|
+
sort?: { field: string; direction: 'asc' | 'desc' }[]
|
|
22
|
+
/** Called when a view is applied and its filters should be restored. */
|
|
23
|
+
onFiltersChange?: (filters: Record<string, unknown>) => void
|
|
24
|
+
/** Called when a view is applied and its sort should be restored. */
|
|
25
|
+
onSortChange?: (sort: { field: string; direction: 'asc' | 'desc' }[]) => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Connects the table view config hook to the DataGridViewMenu component.
|
|
30
|
+
*
|
|
31
|
+
* Handles:
|
|
32
|
+
* - Collecting current table state (filters, sort, column visibility/order/pinning)
|
|
33
|
+
* - Saving/updating views with automatic JSON serialization
|
|
34
|
+
* - Applying loaded views back to the table and page filter/sort state
|
|
35
|
+
* - Auto-restoring the previously active view on mount
|
|
36
|
+
* - Rename and delete operations
|
|
37
|
+
*/
|
|
38
|
+
export function TableViewPanel<TData>({
|
|
39
|
+
model,
|
|
40
|
+
table,
|
|
41
|
+
currentUserId,
|
|
42
|
+
isAdmin,
|
|
43
|
+
filters,
|
|
44
|
+
sort,
|
|
45
|
+
onFiltersChange,
|
|
46
|
+
onSortChange,
|
|
47
|
+
}: TableViewPanelProps<TData>) {
|
|
48
|
+
const viewConfig = useTableViewConfig({ model, currentUserId })
|
|
49
|
+
const hasAutoAppliedRef = useRef(false)
|
|
50
|
+
|
|
51
|
+
const applyViewState = useCallback(
|
|
52
|
+
(viewId: string) => {
|
|
53
|
+
const view = viewConfig.views.find((v) => v.id === viewId)
|
|
54
|
+
if (!view) return
|
|
55
|
+
const state = viewConfig.parseViewState(view)
|
|
56
|
+
if (onFiltersChange) {
|
|
57
|
+
onFiltersChange(state.filters ?? {})
|
|
58
|
+
}
|
|
59
|
+
if (onSortChange) {
|
|
60
|
+
onSortChange(state.sorting ?? [])
|
|
61
|
+
}
|
|
62
|
+
if (state.columnVisibility) {
|
|
63
|
+
table.setColumnVisibility(state.columnVisibility)
|
|
64
|
+
}
|
|
65
|
+
if (state.columnOrder) {
|
|
66
|
+
table.setColumnOrder(state.columnOrder)
|
|
67
|
+
}
|
|
68
|
+
if (state.columnPinning) {
|
|
69
|
+
table.setColumnPinning(state.columnPinning)
|
|
70
|
+
}
|
|
71
|
+
if (state.columnSizing) {
|
|
72
|
+
table.setColumnSizing(state.columnSizing)
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
[viewConfig, onFiltersChange, onSortChange, table],
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
// Auto-apply persisted view once views have loaded
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (!viewConfig.isLoaded || hasAutoAppliedRef.current) return
|
|
81
|
+
hasAutoAppliedRef.current = true
|
|
82
|
+
|
|
83
|
+
if (viewConfig.activeViewId && viewConfig.views.find((v) => v.id === viewConfig.activeViewId)) {
|
|
84
|
+
applyViewState(viewConfig.activeViewId)
|
|
85
|
+
}
|
|
86
|
+
}, [viewConfig.isLoaded, viewConfig.activeViewId, viewConfig.views, applyViewState])
|
|
87
|
+
|
|
88
|
+
const collectTableState = useCallback(
|
|
89
|
+
(): TableViewState => ({
|
|
90
|
+
filters: filters,
|
|
91
|
+
sorting: sort,
|
|
92
|
+
columnVisibility: table.getState().columnVisibility,
|
|
93
|
+
columnOrder: table.getState().columnOrder,
|
|
94
|
+
columnPinning: table.getState().columnPinning as { left?: string[]; right?: string[] },
|
|
95
|
+
columnSizing: table.getState().columnSizing,
|
|
96
|
+
}),
|
|
97
|
+
[filters, sort, table],
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
const handleSaveView = useCallback(
|
|
101
|
+
({ name, isGlobal }: { name: string; isGlobal: boolean }) => {
|
|
102
|
+
void viewConfig.saveView({ name, isGlobal, state: collectTableState() }).then((view) => {
|
|
103
|
+
toast.success(`View "${view.name}" saved`)
|
|
104
|
+
})
|
|
105
|
+
},
|
|
106
|
+
[viewConfig, collectTableState],
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
const handleUpdateView = useCallback(
|
|
110
|
+
(viewId: string) => {
|
|
111
|
+
void viewConfig.updateView(viewId, collectTableState()).then(() => {
|
|
112
|
+
toast.success('View updated')
|
|
113
|
+
})
|
|
114
|
+
},
|
|
115
|
+
[viewConfig, collectTableState],
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
const handleApplyView = useCallback(
|
|
119
|
+
(viewId: string) => {
|
|
120
|
+
applyViewState(viewId)
|
|
121
|
+
viewConfig.setActiveViewId(viewId)
|
|
122
|
+
},
|
|
123
|
+
[applyViewState, viewConfig],
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
const handleRenameView = useCallback(
|
|
127
|
+
(viewId: string, name: string) => {
|
|
128
|
+
void viewConfig.renameView(viewId, name).then(() => {
|
|
129
|
+
toast.success(`View renamed to "${name}"`)
|
|
130
|
+
})
|
|
131
|
+
},
|
|
132
|
+
[viewConfig],
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
const handleDeleteView = useCallback(
|
|
136
|
+
(viewId: string) => {
|
|
137
|
+
void viewConfig.deleteView(viewId).then(() => {
|
|
138
|
+
toast.success('View deleted')
|
|
139
|
+
})
|
|
140
|
+
},
|
|
141
|
+
[viewConfig],
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<DataGridViewMenu
|
|
146
|
+
table={table}
|
|
147
|
+
savedViews={viewConfig.savedViews}
|
|
148
|
+
activeViewId={viewConfig.activeViewId}
|
|
149
|
+
currentUserId={currentUserId}
|
|
150
|
+
isAdmin={isAdmin}
|
|
151
|
+
onApplyView={handleApplyView}
|
|
152
|
+
onSaveView={handleSaveView}
|
|
153
|
+
onUpdateView={handleUpdateView}
|
|
154
|
+
onRenameView={handleRenameView}
|
|
155
|
+
onDeleteView={handleDeleteView}
|
|
156
|
+
onClearView={viewConfig.clearActiveView}
|
|
157
|
+
/>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Viewer } from '@authentication/auth.guard'
|
|
2
|
+
import type { ViewerResult } from '@authentication/authentication.types'
|
|
2
3
|
import { useQuery } from '@tanstack/react-query'
|
|
3
|
-
import type { User } from '@types'
|
|
4
4
|
|
|
5
5
|
import Keycloak from 'keycloak-js'
|
|
6
6
|
import * as React from 'react'
|
|
@@ -11,17 +11,18 @@ import { createUseContext } from '@lib/react'
|
|
|
11
11
|
import { useTRPC } from '@lib/trpc'
|
|
12
12
|
import { Result } from '@postxl/utils'
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
// Frontend version of Viewer without userInfo (which is only available server-side)
|
|
15
|
+
type FrontendViewer = Omit<Viewer, 'userInfo'>
|
|
16
|
+
|
|
17
|
+
export const authErrorTypes = ['errorInit', 'errorLoadUser', 'errorTokenRefresh'] as const
|
|
18
|
+
export type AuthErrorType = (typeof authErrorTypes)[number]
|
|
19
|
+
export type AuthState = 'inProgress' | 'loggedIn' | 'loggedOut'
|
|
18
20
|
|
|
19
21
|
export type AuthContext = {
|
|
20
|
-
|
|
21
|
-
isInitComplete: boolean
|
|
22
|
+
loginState: AuthState | AuthErrorType
|
|
22
23
|
login: () => Promise<void>
|
|
23
|
-
logout: () => Promise<void>
|
|
24
|
-
|
|
24
|
+
logout: (options?: { redirectUri?: string }) => Promise<void>
|
|
25
|
+
viewerData: FrontendViewer | null
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
const AuthContext = createContext<AuthContext | null>(null)
|
|
@@ -42,7 +43,7 @@ export function getAuthHeaders(options?: RequestInit) {
|
|
|
42
43
|
const headers = new Headers(options?.headers)
|
|
43
44
|
|
|
44
45
|
if (token) {
|
|
45
|
-
headers.set('Authorization', `Bearer${token}`)
|
|
46
|
+
headers.set('Authorization', `Bearer ${token}`)
|
|
46
47
|
}
|
|
47
48
|
return headers
|
|
48
49
|
}
|
|
@@ -72,9 +73,8 @@ keycloak.onAuthError = () => {
|
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
export function AuthProvider({ children }: Readonly<{ children: React.ReactNode }>) {
|
|
75
|
-
const [
|
|
76
|
-
const [
|
|
77
|
-
const [isInitComplete, setIsInitComplete] = useState(!APP_CONFIG.AUTH)
|
|
76
|
+
const [viewerData, setViewerData] = React.useState<FrontendViewer | null>(null)
|
|
77
|
+
const [loginState, setLoginState] = useState<AuthState | AuthErrorType>('inProgress')
|
|
78
78
|
|
|
79
79
|
const trpc = useTRPC()
|
|
80
80
|
|
|
@@ -86,13 +86,13 @@ export function AuthProvider({ children }: Readonly<{ children: React.ReactNode
|
|
|
86
86
|
trpc.viewer.viewer.queryOptions(
|
|
87
87
|
{ pageUrl: location.href },
|
|
88
88
|
// load user data when keycloak init complete and user is authenticated
|
|
89
|
-
{ enabled:
|
|
89
|
+
{ enabled: loginState === 'loggedIn' },
|
|
90
90
|
),
|
|
91
91
|
)
|
|
92
92
|
|
|
93
|
-
const logout = useCallback(async () => {
|
|
93
|
+
const logout = useCallback(async (options?: { redirectUri?: string }) => {
|
|
94
94
|
setAuthToken()
|
|
95
|
-
await keycloak.logout()
|
|
95
|
+
await keycloak.logout(options)
|
|
96
96
|
}, [])
|
|
97
97
|
|
|
98
98
|
const login = useCallback(async () => {
|
|
@@ -120,59 +120,66 @@ export function AuthProvider({ children }: Readonly<{ children: React.ReactNode
|
|
|
120
120
|
checkLoginIframe: APP_CONFIG.checkLoginIframe,
|
|
121
121
|
})
|
|
122
122
|
|
|
123
|
-
|
|
123
|
+
console.log(
|
|
124
|
+
'Keycloak initialized COMPLETE, user authenticated:',
|
|
125
|
+
authenticated,
|
|
126
|
+
'token:',
|
|
127
|
+
keycloak.token ? 'present' : 'missing',
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if (authenticated && keycloak.token) {
|
|
124
131
|
setAuthToken(keycloak.token)
|
|
125
|
-
|
|
132
|
+
setLoginState('loggedIn')
|
|
133
|
+
} else {
|
|
134
|
+
// if keycloak initialized and the user is not authenticated,
|
|
135
|
+
// the user will be redirected to the keycloak login page (see "/pages/Login")
|
|
136
|
+
setLoginState('loggedOut')
|
|
126
137
|
}
|
|
127
|
-
// if keycloak initialized and the user is not authenticated,
|
|
128
|
-
// the user will be redirected to the keycloak login page (see "/pages/Login")
|
|
129
|
-
console.log('Keycloak initialized COMPLETE, user authenticated:', authenticated)
|
|
130
|
-
setIsInitComplete(true)
|
|
131
138
|
}
|
|
132
139
|
} catch (error) {
|
|
133
140
|
console.error('Failed to initialize adapter:', error)
|
|
134
|
-
|
|
141
|
+
setLoginState('errorInit')
|
|
135
142
|
}
|
|
136
143
|
}
|
|
137
144
|
|
|
138
145
|
if (APP_CONFIG.AUTH) {
|
|
139
146
|
void initKeycloak()
|
|
147
|
+
} else {
|
|
148
|
+
setLoginState('loggedIn')
|
|
140
149
|
}
|
|
141
|
-
}, [
|
|
150
|
+
}, [])
|
|
142
151
|
|
|
143
152
|
/**
|
|
144
153
|
* #2 - user data loading
|
|
145
154
|
* when user authenticated by keycloak, the user data is loaded from the backend
|
|
146
155
|
*/
|
|
147
156
|
useEffect(() => {
|
|
148
|
-
if (
|
|
149
|
-
|
|
157
|
+
if (loginState === 'loggedIn' && isUserDataError) {
|
|
158
|
+
setLoginState('errorLoadUser')
|
|
150
159
|
return
|
|
151
160
|
}
|
|
152
161
|
|
|
153
162
|
if (userData && isUserSuccessfullyLoaded) {
|
|
154
|
-
const userDataResult: ViewerResult = Result.fromObject(userData as
|
|
163
|
+
const userDataResult: ViewerResult = Result.fromObject(userData as any) as ViewerResult
|
|
155
164
|
if (userDataResult.isOk()) {
|
|
156
165
|
const value = userDataResult.unwrap()
|
|
157
|
-
console.log('User data loaded successfully
|
|
158
|
-
|
|
159
|
-
userRoles: value.userRoles,
|
|
160
|
-
userDetails: value.user,
|
|
161
|
-
})
|
|
166
|
+
console.log('User data loaded successfully')
|
|
167
|
+
setViewerData(value)
|
|
162
168
|
} else if (userDataResult.isErr()) {
|
|
163
|
-
console.log('User data loaded with an error => logout
|
|
164
|
-
|
|
165
|
-
|
|
169
|
+
console.log('User data loaded with an error => logout and redirect to auth-error', {
|
|
170
|
+
error: userDataResult.unwrapErr(),
|
|
171
|
+
})
|
|
172
|
+
setLoginState('errorLoadUser')
|
|
166
173
|
}
|
|
167
174
|
}
|
|
168
|
-
}, [
|
|
175
|
+
}, [isUserDataError, isUserSuccessfullyLoaded, loginState, userData])
|
|
169
176
|
|
|
170
177
|
/**
|
|
171
178
|
* #3 - token refreshing
|
|
172
179
|
* when the user is authenticated, the token is refreshed every 5 seconds
|
|
173
180
|
*/
|
|
174
181
|
useEffect(() => {
|
|
175
|
-
if (
|
|
182
|
+
if (loginState !== 'loggedIn' || !APP_CONFIG.AUTH) {
|
|
176
183
|
return
|
|
177
184
|
}
|
|
178
185
|
|
|
@@ -188,17 +195,21 @@ export function AuthProvider({ children }: Readonly<{ children: React.ReactNode
|
|
|
188
195
|
}
|
|
189
196
|
} catch (error) {
|
|
190
197
|
console.error('Failed to refresh token', error)
|
|
191
|
-
|
|
192
|
-
void logout() // NOSONAR
|
|
198
|
+
setLoginState('errorTokenRefresh')
|
|
193
199
|
}
|
|
194
200
|
}, 5000)
|
|
195
201
|
|
|
196
202
|
return () => clearInterval(refreshIntervalId)
|
|
197
|
-
}, [
|
|
203
|
+
}, [loginState])
|
|
198
204
|
|
|
199
205
|
const memoizedValues = useMemo(
|
|
200
|
-
() => ({
|
|
201
|
-
|
|
206
|
+
() => ({
|
|
207
|
+
loginState,
|
|
208
|
+
login,
|
|
209
|
+
logout,
|
|
210
|
+
viewerData,
|
|
211
|
+
}),
|
|
212
|
+
[loginState, login, logout, viewerData],
|
|
202
213
|
)
|
|
203
214
|
|
|
204
215
|
return <AuthContext.Provider value={memoizedValues}>{children}</AuthContext.Provider>
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import type { TableViewId, TableViewViewModel } from '@types'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
|
|
6
|
+
import { useTableViews } from './use-table-views'
|
|
7
|
+
|
|
8
|
+
/** Creates a Zod decoder that parses a JSON string and validates the result against the given schema. */
|
|
9
|
+
const jsonDecoder = <T,>(schema: z.ZodType<T>) =>
|
|
10
|
+
z
|
|
11
|
+
.string()
|
|
12
|
+
.transform((s) => JSON.parse(s) as unknown)
|
|
13
|
+
.pipe(schema)
|
|
14
|
+
|
|
15
|
+
const filtersDecoder = jsonDecoder(z.record(z.string(), z.unknown()))
|
|
16
|
+
const sortingDecoder = jsonDecoder(z.array(z.object({ field: z.string(), direction: z.enum(['asc', 'desc']) })))
|
|
17
|
+
const columnVisibilityDecoder = jsonDecoder(z.record(z.string(), z.boolean()))
|
|
18
|
+
const columnOrderDecoder = jsonDecoder(z.array(z.string()))
|
|
19
|
+
const columnPinningDecoder = jsonDecoder(
|
|
20
|
+
z.object({ left: z.array(z.string()).optional(), right: z.array(z.string()).optional() }),
|
|
21
|
+
)
|
|
22
|
+
const columnSizingDecoder = jsonDecoder(z.record(z.string(), z.number()))
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parsed table state stored in a TableView.
|
|
26
|
+
* All JSON string fields are deserialized into their typed representations.
|
|
27
|
+
*/
|
|
28
|
+
export type TableViewState = {
|
|
29
|
+
filters?: z.infer<typeof filtersDecoder>
|
|
30
|
+
sorting?: z.infer<typeof sortingDecoder>
|
|
31
|
+
columnVisibility?: z.infer<typeof columnVisibilityDecoder>
|
|
32
|
+
columnOrder?: z.infer<typeof columnOrderDecoder>
|
|
33
|
+
columnPinning?: z.infer<typeof columnPinningDecoder>
|
|
34
|
+
columnSizing?: z.infer<typeof columnSizingDecoder>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type UseTableViewConfigOptions = {
|
|
38
|
+
/** The model/table name to scope views to (e.g. 'user', 'publication'). */
|
|
39
|
+
model: string
|
|
40
|
+
/** The current user's ID — used to filter views (own + global). */
|
|
41
|
+
currentUserId?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type UseTableViewConfigResult = {
|
|
45
|
+
/** Views for this model visible to the current user (own + global). */
|
|
46
|
+
views: TableViewViewModel[]
|
|
47
|
+
/** Views mapped to the SavedView shape expected by DataGridViewMenu. */
|
|
48
|
+
savedViews: { id: string; name: string; isGlobal: boolean; userId: string }[]
|
|
49
|
+
/** Whether the initial data has been loaded. */
|
|
50
|
+
isLoaded: boolean
|
|
51
|
+
/** The currently active/applied view ID (local state, persisted to localStorage). */
|
|
52
|
+
activeViewId: string | null
|
|
53
|
+
/** Set the active view ID (also persists to localStorage). */
|
|
54
|
+
setActiveViewId: (id: string | null) => void
|
|
55
|
+
/** Clear the active view. */
|
|
56
|
+
clearActiveView: () => void
|
|
57
|
+
/** Parse a view's JSON string fields into typed TableViewState. */
|
|
58
|
+
parseViewState: (view: TableViewViewModel) => TableViewState
|
|
59
|
+
/** Get the parsed state of the currently active view, or null. */
|
|
60
|
+
getActiveViewState: () => TableViewState | null
|
|
61
|
+
/** Save a new view with the given state. Sets it as active on success. */
|
|
62
|
+
saveView: (args: { name: string; isGlobal?: boolean; state: TableViewState }) => Promise<TableViewViewModel>
|
|
63
|
+
/** Update an existing view's state. */
|
|
64
|
+
updateView: (viewId: string, state: TableViewState) => Promise<TableViewViewModel>
|
|
65
|
+
/** Rename an existing view. */
|
|
66
|
+
renameView: (viewId: string, name: string) => Promise<TableViewViewModel>
|
|
67
|
+
/** Delete a view. Clears active view if it was the deleted one. */
|
|
68
|
+
deleteView: (viewId: string) => Promise<void>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const STORAGE_PREFIX = 'pxl.tableView:'
|
|
72
|
+
|
|
73
|
+
function getStorageKey(model: string): string {
|
|
74
|
+
return `${STORAGE_PREFIX}${model}`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readPersistedViewId(model: string): string | null {
|
|
78
|
+
try {
|
|
79
|
+
return localStorage.getItem(getStorageKey(model))
|
|
80
|
+
} catch {
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function persistViewId(model: string, viewId: string | null): void {
|
|
86
|
+
try {
|
|
87
|
+
const key = getStorageKey(model)
|
|
88
|
+
if (viewId) {
|
|
89
|
+
localStorage.setItem(key, viewId)
|
|
90
|
+
} else {
|
|
91
|
+
localStorage.removeItem(key)
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
// ignore storage errors
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Hook that wraps the generated `useTableViews` CRUD hook and adds:
|
|
100
|
+
* - Scoping by model name and current user
|
|
101
|
+
* - JSON parsing/serialization for table state fields
|
|
102
|
+
* - Active view tracking (persisted to localStorage)
|
|
103
|
+
* - Auto-restore of previously active view on mount
|
|
104
|
+
* - Simplified save/update/rename/delete API
|
|
105
|
+
*/
|
|
106
|
+
export function useTableViewConfig(options: UseTableViewConfigOptions): UseTableViewConfigResult {
|
|
107
|
+
const { model, currentUserId } = options
|
|
108
|
+
const tableViews = useTableViews()
|
|
109
|
+
const [activeViewId, setActiveViewIdState] = useState<string | null>(() => readPersistedViewId(model))
|
|
110
|
+
const hasRestoredRef = useRef(false)
|
|
111
|
+
|
|
112
|
+
// Persist active view ID to localStorage whenever it changes
|
|
113
|
+
const setActiveViewId = useCallback(
|
|
114
|
+
(id: string | null) => {
|
|
115
|
+
setActiveViewIdState(id)
|
|
116
|
+
persistViewId(model, id)
|
|
117
|
+
},
|
|
118
|
+
[model],
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
// Filter views: only this model, and only the user's own or global views
|
|
122
|
+
const views = useMemo(
|
|
123
|
+
(): TableViewViewModel[] =>
|
|
124
|
+
tableViews.list.filter((v) => v.model === model && (v.isGlobal || v.userId === currentUserId)),
|
|
125
|
+
[tableViews.list, model, currentUserId],
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
// Map to the shape expected by DataGridViewMenu
|
|
129
|
+
const savedViews = useMemo(
|
|
130
|
+
() => views.map((v) => ({ id: v.id, name: v.name, isGlobal: v.isGlobal, userId: v.userId })),
|
|
131
|
+
[views],
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
// Once views are loaded, validate the persisted view ID still exists.
|
|
135
|
+
// If the persisted view was deleted, clear it.
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (!tableViews.isLoaded || hasRestoredRef.current) return
|
|
138
|
+
hasRestoredRef.current = true
|
|
139
|
+
|
|
140
|
+
if (activeViewId && !views.find((v) => v.id === activeViewId)) {
|
|
141
|
+
setActiveViewId(null)
|
|
142
|
+
}
|
|
143
|
+
}, [tableViews.isLoaded, views, activeViewId, setActiveViewId])
|
|
144
|
+
|
|
145
|
+
const parseViewState = useCallback((view: TableViewViewModel): TableViewState => {
|
|
146
|
+
const decode = <T,>(json: string | null | undefined, decoder: z.ZodType<T>): T | undefined => {
|
|
147
|
+
if (!json) {
|
|
148
|
+
return undefined
|
|
149
|
+
}
|
|
150
|
+
const result = decoder.safeParse(json)
|
|
151
|
+
return result.success ? result.data : undefined
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
filters: decode(view.filters, filtersDecoder),
|
|
155
|
+
sorting: decode(view.sorting, sortingDecoder),
|
|
156
|
+
columnVisibility: decode(view.columnVisibility, columnVisibilityDecoder),
|
|
157
|
+
columnOrder: decode(view.columnOrder, columnOrderDecoder),
|
|
158
|
+
columnPinning: decode(view.columnPinning, columnPinningDecoder),
|
|
159
|
+
columnSizing: decode(view.columnSizing, columnSizingDecoder),
|
|
160
|
+
}
|
|
161
|
+
}, [])
|
|
162
|
+
|
|
163
|
+
const getActiveViewState = useCallback((): TableViewState | null => {
|
|
164
|
+
if (!activeViewId) return null
|
|
165
|
+
const view = views.find((v) => v.id === activeViewId)
|
|
166
|
+
if (!view) return null
|
|
167
|
+
return parseViewState(view)
|
|
168
|
+
}, [activeViewId, views, parseViewState])
|
|
169
|
+
|
|
170
|
+
const serializeState = useCallback(
|
|
171
|
+
(state: TableViewState) => ({
|
|
172
|
+
filters: state.filters ? JSON.stringify(state.filters) : null,
|
|
173
|
+
sorting: state.sorting ? JSON.stringify(state.sorting) : null,
|
|
174
|
+
columnVisibility: state.columnVisibility ? JSON.stringify(state.columnVisibility) : null,
|
|
175
|
+
columnOrder: state.columnOrder ? JSON.stringify(state.columnOrder) : null,
|
|
176
|
+
columnPinning: state.columnPinning ? JSON.stringify(state.columnPinning) : null,
|
|
177
|
+
columnSizing: state.columnSizing ? JSON.stringify(state.columnSizing) : null,
|
|
178
|
+
}),
|
|
179
|
+
[],
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
const saveView = useCallback(
|
|
183
|
+
async (args: { name: string; isGlobal?: boolean; state: TableViewState }) => {
|
|
184
|
+
if (!currentUserId) {
|
|
185
|
+
throw new Error('Cannot save a view without a currentUserId')
|
|
186
|
+
}
|
|
187
|
+
const result = await tableViews.create({
|
|
188
|
+
name: args.name,
|
|
189
|
+
model,
|
|
190
|
+
userId: currentUserId as TableViewViewModel['userId'],
|
|
191
|
+
isGlobal: args.isGlobal ?? false,
|
|
192
|
+
...serializeState(args.state),
|
|
193
|
+
})
|
|
194
|
+
setActiveViewId(result.id)
|
|
195
|
+
return result
|
|
196
|
+
},
|
|
197
|
+
[tableViews, model, currentUserId, serializeState, setActiveViewId],
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
const updateView = useCallback(
|
|
201
|
+
async (viewId: string, state: TableViewState) => {
|
|
202
|
+
const existing = views.find((v) => v.id === viewId)
|
|
203
|
+
if (!existing) throw new Error(`View ${viewId} not found`)
|
|
204
|
+
return tableViews.update({
|
|
205
|
+
id: viewId as TableViewId,
|
|
206
|
+
name: existing.name,
|
|
207
|
+
model: existing.model,
|
|
208
|
+
userId: existing.userId,
|
|
209
|
+
isGlobal: existing.isGlobal,
|
|
210
|
+
...serializeState(state),
|
|
211
|
+
})
|
|
212
|
+
},
|
|
213
|
+
[tableViews, views, serializeState],
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
const renameView = useCallback(
|
|
217
|
+
async (viewId: string, name: string) => {
|
|
218
|
+
return tableViews.updateField({ id: viewId as TableViewId, field: 'name', value: name })
|
|
219
|
+
},
|
|
220
|
+
[tableViews],
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
const deleteView = useCallback(
|
|
224
|
+
async (viewId: string) => {
|
|
225
|
+
await tableViews.delete(viewId as TableViewId)
|
|
226
|
+
if (activeViewId === viewId) {
|
|
227
|
+
setActiveViewId(null)
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
[tableViews, activeViewId, setActiveViewId],
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
const clearActiveView = useCallback(() => setActiveViewId(null), [setActiveViewId])
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
views,
|
|
237
|
+
savedViews,
|
|
238
|
+
isLoaded: tableViews.isLoaded,
|
|
239
|
+
activeViewId,
|
|
240
|
+
setActiveViewId,
|
|
241
|
+
clearActiveView,
|
|
242
|
+
parseViewState,
|
|
243
|
+
getActiveViewState,
|
|
244
|
+
saveView,
|
|
245
|
+
updateView,
|
|
246
|
+
renameView,
|
|
247
|
+
deleteView,
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -16,7 +16,7 @@ import { APP_CONFIG } from '@lib/config'
|
|
|
16
16
|
import { Button, ContentFrame } from '@postxl/ui-components'
|
|
17
17
|
|
|
18
18
|
export const DashboardPage = () => {
|
|
19
|
-
const { logout,
|
|
19
|
+
const { logout, viewerData } = useAuth()
|
|
20
20
|
|
|
21
21
|
const [selectedPost, setSelectedPost] = useState<Post | null>(null)
|
|
22
22
|
const [isUpdateModalOpen, setIsUpdateModalOpen] = useState(false)
|
|
@@ -42,16 +42,16 @@ export const DashboardPage = () => {
|
|
|
42
42
|
controls={[
|
|
43
43
|
<ColorModeToggle key="btn-color-mode" onClick={onToggleTheme} __e2e_test_id__="button-toggle-theme" />,
|
|
44
44
|
APP_CONFIG.AUTH && (
|
|
45
|
-
<Button key="btn-logout" onClick={logout}>
|
|
45
|
+
<Button key="btn-logout" onClick={() => logout()}>
|
|
46
46
|
<PersonIcon /> Logout
|
|
47
47
|
</Button>
|
|
48
48
|
),
|
|
49
49
|
]}
|
|
50
50
|
>
|
|
51
51
|
<div className="p-3">
|
|
52
|
-
<h3 className="mb-4">Hi {user?.
|
|
53
|
-
<p>eMail: {user?.
|
|
54
|
-
<p>roles: {
|
|
52
|
+
<h3 className="mb-4">Hi {viewerData?.user?.name}!</h3>
|
|
53
|
+
<p>eMail: {viewerData?.user?.email}</p>
|
|
54
|
+
<p>roles: {viewerData?.userRoles.join(', ')}</p>
|
|
55
55
|
</div>
|
|
56
56
|
<div className="p-3">
|
|
57
57
|
<h3 className="mb-4">Posts:</h3>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { AuthErrorType, useAuth } from '@context-providers/auth-context-provider'
|
|
2
|
+
|
|
3
|
+
import { Button, InfoCard } from '@postxl/ui-components'
|
|
4
|
+
|
|
5
|
+
const errorMessages: Record<AuthErrorType, { title: string; message: string }> = {
|
|
6
|
+
errorInit: {
|
|
7
|
+
title: 'Authentication Failed',
|
|
8
|
+
message: 'Failed to initialize the authentication system. Please try logging in again.',
|
|
9
|
+
},
|
|
10
|
+
errorLoadUser: {
|
|
11
|
+
title: 'Unable to Load User Data',
|
|
12
|
+
message: 'There was an error loading your user information. Please try logging in again.',
|
|
13
|
+
},
|
|
14
|
+
errorTokenRefresh: {
|
|
15
|
+
title: 'Session Expired',
|
|
16
|
+
message: 'Your session could not be refreshed. Please log in again to continue.',
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const AuthErrorPage = ({ errorType }: { errorType?: AuthErrorType }) => {
|
|
21
|
+
const errorInfo = errorMessages[errorType ?? 'errorInit']
|
|
22
|
+
const { logout } = useAuth()
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<InfoCard variant="error" title={errorInfo.title} message={errorInfo.message}>
|
|
26
|
+
<div className="flex gap-2">
|
|
27
|
+
<Button asChild>
|
|
28
|
+
{/* we don't use a tanstack link here to force a page reload. So all auth states will be re-initialized */}
|
|
29
|
+
<a href="/">Try Again</a>
|
|
30
|
+
</Button>
|
|
31
|
+
<Button variant="secondary" onClick={() => void logout({ redirectUri: window.location.origin })}>
|
|
32
|
+
Login Again
|
|
33
|
+
</Button>
|
|
34
|
+
</div>
|
|
35
|
+
</InfoCard>
|
|
36
|
+
)
|
|
37
|
+
}
|