TASKS-1823 WebManager module

This commit is contained in:
Igor Martyniuk
2026-08-28 08:35:58 +00:00
parent fdb2cf3837
commit c93d8c025b
654 changed files with 26232 additions and 6075 deletions
@@ -16,7 +16,7 @@ import PathService from '#base_modules/FileManager/services/path_service'
import {
EntryExistsException,
FilenameMissingExtensionException,
} from '#exceptions/file_manager_exeptions'
} from '#exceptions/file_manager_exceptions'
export default class FileManagerService {
private filePreviewService = new FilePreviewService()
@@ -3,7 +3,7 @@ import {
DirExistsException,
DirNotFoundException,
FileNotFoundException,
} from '#exceptions/file_manager_exeptions'
} from '#exceptions/file_manager_exceptions'
import { Entry, FileEntry, FolderEntry } from '#base_modules/FileManager/types/entries'
import { StorageService } from './storage_service_interface.js'
import PathService from '#base_modules/FileManager/services/path_service'
@@ -4,7 +4,7 @@ import {
DirExistsException,
DirNotFoundException,
FileNotFoundException,
} from '#exceptions/file_manager_exeptions'
} from '#exceptions/file_manager_exceptions'
import { Entry, FileEntry, FolderEntry } from '#base_modules/FileManager/types/entries'
import { StorageService } from './storage_service_interface.js'
import PathService from '#base_modules/FileManager/services/path_service'
@@ -0,0 +1,21 @@
export const PAGE_MEDIA_UPLOADS_PATH = '/'
export const PAGE_MEDIA_FILE_SIZE_LIMIT = '10mb'
export const PREVIEW_TOKEN_DEFAULT_TTL_SECONDS = 900
export const PREVIEW_TOKEN_MIN_TTL_SECONDS = 60
export const PREVIEW_TOKEN_MAX_TTL_SECONDS = 3600
export const BREADCRUMB_PAGE_TYPE_PARENTS: Record<string, string> = {
'blog-item': 'blog',
}
/**
* Pages per sitemap request. Large enough that most sites need one round trip, small enough
* that no single query is unbounded; the sitemap protocol's own limit is 50,000 URLs per file.
*/
export const SITEMAP_PAGE_SIZE = 1000
export const SITEMAP_MAX_PAGE_SIZE = 5000
@@ -0,0 +1,8 @@
export default [
{ slug: 'web_manager.view', name: 'Web Manager view' },
{ slug: 'web_manager.create', name: 'Web Manager create' },
{ slug: 'web_manager.update', name: 'Web Manager update drafts' },
{ slug: 'web_manager.publish', name: 'Web Manager publish' },
{ slug: 'web_manager.delete', name: 'Web Manager delete drafts and pages' },
{ slug: 'web_manager.settings', name: 'Web Manager manage settings' },
]
@@ -0,0 +1,50 @@
import type { HttpContext } from '@adonisjs/core/http'
import WebManagerService from '#base_modules/WebManager/services/web_manager_service'
import PreviewTokenService from '#base_modules/WebManager/services/preview_token_service'
import {
previewRequestValidator,
publicBlogListValidator,
publicPageValidator,
publicSitemapValidator,
} from '#base_modules/WebManager/validator/page_validator'
import { settingsQueryValidator } from '#base_modules/WebManager/validator/settings_validator'
export default class PublicWebManagerController {
private webManagerService = new WebManagerService()
private previewTokenService = new PreviewTokenService()
async blogList({ request }: HttpContext) {
const filters = await publicBlogListValidator.validate(request.qs())
return this.webManagerService.listPublicBlogPages(filters)
}
async sitemap({ request }: HttpContext) {
const filters = await publicSitemapValidator.validate(request.qs())
return this.webManagerService.listPublicSitemapPages(filters)
}
async resolve({ request, response }: HttpContext) {
const { locale, path } = await publicPageValidator.validate(request.qs())
const result = await this.webManagerService.resolvePublicPage(locale, path)
if (!result) return response.notFound({ message: 'Page not found' })
return response.ok(result)
}
async preview({ request, response }: HttpContext) {
const { token } = await previewRequestValidator.validate(request.qs())
const payload = this.previewTokenService.verify(token)
if (!payload) return response.notFound({ message: 'Preview token is invalid or expired' })
const result = await this.webManagerService.resolvePreviewPage(payload.pageId)
return response.ok(result)
}
async settings({ request, response }: HttpContext) {
const { locale } = await settingsQueryValidator.validate(request.qs())
if (!locale) return response.badRequest({ message: 'Locale is required' })
const settings = await this.webManagerService.getSettings(locale)
if (!settings) return response.notFound({ message: 'Settings not found' })
return response.ok({ settings })
}
}
@@ -0,0 +1,98 @@
import type { HttpContext } from '@adonisjs/core/http'
import WebManagerPolicy from '#base_modules/WebManager/policy/web_manager_policy'
import WebManagerService from '#base_modules/WebManager/services/web_manager_service'
import PagePayloadService from '#base_modules/WebManager/services/page_payload_service'
import PreviewTokenService from '#base_modules/WebManager/services/preview_token_service'
import {
createPageValidator,
listPagesValidator,
previewTokenValidator,
saveDraftValidator,
} from '#base_modules/WebManager/validator/page_validator'
import {
saveSettingsValidator,
settingsQueryValidator,
} from '#base_modules/WebManager/validator/settings_validator'
export default class WebManagerController {
private webManagerService = new WebManagerService()
private pagePayloadService = new PagePayloadService()
private previewTokenService = new PreviewTokenService()
async index({ request, response, bouncer }: HttpContext) {
await bouncer.with(WebManagerPolicy).authorize('view')
const filters = await listPagesValidator.validate(request.qs())
const pages = await this.webManagerService.listPages(filters)
return response.ok(pages.serialize())
}
async groupKeys({ response, bouncer }: HttpContext) {
await bouncer.with(WebManagerPolicy).authorize('view')
const groupKeys = await this.webManagerService.listGroupKeys()
return response.ok({ data: groupKeys })
}
async store({ request, response, bouncer }: HttpContext) {
await bouncer.with(WebManagerPolicy).authorize('create')
const payload = this.pagePayloadService.parse(request)
const input = await createPageValidator.validate(payload)
const resolved = await this.pagePayloadService.resolveLocalFiles(request, input)
const page = await this.webManagerService.createDraft(resolved)
return response.created({ data: page })
}
async show({ params, response, bouncer }: HttpContext) {
await bouncer.with(WebManagerPolicy).authorize('view')
const page = await this.webManagerService.getPage(Number(params.id))
return response.ok({ data: page })
}
async saveDraft({ params, request, response, bouncer }: HttpContext) {
await bouncer.with(WebManagerPolicy).authorize('update')
const payload = this.pagePayloadService.parse(request)
const input = await saveDraftValidator.validate(payload)
const resolved = await this.pagePayloadService.resolveLocalFiles(request, input)
const page = await this.webManagerService.saveDraft(Number(params.id), resolved)
return response.ok({ data: page })
}
async publish({ params, response, bouncer }: HttpContext) {
await bouncer.with(WebManagerPolicy).authorize('publish')
const { page, revalidation } = await this.webManagerService.publish(Number(params.id))
return response.ok({ data: page, meta: { revalidation } })
}
async unpublish({ params, response, bouncer }: HttpContext) {
await bouncer.with(WebManagerPolicy).authorize('publish')
const { page, revalidation } = await this.webManagerService.unpublish(Number(params.id))
return response.ok({ data: page, meta: { revalidation } })
}
async destroy({ params, response, bouncer }: HttpContext) {
await bouncer.with(WebManagerPolicy).authorize('delete')
await this.webManagerService.discard(Number(params.id))
return response.noContent()
}
async createPreviewToken({ params, request, response, bouncer }: HttpContext) {
await bouncer.with(WebManagerPolicy).authorize('view')
await this.webManagerService.getPage(Number(params.id))
const { expiresIn } = await previewTokenValidator.validate(request.all())
const preview = this.previewTokenService.create(Number(params.id), expiresIn)
return response.created({ data: preview })
}
async getSettings({ request, response, bouncer }: HttpContext) {
await bouncer.with(WebManagerPolicy).authorize('view')
const { locale } = await settingsQueryValidator.validate(request.qs())
const settings = await this.webManagerService.getSettings(locale)
return response.ok({ data: settings })
}
async saveSettings({ request, response, bouncer }: HttpContext) {
await bouncer.with(WebManagerPolicy).authorize('manageSettings')
const input = await saveSettingsValidator.validate(request.all())
const { settings, revalidation } = await this.webManagerService.saveSettings(input)
return response.ok({ data: settings, meta: { revalidation } })
}
}
@@ -0,0 +1,15 @@
import { belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import WebPage from '#base_modules/WebManager/model/web_page'
import type { BlockData, BlockType } from '#base_modules/WebManager/types/web_content'
import { WebComponentSchema } from '#database/schema'
export default class WebComponent extends WebComponentSchema {
static table = 'web_components'
declare type: BlockType
declare data: BlockData
@belongsTo(() => WebPage, { foreignKey: 'pageId' })
declare page: BelongsTo<typeof WebPage>
}
@@ -0,0 +1,20 @@
import { hasMany, hasOne } from '@adonisjs/lucid/orm'
import type { HasMany, HasOne } from '@adonisjs/lucid/types/relations'
import WebComponent from '#base_modules/WebManager/model/web_component'
import type { Hero, PageType } from '#base_modules/WebManager/types/web_content'
import type { PageStatus } from '#base_modules/WebManager/types/page_status'
import { WebPageSchema } from '#database/schema'
export default class WebPage extends WebPageSchema {
static table = 'web_pages'
declare status: PageStatus
declare pageType: PageType
declare hero: Hero | null
@hasMany(() => WebComponent, { foreignKey: 'pageId' })
declare components: HasMany<typeof WebComponent>
@hasOne(() => WebPage, { foreignKey: 'draftOfId' })
declare draft: HasOne<typeof WebPage>
}
@@ -0,0 +1,10 @@
import type { SiteSettingsContent } from '#base_modules/WebManager/types/web_content'
import { WebSettingSchema } from '#database/schema'
export default class WebSetting extends WebSettingSchema {
static table = 'web_settings'
declare header: SiteSettingsContent['header']
declare footer: SiteSettingsContent['footer']
declare legal: SiteSettingsContent['legal']
}
@@ -0,0 +1,36 @@
import { BasePolicy } from '@adonisjs/bouncer'
import type { AuthorizerResponse } from '@adonisjs/bouncer/types'
import Admin from '#base_modules/Admin/model/admin'
import { getAdminPermissionSlugs } from '#services/permission_service'
export default class WebManagerPolicy extends BasePolicy {
view(user: Admin): Promise<AuthorizerResponse> {
return this.hasPermission(user, 'web_manager.view')
}
create(user: Admin): Promise<AuthorizerResponse> {
return this.hasPermission(user, 'web_manager.create')
}
update(user: Admin): Promise<AuthorizerResponse> {
return this.hasPermission(user, 'web_manager.update')
}
publish(user: Admin): Promise<AuthorizerResponse> {
return this.hasPermission(user, 'web_manager.publish')
}
delete(user: Admin): Promise<AuthorizerResponse> {
return this.hasPermission(user, 'web_manager.delete')
}
manageSettings(user: Admin): Promise<AuthorizerResponse> {
return this.hasPermission(user, 'web_manager.settings')
}
private async hasPermission(user: Admin, permission: string) {
if (!user) return false
const permissions = await getAdminPermissionSlugs(user)
return permissions.has(permission)
}
}
@@ -0,0 +1,34 @@
import router from '@adonisjs/core/services/router'
import { middleware } from '#start/kernel'
const WebManagerController = () =>
import('#base_modules/WebManager/controller/web_manager_controller')
const PublicWebManagerController = () =>
import('#base_modules/WebManager/controller/public_web_manager_controller')
router
.group(() => {
router.get('/blog/pages', [PublicWebManagerController, 'blogList'])
router.get('/pages/sitemap', [PublicWebManagerController, 'sitemap'])
router.get('/pages/resolve', [PublicWebManagerController, 'resolve'])
router.get('/pages/preview', [PublicWebManagerController, 'preview'])
router.get('/settings', [PublicWebManagerController, 'settings'])
})
.prefix('/web-manager/public')
router
.group(() => {
router.get('/pages', [WebManagerController, 'index'])
router.get('/pages/group-keys', [WebManagerController, 'groupKeys'])
router.post('/pages', [WebManagerController, 'store'])
router.get('/pages/:id', [WebManagerController, 'show'])
router.put('/pages/:id/draft', [WebManagerController, 'saveDraft'])
router.post('/pages/:id/publish', [WebManagerController, 'publish'])
router.post('/pages/:id/unpublish', [WebManagerController, 'unpublish'])
router.post('/pages/:id/preview-token', [WebManagerController, 'createPreviewToken'])
router.delete('/pages/:id', [WebManagerController, 'destroy'])
router.get('/settings', [WebManagerController, 'getSettings'])
router.put('/settings', [WebManagerController, 'saveSettings'])
})
.prefix('/web-manager')
.use([middleware.auth()])
@@ -0,0 +1,50 @@
import { BREADCRUMB_PAGE_TYPE_PARENTS } from '#base_modules/WebManager/config/general'
/**
* Utility functions that identify candidate pages for buildBreadcrumbs database queries.
* Breadcrumbs climb hierarchically from a target page to the home page.
* To avoid querying all published pages in a locale, queries are filtered down to two
* predictable sets of candidates:
* 1. Path-based ancestors: The small chain of parent paths above the target page.
* 2. Group-key ancestors: Static relationships where pages belong to a specific parent
* type regardless of location (e.g., blog-item under blog).
*/
export const BREADCRUMB_PARENT_GROUP_KEYS = [
...new Set(Object.values(BREADCRUMB_PAGE_TYPE_PARENTS)),
]
/**
* The paths a page can climb to, nearest parent first, ending at the home page (`''`).
*
* 'blog/post/hello' → ['blog/post', 'blog', '']
* 'about' → ['']
* '' → ['']
*
* The page's own path is never included — a page cannot be its own parent.
*/
export function ancestorPaths(path: string) {
const segments = path.split('/').filter(Boolean)
const paths: string[] = []
for (let depth = segments.length - 1; depth >= 1; depth--) {
paths.push(segments.slice(0, depth).join('/'))
}
paths.push('')
return paths
}
/**
* True when `path` is an already loaded path or an ancestor of one. Only false after a
* group-key jump to another branch, never during a path climb.
*/
export function ancestorsAlreadyLoaded(path: string, loadedPaths: Iterable<string>) {
for (const loaded of loadedPaths) {
// The trailing slash keeps 'blog' from matching 'blogging'; '' matches every path.
if (loaded === path || loaded.startsWith(path === '' ? '' : `${path}/`)) return true
}
return false
}
@@ -0,0 +1,103 @@
import sanitizeHtml from 'sanitize-html'
const ALLOWED_REL_VALUES = new Set(['nofollow', 'noopener', 'noreferrer'])
const ALLOWED_URL_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:'])
const URL_KEYS = new Set(['href', 'url', 'externalUrl'])
const RICH_TEXT_OPTIONS: sanitizeHtml.IOptions = {
allowedTags: [
'a',
'b',
'blockquote',
'br',
'code',
'em',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'hr',
'i',
'li',
'ol',
'p',
'pre',
's',
'span',
'strong',
'u',
'ul',
],
allowedAttributes: {
'*': ['class', 'title'],
'a': ['href', 'target', 'rel'],
},
allowedSchemes: ['http', 'https', 'mailto', 'tel'],
allowProtocolRelative: true,
disallowedTagsMode: 'discard',
nonTextTags: [
'script',
'style',
'textarea',
'option',
'iframe',
'object',
'embed',
'form',
'svg',
'math',
],
transformTags: {
a: (tagName, attribs) => {
if (attribs.target !== undefined && !['_blank', '_self'].includes(attribs.target)) {
delete attribs.target
}
if (attribs.rel !== undefined) {
const rel = attribs.rel
.split(/\s+/)
.filter((value) => ALLOWED_REL_VALUES.has(value))
.join(' ')
if (rel) attribs.rel = rel
else delete attribs.rel
}
if (attribs.target === '_blank') attribs.rel = 'noopener noreferrer'
return { tagName, attribs }
},
},
}
export function sanitizeRichText(value: string) {
return sanitizeHtml(value, RICH_TEXT_OPTIONS)
}
export function sanitizeUrl(value: string) {
try {
const parsed = new URL(value, 'https://relative.invalid')
return ALLOWED_URL_PROTOCOLS.has(parsed.protocol) ? value : ''
} catch {
return ''
}
}
export function sanitizePayload(value: unknown, key = ''): unknown {
if (typeof value === 'string') {
return URL_KEYS.has(key) ? sanitizeUrl(value) : sanitizeRichText(value)
}
if (Array.isArray(value)) return value.map((item) => sanitizePayload(item))
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([childKey, childValue]) => [
childKey,
sanitizePayload(childValue, childKey),
])
)
}
return value
}
@@ -0,0 +1,127 @@
import type { HttpContext } from '@adonisjs/core/http'
import {
InvalidPagePayloadException,
PageMediaFileInvalidException,
PageMediaFileMissingException,
} from '#exceptions/web_manager_exceptions'
import FileManagerService from '#base_modules/FileManager/services/file_manager_service'
import {
PAGE_MEDIA_FILE_SIZE_LIMIT,
PAGE_MEDIA_UPLOADS_PATH,
} from '#base_modules/WebManager/config/general'
import type { JsonObject, PageInput } from '#base_modules/WebManager/types/web_manager'
type Request = HttpContext['request']
interface StoredFileRef {
name: string
ref: string
}
export default class PagePayloadService {
private fileManagerService = new FileManagerService()
/**
* Returns the page body from a save request. Multipart requests carry the page
* as a JSON string in the `payload` field (so value types survive transport);
* plain JSON requests are passed through unchanged.
*/
parse(request: Request): Record<string, unknown> {
const rawPayload = request.input('payload')
if (rawPayload === undefined) return request.all()
if (typeof rawPayload !== 'string') throw new InvalidPagePayloadException()
let parsed: unknown
try {
parsed = JSON.parse(rawPayload)
} catch {
throw new InvalidPagePayloadException()
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new InvalidPagePayloadException()
}
return parsed as Record<string, unknown>
}
/**
* Stores every uploaded file referenced by a `{ name, fileKey }` media value in
* the hero or component data, and replaces the value with `{ name, ref }` so
* only stored-file references are persisted.
*/
async resolveLocalFiles(request: Request, input: PageInput): Promise<PageInput> {
const resolvedByKey = new Map<string, StoredFileRef>()
const hero = input.hero
? ((await this.resolveValue(request, input.hero, resolvedByKey)) as JsonObject)
: input.hero
const components: PageInput['components'] = []
for (const component of input.components) {
components.push({
...component,
data: (await this.resolveValue(request, component.data, resolvedByKey)) as JsonObject,
})
}
return { ...input, hero, components }
}
private async resolveValue(
request: Request,
value: unknown,
resolvedByKey: Map<string, StoredFileRef>
): Promise<unknown> {
if (Array.isArray(value)) {
const items: unknown[] = []
for (const item of value) {
items.push(await this.resolveValue(request, item, resolvedByKey))
}
return items
}
if (value !== null && typeof value === 'object') {
if (this.isFileKeyValue(value)) {
return this.storeFile(request, value.fileKey, resolvedByKey)
}
const entries: Record<string, unknown> = {}
for (const [key, item] of Object.entries(value)) {
entries[key] = await this.resolveValue(request, item, resolvedByKey)
}
return entries
}
return value
}
private isFileKeyValue(value: object): value is { fileKey: string } {
return 'fileKey' in value && typeof (value as { fileKey: unknown }).fileKey === 'string'
}
private async storeFile(
request: Request,
fileKey: string,
resolvedByKey: Map<string, StoredFileRef>
): Promise<StoredFileRef> {
const cached = resolvedByKey.get(fileKey)
if (cached) return cached
const file = request.file(fileKey, { size: PAGE_MEDIA_FILE_SIZE_LIMIT })
if (!file) {
throw new PageMediaFileMissingException(fileKey)
}
if (!file.isValid) {
const message = file.errors.map((error: { message: string }) => error.message).join(', ')
throw new PageMediaFileInvalidException(fileKey, message)
}
const entry = await this.fileManagerService.writeFile(PAGE_MEDIA_UPLOADS_PATH, file)
const stored: StoredFileRef = { name: entry.name, ref: entry.path }
resolvedByKey.set(fileKey, stored)
return stored
}
}
@@ -0,0 +1,61 @@
import { createHmac, timingSafeEqual } from 'node:crypto'
import { PREVIEW_TOKEN_DEFAULT_TTL_SECONDS } from '#base_modules/WebManager/config/general'
import env from '#start/env'
interface PreviewTokenPayload {
pageId: number
expiresAt: number
}
export default class PreviewTokenService {
create(pageId: number, expiresInSeconds = PREVIEW_TOKEN_DEFAULT_TTL_SECONDS) {
const payload: PreviewTokenPayload = {
pageId,
expiresAt: Math.floor(Date.now() / 1000) + expiresInSeconds,
}
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url')
return {
token: `${encodedPayload}.${this.sign(encodedPayload)}`,
expiresAt: new Date(payload.expiresAt * 1000).toISOString(),
}
}
verify(token: string): PreviewTokenPayload | null {
const [encodedPayload, suppliedSignature, ...extraParts] = token.split('.')
if (!encodedPayload || !suppliedSignature || extraParts.length > 0) return null
const expectedSignature = this.sign(encodedPayload)
const suppliedBuffer = Buffer.from(suppliedSignature)
const expectedBuffer = Buffer.from(expectedSignature)
if (
suppliedBuffer.length !== expectedBuffer.length ||
!timingSafeEqual(suppliedBuffer, expectedBuffer)
) {
return null
}
try {
const payload = JSON.parse(
Buffer.from(encodedPayload, 'base64url').toString('utf8')
) as Partial<PreviewTokenPayload>
if (
!Number.isInteger(payload.pageId) ||
typeof payload.expiresAt !== 'number' ||
payload.expiresAt <= Math.floor(Date.now() / 1000)
) {
return null
}
return payload as PreviewTokenPayload
} catch {
return null
}
}
private sign(payload: string) {
return createHmac('sha256', env.get('APP_KEY')).update(payload).digest('base64url')
}
}
@@ -0,0 +1,113 @@
import drive from '@adonisjs/drive/services/main'
import WebPage from '#base_modules/WebManager/model/web_page'
import { sanitizePayload } from '#base_modules/WebManager/services/content_sanitizer'
import type { JsonObject } from '#base_modules/WebManager/types/web_manager'
function publicHref(locale: string, path: string) {
return `/${locale}${path ? `/${path}` : ''}`
}
export default class PublicContentService {
/**
* Page ids referenced by internal links anywhere inside a stored content value. Collected
* before rendering so the link map can be fetched by id instead of loading every published
* page in the site.
*/
collectInternalPageIds(value: unknown, into: Set<string> = new Set<string>()) {
if (Array.isArray(value)) {
for (const item of value) this.collectInternalPageIds(item, into)
return into
}
if (!value || typeof value !== 'object') return into
const record = value as JsonObject
if (record.linkType === 'internal' && record.pageId !== undefined) {
into.add(String(record.pageId))
}
for (const child of Object.values(record)) this.collectInternalPageIds(child, into)
return into
}
/**
* Maps the given page ids to their public hrefs. Ids that are unknown or no longer
* published are simply absent, which `prepare` renders as an empty href — the same
* outcome as before, when the map only ever held published pages.
*/
async createPageLinkMap(pageIds: Iterable<string>) {
const ids = [...new Set(pageIds)]
.map((pageId) => Number(pageId))
.filter((pageId) => Number.isInteger(pageId) && pageId > 0)
if (ids.length === 0) return new Map<string, string>()
const linkedPages = await WebPage.query()
.where('status', 'published')
.whereIn('id', ids)
.select(['id', 'locale', 'path'])
return new Map(linkedPages.map((page) => [String(page.id), publicHref(page.locale, page.path)]))
}
/**
* Turns a stored content value (hero, block data, settings section) into its
* public shape: internal links gain an href, stored-file refs become URLs and
* rich text is sanitized. Works structurally, so it accepts any of the typed
* stored content entities.
*/
async prepare(value: object | null, pageLinks: Map<string, string>) {
if (!value) return null
const resolveLinks = (candidate: unknown): unknown => {
if (Array.isArray(candidate)) return candidate.map(resolveLinks)
if (!candidate || typeof candidate !== 'object') return candidate
const record = Object.fromEntries(
Object.entries(candidate).map(([key, child]) => [key, resolveLinks(child)])
) as JsonObject
if (record.linkType === 'internal' && record.pageId !== undefined) {
record.href = pageLinks.get(String(record.pageId)) ?? ''
}
return record
}
const resolved = await this.resolveStoredFiles(resolveLinks(value))
const sanitized = sanitizePayload(resolved) as JsonObject
return sanitized
}
/**
* Media values are persisted as `{ name, ref }` stored-file references;
* public payloads need browser-loadable URLs instead.
*/
private async resolveStoredFiles(candidate: unknown): Promise<unknown> {
if (Array.isArray(candidate)) {
return Promise.all(candidate.map((item) => this.resolveStoredFiles(item)))
}
if (!candidate || typeof candidate !== 'object') return candidate
const record = candidate as JsonObject
if (
typeof record.ref === 'string' &&
Object.keys(record).every((k) => k === 'ref' || k === 'name')
) {
try {
return await drive.use().getUrl(record.ref)
} catch {
return ''
}
}
const entries: JsonObject = {}
for (const [key, child] of Object.entries(record)) {
entries[key] = (await this.resolveStoredFiles(child)) as JsonObject[string]
}
return entries
}
}
@@ -0,0 +1,654 @@
import db from '@adonisjs/lucid/services/db'
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
import { DateTime } from 'luxon'
import {
PageNotFoundException,
PageNotPublishedException,
PageRouteConflictException,
} from '#exceptions/web_manager_exceptions'
import WebComponent from '#base_modules/WebManager/model/web_component'
import WebPage from '#base_modules/WebManager/model/web_page'
import WebSetting from '#base_modules/WebManager/model/web_setting'
import PublicContentService from '#base_modules/WebManager/services/public_content_service'
import WebRevalidationService from '#base_modules/WebManager/services/web_revalidation_service'
import {
BREADCRUMB_PAGE_TYPE_PARENTS,
SITEMAP_PAGE_SIZE,
} from '#base_modules/WebManager/config/general'
import {
ancestorPaths,
ancestorsAlreadyLoaded,
BREADCRUMB_PARENT_GROUP_KEYS,
} from '#base_modules/WebManager/services/breadcrumb_paths'
import type {
NormalizedPageInput,
PageInput,
SettingsInput,
} from '#base_modules/WebManager/types/web_manager'
import type { PageStatus } from '#base_modules/WebManager/types/page_status'
import type { ComponentContent, PageType } from '#base_modules/WebManager/types/web_content'
import {
parseComponentContent,
parseHero,
parseSiteSettingsContent,
} from '#base_modules/WebManager/validator/web_content_validator'
export default class WebManagerService {
private publicContentService = new PublicContentService()
private webRevalidationService = new WebRevalidationService()
async listPages(filters: {
locale?: string
status?: PageStatus
groupKey?: string
pageType?: PageType
search?: string
page?: number
perPage?: number
}) {
const query = WebPage.query()
.whereNull('draft_of_id')
.preload('draft')
.orderBy('updated_at', 'desc')
if (filters.locale) query.where('locale', filters.locale)
if (filters.status) query.where('status', filters.status)
if (filters.groupKey) query.where('group_key', filters.groupKey)
if (filters.pageType) query.where('page_type', filters.pageType)
if (filters.search) {
query.where((searchQuery) => {
searchQuery
.whereILike('title', `%${filters.search}%`)
.orWhereILike('group_key', `%${filters.search}%`)
.orWhereILike('path', `%${filters.search}%`)
.orWhereILike('seo_title', `%${filters.search}%`)
})
}
return query.paginate(filters.page ?? 1, filters.perPage ?? 25)
}
async listGroupKeys() {
const pages = await WebPage.query()
.select('group_key')
.whereNot('group_key', '')
.distinct()
.orderBy('group_key')
return pages.map((page) => page.groupKey)
}
async getPage(id: number) {
const page = await this.findPage(id)
return this.serializeAdminPage(page)
}
async createDraft(input: PageInput) {
const normalized = await this.normalizeInput(input)
await this.assertNewRouteAvailable(normalized.locale, normalized.path)
const page = await db.transaction(async (trx) => {
const createdPage = await WebPage.create(
{
status: 'draft',
draftOfId: null,
...this.pageFields(normalized),
publishedAt: null,
},
{ client: trx }
)
await this.replaceComponents(createdPage.id, normalized.components, trx)
return createdPage
})
return this.serializeAdminPage(page)
}
async saveDraft(id: number, input: PageInput) {
const normalized = await this.normalizeInput(input)
const draft = await this.ensureDraft(id)
await db.transaction(async (trx) => {
const lockedDraft = await WebPage.query({ client: trx })
.where('id', draft.id)
.whereNot('status', 'published')
.forUpdate()
.first()
if (!lockedDraft) throw new PageNotFoundException('Draft page')
if (lockedDraft.draftOfId === null) {
await this.assertNewRouteAvailable(normalized.locale, normalized.path, lockedDraft.id, trx)
}
lockedDraft.merge(this.pageFields(normalized))
await lockedDraft.save()
await this.replaceComponents(lockedDraft.id, normalized.components, trx)
})
return this.getPage(draft.id)
}
async publish(id: number) {
const published = await db.transaction(async (trx) => {
const target = await WebPage.query({ client: trx }).where('id', id).forUpdate().first()
if (!target) throw new PageNotFoundException()
let draft = target
if (target.draftOfId === null) {
const pendingDraft = await WebPage.query({ client: trx })
.where('draft_of_id', target.id)
.forUpdate()
.first()
if (pendingDraft) draft = pendingDraft
else if (target.status === 'published') return { page: target, structural: false }
}
if (draft.draftOfId === null) {
await this.assertPublishedRouteAvailable(draft.locale, draft.path, draft.id, trx)
draft.status = 'published'
draft.publishedAt = DateTime.now()
await draft.save()
return { page: draft, structural: true }
}
const original = await WebPage.query({ client: trx })
.where('id', draft.draftOfId)
.forUpdate()
.first()
if (!original) throw new PageNotFoundException('Published page')
await this.assertPublishedRouteAvailable(draft.locale, draft.path, original.id, trx)
const structural =
original.status !== 'published' ||
original.locale !== draft.locale ||
original.path !== draft.path ||
original.title !== draft.title ||
original.groupKey !== draft.groupKey ||
original.pageType !== draft.pageType
original.merge({
status: 'published',
locale: draft.locale,
path: draft.path,
groupKey: draft.groupKey,
pageType: draft.pageType,
title: draft.title,
hero: draft.hero,
seoTitle: draft.seoTitle,
seoDescription: draft.seoDescription,
publishedAt: DateTime.now(),
})
await original.save()
await WebComponent.query({ client: trx }).where('page_id', original.id).delete()
await WebComponent.query({ client: trx })
.where('page_id', draft.id)
.update({ page_id: original.id })
await draft.delete()
return { page: original, structural }
})
const revalidation = await this.revalidatePublishedPage(published.page, published.structural)
return { page: await this.getPage(published.page.id), revalidation }
}
async unpublish(id: number) {
const page = await db.transaction(async (trx) => {
const target = await WebPage.query({ client: trx }).where('id', id).forUpdate().first()
if (!target) throw new PageNotFoundException()
if (target.status !== 'published') throw new PageNotPublishedException()
target.status = 'unpublished'
await target.save()
return target
})
const revalidation = await this.webRevalidationService.revalidatePublicContent()
return { page: await this.getPage(page.id), revalidation }
}
private async revalidatePublishedPage(page: WebPage, structural: boolean) {
if (structural) {
return this.webRevalidationService.revalidatePublicContent()
}
return this.webRevalidationService.revalidatePage({
locale: page.locale,
path: page.path,
pageType: page.pageType,
translations: await this.findPublishedTranslations(page),
})
}
private async findPublishedTranslations(page: WebPage) {
if (!page.groupKey) return []
const siblings = await WebPage.query()
.where('status', 'published')
.where('group_key', page.groupKey)
.whereNot('id', page.id)
.select(['locale', 'path'])
return siblings.map((sibling) => ({ locale: sibling.locale, path: sibling.path }))
}
async discard(id: number) {
const page = await this.findPage(id)
const wasPublished = page.status === 'published'
await page.delete()
if (wasPublished) {
await this.webRevalidationService.revalidatePublicContent()
}
}
async getSettings(locale?: string) {
if (locale) return WebSetting.query().where('locale', locale).first()
return WebSetting.query().orderBy('locale')
}
async saveSettings(input: SettingsInput) {
const content = await parseSiteSettingsContent({
header: input.header,
footer: input.footer,
legal: input.legal,
})
const settings = await WebSetting.updateOrCreate(
{ locale: input.locale },
{ header: content.header, footer: content.footer, legal: content.legal }
)
const revalidation = await this.webRevalidationService.revalidateSettings(input.locale)
return { settings, revalidation }
}
async listPublicBlogPages(filters: { locale: string; page?: number; perPage?: number }) {
const paginated = await WebPage.query()
.where('status', 'published')
.where('locale', filters.locale)
.where('page_type', 'blog-item' satisfies PageType)
.orderBy('published_at', 'desc')
.orderBy('id', 'desc')
.paginate(filters.page ?? 1, filters.perPage ?? 24)
const noLinks = new Map<string, string>()
const items = await Promise.all(
paginated.all().map(async (page) => {
const heroMedia = page.hero?.media ?? null
const prepared = heroMedia
? await this.publicContentService.prepare({ media: heroMedia }, noLinks)
: null
return {
id: page.id,
locale: page.locale,
path: page.path,
groupKey: page.groupKey,
pageType: page.pageType,
title: page.title,
description: page.seoDescription,
media: prepared?.media ?? null,
publishedAt: page.publishedAt?.toISO() ?? null,
}
})
)
return {
items,
pagination: {
page: paginated.currentPage,
perPage: paginated.perPage,
total: paginated.total,
lastPage: paginated.lastPage,
},
}
}
async listPublicSitemapPages(filters: { page?: number; perPage?: number }) {
const paginated = await WebPage.query()
.where('status', 'published')
.whereNull('draft_of_id')
.select(['id', 'locale', 'path', 'group_key', 'page_type', 'published_at', 'updated_at'])
.orderBy('locale')
.orderBy('path')
.paginate(filters.page ?? 1, filters.perPage ?? SITEMAP_PAGE_SIZE)
return {
items: paginated.all().map((page) => ({
id: page.id,
locale: page.locale,
path: page.path,
groupKey: page.groupKey,
pageType: page.pageType,
publishedAt: page.publishedAt?.toISO() ?? null,
updatedAt: page.updatedAt?.toISO() ?? null,
})),
pagination: {
page: paginated.currentPage,
perPage: paginated.perPage,
total: paginated.total,
lastPage: paginated.lastPage,
},
}
}
async resolvePublicPage(locale: string, path: string) {
const page = await WebPage.query()
.where('status', 'published')
.where('locale', locale)
.where('path', this.normalizePath(path))
.first()
if (!page) return null
return this.serializePublicPage(page)
}
async resolvePreviewPage(id: number) {
const page = await this.findPage(id)
return this.serializePublicPage(page)
}
private async ensureDraft(id: number) {
return db.transaction(async (trx) => {
const page = await WebPage.query({ client: trx }).where('id', id).forUpdate().first()
if (!page) throw new PageNotFoundException()
if (page.status !== 'published') return page
const existingDraft = await WebPage.query({ client: trx })
.where('draft_of_id', page.id)
.first()
if (existingDraft) return existingDraft
const draft = await WebPage.create(
{
status: 'draft',
draftOfId: page.id,
locale: page.locale,
path: page.path,
groupKey: page.groupKey,
pageType: page.pageType,
title: page.title,
hero: page.hero,
seoTitle: page.seoTitle,
seoDescription: page.seoDescription,
publishedAt: null,
},
{ client: trx }
)
const components = await WebComponent.query({ client: trx })
.where('page_id', page.id)
.orderBy('sort_order')
if (components.length > 0) {
await WebComponent.createMany(
components.map((component) => ({
pageId: draft.id,
type: component.type,
data: component.data,
sortOrder: component.sortOrder,
})),
{ client: trx }
)
}
return draft
})
}
private async serializeAdminPage(page: WebPage) {
const components = await WebComponent.query()
.where('page_id', page.id)
.orderBy('sort_order')
.orderBy('id')
return {
page: page.serialize(),
components: components.map((component) => component.serialize()),
}
}
private async serializePublicPage(page: WebPage) {
const [components, settings, alternatives, breadcrumbs] = await Promise.all([
WebComponent.query().where('page_id', page.id).orderBy('sort_order').orderBy('id'),
WebSetting.query().where('locale', page.locale).first(),
WebPage.query()
.where('status', 'published')
.where('group_key', page.groupKey)
.select(['locale', 'path'])
.orderBy('locale'),
this.buildBreadcrumbs(page),
])
const linkedPageIds = new Set<string>()
this.publicContentService.collectInternalPageIds(page.hero, linkedPageIds)
for (const component of components) {
this.publicContentService.collectInternalPageIds(component.data, linkedPageIds)
}
if (settings) {
this.publicContentService.collectInternalPageIds(settings.header, linkedPageIds)
this.publicContentService.collectInternalPageIds(settings.footer, linkedPageIds)
this.publicContentService.collectInternalPageIds(settings.legal, linkedPageIds)
}
const pageLinks = await this.publicContentService.createPageLinkMap(linkedPageIds)
const hero = await this.publicContentService.prepare(page.hero, pageLinks)
const publicComponents = await Promise.all(
components.map(async (component) => ({
id: component.id,
type: component.type,
data: await this.publicContentService.prepare(component.data, pageLinks),
}))
)
const publicSettings = settings
? {
locale: settings.locale,
header: await this.publicContentService.prepare(settings.header, pageLinks),
footer: await this.publicContentService.prepare(settings.footer, pageLinks),
legal: await this.publicContentService.prepare(settings.legal, pageLinks),
}
: null
return {
page: {
id: page.id,
status: page.status,
locale: page.locale,
path: page.path,
groupKey: page.groupKey,
layout: page.pageType,
pageType: page.pageType,
title: page.title,
hero,
seo: { title: page.seoTitle || page.title, description: page.seoDescription },
publishedAt: page.publishedAt?.toISO() ?? null,
updatedAt: page.updatedAt?.toISO() ?? null,
},
components: publicComponents,
settings: publicSettings,
breadcrumbs,
languageAlternatives: alternatives.map((alternative) => ({
locale: alternative.locale,
path: alternative.path,
})),
}
}
private async buildBreadcrumbs(page: WebPage) {
const byPath = new Map<string, WebPage>()
const byGroupKey = new Map<string, WebPage>()
const loadedPaths: string[] = []
const loadCandidatesFor = async (target: WebPage) => {
if (ancestorsAlreadyLoaded(target.path, loadedPaths)) return
loadedPaths.push(target.path)
const candidates = await WebPage.query()
.where('status', 'published')
.where('locale', page.locale)
.whereNull('draft_of_id')
.where((scope) => {
scope.whereIn('path', ancestorPaths(target.path))
if (BREADCRUMB_PARENT_GROUP_KEYS.length > 0) {
scope.orWhereIn('group_key', BREADCRUMB_PARENT_GROUP_KEYS)
}
})
.select(['id', 'path', 'group_key', 'page_type', 'title'])
for (const candidate of candidates) {
if (!byPath.has(candidate.path)) byPath.set(candidate.path, candidate)
if (candidate.groupKey && !byGroupKey.has(candidate.groupKey)) {
byGroupKey.set(candidate.groupKey, candidate)
}
}
}
const resolveParent = (current: WebPage) => {
const parentGroupKey = BREADCRUMB_PAGE_TYPE_PARENTS[current.pageType]
if (parentGroupKey) {
const parent = byGroupKey.get(parentGroupKey)
if (parent && parent.id !== current.id) return parent
}
const segments = current.path.split('/').filter(Boolean)
for (let depth = segments.length - 1; depth >= 1; depth--) {
const ancestor = byPath.get(segments.slice(0, depth).join('/'))
if (ancestor && ancestor.id !== current.id) return ancestor
}
if (current.path !== '') {
const home = byPath.get('')
if (home && home.id !== current.id) return home
}
return null
}
const chain: Array<{ title: string; path: string }> = [{ title: page.title, path: page.path }]
const visited = new Set<number>([page.id])
let current: WebPage = page
await loadCandidatesFor(current)
while (true) {
const parent = resolveParent(current)
if (!parent || visited.has(parent.id)) break
visited.add(parent.id)
chain.unshift({ title: parent.title, path: parent.path })
current = parent
await loadCandidatesFor(current)
}
if (chain[0].path !== '') {
chain.unshift({ title: 'Home', path: '' })
}
return chain.map((crumb) => ({
label: crumb.title,
href: `/${page.locale}${crumb.path ? `/${crumb.path}` : ''}`,
}))
}
private async replaceComponents(
pageId: number,
components: ComponentContent[],
trx: TransactionClientContract
) {
await WebComponent.query({ client: trx }).where('page_id', pageId).delete()
if (components.length === 0) return
await WebComponent.createMany(
components.map((component, index) => ({
pageId,
type: component.type,
data: component.data,
sortOrder: index,
})),
{ client: trx }
)
}
private async normalizeInput(input: PageInput): Promise<NormalizedPageInput> {
let hero = null
if (input.hero) hero = await parseHero(input.hero)
const components = await Promise.all(
input.components.map(async (component, index) =>
parseComponentContent({ type: component.type.trim(), data: component.data }, index)
)
)
return {
locale: input.locale.trim(),
path: this.normalizePath(input.path),
groupKey: input.groupKey.trim(),
pageType: input.pageType,
title: input.title.trim(),
hero,
seoTitle: input.seoTitle?.trim() ?? '',
seoDescription: input.seoDescription?.trim() ?? '',
components,
}
}
private pageFields(input: NormalizedPageInput) {
return {
locale: input.locale,
path: input.path,
groupKey: input.groupKey,
pageType: input.pageType,
title: input.title,
hero: input.hero,
seoTitle: input.seoTitle,
seoDescription: input.seoDescription,
}
}
private normalizePath(path: string) {
return path.split('/').filter(Boolean).join('/')
}
private async findPage(id: number) {
const page = await WebPage.find(id)
if (!page) throw new PageNotFoundException()
return page
}
private async assertNewRouteAvailable(
locale: string,
path: string,
exceptId?: number,
trx?: TransactionClientContract
) {
const query = WebPage.query(trx ? { client: trx } : undefined)
.where('locale', locale)
.where('path', path)
.whereNull('draft_of_id')
if (exceptId) query.whereNot('id', exceptId)
if (await query.first()) throw new PageRouteConflictException(locale, path)
}
private async assertPublishedRouteAvailable(
locale: string,
path: string,
exceptId: number,
trx: TransactionClientContract
) {
const conflict = await WebPage.query({ client: trx })
.where('status', 'published')
.where('locale', locale)
.where('path', path)
.whereNot('id', exceptId)
.first()
if (conflict) throw new PageRouteConflictException(locale, path)
}
}
@@ -0,0 +1,178 @@
import logger from '@adonisjs/core/services/logger'
import env from '#start/env'
import type { PageType } from '#base_modules/WebManager/types/web_content'
export const WEB_MANAGER_CACHE_TAG = 'web-manager'
/**
* Tag builders mirroring `apps/web/web-runtime/api/cache-tags.ts`. The two files must
* stay in sync: the web app labels its cached fetches with these names and this service
* purges by the same names.
*/
export function pageCacheTag(locale: string, path: string) {
return `${WEB_MANAGER_CACHE_TAG}:page:${locale}:${path || '/'}`
}
export function settingsCacheTag(locale: string) {
return `${WEB_MANAGER_CACHE_TAG}:settings:${locale}`
}
export function blogCacheTag(locale: string) {
return `${WEB_MANAGER_CACHE_TAG}:blog:${locale}`
}
export const REVALIDATION_MAX_ATTEMPTS = 3
export const REVALIDATION_TIMEOUT_MS = 5000
export const REVALIDATION_RETRY_BASE_DELAY_MS = 1000
/**
* `purged` — the web app confirmed the purge.
* `retrying` — the first attempt failed and retries are running in the background;
* until one succeeds the public site serves cached content up to its `revalidate`
* window (60s, see `apps/web/web-runtime/api/web-runtime-api.ts`).
* `disabled` — no `WEB_APP_URL`/`WEB_REVALIDATE_SECRET`, so nothing was sent.
*/
export type RevalidationStatus = 'purged' | 'retrying' | 'disabled'
export interface RevalidationResult {
status: RevalidationStatus
tags: string[]
}
export interface PageRevalidationTarget {
locale: string
path: string
pageType: PageType
translations: Array<{ locale: string; path: string }>
}
const pendingRetries = new Set<Promise<unknown>>()
export async function flushPendingRevalidations() {
while (pendingRetries.size > 0) {
await Promise.all([...pendingRetries])
}
}
export default class WebRevalidationService {
/**
* Full-site revalidation. Reserved for structural changes — a page created, deleted or moved —
* where internal links resolved into other pages' HTML and descendants' breadcrumbs go
* stale in ways that would take a dependency graph to enumerate.
*/
async revalidatePublicContent() {
return this.revalidate([WEB_MANAGER_CACHE_TAG])
}
async revalidatePage(target: PageRevalidationTarget) {
const tags = new Set<string>([pageCacheTag(target.locale, target.path)])
for (const translation of target.translations) {
tags.add(pageCacheTag(translation.locale, translation.path))
}
if (target.pageType === 'blog-item') {
tags.add(blogCacheTag(target.locale))
}
return this.revalidate([...tags])
}
async revalidateSettings(locale: string) {
return this.revalidate([settingsCacheTag(locale)])
}
async revalidate(tags: string[]): Promise<RevalidationResult> {
const target = this.resolveTarget(tags)
if (!target) return { status: 'disabled', tags }
const failure = await this.attempt(tags, target.webAppUrl, target.secret)
if (!failure) return { status: 'purged', tags }
this.reportFailedAttempt(tags, 1, failure)
this.scheduleRetries(tags, target.webAppUrl, target.secret)
return { status: 'retrying', tags }
}
protected resolveTarget(tags: string[]): { webAppUrl: string; secret: string } | null {
const webAppUrl = env.get('WEB_APP_URL')
const secret = env.get('WEB_REVALIDATE_SECRET')
if (webAppUrl && secret) return { webAppUrl, secret }
const message = `Web revalidation of [${tags.join(', ')}] skipped: WEB_APP_URL or WEB_REVALIDATE_SECRET is not set`
if (env.get('NODE_ENV') === 'production') logger.warn(message)
else logger.debug(message)
return null
}
private reportFailedAttempt(tags: string[], attempt: number, failure: string) {
logger.warn(
`Web revalidation of [${tags.join(', ')}] failed on attempt ${attempt}/${REVALIDATION_MAX_ATTEMPTS}: ${failure}`
)
}
/** Returns a failure description, or `null` when the web app confirmed the purge. */
protected async attempt(tags: string[], webAppUrl: string, secret: string) {
try {
const response = await fetch(new URL('/api/revalidate', webAppUrl), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Revalidate-Secret': secret,
},
body: JSON.stringify({ tags }),
signal: AbortSignal.timeout(REVALIDATION_TIMEOUT_MS),
})
return response.ok ? null : `responded with status ${response.status}`
} catch (error) {
return error instanceof Error ? error.message : String(error)
}
}
private scheduleRetries(tags: string[], webAppUrl: string, secret: string) {
const retries = this.runRetries(tags, webAppUrl, secret)
pendingRetries.add(retries)
void retries.finally(() => pendingRetries.delete(retries))
}
/** Runs attempts 2..N with backoff, stopping at the first one that purges. */
private async runRetries(tags: string[], webAppUrl: string, secret: string) {
let failure = 'not attempted'
for (let attempt = 2; attempt <= REVALIDATION_MAX_ATTEMPTS; attempt++) {
await this.wait(this.backoffFor(attempt))
const attemptFailure = await this.attempt(tags, webAppUrl, secret)
if (!attemptFailure) {
logger.info(`Web revalidation of [${tags.join(', ')}] succeeded on attempt ${attempt}`)
return
}
failure = attemptFailure
this.reportFailedAttempt(tags, attempt, failure)
}
logger.error(
`Web revalidation of [${tags.join(', ')}] gave up after ${REVALIDATION_MAX_ATTEMPTS} attempts: ${failure}. ` +
'The public site serves cached content until its revalidate window expires (60s); ' +
'publishing again sends a fresh purge.'
)
}
protected backoffFor(attempt: number) {
const delay = REVALIDATION_RETRY_BASE_DELAY_MS * 2 ** (attempt - 2)
return delay + Math.random() * delay
}
protected wait(ms: number) {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms).unref?.()
})
}
}
@@ -0,0 +1,12 @@
/**
* Lifecycle of a page row:
*
* - `draft` — never published, or the working copy of a published page.
* - `published` — live on the public site.
* - `unpublished` — was live and was taken down; keeps its content, components and
* `published_at`, and keeps its route reserved so nothing else can claim it.
*
*/
export const PAGE_STATUSES = ['draft', 'published', 'unpublished'] as const
export type PageStatus = (typeof PAGE_STATUSES)[number]
@@ -0,0 +1,156 @@
export const PAGE_TYPES = ['landing', 'blog', 'blog-item'] as const
export type PageType = (typeof PAGE_TYPES)[number]
export const BLOCK_TYPES = [
'content',
'faq',
'media-content',
'articles',
'banner',
'media',
] as const
export type BlockType = (typeof BLOCK_TYPES)[number]
export const HERO_TYPES = ['none', 'low', 'middle', 'high'] as const
export type HeroType = (typeof HERO_TYPES)[number]
export const NAVIGATION_LINK_VARIANTS = [
'primary',
'secondary',
'outline',
'ghost',
'inline',
] as const
export type NavigationLinkVariant = (typeof NAVIGATION_LINK_VARIANTS)[number]
export const BANNER_STYLES = ['info', 'success', 'warning', 'error'] as const
export type BannerStyle = (typeof BANNER_STYLES)[number]
export const MEDIA_CONTENT_POSITIONS = ['left', 'right'] as const
export type MediaContentPosition = (typeof MEDIA_CONTENT_POSITIONS)[number]
export interface RichText {
content: string
}
export interface FaqItem {
question: string
answer: string
}
export interface MediaRef {
name: string
ref: string
}
export interface Media {
url: MediaRef | null
alt: string
width?: number
height?: number
mimeType?: string
}
interface NavigationLinkBase {
openInNewTab: boolean
label: string
variant: NavigationLinkVariant
}
export interface InternalNavigationLink extends NavigationLinkBase {
linkType: 'internal'
pageId: string
}
export interface ExternalNavigationLink extends NavigationLinkBase {
linkType: 'external'
externalUrl: string
}
export type NavigationLink = InternalNavigationLink | ExternalNavigationLink
export interface Hero {
type: HeroType
title?: string
subtitle?: string
content?: RichText
ctaItems: NavigationLink[]
media?: Media
}
export interface ContentColumn {
content: RichText
ctaItems: NavigationLink[]
}
export interface ContentBlockData {
title: string
subtitle: string
columns: ContentColumn[]
}
export interface FaqBlockData {
title: string
subtitle: string
items: FaqItem[]
}
export interface MediaContentBlockData {
title: string
mediaPosition: MediaContentPosition
media: Media
content: RichText
ctaItems: NavigationLink[]
}
export interface ArticlesBlockItem {
title: string
content: RichText
}
export interface ArticlesBlockData {
cta?: NavigationLink
title?: string
subtitle?: RichText
items: ArticlesBlockItem[]
}
export interface BannerBlockData {
style: BannerStyle
content: RichText
ctaItems: NavigationLink[]
}
export interface MediaBlockData {
media: Media
}
export type ComponentContent =
| { type: 'content'; data: ContentBlockData }
| { type: 'faq'; data: FaqBlockData }
| { type: 'media-content'; data: MediaContentBlockData }
| { type: 'articles'; data: ArticlesBlockData }
| { type: 'banner'; data: BannerBlockData }
| { type: 'media'; data: MediaBlockData }
export type BlockData = ComponentContent['data']
export interface SiteMenu {
title: string
items: NavigationLink[]
}
export interface SiteSettingsContent {
header: {
menus: SiteMenu[]
ctaItems: NavigationLink[]
}
footer: {
copyright: string
menus: SiteMenu[]
}
legal: {
privacyPath: string
termsPath: string
}
}
@@ -0,0 +1,39 @@
import type { ComponentContent, Hero, PageType } from '#base_modules/WebManager/types/web_content'
export type JsonObject = Record<string, unknown>
export interface ComponentInput {
type: string
data: JsonObject
}
export interface PageInput {
locale: string
path: string
groupKey: string
pageType: PageType
title: string
hero?: JsonObject | null
seoTitle?: string
seoDescription?: string
components: ComponentInput[]
}
export interface NormalizedPageInput {
locale: string
path: string
groupKey: string
pageType: PageType
title: string
hero: Hero | null
seoTitle: string
seoDescription: string
components: ComponentContent[]
}
export interface SettingsInput {
locale: string
header: JsonObject
footer: JsonObject
legal: JsonObject
}
@@ -0,0 +1,78 @@
import vine from '@vinejs/vine'
import {
PREVIEW_TOKEN_MAX_TTL_SECONDS,
PREVIEW_TOKEN_MIN_TTL_SECONDS,
SITEMAP_MAX_PAGE_SIZE,
} from '#base_modules/WebManager/config/general'
import {
componentSchema,
groupKeySchema,
localeSchema,
pathSchema,
} from '#base_modules/WebManager/validator/schemas'
import { PAGE_TYPES } from '#base_modules/WebManager/types/web_content'
import { PAGE_STATUSES } from '#base_modules/WebManager/types/page_status'
import { jsonObjectSchema } from '#validators/schemas/json_object_schema'
const pageFields = {
locale: localeSchema,
path: pathSchema,
groupKey: groupKeySchema,
pageType: vine.enum(PAGE_TYPES),
title: vine.string().trim().minLength(1).maxLength(255),
hero: jsonObjectSchema,
seoTitle: vine.string().trim().maxLength(255).optional(),
seoDescription: vine.string().trim().maxLength(2000).optional(),
components: vine.array(componentSchema).maxLength(200),
}
export const createPageValidator = vine.create(vine.object(pageFields))
export const saveDraftValidator = vine.create(vine.object(pageFields))
export const listPagesValidator = vine.create(
vine.object({
locale: localeSchema.optional(),
status: vine.enum(PAGE_STATUSES).optional(),
groupKey: groupKeySchema.optional(),
pageType: vine.enum(PAGE_TYPES).optional(),
search: vine.string().trim().maxLength(255).optional(),
page: vine.number().min(1).optional(),
perPage: vine.number().min(1).max(100).optional(),
})
)
export const publicPageValidator = vine.create(
vine.object({
locale: localeSchema,
path: pathSchema,
})
)
export const publicBlogListValidator = vine.create(
vine.object({
locale: localeSchema,
page: vine.number().min(1).optional(),
perPage: vine.number().min(1).max(100).optional(),
})
)
export const publicSitemapValidator = vine.create(
vine.object({
page: vine.number().min(1).optional(),
perPage: vine.number().min(1).max(SITEMAP_MAX_PAGE_SIZE).optional(),
})
)
export const previewTokenValidator = vine.create(
vine.object({
expiresIn: vine
.number()
.min(PREVIEW_TOKEN_MIN_TTL_SECONDS)
.max(PREVIEW_TOKEN_MAX_TTL_SECONDS)
.optional(),
})
)
export const previewRequestValidator = vine.create(
vine.object({ token: vine.string().trim().minLength(1) })
)
@@ -0,0 +1,32 @@
import { jsonObjectSchema } from '#validators/schemas/json_object_schema'
import vine from '@vinejs/vine'
export const localeSchema = vine
.string()
.trim()
.minLength(2)
.maxLength(16)
.regex(/^[a-z]{2,3}(?:-[A-Z]{2})?$/)
export const pathSchema = vine
.string()
.trim()
.maxLength(2048)
.regex(/^(?:(?:[a-z0-9]+(?:-[a-z0-9]+)*)(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*)?$/)
export const groupKeySchema = vine
.string()
.trim()
.minLength(1)
.maxLength(255)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
export const componentSchema = vine.object({
type: vine
.string()
.trim()
.minLength(1)
.maxLength(100)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
data: jsonObjectSchema,
})
@@ -0,0 +1,14 @@
import vine from '@vinejs/vine'
import { localeSchema } from '#base_modules/WebManager/validator/schemas'
import { jsonObjectSchema } from '#validators/schemas/json_object_schema'
export const settingsQueryValidator = vine.create(vine.object({ locale: localeSchema.optional() }))
export const saveSettingsValidator = vine.create(
vine.object({
locale: localeSchema,
header: jsonObjectSchema,
footer: jsonObjectSchema,
legal: jsonObjectSchema,
})
)
@@ -0,0 +1,225 @@
import vine, { errors } from '@vinejs/vine'
import { InvalidPagePayloadException } from '#exceptions/web_manager_exceptions'
import {
BANNER_STYLES,
HERO_TYPES,
MEDIA_CONTENT_POSITIONS,
NAVIGATION_LINK_VARIANTS,
} from '#base_modules/WebManager/types/web_content'
import type {
ComponentContent,
Hero,
SiteSettingsContent,
} from '#base_modules/WebManager/types/web_content'
const richTextSchema = () => vine.object({ content: vine.string() })
const faqItemSchema = () =>
vine.object({
question: vine.string(),
answer: vine.string(),
})
const mediaSchema = () =>
vine.object({
url: vine
.object({
name: vine.string(),
ref: vine.string(),
})
.nullable(),
alt: vine.string(),
width: vine.number().optional(),
height: vine.number().optional(),
mimeType: vine.string().optional(),
})
const navigationLinkBaseShape = () => ({
openInNewTab: vine.boolean(),
label: vine.string(),
variant: vine.enum(NAVIGATION_LINK_VARIANTS),
})
const isObjectWith = (value: unknown, key: string, expected: string) =>
vine.helpers.isObject(value) && value[key] === expected
const navigationLinkSchema = () =>
vine
.union([
vine.union.if(
(value) => isObjectWith(value, 'linkType', 'internal'),
vine.object({
...navigationLinkBaseShape(),
linkType: vine.literal('internal'),
pageId: vine.string().trim().minLength(1),
})
),
vine.union.if(
(value) => isObjectWith(value, 'linkType', 'external'),
vine.object({
...navigationLinkBaseShape(),
linkType: vine.literal('external'),
externalUrl: vine.string().url(),
})
),
])
.otherwise((_, field) => {
field.report('Navigation link must be internal or external', 'navigationLink', field)
})
const heroSchema = () =>
vine.object({
type: vine.enum(HERO_TYPES),
title: vine.string().optional(),
subtitle: vine.string().optional(),
content: richTextSchema().optional(),
ctaItems: vine.array(navigationLinkSchema()),
media: mediaSchema().optional(),
})
const contentBlockDataSchema = () =>
vine.object({
title: vine.string(),
subtitle: vine.string(),
columns: vine.array(
vine.object({
content: richTextSchema(),
ctaItems: vine.array(navigationLinkSchema()),
})
),
})
const faqBlockDataSchema = () =>
vine.object({
title: vine.string(),
subtitle: vine.string(),
items: vine.array(faqItemSchema()),
})
const mediaContentBlockDataSchema = () =>
vine.object({
title: vine.string(),
mediaPosition: vine.enum(MEDIA_CONTENT_POSITIONS),
media: mediaSchema(),
content: richTextSchema(),
ctaItems: vine.array(navigationLinkSchema()),
})
const articlesBlockDataSchema = () =>
vine.object({
cta: navigationLinkSchema().optional(),
title: vine.string().optional(),
subtitle: richTextSchema().optional(),
items: vine.array(
vine.object({
title: vine.string(),
content: richTextSchema(),
})
),
})
const bannerBlockDataSchema = () =>
vine.object({
style: vine.enum(BANNER_STYLES),
content: richTextSchema(),
ctaItems: vine.array(navigationLinkSchema()),
})
const mediaBlockDataSchema = () =>
vine.object({
media: mediaSchema(),
})
const componentContentSchema = () =>
vine
.union([
vine.union.if(
(value) => isObjectWith(value, 'type', 'content'),
vine.object({ type: vine.literal('content'), data: contentBlockDataSchema() })
),
vine.union.if(
(value) => isObjectWith(value, 'type', 'faq'),
vine.object({ type: vine.literal('faq'), data: faqBlockDataSchema() })
),
vine.union.if(
(value) => isObjectWith(value, 'type', 'media-content'),
vine.object({ type: vine.literal('media-content'), data: mediaContentBlockDataSchema() })
),
vine.union.if(
(value) => isObjectWith(value, 'type', 'articles'),
vine.object({ type: vine.literal('articles'), data: articlesBlockDataSchema() })
),
vine.union.if(
(value) => isObjectWith(value, 'type', 'banner'),
vine.object({ type: vine.literal('banner'), data: bannerBlockDataSchema() })
),
vine.union.if(
(value) => isObjectWith(value, 'type', 'media'),
vine.object({ type: vine.literal('media'), data: mediaBlockDataSchema() })
),
])
.otherwise((_, field) => {
field.report('Unknown component type', 'componentType', field)
})
const siteMenuSchema = () =>
vine.object({
title: vine.string(),
items: vine.array(navigationLinkSchema()),
})
const siteSettingsContentSchema = () =>
vine.object({
header: vine.object({
menus: vine.array(siteMenuSchema()),
ctaItems: vine.array(navigationLinkSchema()),
}),
footer: vine.object({
copyright: vine.string(),
menus: vine.array(siteMenuSchema()),
}),
legal: vine.object({
privacyPath: vine.string(),
termsPath: vine.string(),
}),
})
const heroValidator = vine.create(heroSchema())
const componentContentValidator = vine.create(componentContentSchema())
const siteSettingsContentValidator = vine.create(siteSettingsContentSchema())
function describeError(context: string, error: unknown): string {
if (error instanceof errors.E_VALIDATION_ERROR) {
const issue = (error.messages as Array<{ field?: string; message?: string }>)[0]
const path = issue?.field ? ` at "${issue.field}"` : ''
return `${context}${path}: ${issue?.message ?? 'invalid value'}`
}
return context
}
async function runValidator<Output>(
context: string,
validator: { validate(value: unknown): Promise<Output> },
value: unknown
): Promise<Output> {
try {
return await validator.validate(value)
} catch (error) {
throw new InvalidPagePayloadException(describeError(context, error))
}
}
export async function parseHero(value: unknown): Promise<Hero> {
return runValidator('Invalid hero', heroValidator, value)
}
export async function parseComponentContent(
value: unknown,
index: number
): Promise<ComponentContent> {
return runValidator(`Invalid component #${index}`, componentContentValidator, value)
}
export async function parseSiteSettingsContent(value: unknown): Promise<SiteSettingsContent> {
return runValidator('Invalid settings', siteSettingsContentValidator, value)
}
+37 -461
View File
@@ -1,483 +1,59 @@
import { HttpContext } from '@adonisjs/core/http'
import { join, dirname } from 'node:path'
import { mkdirSync, writeFileSync, readFileSync, existsSync, cpSync } from 'node:fs'
import { modelToSchema } from '#services/swagger_model'
import handlebars from 'handlebars'
import { getDatabaseTables, getColumnsForTable } from '#services/database_inspector'
import { mapDBTypeToTs } from '#services/module_generator/db_types'
import { generateModule } from '#services/module_generator/generate_module'
import {
getDatabaseTables,
getColumnsForTable,
getForeignKeysReferencing,
} from '#services/database_inspector'
handlebars.registerHelper('eq', (a, b) => a === b)
handlebars.registerHelper('not', (v) => !v)
handlebars.registerHelper('or', (a, b) => a || b)
handlebars.registerHelper('and', (a, b) => a && b)
function renderTemplate(templatePath: string, context: any): string {
const raw = readFileSync(templatePath, 'utf8')
const template = handlebars.compile(raw)
return template(context)
}
ModuleAsset,
ModuleLayoutConfig,
ModuleTableConfig,
} from '#services/module_generator/types'
export default class MakeModuleController {
capitalize(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1)
}
camelCase(str: string) {
return str.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
}
pascalCase(str: string) {
return this.capitalize(this.camelCase(str))
}
snakeCase(str: string) {
return str.slice(0, str.length - 1)
}
mapDBTypeToTs(type: string): string {
if (type.includes('int')) return 'number'
if (type === 'boolean') return 'boolean'
if (type.includes('timestamp') || type === 'date') return 'DateTime'
return 'string'
}
mapDBTypeToValidator(type: string): string {
if (
type.includes('int') ||
type === 'numeric' ||
type === 'decimal' ||
type === 'real' ||
type === 'double precision'
) {
return 'number'
}
if (type === 'boolean') return 'boolean'
if (type.includes('timestamp') || type === 'date') return 'date'
return 'string'
}
mapDBTypeToFrontend(type: string): string {
if (type.includes('int') || type === 'numeric' || type === 'bigint') return 'number'
if (type === 'boolean') return 'boolean'
if (type.includes('timestamp') || type === 'date') return 'IsoDateString'
return 'string'
}
async generateRelationships(table: string) {
const lines: string[] = []
const imports: Set<string> = new Set()
const columns = await getColumnsForTable(table)
const references = await getForeignKeysReferencing(table)
for (const col of columns) {
if (col.column_name.endsWith('_id')) {
const refTable = col.column_name.replace('_id', '')
const refModel = this.capitalize(refTable)
imports.add('belongsTo')
lines.push(
` @belongsTo(() => ${refModel})\n declare ${refTable}: BelongsTo<typeof ${refModel}>`
)
}
}
for (const ref of references) {
const relModel = this.capitalize(ref.table_name)
imports.add('hasMany')
lines.push(
` @hasMany(() => ${relModel})\n declare ${ref.table_name}: HasMany<typeof ${relModel}>`
)
}
return {
imports: Array.from(imports),
code: lines,
}
}
async tables({ response }: HttpContext) {
const tables = await getDatabaseTables()
return response.ok(tables)
}
async columns({ request, response }: HttpContext) {
const { table } = request.params()
const columns = await getColumnsForTable(table)
return response.ok({
columns: columns.map((col) => ({
name: col.column_name,
type: mapDBTypeToTs(col.data_type) + (col.is_nullable === 'YES' ? ' | null' : ''),
isDateTime: ['created_at', 'updated_at'].includes(col.column_name),
})),
})
}
async generate({ request, response }: HttpContext) {
const {
name,
table,
generate,
form,
formDefinition,
dataTable,
list,
formDialog,
menu,
module,
} = request.only([
const { name, table, generate, form, datatable } = request.only([
'name',
'table',
'generate',
'form',
'formDefinition',
'dataTable',
'list',
'formDialog',
'menu',
'module',
])
const appBase = process.cwd()
const backendTplDir = join(appBase, 'app/templates/backend')
const apiTplDir = join(appBase, 'app/templates/backend/api')
const moduleName = this.capitalize(name)
const modulePath = join(appBase, 'modules', moduleName)
const snake = this.snakeCase(table)
// Backend and admin are sibling workspaces under apps/.
const clientBase = join(appBase, '..', 'admin', 'src', 'modules', moduleName)
const templateModuleDir = join(appBase, '..', 'admin', 'src', 'modules', '_template')
const paths = {
// backend
controller: join(modulePath, 'controller', `${snake}_controller.ts`),
model: join(modulePath, 'model', `${snake}.ts`),
// validatorCreate: join('app', 'validators', `${snake}_create_validator.ts`),
// validatorUpdate: join('app', 'validators', `${snake}_update_validator.ts`),
validatorCreate: join(modulePath, 'validators', `${snake}_create_validator.ts`),
validatorUpdate: join(modulePath, 'validators', `${snake}_update_validator.ts`),
routes: join(modulePath, 'routes.ts'),
policy: join(modulePath, 'policy', `${snake}_policy.ts`),
policiesMain: join(appBase, 'app', 'policies', 'main.ts'),
permissionsConfig: join(modulePath, 'config', 'permissions.ts'),
// API subtree
apiController: join(modulePath, 'api', 'controllers', `${snake}_api_controller.ts`),
// apiValidatorCreate: join('app', 'validators', `${snake}_create_api_validator.ts`),
// apiValidatorUpdate: join('app', 'validators', `${snake}_update_api_validator.ts`),
apiValidatorCreate: join(modulePath, 'api', 'validators', `${snake}_create_api_validator.ts`),
apiValidatorUpdate: join(modulePath, 'api', 'validators', `${snake}_update_api_validator.ts`),
apiRoutes: join(modulePath, 'api', 'routes.ts'),
// tests
testController: join(
appBase,
'tests',
'modules',
moduleName,
`${this.snakeCase(table)}.spec.ts`
),
testApi: join(
appBase,
'tests',
'modules',
moduleName,
`${this.snakeCase(table)}.api.spec.ts`
),
// frontend
clientModule: clientBase,
clientForm: join(clientBase, 'view', 'forms', 'MainForm', 'Component.tsx'),
clientFormDefinition: join(clientBase, 'view', 'forms', 'MainForm', 'form.ts'),
clientDatatable: join(clientBase, 'view', 'widgets', 'DataTable.tsx'),
clientList: join(clientBase, 'pages', 'ListPage', 'index.tsx'),
clientDialog: join(clientBase, 'view', 'widgets', 'ManageTrigger.tsx'),
clientConfig: join(clientBase, 'config', 'meta.ts'),
clientMenu: join(clientBase, 'config', 'menu.ts'),
clientModuleConfig: join(clientBase, 'config', 'module.ts'),
clientEntity: join(clientBase, 'entities', 'Entity.ts'),
'datatable',
]) as {
name: string
table: string
generate: ModuleAsset[]
form: ModuleLayoutConfig | null
datatable: ModuleTableConfig | null
}
if (existsSync(templateModuleDir)) {
cpSync(templateModuleDir, paths.clientModule, { recursive: true })
if (typeof name !== 'string' || !/^[a-z][a-zA-Z0-9]*$/.test(name)) {
return response.unprocessableEntity({ message: 'Invalid module name' })
}
if (typeof table !== 'string' || !/^[a-z_][a-z0-9_]*$/i.test(table)) {
return response.unprocessableEntity({ message: 'Invalid table name' })
}
if (!Array.isArray(generate)) {
return response.unprocessableEntity({ message: 'Invalid generate list' })
}
Object.values(paths).forEach((filePath) => {
mkdirSync(dirname(filePath), { recursive: true })
})
const columns = await getColumnsForTable(table)
const modelClass = this.capitalize(table)
const relationship = await this.generateRelationships(table)
const enhancedColumns = columns.map((col) => {
const colName = col.column_name
const camelName = this.camelCase(colName)
const type = this.mapDBTypeToTs(col.data_type) + (col.is_nullable === 'YES' ? ' | null' : '')
return {
name: colName,
camelCase: camelName,
type,
isPrimary: colName === 'id',
isPassword: colName === 'password',
isEmail: colName === 'email',
isDateTime: ['created_at', 'updated_at'].includes(colName),
isSpecial: ['created_at', 'updated_at', 'deleted_at'].includes(colName),
isRequired: col.is_nullable === 'NO',
isNullable: col.is_nullable === 'YES',
validatorType: this.mapDBTypeToValidator(col.data_type),
needsTrim:
this.mapDBTypeToValidator(col.data_type) === 'string' &&
colName !== 'email' &&
colName !== 'password',
}
})
const sampleFor = (col: any) => {
const t = col.validatorType
if (t === 'number') return 1
if (t === 'boolean') return false
if (t === 'date') return `'${new Date().toISOString()}'`
return `'sample_${col.camelCase}'`
}
const createFields = enhancedColumns
.filter((c) => !c.isPrimary && !c.isSpecial)
.map((c) => ({
camelCase: c.camelCase,
sampleValue: sampleFor(c),
column_name: c.name,
}))
const updateFields = enhancedColumns
.filter((c) => !c.isPrimary && !c.isDateTime)
.map((c) => ({
camelCase: c.camelCase,
sampleValue: sampleFor(c),
column_name: c.name,
}))
// minimal insert json for show/update/destroy fixtures
const insertJson = `{
${createFields.map((f) => ` ${f.column_name}: ${f.sampleValue}`).join(',\n')}
}`
const frontendFields = columns.map((col) => {
return {
name: col.column_name,
type:
this.mapDBTypeToFrontend(col.data_type) + (col.is_nullable === 'YES' ? ' | null' : ''),
}
})
// FRONTEND writer
writeFileSync(paths.clientForm, form ?? '', 'utf8')
writeFileSync(paths.clientFormDefinition, formDefinition ?? '', 'utf8')
writeFileSync(paths.clientDatatable, dataTable ?? '', 'utf8')
writeFileSync(paths.clientList, list ?? '', 'utf8')
writeFileSync(paths.clientDialog, formDialog ?? '', 'utf8')
writeFileSync(paths.clientMenu, menu ?? '', 'utf8')
writeFileSync(paths.clientModuleConfig, module ?? '', 'utf8')
const configContent = renderTemplate(join(backendTplDir, '..', 'frontend', 'config.hbs'), {
name,
})
writeFileSync(paths.clientConfig, configContent)
const entityContent = renderTemplate(join(backendTplDir, '..', 'frontend', 'entity.hbs'), {
fields: frontendFields,
})
writeFileSync(paths.clientEntity, entityContent)
// BACKEND: model
if (generate.includes('model')) {
const content = renderTemplate(join(backendTplDir, 'model.hbs'), {
name,
pascalCaseName: this.pascalCase(name),
capName: this.capitalize(name),
className: modelClass,
imports: relationship.imports.join(', '),
columns: enhancedColumns,
relationships: relationship.code,
hasPassword: columns.some((col) => col.column_name === 'password'),
})
await writeFileSync(paths.model, content)
}
// BACKEND: controller
if (generate.includes('controller')) {
const content = renderTemplate(join(backendTplDir, 'controller.hbs'), {
pascalCaseName: this.pascalCase(name),
modelName: modelClass,
capName: this.capitalize(name),
moduleName,
tableName: table,
snakeName: snake,
name,
})
writeFileSync(paths.controller, content)
const testsTplDir = join(backendTplDir, 'tests')
// controller tests
const ctrlSpec = renderTemplate(join(testsTplDir, 'controller.spec.hbs'), {
pascalCaseName: this.pascalCase(name),
snakeCaseName: this.snakeCase(table),
tableName: table,
createFields,
updateFields,
insertJson,
})
mkdirSync(dirname(paths.testController), { recursive: true })
writeFileSync(paths.testController, ctrlSpec, 'utf8')
}
// BACKEND: routes
if (generate.includes('routes')) {
const content = renderTemplate(join(backendTplDir, 'routes.hbs'), {
name,
capName: this.capitalize(name),
className: moduleName,
routeName: name,
snakeCaseName: snake,
})
writeFileSync(paths.routes, content)
}
// BACKEND: validators
if (generate.includes('validator')) {
const createContent = renderTemplate(join(backendTplDir, 'validator.hbs'), {
className: moduleName,
functionName: `create${moduleName}Validator`,
fields: enhancedColumns.filter((col) => !col.isPrimary && !col.isSpecial),
})
writeFileSync(paths.validatorCreate, createContent)
const updateContent = renderTemplate(join(backendTplDir, 'validator.hbs'), {
className: moduleName,
functionName: `update${moduleName}Validator`,
fields: enhancedColumns.filter((col) => !col.isPrimary && !col.isDateTime),
})
writeFileSync(paths.validatorUpdate, updateContent)
}
// BACKEND: policy + permissions
if (generate.includes('policy')) {
const policyTpl = join(backendTplDir, 'policy.hbs')
const base = name.toLowerCase()
const slugBase = base.endsWith('s') ? base : `${base}s`
const policyContent = renderTemplate(policyTpl, {
name,
slugBase,
moduleName,
modelName: modelClass,
pascalCaseName: this.pascalCase(name),
})
writeFileSync(paths.policy, policyContent)
if (!existsSync(paths.policiesMain)) {
mkdirSync(dirname(paths.policiesMain), { recursive: true })
writeFileSync(paths.policiesMain, `export const policies = {}\n`)
}
const capName = this.capitalize(name)
const key = `${modelClass}Policy`
const importLine = ` ${key}: () => import('#modules/${capName}/policy/${name}_policy'),`
const current = readFileSync(paths.policiesMain, 'utf8')
if (current.includes('export const policies =')) {
if (!current.includes(importLine)) {
const updated = current.replace(
/export const policies\s*=\s*\{\s*/m,
(m) => m + '\n' + importLine + '\n'
)
writeFileSync(paths.policiesMain, updated)
}
} else {
writeFileSync(paths.policiesMain, `export const policies = {\n${importLine}\n}\n`)
}
const labelBase = this.capitalize(name.endsWith('s') ? name.slice(0, -1) : name)
const perms = [
{ action: 'view', slug: `${name}s.view`, name: `${labelBase} view` },
{ action: 'create', slug: `${name}s.create`, name: `${labelBase} create` },
{ action: 'update', slug: `${name}s.update`, name: `${labelBase} update` },
{ action: 'delete', slug: `${name}s.delete`, name: `${labelBase} delete` },
]
const fileContents =
'export default [\n' +
perms.map((p) => ` {\n slug: '${p.slug}',\n name: '${p.name}',\n },`).join('\n') +
'\n]\n'
if (!existsSync(paths.permissionsConfig)) {
mkdirSync(dirname(paths.permissionsConfig), { recursive: true })
writeFileSync(paths.permissionsConfig, fileContents, 'utf8')
}
}
// API subtree
if (generate.includes('api')) {
const { default: genModel } = await import(paths.model)
const test = await modelToSchema(genModel)
console.warn(test)
const apiCtx = {
pascalCaseName: this.pascalCase(name),
modelName: modelClass,
capName: this.capitalize(name),
moduleName,
tableName: table,
snakeCaseName: snake,
name,
snake,
swaggerModel: await modelToSchema(genModel),
}
// Controller
const apiControllerTpl = join(apiTplDir, 'controller.hbs')
const apiControllerContent = renderTemplate(apiControllerTpl, apiCtx)
writeFileSync(paths.apiController, apiControllerContent, 'utf8')
// test
const testsTplDir = join(backendTplDir, 'tests')
const apiSpec = renderTemplate(join(testsTplDir, 'api.spec.hbs'), {
snakeCaseName: this.snakeCase(table),
createFields,
})
writeFileSync(paths.testApi, apiSpec, 'utf8')
// Validators
const apiValidatorTpl = join(apiTplDir, 'validator.hbs')
const apiCreateValidatorContent = renderTemplate(apiValidatorTpl, {
...apiCtx,
className: moduleName,
functionName: `create${moduleName}ApiValidator`,
fields: enhancedColumns.filter((c) => !c.isPrimary && !c.isSpecial),
})
writeFileSync(paths.apiValidatorCreate, apiCreateValidatorContent, 'utf8')
const apiUpdateValidatorContent = renderTemplate(apiValidatorTpl, {
...apiCtx,
className: moduleName,
functionName: `update${moduleName}ApiValidator`,
fields: enhancedColumns.filter((c) => !c.isPrimary && !c.isDateTime),
})
writeFileSync(paths.apiValidatorUpdate, apiUpdateValidatorContent, 'utf8')
// Routes
const apiRoutesTpl = join(apiTplDir, 'routes.hbs')
const apiRoutesContent = renderTemplate(apiRoutesTpl, apiCtx)
writeFileSync(paths.apiRoutes, apiRoutesContent, 'utf8')
}
const moduleName = await generateModule({ name, table, generate, form, datatable })
return response.ok({ message: `Module "${moduleName}" created based on table "${table}"` })
}
public async columns({ request, response }: HttpContext) {
const { table } = request.params()
const columns = await getColumnsForTable(table)
const enhancedColumns = columns.map((col) => {
const colName = col.column_name
const type = this.mapDBTypeToTs(col.data_type) + (col.is_nullable === 'YES' ? ' | null' : '')
return {
name: colName,
type,
isDateTime: ['created_at', 'updated_at'].includes(colName),
}
})
return response.ok({ columns: enhancedColumns })
}
}
@@ -0,0 +1,8 @@
import { Exception } from '@adonisjs/core/exceptions'
import type { HttpContext } from '@adonisjs/core/http'
export default class ApiException extends Exception {
async handle(error: this, ctx: HttpContext) {
ctx.response.status(error.status).send({ message: error.message, code: error.code })
}
}
@@ -1,6 +1,6 @@
import { Exception } from '@adonisjs/core/exceptions'
import ApiException from '#exceptions/api_exception'
export class FilenameMissingExtensionException extends Exception {
export class FilenameMissingExtensionException extends ApiException {
static status = 400
static code = 'E_FILE_NAME_MISSING_EXTENSION'
@@ -9,7 +9,7 @@ export class FilenameMissingExtensionException extends Exception {
}
}
export class EntryNameNotChangedException extends Exception {
export class EntryNameNotChangedException extends ApiException {
static status = 400
static code = 'E_ENTRY_NAME_NOT_CHANGED'
@@ -18,7 +18,7 @@ export class EntryNameNotChangedException extends Exception {
}
}
export class EntryExistsException extends Exception {
export class EntryExistsException extends ApiException {
static status = 400
static code = 'E_ENTRY_ALREADY_EXISTS'
@@ -27,7 +27,7 @@ export class EntryExistsException extends Exception {
}
}
export class DirExistsException extends Exception {
export class DirExistsException extends ApiException {
static status = 400
static code = 'E_DIRECTORY_ALREADY_EXISTS'
@@ -36,7 +36,7 @@ export class DirExistsException extends Exception {
}
}
export class FileNotFoundException extends Exception {
export class FileNotFoundException extends ApiException {
static status = 404
static code = 'E_FILE_NOT_FOUND'
@@ -45,7 +45,7 @@ export class FileNotFoundException extends Exception {
}
}
export class DirNotFoundException extends Exception {
export class DirNotFoundException extends ApiException {
static status = 404
static code = 'E_DIR_NOT_FOUND'
-38
View File
@@ -1,6 +1,5 @@
import app from '@adonisjs/core/services/app'
import { HttpContext, ExceptionHandler } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions'
export default class HttpExceptionHandler extends ExceptionHandler {
/**
@@ -14,43 +13,6 @@ export default class HttpExceptionHandler extends ExceptionHandler {
* response to the client
*/
async handle(error: unknown, ctx: HttpContext) {
if (error instanceof Exception && error.code === 'E_FILE_NAME_MISSING_EXTENSION') {
return ctx.response.status(error.status).send({
message: error.message,
code: error.code,
})
}
if (error instanceof Exception && error.code === 'E_ENTRY_NAME_NOT_CHANGED') {
return ctx.response.status(error.status).send({
message: error.message,
code: error.code,
})
}
if (error instanceof Exception && error.code === 'E_ENTRY_ALREADY_EXISTS') {
return ctx.response.status(error.status).send({
message: error.message,
code: error.code,
})
}
if (error instanceof Exception && error.code === 'E_DIRECTORY_ALREADY_EXISTS') {
return ctx.response.status(error.status).send({
message: error.message,
code: error.code,
})
}
if (error instanceof Exception && error.code === 'E_FILE_NOT_FOUND') {
return ctx.response.status(error.status).send({
message: error.message,
code: error.code,
})
}
if (error instanceof Exception && error.code === 'E_DIR_NOT_FOUND') {
return ctx.response.status(error.status).send({
message: error.message,
code: error.code,
})
}
return super.handle(error, ctx)
}
@@ -0,0 +1,55 @@
import ApiException from '#exceptions/api_exception'
export class PageNotFoundException extends ApiException {
static status = 404
static code = 'E_PAGE_NOT_FOUND'
constructor(resource: string = 'Page') {
super(`${resource} not found`)
}
}
export class PageRouteConflictException extends ApiException {
static status = 409
static code = 'E_PAGE_ROUTE_CONFLICT'
constructor(locale: string, path: string) {
super(`A page already uses /${locale}/${path}`)
}
}
export class PageNotPublishedException extends ApiException {
static status = 409
static code = 'E_PAGE_NOT_PUBLISHED'
constructor() {
super('Only a published page can be unpublished')
}
}
export class InvalidPagePayloadException extends ApiException {
static status = 422
static code = 'E_INVALID_PAGE_PAYLOAD'
constructor(message: string = 'Invalid page payload') {
super(message)
}
}
export class PageMediaFileMissingException extends ApiException {
static status = 422
static code = 'E_PAGE_MEDIA_FILE_MISSING'
constructor(fileKey: string) {
super(`Missing uploaded file for key "${fileKey}"`)
}
}
export class PageMediaFileInvalidException extends ApiException {
static status = 422
static code = 'E_PAGE_MEDIA_FILE_INVALID'
constructor(fileKey: string, message?: string) {
super(message || `Invalid uploaded file for key "${fileKey}"`)
}
}
@@ -26,6 +26,9 @@ export async function getDatabaseTables(): Promise<string[]> {
'pg_index',
'pg_namespace',
'pg_tables',
'web_components',
'web_pages',
'web_settings',
])
.whereRaw(`table_name NOT LIKE 'pg_%'`)
.whereRaw(`table_name NOT LIKE 'sql_%'`)
@@ -0,0 +1,389 @@
import {
indentSource,
source,
SourceFile,
} from '#services/module_generator/builders/shared/source_file'
import {
ModuleAsset,
ModuleGeneratorConfig,
ModuleTableColumn,
ModuleTableConfig,
} from '#services/module_generator/types'
class DataTableSourceBuilder {
build(data: ModuleGeneratorConfig) {
const file = new SourceFile()
this.addImports(file, data)
file.addSection(this.buildColumnFilterPropsInterface())
file.addSection(this.buildDataTableComponent(data))
file.addSection(this.buildToolbarComponent(data))
file.addSection(this.buildColumnInputFilterComponent())
return file.toString()
}
private addImports(file: SourceFile, data: ModuleGeneratorConfig) {
const shouldAddEditForm = this.shouldAddEditForm(data.generate)
const icons = ['LoaderCircleIcon', 'SearchIcon', 'Settings2']
const metaImports = ['MODULE_PERMISSION_DELETE']
if (shouldAddEditForm) {
icons.push('SquarePen')
metaImports.unshift('MODULE_NAME_LABEL', 'MODULE_PERMISSION_CREATE')
metaImports.push('MODULE_PERMISSION_UPDATE')
}
file.addImports([
"import React from 'react';",
`
import {
Column,
ColumnDef,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
PaginationState,
SortingState,
useReactTable,
} from '@tanstack/react-table';
`,
`import { ${icons.join(', ')} } from 'lucide-react';`,
"import { useIntl } from 'react-intl';",
"import { Badge } from '#shared/ui/Badge';",
"import { CopyButton } from '#shared/ui/CopyButton';",
"import { Button } from '#shared/ui/Button';",
`
import {
Card,
CardFooter,
CardHeader,
CardTable,
CardTitle,
CardToolbar,
} from '#shared/ui/Card';
`,
"import { DataGrid, useDataGrid, DataGridColumnHeader, DataGridColumnVisibility, DataGridPagination, DataGridTable } from '#shared/widgets/DataGrid';",
"import { Input, InputWrapper } from '#shared/ui/Input';",
"import { ScrollArea, ScrollBar } from '#shared/ui/ScrollArea';",
"import { Skeleton } from '#shared/ui/Skeleton';",
"import { mapTableStateToListQueryParams } from '#infrastructure/reactQuery';",
"import { useDebounceSearch } from '@crux-kit/hooks';",
"import { PermissionGate } from '#core/Permission';",
"import { useGetList } from '../../api/list';",
`import { ${metaImports.join(', ')} } from '../../config/meta';`,
"import { Entity } from '../../entities/Entity';",
"import { DeleteTrigger } from './DeleteTrigger';",
])
if (shouldAddEditForm) {
file.addImport("import { ManageTrigger } from './ManageTrigger';")
}
}
private buildColumnFilterPropsInterface() {
return `
interface IColumnFilterProps<TData, TValue> {
column: Column<TData, TValue>
};
`
}
private buildDataTableComponent(data: ModuleGeneratorConfig) {
return `
export const DataTable = () => {
const intl = useIntl();
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [search, setSearch] = React.useState<string>('');
const { handleChange } = useDebounceSearch({
minQueryNumber: 3,
delay: 400,
initialQuery: search,
onTimeOut: setSearch,
});
const [sorting, setSorting] = React.useState<SortingState>([]);
const { list, total, isFetching, isLoading, pagesCount } = useGetList(
mapTableStateToListQueryParams(pagination, sorting, search)
);
const columns = React.useMemo<ColumnDef<Entity>[]>(
() => [
${indentSource(this.createColumnsSource(data), 12)}
],
[intl]
);
const table = useReactTable({
columns,
autoResetPageIndex: false,
enableMultiSort: false,
data: list,
manualPagination: true,
pageCount: pagesCount,
getRowId: (row) => String(row.id),
state: {
pagination,
sorting,
},
onPaginationChange: setPagination,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
});
return (
<DataGrid
table={table}
recordCount={total || 0}
emptyMessage={intl.formatMessage({ id: 'TABLE.COMMON.NO_DATA_AVAILABLE' })}
loadingMode="skeleton"
isLoading={isLoading}
tableLayout={{
columnsDraggable: false,
columnsResizable: false,
width: 'auto',
columnsPinnable: true,
columnsMovable: true,
columnsVisibility: true,
cellBorder: true,
}}
>
<Card>
<CardHeader>
<CardTitle>
<InputWrapper>
{search.length > 0 && isFetching ? (
<LoaderCircleIcon className="size-4 animate-spin" />
) : (
<SearchIcon className="size-4" />
)}
<Input
type="text"
placeholder={intl.formatMessage({ id: 'ACTION.SEARCH' })}
onChange={(e) => handleChange(e.target.value)}
/>
</InputWrapper>
</CardTitle>
<Toolbar />
</CardHeader>
<CardTable>
<ScrollArea>
<DataGridTable />
<ScrollBar orientation="horizontal" />
</ScrollArea>
</CardTable>
<CardFooter>
<DataGridPagination />
</CardFooter>
</Card>
</DataGrid>
)
};
`
}
private buildToolbarComponent(data: ModuleGeneratorConfig) {
return `
const Toolbar = () => {
const { table } = useDataGrid();
const intl = useIntl();
return (
<CardToolbar>
<div className="flex flex-wrap items-center gap-2.5">
${this.shouldAddEditForm(data.generate) ? indentSource(this.buildCreateButton(), 14) : ''}
<DataGridColumnVisibility
table={table}
trigger={
<Button variant="outline">
<Settings2 />
{intl.formatMessage({ id: 'TABLE.COMMON.COLUMNS' })}
</Button>
}
/>
</div>
</CardToolbar>
);
};
`
}
private buildColumnInputFilterComponent() {
return `
const ColumnInputFilter = <TData, TValue>({ column }: IColumnFilterProps<TData, TValue>) => {
const intl = useIntl();
return (
<Input
placeholder={intl.formatMessage({ id: 'FILTER' })}
value={(column.getFilterValue() as string) ?? ''}
onChange={(event) => column.setFilterValue(event.target.value)}
className="h-9 w-full max-w-40"
/>
);
};
`
}
private buildCreateButton() {
return `
<PermissionGate requiredPermissions={MODULE_PERMISSION_CREATE}>
<ManageTrigger
id={null}
title={intl.formatMessage(
{ id: 'MODULE.COMMON.CREATE_NEW_ENTITY' },
{ entity: MODULE_NAME_LABEL }
)}
>
<Button>{intl.formatMessage({ id: 'ACTION.CREATE' })}</Button>
</ManageTrigger>
</PermissionGate>
`
}
private buildEditButton() {
return `
<PermissionGate requiredPermissions={MODULE_PERMISSION_UPDATE}>
<ManageTrigger title={\`Edit \${MODULE_NAME_LABEL\}\`} id={id}>
<Button variant="secondary" size="sm" shape="default" mode="icon">
<SquarePen />
</Button>
</ManageTrigger>
</PermissionGate>
`
}
private shouldAddEditForm(assets: ModuleAsset[]): boolean {
return assets.includes(ModuleAsset.FORM)
}
private createColumnsSource(ctx: ModuleGeneratorConfig) {
const data: ModuleTableConfig = ctx.datatable ?? { columns: [] }
const columns: string[] = []
data.columns
.filter((column) => column.hidden === false)
.forEach((column) => {
const name = column.value
if (column.isActionColumn) {
columns.push(this.createActionColumn(column, ctx))
return
}
if (name === 'id' || name === 'uuid') {
columns.push(this.createIdColumn(column))
return
}
columns.push(this.createBaseColumn(column))
})
return columns.map(source).join(',\n')
}
private createBaseColumn(data: ModuleTableColumn) {
const enableSorting = data.innerProps.sort ? 'true' : 'false'
return `
{
id: '${data.value}',
accessorFn: (row) => row.${data.value},
header: ({ column }) => (
<DataGridColumnHeader
title="${data.label}"
filter={<ColumnInputFilter column={column} />}
column={column}
/>
),
cell: ${source(this.createCellContent(data))},
enableSorting: ${enableSorting},
meta: {
cellClassName: '',
skeleton: <Skeleton className="h-4" />,
},
}
`
}
private createIdColumn(data: ModuleTableColumn) {
return `
{
id: '${data.value}',
accessorFn: (row) => row.id,
header: ({ column }) => <DataGridColumnHeader title="${data.label}" column={column} />,
cell: ${source(this.createCellContent(data))},
enableSorting: true,
size: 80,
meta: {
cellClassName: '',
skeleton: <Skeleton className="h-4" />,
},
}
`
}
private createActionColumn(data: ModuleTableColumn, ctx: ModuleGeneratorConfig) {
const editButtonSource = this.shouldAddEditForm(ctx.generate) ? this.buildEditButton() : ''
return `
{
id: '${data.value}',
header: '${data.label}',
cell: (info) => {
const data = info.row.original;
const id = data.id;
return (
<div className="flex items-center gap-2 justify-end">
${editButtonSource ? indentSource(editButtonSource, 14) : ''}
<PermissionGate requiredPermissions={MODULE_PERMISSION_DELETE}>
<DeleteTrigger id={id} deleteTarget={id} />
</PermissionGate>
</div>
);
},
enableSorting: false,
size: 96,
meta: {
headerClassName: '',
skeleton: <Skeleton className="h-4" />,
},
}
`
}
private createCellContent(data: ModuleTableColumn) {
if (data.innerProps.copy) {
return `
(info) => {
const val = String(info.getValue());
return (
<div className="flex items-center justify-between">
<code className="line-clamp-1">{val}</code>
<CopyButton copyText={val} />
</div>
);
}
`
}
if (data.innerProps.badge) {
return `(info) => <Badge variant="primary" size="sm">{String(info.getValue())}</Badge>`
}
return '(info) => info.getValue()'
}
}
export default new DataTableSourceBuilder()
@@ -0,0 +1,670 @@
import {
toComboboxGroups,
toFlatOptions,
toMultiSelectOptions,
} from '#services/module_generator/builders/shared/field_option_groups'
import {
indentSource,
source,
SourceFile,
} from '#services/module_generator/builders/shared/source_file'
import {
ModuleFieldType,
ModuleGridCell,
ModuleLayoutConfig,
} from '#services/module_generator/types'
// Every component from '#shared/forms/composed' the builder is able to emit. Only the ones
// actually present in the generated source are imported, so the module passes `noUnusedLocals`.
const COMPOSED_COMPONENTS = [
'FormElement',
'FormGrid',
'GridElement',
'InputCalendar',
'InputCheckboxGroup',
'InputColorPicker',
'InputCombobox',
'InputCreatableCombobox',
'InputDate',
'InputDatePicker',
'InputDatePickerRange',
'InputDropzone',
'InputFile',
'InputMultiSelect',
'InputNumberCounter',
'InputPhone',
'InputRadioGroup',
'InputSelect',
'InputSwitch',
'InputText',
'InputTextarea',
'InputTime',
'InputTiptapEditor',
]
class FormSourceBuilder {
buildComponent(data: ModuleLayoutConfig) {
const file = new SourceFile()
const section = this.buildMainFormComponent(data)
file.addImports([
"import React from 'react';",
"import { useForm } from 'react-hook-form';",
"import { zodResolver } from '@hookform/resolvers/zod';",
"import { Form as FormProvider } from '#shared/ui/form';",
`import { ${this.collectComposedComponents(section).join(', ')} } from '#shared/forms/composed';`,
"import { FORM_ID } from './constants';",
"import { FormValues, SubmitFunc, validationSchema } from './form';",
])
file.addSection(section)
return file.toString()
}
private collectComposedComponents(section: string): string[] {
return COMPOSED_COMPONENTS.filter((name) => new RegExp(`<${name}\\b`).test(section))
}
buildDefinition(data: ModuleLayoutConfig) {
const file = new SourceFile()
file.addImports([
"import { UseFormReturn } from 'react-hook-form';",
"import z from 'zod';",
"import { Entity } from '../../../entities/Entity';",
])
if (data.grid.find((item) => item.body?.type === ModuleFieldType.PHONE)) {
file.addImport("import { e164ToMask } from '#shared/lib/phoneNumber';")
}
file.addSection(this.buildFormDefinition(data))
return file.toString()
}
private buildMainFormComponent(data: ModuleLayoutConfig) {
return `
export const MainForm: React.FC<{
initialValues: FormValues;
onSubmit: SubmitFunc;
}> = ({ initialValues, onSubmit }) => {
const form = useForm<FormValues>({
values: initialValues,
resolver: zodResolver(validationSchema),
});
return (
<FormProvider {...form}>
<FormElement
id={FORM_ID}
onSubmit={form.handleSubmit((values) => onSubmit(values, form))}
>
<FormGrid columns={${data.cols}}>
${indentSource(this.createFieldsSource(data), 16)}
</FormGrid>
</FormElement>
</FormProvider>
)
}
`
}
private buildFormDefinition(data: ModuleLayoutConfig) {
return `
export const validationSchema = ${source(this.createValidationSchema(data))};
export type FormValues = z.infer<typeof validationSchema>;
export type SubmitFunc = (values: FormValues, handlers: UseFormReturn<FormValues>) => void;
export function prepareInitialValues(data: Entity): FormValues {
if (data == null) return initialValues;
return ${this.createPreparedValues(data)}
}
export const initialValues: FormValues = ${this.createInitialValues(data)};
`
}
private createFieldsSource(formConfig: ModuleLayoutConfig): string {
return formConfig.grid
.map((cell) => this.mapGridCellToComponent(cell))
.filter((str): str is string => str !== null)
.map(source)
.join('\n')
}
private createInitialValues(formConfig: ModuleLayoutConfig): string {
const values = formConfig.grid
.filter((item) => item.isEmpty === false)
.map((item) => ({
name: item.body?.dbColumn || '',
type: item.body?.type || ModuleFieldType.TEXT,
}))
.map((item) => this.mapTypeToInitialValue(item).created)
.filter((str) => str.trim().length !== 0)
.join(',')
return `{ id: null, ${values} }`
}
private createValidationSchema(formConfig: ModuleLayoutConfig): string {
const schemaStr = formConfig.grid
.filter((item) => item.isEmpty === false)
.map((item) => ({
name: item.body?.dbColumn || '',
type: item.body?.type || ModuleFieldType.TEXT,
required: item.body?.required ?? false,
}))
.map(this.mapTypeToZod)
.filter((str) => str.trim().length !== 0)
.join(',')
return `
z.object({
id: z.number().nullable(),
${schemaStr}
})
`
}
private createPreparedValues(formConfig: ModuleLayoutConfig): string {
const values = formConfig.grid
.filter((item) => item.isEmpty === false)
.map((item) => ({
name: item.body?.dbColumn || '',
type: item.body?.type || ModuleFieldType.TEXT,
}))
.map((item) => this.mapTypeToInitialValue(item).prepared)
.filter((str) => str.trim().length !== 0)
.join(',')
return `{id: data?.id ?? null, ${values}}`
}
private mapTypeToInitialValue({ name, type }: { name: string; type: ModuleFieldType }): {
created: string
prepared: string
} {
if (name === 'id') {
return {
created: `id: null`,
prepared: `id: data?.id ?? null`,
}
}
switch (type) {
case ModuleFieldType.CHECKBOX:
case ModuleFieldType.RADIO:
case ModuleFieldType.TEXT:
case ModuleFieldType.EMAIL:
case ModuleFieldType.NUMBER:
case ModuleFieldType.PASSWORD:
case ModuleFieldType.TEXTAREA:
case ModuleFieldType.SELECT:
case ModuleFieldType.COMBOBOX:
case ModuleFieldType.CREATABLE_COMBOBOX:
case ModuleFieldType.EDITOR:
case ModuleFieldType.COLOR_PICKER:
case ModuleFieldType.NUMBER_COUNTER: {
return {
created: `${name}: ''`,
prepared: `${name}: data?.${name}?.toString() ?? ''`,
}
}
case ModuleFieldType.PHONE: {
return {
created: `${name}: ''`,
prepared: `${name}: data?.${name} ? e164ToMask(data?.${name}) : ''`,
}
}
case ModuleFieldType.MULTISELECT: {
return {
created: `${name}: []`,
prepared: `${name}: data?.${name} ?? []`,
}
}
case ModuleFieldType.FILE: {
return {
created: `${name}: null`,
prepared: `${name}: data?.${name} ?? null`,
}
}
case ModuleFieldType.DROPZONE: {
return {
created: `${name}: null`,
prepared: `${name}: data?.${name} ?? null`,
}
}
case ModuleFieldType.SWITCH: {
return {
created: `${name}: false`,
prepared: `${name}: data?.${name} ?? false`,
}
}
case ModuleFieldType.TIME_INPUT_ISO:
case ModuleFieldType.TIME_INPUT_HMS: {
return {
created: `${name}: ''`,
prepared: `${name}: data?.${name} ? data?.${name} : ''`,
}
}
case ModuleFieldType.DATE_INPUT: {
return {
created: `${name}: ''`,
prepared: `${name}: data?.${name} ? data?.${name} : ''`,
}
}
case ModuleFieldType.DATE_PICKER_RANGE: {
return {
created: `${name}: null`,
prepared: `${name}: null`,
}
}
case ModuleFieldType.DATE_PICKER_SINGLE:
case ModuleFieldType.CALENDAR: {
return {
created: `${name}: null`,
prepared: `${name}: data?.${name} ? new Date(data?.${name}) : null`,
}
}
default: {
return {
created: '',
prepared: '',
}
}
}
}
private mapTypeToZod({
name,
required,
type,
}: {
name: string
type: ModuleFieldType
required: boolean
}): string {
const withOptional = (schema: string) => {
return required ? schema : `${schema}.optional().nullable()`
}
const withOptionalString = (schema: string) => {
return required ? `${schema}.min(1)` : `${schema}.optional().nullable()`
}
if (name === 'id') {
return `id: z.number().nullable()`
}
if (name === 'uuid') {
return `uuid: z.coerce.string().nullable()`
}
switch (type) {
case ModuleFieldType.TEXT:
case ModuleFieldType.TEXTAREA: {
return `${name}: ${withOptionalString('z.coerce.string()')}`
}
case ModuleFieldType.CHECKBOX:
case ModuleFieldType.RADIO:
case ModuleFieldType.NUMBER:
case ModuleFieldType.PASSWORD:
case ModuleFieldType.SELECT:
case ModuleFieldType.COMBOBOX:
case ModuleFieldType.CREATABLE_COMBOBOX:
case ModuleFieldType.PHONE:
case ModuleFieldType.EDITOR:
case ModuleFieldType.COLOR_PICKER:
case ModuleFieldType.NUMBER_COUNTER: {
return `${name}: ${withOptional('z.coerce.string()')}`
}
case ModuleFieldType.EMAIL: {
const schema = required
? 'z.coerce.string().email()'
: 'z.coerce.string().email().or(z.literal(""))'
return `${name}: ${schema}`
}
case ModuleFieldType.MULTISELECT: {
const schema = required ? 'z.array(z.number()).min(1)' : 'z.array(z.number())'
return `${name}: ${schema}`
}
case ModuleFieldType.FILE: {
return `${name}: ${withOptional('z.union([z.null(), z.string(), z.instanceof(File)])')}`
}
case ModuleFieldType.DROPZONE: {
return `${name}: ${withOptional('z.union([z.null(), z.string(), z.instanceof(File)])')}`
}
case ModuleFieldType.SWITCH: {
return `${name}: z.boolean()`
}
case ModuleFieldType.DATE_PICKER_RANGE: {
const schema = withOptional(`
z.object({
from: z.date().optional().nullable(),
to: z.date().optional().nullable(),
})
`)
return `${name}: ${schema}`
}
case ModuleFieldType.DATE_PICKER_SINGLE:
case ModuleFieldType.CALENDAR: {
return `${name}: ${withOptional('z.date()')}`
}
case ModuleFieldType.DATE_INPUT: {
const schema = withOptional('z.string().datetime()')
return `${name}: ${schema}`
}
case ModuleFieldType.TIME_INPUT_ISO: {
return `${name}: ${withOptional('z.string().datetime()')}`
}
case ModuleFieldType.TIME_INPUT_HMS: {
return `${name}: ${withOptional('z.string().time()')}`
}
default:
return ``
}
}
private mapGridCellToComponent(data: ModuleGridCell) {
const colSpan = data.colSpan
if (!data.body || colSpan === 0) {
return null
}
const { label, options = [], required, type, placeholder, dbColumn: name } = data.body
switch (type) {
case ModuleFieldType.CHECKBOX:
return `
<GridElement colSpan={${colSpan}}>
<InputCheckboxGroup
name="${name}"
label="${label}"
options={${JSON.stringify(toFlatOptions(options))}}
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.RADIO:
return `
<GridElement colSpan={${colSpan}}>
<InputRadioGroup
name="${name}"
label="${label}"
options={${JSON.stringify(toFlatOptions(options))}}
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.TEXT:
return `
<GridElement colSpan={${colSpan}}>
<InputText
name="${name}"
type="text"
label="${label}"
placeholder="${placeholder}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.PHONE:
return `
<GridElement colSpan={${colSpan}}>
<InputPhone
name="${name}"
label="${label}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.EMAIL:
return `
<GridElement colSpan={${colSpan}}>
<InputText
name="${name}"
type="email"
label="${label}"
placeholder="${placeholder}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.NUMBER:
return `
<GridElement colSpan={${colSpan}}>
<InputText
name="${name}"
type="number"
label="${label}"
placeholder="${placeholder}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.PASSWORD:
return `
<GridElement colSpan={${colSpan}}>
<InputText
name="${name}"
type="password"
label="${label}"
placeholder="${placeholder}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.TEXTAREA:
return `
<GridElement colSpan={${colSpan}}>
<InputTextarea
name="${name}"
label="${label}"
placeholder="${placeholder}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.FILE:
return `
<GridElement colSpan={${colSpan}}>
<InputFile
name="${name}"
label="${label}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.DROPZONE:
return `
<GridElement colSpan={${colSpan}}>
<InputDropzone
name="${name}"
label="${label}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.SELECT:
return `
<GridElement colSpan={${colSpan}}>
<InputSelect
name="${name}"
label="${label}"
placeholder="${placeholder}"
options={${JSON.stringify(toFlatOptions(options))}}
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.MULTISELECT:
return `
<GridElement colSpan={${colSpan}}>
<InputMultiSelect
name="${name}"
label="${label}"
placeholder="${placeholder}"
options={${JSON.stringify(toMultiSelectOptions(options))}}
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.COMBOBOX:
return `
<GridElement colSpan={${colSpan}}>
<InputCombobox
name="${name}"
label="${label}"
placeholder="${placeholder}"
groups={${JSON.stringify(toComboboxGroups(options))}}
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.CREATABLE_COMBOBOX:
return `
<GridElement colSpan={${colSpan}}>
<InputCreatableCombobox
name="${name}"
label="${label}"
placeholder="${placeholder}"
options={${JSON.stringify(toFlatOptions(options))}}
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.SWITCH:
return `
<GridElement colSpan={${colSpan}}>
<InputSwitch
name="${name}"
label="${label}"
required={${required}}
wrapperClassName="flex-col items-start"
/>
</GridElement>
`
case ModuleFieldType.DATE_PICKER_RANGE:
return `
<GridElement colSpan={${colSpan}}>
<InputDatePickerRange
name="${name}"
label="${label}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.DATE_PICKER_SINGLE:
return `
<GridElement colSpan={${colSpan}}>
<InputDatePicker
name="${name}"
label="${label}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.CALENDAR:
return `
<GridElement colSpan={${colSpan}}>
<InputCalendar
name="${name}"
label="${label}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.DATE_INPUT:
return `
<GridElement colSpan={${colSpan}}>
<InputDate
name="${name}"
label="${label}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.TIME_INPUT_ISO:
return `
<GridElement colSpan={${colSpan}}>
<InputTime
name="${name}"
format="ISO"
label="${label}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.TIME_INPUT_HMS:
return `
<GridElement colSpan={${colSpan}}>
<InputTime
name="${name}"
format="HMS"
label="${label}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.EDITOR:
return `
<GridElement colSpan={${colSpan}}>
<InputTiptapEditor
name="${name}"
label="${label}"
placeholder="${placeholder}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.COLOR_PICKER:
return `
<GridElement colSpan={${colSpan}}>
<InputColorPicker
name="${name}"
label="${label}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.NUMBER_COUNTER:
return `
<GridElement colSpan={${colSpan}}>
<InputNumberCounter
name="${name}"
label="${label}"
placeholder="${placeholder}"
required={${required}}
/>
</GridElement>
`
default:
return `<GridElement colSpan={${colSpan}} />`
}
}
}
export default new FormSourceBuilder()
@@ -0,0 +1,42 @@
import { SourceFile } from '#services/module_generator/builders/shared/source_file'
class ListPageSourceBuilder {
build(params: { addTable: boolean }) {
const file = new SourceFile()
file.addImports([
"import React from 'react';",
"import { Container } from '#shared/widgets/common/Container';",
"import { PermissionPageGate } from '#core/Permission';",
"import { MODULE_PERMISSION_VIEW } from '../../config/meta';",
])
if (params.addTable) {
file.addImport("import { DataTable } from '../../view/widgets/DataTable';")
}
file.addSection(this.buildComponent(params.addTable ? '<DataTable />' : ''))
return file.toString()
}
private buildComponent(tableSource: string) {
return `
const ListPage: React.FC = () => {
return (
<React.Fragment>
<PermissionPageGate requiredPermissions={[MODULE_PERMISSION_VIEW]}>
<Container>
${tableSource}
</Container>
</PermissionPageGate>
</React.Fragment>
);
};
export default ListPage;
`
}
}
export default new ListPageSourceBuilder()
@@ -0,0 +1,221 @@
import { SourceFile } from '#services/module_generator/builders/shared/source_file'
import { ModuleFormContainer } from '#services/module_generator/types'
type DialogSize = 'default' | 'md' | 'fullscreen'
type SheetSide = 'top' | 'right' | 'bottom' | 'left'
interface ManageTriggerContainerConfig {
body: string
close: string
content: string
contentProps: string
description: string
disclosureName: string
footer: string
header: string
root: string
title: string
trigger: string
}
class ManageTriggerSourceBuilder {
build(container: ModuleFormContainer) {
if (this.isSheet(container)) {
return this.buildSheet(this.getSheetSide(container))
}
return this.buildDialog(this.getDialogSize(container))
}
private isSheet(
container: ModuleFormContainer
): container is Extract<ModuleFormContainer, `sheet-${string}`> {
return container.startsWith('sheet-')
}
private getSheetSide(container: Extract<ModuleFormContainer, `sheet-${string}`>): SheetSide {
switch (container) {
case 'sheet-top':
return 'top'
case 'sheet-right':
return 'right'
case 'sheet-bottom':
return 'bottom'
case 'sheet-left':
return 'left'
}
}
private getDialogSize(container: Extract<ModuleFormContainer, `dialog-${string}`>): DialogSize {
switch (container) {
case 'dialog-default':
return 'default'
case 'dialog-md':
return 'md'
case 'dialog-fullscreen':
return 'fullscreen'
}
}
private buildDialog(dialogSize: DialogSize) {
const file = new SourceFile()
file.addImports([
"import React from 'react';",
"import { LoaderCircleIcon } from 'lucide-react';",
"import { useIntl } from 'react-intl';",
"import { Button } from '#shared/ui/Button';",
`
import {
Dialog,
DialogBody,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '#shared/ui/Dialog';
`,
"import { Skeleton } from '#shared/ui/Skeleton';",
"import { useDisclosure } from '@crux-kit/hooks';",
"import { useGetDetailed } from '../../api/detailed';",
"import { initialValues, FORM_ID, MainForm, prepareInitialValues, useSubmit } from '../forms/MainForm';",
])
file.addSection(
this.buildManageTriggerComponent({
body: 'DialogBody',
close: 'DialogClose',
content: 'DialogContent',
contentProps: `variant="${dialogSize}"`,
description: 'DialogDescription',
disclosureName: 'dialog',
footer: 'DialogFooter',
header: 'DialogHeader',
root: 'Dialog',
title: 'DialogTitle',
trigger: 'DialogTrigger',
})
)
return file.toString()
}
private buildSheet(side: SheetSide) {
const file = new SourceFile()
file.addImports([
"import React from 'react';",
"import { LoaderCircleIcon } from 'lucide-react';",
"import { useIntl } from 'react-intl';",
"import { Button } from '#shared/ui/Button';",
`
import {
Sheet,
SheetBody,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '#shared/ui/Sheet';
`,
"import { Skeleton } from '#shared/ui/Skeleton';",
"import { useDisclosure } from '@crux-kit/hooks';",
"import { useGetDetailed } from '../../api/detailed';",
"import { initialValues, FORM_ID, MainForm, prepareInitialValues, useSubmit } from '../forms/MainForm';",
])
file.addSection(
this.buildManageTriggerComponent({
body: 'SheetBody',
close: 'SheetClose',
content: 'SheetContent',
contentProps: `side="${side}" className="max-h-dvh"`,
description: 'SheetDescription',
disclosureName: 'sheet',
footer: 'SheetFooter',
header: 'SheetHeader',
root: 'Sheet',
title: 'SheetTitle',
trigger: 'SheetTrigger',
})
)
return file.toString()
}
private buildManageTriggerComponent(config: ManageTriggerContainerConfig) {
const bodyProps = config.body === 'SheetBody' ? ' className="overflow-y-auto"' : ''
return `
export const ManageTrigger: React.FC<
React.PropsWithChildren<{
title: string;
id: number | null;
}>
> = ({ id, children, title }) => {
const intl = useIntl();
const ${config.disclosureName} = useDisclosure(false);
const { data, isLoading } = useGetDetailed(
id == null || ${config.disclosureName}.isOpen === false ? undefined : { id: String(id) },
);
const { isSubmitting, submit } = useSubmit({ onSuccess: ${config.disclosureName}.close });
return (
<${config.root} open={${config.disclosureName}.isOpen} onOpenChange={${config.disclosureName}.toggle}>
<${config.trigger} asChild>{children}</${config.trigger}>
<${config.content} ${config.contentProps}>
<${config.header}>
<${config.title}>{title}</${config.title}>
<${config.description} className="sr-only"></${config.description}>
</${config.header}>
<${config.body}${bodyProps}>
{isLoading ? (
<div className="grid grid-cols-2 gap-5">
<Skeleton className="h-15" />
<Skeleton className="h-15" />
<Skeleton className="h-15" />
<Skeleton className="h-15" />
</div>
) : (
<MainForm
initialValues={data ? prepareInitialValues(data) : initialValues}
onSubmit={submit}
/>
)}
</${config.body}>
<${config.footer}>
<Button type="submit" form={FORM_ID} disabled={isSubmitting}>
{isSubmitting ? (
<span className="flex items-center gap-2">
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
{intl.formatMessage({ id: 'STATE.SAVING' })}...
</span>
) : (
intl.formatMessage({ id: 'ACTION.SAVE' })
)}
</Button>
<${config.close} asChild>
<Button variant="outline">
{intl.formatMessage({ id: 'ACTION.CANCEL' })}
</Button>
</${config.close}>
</${config.footer}>
</${config.content}>
</${config.root}>
);
};
`
}
}
export default new ManageTriggerSourceBuilder()
@@ -0,0 +1,30 @@
import { SourceFile } from '#services/module_generator/builders/shared/source_file'
class MenuSourceBuilder {
build() {
const file = new SourceFile()
file.addImports([
"import { NotepadText } from 'lucide-react'",
"import { IntlShape } from 'react-intl'",
"import { MenuItem } from '#app/config/types'",
"import { MODULE_NAME_LABEL, MODULE_PERMISSION_VIEW, MODULE_ROOT_PATH } from './meta'",
])
file.addSection(`
export default (intl: IntlShape): MenuItem => ({
title: intl.formatMessage({
id: \`MODULES.\${MODULE_NAME_LABEL.toUpperCase()}.TITLE\`,
defaultMessage: MODULE_NAME_LABEL,
}),
icon: NotepadText,
path: MODULE_ROOT_PATH,
permissions: [MODULE_PERMISSION_VIEW],
})
`)
return file.toString()
}
}
export default new MenuSourceBuilder()
@@ -0,0 +1,36 @@
import { SourceFile } from '#services/module_generator/builders/shared/source_file'
class ModuleConfigSourceBuilder {
build(
moduleId: string,
options?: {
includeMenu: boolean
}
) {
const file = new SourceFile()
file.addImports([
"import { AppModuleConfig } from '#app/services/modules/types';",
"import messages from './messages';",
"import routes from './routing';",
])
if (options?.includeMenu) file.addImport("import menu from './menu';")
file.addSection(`
export default {
id: '${moduleId}',
area: 'business',
routeScope: 'protected',
order: 100,
routes,
menu: ${options?.includeMenu ? 'menu' : '() => null'},
messages,
} satisfies AppModuleConfig;
`)
return file.toString()
}
}
export default new ModuleConfigSourceBuilder()
@@ -0,0 +1,50 @@
import { ModuleFieldOption } from '#services/module_generator/types'
interface FieldOption {
label: string
value: string
}
export interface ComboboxOptionGroup {
label?: string
options: FieldOption[]
}
export interface MultiSelectOptionGroup {
heading: string
options: FieldOption[]
}
export function hasGroupedOptions(options: ModuleFieldOption[]): boolean {
return options.some((option) => option.group?.trim())
}
export function toFlatOptions(options: ModuleFieldOption[]): FieldOption[] {
return options.map((option) => ({ label: option.label, value: option.value }))
}
export function toComboboxGroups(options: ModuleFieldOption[]): ComboboxOptionGroup[] {
const groups = new Map<string, ComboboxOptionGroup>()
for (const option of options) {
const key = option.group?.trim() ?? ''
const group = groups.get(key) ?? { label: key || undefined, options: [] }
group.options.push({ label: option.label, value: option.value })
groups.set(key, group)
}
return [...groups.values()]
}
export function toMultiSelectOptions(
options: ModuleFieldOption[]
): FieldOption[] | MultiSelectOptionGroup[] {
if (!hasGroupedOptions(options)) {
return toFlatOptions(options)
}
return toComboboxGroups(options).map((group) => ({
heading: group.label ?? '',
options: group.options,
}))
}
@@ -0,0 +1,65 @@
type SourceChunk = string | null | false | undefined
const EMPTY_JSX_TEXT_EXPRESSION_REGEX = /\{\s*'\s*'\s*\}/g
function normalizeSource(value: string): string {
const trimmed = value.replace(/^\n/, '').replace(/\n\s*$/, '')
const lines = trimmed.split('\n')
const indents = lines
.filter((line) => line.trim().length > 0)
.map((line) => line.match(/^\s*/)?.[0].length ?? 0)
const minIndent = indents.length > 0 ? Math.min(...indents) : 0
return lines
.map((line) => line.slice(Math.min(line.match(/^\s*/)?.[0].length ?? 0, minIndent)))
.join('\n')
.replace(EMPTY_JSX_TEXT_EXPRESSION_REGEX, '')
.trim()
}
export function source(value: string): string {
return normalizeSource(value)
}
export function indentSource(value: string, spaces: number): string {
const prefix = ' '.repeat(spaces)
return normalizeSource(value)
.split('\n')
.map((line) => (line.length > 0 ? `${prefix}${line}` : line))
.join('\n')
}
export class SourceFile {
private readonly imports = new Set<string>()
private readonly sections: string[] = []
addImport(value: SourceChunk): this {
if (value) {
this.imports.add(normalizeSource(value))
}
return this
}
addImports(values: SourceChunk[]): this {
values.forEach((value) => this.addImport(value))
return this
}
addSection(value: SourceChunk): this {
if (value) {
this.sections.push(normalizeSource(value))
}
return this
}
toString(): string {
return [Array.from(this.imports).join('\n'), this.sections.join('\n\n')]
.filter((section) => section.length > 0)
.join('\n\n')
}
}
@@ -0,0 +1,29 @@
export function mapDBTypeToTs(type: string): string {
if (type.includes('int')) return 'number'
if (type === 'boolean') return 'boolean'
if (type.includes('timestamp') || type === 'date') return 'DateTime'
return 'string'
}
export function mapDBTypeToValidator(type: string): string {
if (
type.includes('int') ||
type === 'numeric' ||
type === 'decimal' ||
type === 'real' ||
type === 'double precision'
) {
return 'number'
}
if (type === 'boolean') return 'boolean'
if (type.includes('timestamp') || type === 'date') return 'date'
return 'string'
}
export function mapDBTypeToFrontend(type: string): string {
if (type.includes('int') || type === 'numeric' || type === 'bigint') return 'number'
if (type === 'boolean') return 'boolean'
if (type.includes('timestamp') || type === 'date') return 'IsoDateString'
return 'string'
}
@@ -0,0 +1,73 @@
import { join } from 'node:path'
import { writeBackendModule } from '#services/module_generator/writers/backend_module_writer'
import DataTableSourceBuilder from '#services/module_generator/builders/data_table_source_builder'
import FormSourceBuilder from '#services/module_generator/builders/form_source_builder'
import ListPageSourceBuilder from '#services/module_generator/builders/list_page_source_builder'
import ManageTriggerSourceBuilder from '#services/module_generator/builders/manage_trigger_source_builder'
import MenuSourceBuilder from '#services/module_generator/builders/menu_source_builder'
import ModuleConfigSourceBuilder from '#services/module_generator/builders/module_config_source_builder'
import { writeFrontendModule } from '#services/module_generator/writers/frontend_module_writer'
import { buildModuleContext, ModuleContext } from '#services/module_generator/module_context'
import { capitalize } from '#services/module_generator/naming'
import { ModuleAsset, ModuleGeneratorConfig } from '#services/module_generator/types'
/**
* Generates a full module from its table schema and generator config:
* frontend module in the sibling admin workspace, backend assets under
* modules/<Module>. Returns the generated module's name.
*/
export async function generateModule(config: ModuleGeneratorConfig): Promise<string> {
const appBase = process.cwd()
const context = await buildModuleContext(config.table)
writeFrontendAssets(config, context, appBase)
await writeBackendModule({
name: config.name,
table: config.table,
generate: config.generate,
appBase,
context,
})
return capitalize(config.name)
}
// Template tree first (hbs rendered, the rest copied verbatim), then builder
// output for the selected assets. Files whose asset is not selected keep the
// scaffold defaults from the template tree.
function writeFrontendAssets(
config: ModuleGeneratorConfig,
context: ModuleContext,
appBase: string
) {
const { name, generate, form, datatable } = config
const shouldGenerateForm = generate.includes(ModuleAsset.FORM) && !!form
const shouldGenerateDataTable = generate.includes(ModuleAsset.DATATABLE) && !!datatable
writeFrontendModule({
templateDir: join(appBase, 'app', 'templates', 'frontend', 'module'),
outDir: join(appBase, '..', 'admin', 'src', 'modules', capitalize(name)),
context: { name, fields: context.frontendFields },
overrides: {
'view/forms/MainForm/Component.tsx': shouldGenerateForm
? FormSourceBuilder.buildComponent(form)
: null,
'view/forms/MainForm/form.ts': shouldGenerateForm
? FormSourceBuilder.buildDefinition(form)
: null,
'view/widgets/ManageTrigger.tsx': shouldGenerateForm
? ManageTriggerSourceBuilder.build(form.size)
: null,
'view/widgets/DataTable.tsx': shouldGenerateDataTable
? DataTableSourceBuilder.build(config)
: null,
'pages/ListPage/index.tsx': ListPageSourceBuilder.build({
addTable: shouldGenerateDataTable,
}),
'config/menu.ts': shouldGenerateDataTable ? MenuSourceBuilder.build() : null,
'config/module.ts': ModuleConfigSourceBuilder.build(name, {
includeMenu: shouldGenerateDataTable,
}),
},
})
}
@@ -0,0 +1,135 @@
import { getColumnsForTable, getForeignKeysReferencing } from '#services/database_inspector'
import {
mapDBTypeToFrontend,
mapDBTypeToTs,
mapDBTypeToValidator,
} from '#services/module_generator/db_types'
import { camelCase, capitalize } from '#services/module_generator/naming'
export interface EnhancedColumn {
name: string
camelCase: string
type: string
isPrimary: boolean
isPassword: boolean
isEmail: boolean
isDateTime: boolean
isSpecial: boolean
isRequired: boolean
isNullable: boolean
validatorType: string
needsTrim: boolean
}
export interface TestField {
camelCase: string
sampleValue: string | number | boolean
column_name: string
}
export interface ModuleContext {
hasPassword: boolean
enhancedColumns: EnhancedColumn[]
createFields: TestField[]
updateFields: TestField[]
insertJson: string
frontendFields: Array<{ name: string; type: string }>
relationships: { imports: string[]; code: string[] }
}
/** Derives everything the templates and builders need from the table schema. */
export async function buildModuleContext(table: string): Promise<ModuleContext> {
const columns = await getColumnsForTable(table)
const enhancedColumns = columns.map((col): EnhancedColumn => {
const colName = col.column_name
const type = mapDBTypeToTs(col.data_type) + (col.is_nullable === 'YES' ? ' | null' : '')
return {
name: colName,
camelCase: camelCase(colName),
type,
isPrimary: colName === 'id',
isPassword: colName === 'password',
isEmail: colName === 'email',
isDateTime: ['created_at', 'updated_at'].includes(colName),
isSpecial: ['created_at', 'updated_at', 'deleted_at'].includes(colName),
isRequired: col.is_nullable === 'NO',
isNullable: col.is_nullable === 'YES',
validatorType: mapDBTypeToValidator(col.data_type),
needsTrim:
mapDBTypeToValidator(col.data_type) === 'string' &&
colName !== 'email' &&
colName !== 'password',
}
})
const sampleFor = (col: EnhancedColumn) => {
const t = col.validatorType
if (t === 'number') return 1
if (t === 'boolean') return false
if (t === 'date') return `'${new Date().toISOString()}'`
return `'sample_${col.camelCase}'`
}
const toTestField = (col: EnhancedColumn): TestField => ({
camelCase: col.camelCase,
sampleValue: sampleFor(col),
column_name: col.name,
})
const createFields = enhancedColumns.filter((c) => !c.isPrimary && !c.isSpecial).map(toTestField)
const updateFields = enhancedColumns.filter((c) => !c.isPrimary && !c.isDateTime).map(toTestField)
// minimal insert json for show/update/destroy fixtures
const insertJson = `{
${createFields.map((f) => ` ${f.column_name}: ${f.sampleValue}`).join(',\n')}
}`
const frontendFields = columns.map((col) => ({
name: col.column_name,
type: mapDBTypeToFrontend(col.data_type) + (col.is_nullable === 'YES' ? ' | null' : ''),
}))
return {
hasPassword: columns.some((col) => col.column_name === 'password'),
enhancedColumns,
createFields,
updateFields,
insertJson,
frontendFields,
relationships: await buildRelationships(table),
}
}
/** Lucid relationship decorators inferred from the `<table>_id` column convention. */
async function buildRelationships(table: string) {
const lines: string[] = []
const imports: Set<string> = new Set()
const columns = await getColumnsForTable(table)
const references = await getForeignKeysReferencing(table)
for (const col of columns) {
if (col.column_name.endsWith('_id')) {
const refTable = col.column_name.replace('_id', '')
const refModel = capitalize(refTable)
imports.add('belongsTo')
lines.push(
` @belongsTo(() => ${refModel})\n declare ${refTable}: BelongsTo<typeof ${refModel}>`
)
}
}
for (const ref of references) {
const relModel = capitalize(ref.table_name)
imports.add('hasMany')
lines.push(
` @hasMany(() => ${relModel})\n declare ${ref.table_name}: HasMany<typeof ${relModel}>`
)
}
return {
imports: Array.from(imports),
code: lines,
}
}
@@ -0,0 +1,27 @@
import app from '@adonisjs/core/services/app'
export function capitalize(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1)
}
export function camelCase(str: string) {
return str.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
}
export function pascalCase(str: string) {
return capitalize(camelCase(str))
}
// Naive singularization used for generated file names ("posts" -> "post"):
// simply drops the trailing character.
export function singularize(str: string) {
return str.slice(0, str.length - 1)
}
export function tableToModelName(tableName: string): string {
return app.generators.modelName(tableName)
}
export function tableToModelSchemaName(tableName: string): string {
return app.generators.modelName(tableName) + 'Schema'
}
@@ -0,0 +1,106 @@
/* prettier-ignore */
export enum ModuleFieldType {
TEXT = 'text', // InputText
EMAIL = 'email', // InputText
PHONE = 'phone', // InputText
NUMBER = 'number', // InputText
PASSWORD = 'password', // InputText
TEXTAREA = 'textarea', // InputTextarea
SELECT = 'select', // InputSelect
FILE = 'file', // InputFile
DROPZONE = 'dropzone', // InputDropzone
CHECKBOX = 'checkbox', // InputCheckboxGroup
RADIO = 'radio', // InputRadioGroup
SWITCH = 'switch', // InputSwitch
DATE_PICKER_RANGE = 'datepickerRange', // InputDatePickerRange
DATE_PICKER_SINGLE = 'datepickerSingle', // InputDatePicker
DATE_INPUT = 'dateInput', // InputDate
TIME_INPUT_ISO = 'timeInputIso', // InputTime
TIME_INPUT_HMS = 'timeInputHMS', // InputTime
CALENDAR = 'calendar', // InputCalendar
EDITOR = 'editor', // InputTiptapEditor
COLOR_PICKER = 'colorPicker', // InputColorPicker
NUMBER_COUNTER = 'numberCounter', // InputNumberCounter
COMBOBOX = 'comboboxSelect', // InputCombobox
CREATABLE_COMBOBOX = 'creatableCombobox', // InputCreatableCombobox
MULTISELECT = 'multiselect', // InputMultiSelect
}
/* prettier-ignore */
export enum ModuleAsset {
API = 'api',
MODEL = 'model', // BACKEND
POLICY = 'policy', // BACKEND
ROUTES = 'routes', // BACKEND
CONTROLLER = 'controller', // BACKEND
VALIDATOR = 'validator', // BACKEND
FORM = 'form', // FRONTEND
DATATABLE = 'datatable', // FRONTEND
}
export type ModuleFormContainer =
| 'dialog-default'
| 'dialog-md'
| 'dialog-fullscreen'
| 'sheet-top'
| 'sheet-right'
| 'sheet-bottom'
| 'sheet-left'
export interface ModuleLayoutConfig {
grid: ModuleGridCell[]
rows: number
cols: number
selectedCells: string[]
size: ModuleFormContainer
}
export interface ModuleTableColumn {
label: string
value: string
hidden: boolean
isActionColumn: boolean
innerProps: {
badge: boolean
sort: boolean
copy: boolean
}
}
export interface ModuleTableConfig {
columns: ModuleTableColumn[]
}
export interface ModuleFieldConfig {
dbColumn: string
type: ModuleFieldType
label: string
placeholder: string
required: boolean
options: ModuleFieldOption[]
}
export interface ModuleFieldOption {
value: string
label: string
group?: string
}
export interface ModuleGridCell {
id: string
row: number
col: number
rowSpan: number
colSpan: number
body: ModuleFieldConfig | null
isEmpty: boolean
mergeTo: string | null // id of main cell
}
export interface ModuleGeneratorConfig {
name: string
table: string
generate: ModuleAsset[]
form: ModuleLayoutConfig | null
datatable: ModuleTableConfig | null
}
@@ -0,0 +1,235 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { modelToSchema } from '#services/swagger_model'
import { ModuleContext } from '#services/module_generator/module_context'
import {
capitalize,
pascalCase,
singularize,
tableToModelSchemaName,
tableToModelName,
} from '#services/module_generator/naming'
import { renderTemplate } from '#services/module_generator/writers/template_renderer'
import { ModuleAsset } from '#services/module_generator/types'
export interface WriteBackendModuleOptions {
name: string
table: string
generate: ModuleAsset[]
appBase: string
context: ModuleContext
}
/** Renders the selected backend assets from app/templates/backend into modules/<Module>. */
export async function writeBackendModule(options: WriteBackendModuleOptions) {
const { name, table, generate, appBase, context } = options
const { enhancedColumns, createFields, updateFields, insertJson, relationships } = context
const backendTplDir = join(appBase, 'app/templates/backend')
const apiTplDir = join(appBase, 'app/templates/backend/api')
const moduleName = capitalize(name)
const modulePath = join(appBase, 'modules', moduleName)
const modelClass = capitalize(table)
const snake = singularize(table)
const paths = {
controller: join(modulePath, 'controller', `${snake}_controller.ts`),
model: join(modulePath, 'model', `${tableToModelName(table)}.ts`),
// model: join(modulePath, 'model', `${snake}.ts`),
validatorCreate: join(modulePath, 'validators', `${snake}_create_validator.ts`),
validatorUpdate: join(modulePath, 'validators', `${snake}_update_validator.ts`),
routes: join(modulePath, 'routes.ts'),
policy: join(modulePath, 'policy', `${snake}_policy.ts`),
policiesMain: join(appBase, 'app', 'policies', 'main.ts'),
permissionsConfig: join(modulePath, 'config', 'permissions.ts'),
// API subtree
apiController: join(modulePath, 'api', 'controllers', `${snake}_api_controller.ts`),
apiValidatorCreate: join(modulePath, 'api', 'validators', `${snake}_create_api_validator.ts`),
apiValidatorUpdate: join(modulePath, 'api', 'validators', `${snake}_update_api_validator.ts`),
apiRoutes: join(modulePath, 'api', 'routes.ts'),
// tests
testController: join(appBase, 'tests', 'modules', moduleName, `${snake}.spec.ts`),
testApi: join(appBase, 'tests', 'modules', moduleName, `${snake}.api.spec.ts`),
}
Object.values(paths).forEach((filePath) => {
mkdirSync(dirname(filePath), { recursive: true })
})
if (generate.includes(ModuleAsset.MODEL)) {
const content = renderTemplate(join(backendTplDir, 'model.hbs'), {
name,
pascalCaseName: pascalCase(name),
capName: capitalize(name),
className: modelClass,
imports: relationships.imports.join(', '),
columns: enhancedColumns,
relationships: relationships.code,
hasPassword: context.hasPassword,
tableName: table,
modelSchemaName: tableToModelSchemaName(table),
})
writeFileSync(paths.model, content)
}
if (generate.includes(ModuleAsset.CONTROLLER)) {
const content = renderTemplate(join(backendTplDir, 'controller.hbs'), {
pascalCaseName: pascalCase(name),
modelName: modelClass,
capName: capitalize(name),
moduleName,
tableName: table,
snakeName: snake,
name,
})
writeFileSync(paths.controller, content)
const ctrlSpec = renderTemplate(join(backendTplDir, 'tests', 'controller.spec.hbs'), {
pascalCaseName: pascalCase(name),
snakeCaseName: snake,
tableName: table,
createFields,
updateFields,
insertJson,
})
writeFileSync(paths.testController, ctrlSpec, 'utf8')
}
if (generate.includes(ModuleAsset.ROUTES)) {
const content = renderTemplate(join(backendTplDir, 'routes.hbs'), {
name,
capName: capitalize(name),
className: moduleName,
routeName: name,
snakeCaseName: snake,
})
writeFileSync(paths.routes, content)
}
if (generate.includes(ModuleAsset.VALIDATOR)) {
const createContent = renderTemplate(join(backendTplDir, 'validator.hbs'), {
className: moduleName,
functionName: `create${moduleName}Validator`,
fields: enhancedColumns.filter((col) => !col.isPrimary && !col.isSpecial),
})
writeFileSync(paths.validatorCreate, createContent)
const updateContent = renderTemplate(join(backendTplDir, 'validator.hbs'), {
className: moduleName,
functionName: `update${moduleName}Validator`,
fields: enhancedColumns.filter((col) => !col.isPrimary && !col.isDateTime),
})
writeFileSync(paths.validatorUpdate, updateContent)
}
if (generate.includes(ModuleAsset.POLICY)) {
writePolicy(paths, backendTplDir, name, moduleName, modelClass)
}
if (generate.includes(ModuleAsset.API)) {
const { default: genModel } = await import(paths.model)
const apiCtx = {
pascalCaseName: pascalCase(name),
modelName: modelClass,
capName: capitalize(name),
moduleName,
tableName: table,
snakeCaseName: snake,
name,
snake,
swaggerModel: await modelToSchema(genModel),
}
writeFileSync(
paths.apiController,
renderTemplate(join(apiTplDir, 'controller.hbs'), apiCtx),
'utf8'
)
const apiSpec = renderTemplate(join(backendTplDir, 'tests', 'api.spec.hbs'), {
snakeCaseName: snake,
createFields,
})
writeFileSync(paths.testApi, apiSpec, 'utf8')
const apiValidatorTpl = join(apiTplDir, 'validator.hbs')
const apiCreateValidatorContent = renderTemplate(apiValidatorTpl, {
...apiCtx,
className: moduleName,
functionName: `create${moduleName}ApiValidator`,
fields: enhancedColumns.filter((c) => !c.isPrimary && !c.isSpecial),
})
writeFileSync(paths.apiValidatorCreate, apiCreateValidatorContent, 'utf8')
const apiUpdateValidatorContent = renderTemplate(apiValidatorTpl, {
...apiCtx,
className: moduleName,
functionName: `update${moduleName}ApiValidator`,
fields: enhancedColumns.filter((c) => !c.isPrimary && !c.isDateTime),
})
writeFileSync(paths.apiValidatorUpdate, apiUpdateValidatorContent, 'utf8')
writeFileSync(paths.apiRoutes, renderTemplate(join(apiTplDir, 'routes.hbs'), apiCtx), 'utf8')
}
}
/** Policy file + registration in app/policies/main.ts + module permissions config. */
function writePolicy(
paths: { policy: string; policiesMain: string; permissionsConfig: string },
backendTplDir: string,
name: string,
moduleName: string,
modelClass: string
) {
const base = name.toLowerCase()
const slugBase = base.endsWith('s') ? base : `${base}s`
const policyContent = renderTemplate(join(backendTplDir, 'policy.hbs'), {
name,
slugBase,
moduleName,
modelName: modelClass,
pascalCaseName: pascalCase(name),
})
writeFileSync(paths.policy, policyContent)
if (!existsSync(paths.policiesMain)) {
mkdirSync(dirname(paths.policiesMain), { recursive: true })
writeFileSync(paths.policiesMain, `export const policies = {}\n`)
}
const capName = capitalize(name)
const key = `${modelClass}Policy`
const importLine = ` ${key}: () => import('#modules/${capName}/policy/${name}_policy'),`
const current = readFileSync(paths.policiesMain, 'utf8')
if (current.includes('export const policies =')) {
if (!current.includes(importLine)) {
const updated = current.replace(
/export const policies\s*=\s*\{\s*/m,
(m) => m + '\n' + importLine + '\n'
)
writeFileSync(paths.policiesMain, updated)
}
} else {
writeFileSync(paths.policiesMain, `export const policies = {\n${importLine}\n}\n`)
}
const labelBase = capitalize(name.endsWith('s') ? name.slice(0, -1) : name)
const perms = [
{ action: 'view', slug: `${name}s.view`, name: `${labelBase} view` },
{ action: 'create', slug: `${name}s.create`, name: `${labelBase} create` },
{ action: 'update', slug: `${name}s.update`, name: `${labelBase} update` },
{ action: 'delete', slug: `${name}s.delete`, name: `${labelBase} delete` },
]
const fileContents =
'export default [\n' +
perms.map((p) => ` {\n slug: '${p.slug}',\n name: '${p.name}',\n },`).join('\n') +
'\n]\n'
if (!existsSync(paths.permissionsConfig)) {
mkdirSync(dirname(paths.permissionsConfig), { recursive: true })
writeFileSync(paths.permissionsConfig, fileContents, 'utf8')
}
}
@@ -0,0 +1,59 @@
import { copyFileSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import handlebars from 'handlebars'
const HBS_EXTENSION = '.hbs'
export interface WriteFrontendModuleOptions {
/** Root of the module template tree (app/templates/frontend/module). */
templateDir: string
/** Module output directory inside the admin workspace. */
outDir: string
/** Context for rendering the `*.hbs` files in the tree. */
context: Record<string, unknown>
/**
* Generated sources keyed by module-relative path. They are written after the
* template tree, replacing the scaffold defaults. A null value means the asset
* was not selected — the scaffold default from the tree is kept as-is.
*/
overrides: Record<string, string | null>
}
/**
* Materializes a frontend module from the template tree: `*.hbs` files are
* rendered with `context` and written without the extension, every other file
* is copied verbatim, and directory nesting is preserved. Builder-generated
* sources in `overrides` are then written on top.
*/
export function writeFrontendModule(options: WriteFrontendModuleOptions) {
renderTree(options.templateDir, options.outDir, options.context)
for (const [relativePath, content] of Object.entries(options.overrides)) {
if (content === null) continue
const target = join(options.outDir, relativePath)
mkdirSync(dirname(target), { recursive: true })
writeFileSync(target, content, 'utf8')
}
}
function renderTree(dir: string, outDir: string, context: Record<string, unknown>) {
mkdirSync(outDir, { recursive: true })
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const sourcePath = join(dir, entry.name)
if (entry.isDirectory()) {
renderTree(sourcePath, join(outDir, entry.name), context)
continue
}
if (entry.name.endsWith(HBS_EXTENSION)) {
// noEscape: the templates emit TypeScript, HTML entities would corrupt it.
const template = handlebars.compile(readFileSync(sourcePath, 'utf8'), { noEscape: true })
writeFileSync(join(outDir, entry.name.slice(0, -HBS_EXTENSION.length)), template(context))
} else {
copyFileSync(sourcePath, join(outDir, entry.name))
}
}
}
@@ -0,0 +1,13 @@
import { readFileSync } from 'node:fs'
import handlebars from 'handlebars'
handlebars.registerHelper('eq', (a, b) => a === b)
handlebars.registerHelper('not', (v) => !v)
handlebars.registerHelper('or', (a, b) => a || b)
handlebars.registerHelper('and', (a, b) => a && b)
export function renderTemplate(templatePath: string, context: Record<string, unknown>): string {
const raw = readFileSync(templatePath, 'utf8')
const template = handlebars.compile(raw)
return template(context)
}
+4 -1
View File
@@ -1,12 +1,15 @@
import { BaseModel, column{{#if imports}}, {{imports}}{{/if}} } from '@adonisjs/lucid/orm'
import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations'
import { DateTime } from 'luxon'
import { {{modelSchemaName}} } from '#database/schema'
{{#if hasPassword}}
import hash from '@adonisjs/core/services/hash'
import { beforeSave } from '@adonisjs/lucid/orm'
{{/if}}
export default class {{pascalCaseName}} extends BaseModel {
export default class {{pascalCaseName}} extends {{modelSchemaName}} {
public static table = '{{tableName}}'
static searchable = {
string: [{{#each columns}}{{#if (and (eq validatorType "string") (not isPassword))}}'{{camelCase}}'{{#unless @last}}, {{/unless}}{{/if}}{{/each}}],
number: [{{#each columns}}{{#if (eq validatorType "number")}}'{{camelCase}}'{{#unless @last}}, {{/unless}}{{/if}}{{/each}}],
@@ -1 +0,0 @@
{{detaTable}}
@@ -1 +0,0 @@
{{form}}
+18
View File
@@ -0,0 +1,18 @@
import { useMutation } from '@tanstack/react-query';
import { toast } from 'sonner';
import { axiosClient } from '#infrastructure/axios';
import { ErrorResponse, SuccessMutationResponse } from '#infrastructure/reactQuery';
import { MODULE_API_ENDPOINT } from '../config/meta';
export const useCreate = () => {
return useMutation<SuccessMutationResponse, ErrorResponse, Record<string, unknown>>({
mutationFn: async (payload) => {
return axiosClient
.post<SuccessMutationResponse>(MODULE_API_ENDPOINT, payload)
.then((data) => data.data);
},
onSuccess(data) {
toast.success(data.message);
}
});
};
+18
View File
@@ -0,0 +1,18 @@
import { useMutation } from '@tanstack/react-query';
import { toast } from 'sonner';
import { axiosClient } from '#infrastructure/axios';
import { ErrorResponse, SuccessMutationResponse } from '#infrastructure/reactQuery';
import { MODULE_API_ENDPOINT } from '../config/meta';
export const useDelete = () => {
return useMutation<SuccessMutationResponse, ErrorResponse, { id: number }>({
mutationFn: async (payload) => {
return axiosClient
.delete<SuccessMutationResponse>(`${MODULE_API_ENDPOINT}/${payload.id}`)
.then((data) => data.data);
},
onSuccess(data) {
toast.success(data.message);
}
});
};
+32
View File
@@ -0,0 +1,32 @@
import { useQuery } from '@tanstack/react-query';
import { queryClient } from '#app/providers/QueryProvider';
import { axiosClient } from '#infrastructure/axios';
import { MODULE_API_ENDPOINT } from '../config/meta';
import { Entity } from '../entities/Entity';
import { detailedKeyBuilder } from './keys';
export const invalidateDetailed = async (id?: number) => {
if (id === undefined) {
await queryClient.invalidateQueries(detailedKeyBuilder.invalidateAll());
} else {
await queryClient.invalidateQueries(detailedKeyBuilder.invalidate({ id: String(id) }));
}
};
export interface DetailedPayload {
id: string;
}
export const useGetDetailed = (payload?: DetailedPayload) => {
return useQuery({
queryKey: detailedKeyBuilder.store(payload as DetailedPayload),
queryFn: async () => {
return axiosClient
.get<Entity>(`${MODULE_API_ENDPOINT}/${payload?.id}`)
.then((data) => data.data);
},
enabled: Boolean(payload?.id),
throwOnError(_error) {
return false;
}
});
};
+9
View File
@@ -0,0 +1,9 @@
import { createKeyBuilder, ListQueryParams } from '#infrastructure/reactQuery';
import { MODULE_NAME } from '../config/meta';
import { DetailedPayload } from './detailed';
const ROOT = MODULE_NAME;
export const listKeyBuilder = createKeyBuilder<ListQueryParams>(ROOT, 'LIST');
export const detailedKeyBuilder = createKeyBuilder<DetailedPayload>(ROOT, 'DETAILED');
+35
View File
@@ -0,0 +1,35 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import React from 'react';
import { queryClient } from '#app/providers/QueryProvider';
import { axiosClient } from '#infrastructure/axios';
import { getPaginationInfo, ListQueryParams, SuccessListResponse } from '#infrastructure/reactQuery';
import { MODULE_API_ENDPOINT } from '../config/meta';
import { Entity } from '../entities/Entity';
import { listKeyBuilder } from './keys';
export const invalidateList = async () => {
await queryClient.invalidateQueries(listKeyBuilder.invalidateAll());
};
export const useGetList = (params: ListQueryParams = { page: 1, perPage: 10 }) => {
const { data, ...query } = useQuery({
queryKey: listKeyBuilder.store(params),
placeholderData: keepPreviousData,
queryFn: async () => {
return axiosClient
.get<SuccessListResponse<Entity>>(MODULE_API_ENDPOINT, { params })
.then((response) => response.data);
},
throwOnError(_error) {
return false;
}
});
const list = React.useMemo(() => {
return data?.data ?? [];
}, [data?.data]);
const paginationInfo = getPaginationInfo(data?.meta);
return { ...query, ...paginationInfo, list };
};
+22
View File
@@ -0,0 +1,22 @@
import { useMutation } from '@tanstack/react-query';
import { toast } from 'sonner';
import { axiosClient } from '#infrastructure/axios';
import { ErrorResponse, SuccessMutationResponse } from '#infrastructure/reactQuery';
import { MODULE_API_ENDPOINT } from '../config/meta';
export const useUpdate = () => {
return useMutation<
SuccessMutationResponse,
ErrorResponse,
{ id: number } & Record<string, unknown>
>({
mutationFn: async (payload) => {
return axiosClient
.put<SuccessMutationResponse>(`${MODULE_API_ENDPOINT}/${payload.id}`, payload)
.then((data) => data.data);
},
onSuccess(data) {
toast.success(data.message);
}
});
};
+14
View File
@@ -0,0 +1,14 @@
import { NotepadText } from 'lucide-react';
import { IntlShape } from 'react-intl';
import { MenuItem } from '#app/config/types';
import { MODULE_NAME_LABEL, MODULE_PERMISSION_VIEW, MODULE_ROOT_PATH } from './meta';
export default (intl: IntlShape): MenuItem => ({
title: intl.formatMessage({
id: `MODULES.${MODULE_NAME_LABEL.toUpperCase()}.TITLE`,
defaultMessage: MODULE_NAME_LABEL
}),
icon: NotepadText,
path: MODULE_ROOT_PATH,
permissions: [MODULE_PERMISSION_VIEW]
});
@@ -0,0 +1,9 @@
import arTranslation from './translation/ar.json';
import enTranslation from './translation/en.json';
import ukTranslation from './translation/ua.json';
export default {
en: enTranslation,
ar: arTranslation,
uk: ukTranslation
};
@@ -0,0 +1,14 @@
import { AppModuleConfig } from '#app/services/modules/types';
import menu from './menu';
import messages from './messages';
import routes from './routing';
export default {
id: 'template',
area: 'business',
routeScope: 'protected',
order: 100,
routes,
menu,
messages
} satisfies AppModuleConfig;
@@ -0,0 +1,30 @@
/**
* Handles all module related routes.
* This component is mounted at /[moduleName]/* in the main application router.
*/
import { Navigate } from 'react-router-dom';
import ListPage from '../pages/ListPage';
import Layout from '../view/layouts/Layout';
import { MODULE_ROOT_PATH } from './meta';
export default {
path: MODULE_ROOT_PATH,
children: [
{ index: true, element: <Navigate to="list" replace /> },
{
path: `${MODULE_ROOT_PATH}/*`,
element: <Navigate to="/error/404" replace />
},
{
path: '',
element: <Layout />,
children: [
{
path: 'list',
element: <ListPage />
}
]
}
]
};
@@ -0,0 +1,3 @@
{
"MODULES.TEMPLATE.TITLE": "قالب الوحدة"
}
@@ -0,0 +1,3 @@
{
"MODULES.TEMPLATE.TITLE": "Module template"
}
@@ -0,0 +1,3 @@
{
"MODULES.TEMPLATE.TITLE": "Шаблон модуля"
}
@@ -0,0 +1,19 @@
import React from 'react';
import { PermissionPageGate } from '#core/Permission';
import { Container } from '#shared/widgets/common/Container';
import { MODULE_PERMISSION_VIEW } from '../../config/meta';
import { DataTable } from '../../view/widgets/DataTable';
const ListPage: React.FC = () => {
return (
<React.Fragment>
<PermissionPageGate requiredPermissions={[MODULE_PERMISSION_VIEW]}>
<Container>
<DataTable />
</Container>
</PermissionPageGate>
</React.Fragment>
);
};
export default ListPage;
@@ -0,0 +1,38 @@
import { zodResolver } from '@hookform/resolvers/zod';
import React from 'react';
import { useForm } from 'react-hook-form';
import { FormElement, FormGrid, GridElement, InputText } from '#shared/forms/composed';
import { Form as FormProvider } from '#shared/ui/form';
import { FORM_ID } from './constants';
import { FormValues, SubmitFunc, validationSchema } from './form';
export const MainForm: React.FC<{
initialValues: FormValues;
onSubmit: SubmitFunc;
}> = ({ initialValues, onSubmit }) => {
const form = useForm<FormValues>({
values: initialValues,
resolver: zodResolver(validationSchema)
});
return (
<FormProvider {...form}>
<FormElement id={FORM_ID} onSubmit={form.handleSubmit((values) => onSubmit(values, form))}>
<FormGrid columns={2}>
<GridElement colSpan={1}>
<InputText name="name" type="text" placeholder="" label="" />
</GridElement>
<GridElement colSpan={1}>
<InputText name="name2" type="text" placeholder="" label="" />
</GridElement>
<GridElement colSpan={1}>
<InputText name="order" type="number" placeholder="" label="" />
</GridElement>
<GridElement colSpan={1}>
<InputText name="number" type="number" placeholder="" label="" />
</GridElement>
</FormGrid>
</FormElement>
</FormProvider>
);
};
@@ -0,0 +1,3 @@
import { uid } from '@crux-kit/utils';
export const FORM_ID = uid();
@@ -0,0 +1,33 @@
import { UseFormReturn } from 'react-hook-form';
import z from 'zod';
import { Entity } from '../../../entities/Entity';
export const validationSchema = z.object({
id: z.number().nullable(),
name: z.string(),
name2: z.string(),
order: z.coerce.string(),
number: z.coerce.string()
});
export type FormValues = z.infer<typeof validationSchema>;
export type SubmitFunc = (values: FormValues, handlers: UseFormReturn<FormValues>) => void;
export function prepareInitialValues(data: Entity): FormValues {
return {
id: data.id ?? null,
name: data.name ?? '',
name2: data.name2 ?? '',
order: data.order.toString() ?? '',
number: data.number.toString() ?? ''
};
}
export const initialValues: FormValues = {
id: null,
name: '',
name2: '',
number: '',
order: ''
};
@@ -0,0 +1,4 @@
export * from './form';
export * from './Component';
export * from './submit';
export { FORM_ID } from './constants';
@@ -0,0 +1,71 @@
import { extractValidationErrors } from '#infrastructure/reactQuery';
import { useCreate } from '../../../api/create';
import { invalidateDetailed } from '../../../api/detailed';
import { invalidateList } from '../../../api/list';
import { useUpdate } from '../../../api/update';
import { SubmitFunc } from './form';
interface Params {
onSuccess?: (id?: number) => void;
}
export const useSubmit = (params?: Params) => {
const onSuccess = params?.onSuccess;
const { mutate: createMutation, isPending: isCreatePending } = useCreate();
const { mutate: updateMutation, isPending: isUpdateending } = useUpdate();
const handleSubmit: SubmitFunc = (values, handlers) => {
if (values.id) {
handleUpdate(values, handlers);
} else {
handleCreate(values, handlers);
}
};
const handleCreate: SubmitFunc = (values, handlers) => {
createMutation(values, {
onSuccess() {
invalidateList();
if (typeof onSuccess === 'function') onSuccess();
},
onError(error) {
extractValidationErrors(error).forEach((e) => {
handlers.setError(e.field as keyof typeof values, {
type: 'apiError',
message: e.message
});
});
}
});
};
const handleUpdate: SubmitFunc = (values, handlers) => {
if (!values.id) return;
updateMutation(
{ ...values, id: values.id },
{
onSuccess() {
invalidateList();
if (values.id) invalidateDetailed(values.id);
if (typeof onSuccess === 'function') {
onSuccess(values.id ?? undefined);
}
},
onError(error) {
extractValidationErrors(error).forEach((e) => {
handlers.setError(e.field as keyof typeof values, {
type: 'apiError',
message: e.message
});
});
}
}
);
};
return {
submit: handleSubmit,
isSubmitting: isCreatePending || isUpdateending
};
};
@@ -0,0 +1,12 @@
import React from 'react';
import { Outlet } from 'react-router';
const Layout: React.FC = () => {
return (
<React.Fragment>
<Outlet />
</React.Fragment>
);
};
export default Layout;
@@ -0,0 +1,264 @@
import { Column, ColumnDef, getCoreRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, PaginationState, SortingState, useReactTable } from '@tanstack/react-table';
import { LoaderCircleIcon, SearchIcon, Settings2, SquarePen } from 'lucide-react';
import React from 'react';
import { useIntl } from 'react-intl';
import { useDebounceSearch } from '@crux-kit/hooks';
import { mapTableStateToListQueryParams } from '#infrastructure/reactQuery';
import { PermissionGate } from '#core/Permission';
import { Button } from '#shared/ui/Button';
import { Card, CardFooter, CardHeader, CardTable, CardTitle, CardToolbar } from '#shared/ui/Card';
import { Input, InputWrapper } from '#shared/ui/Input';
import { ScrollArea, ScrollBar } from '#shared/ui/ScrollArea';
import { Skeleton } from '#shared/ui/Skeleton';
import { DataGrid, DataGridColumnHeader, DataGridColumnVisibility, DataGridPagination, DataGridTable, useDataGrid } from '#shared/widgets/DataGrid';
import { useGetList } from '../../api/list';
import { MODULE_NAME_LABEL, MODULE_PERMISSION_CREATE, MODULE_PERMISSION_DELETE, MODULE_PERMISSION_UPDATE } from '../../config/meta';
import { Entity } from '../../entities/Entity';
import { DeleteTrigger } from './DeleteTrigger';
import { ManageTrigger } from './ManageTrigger';
interface IColumnFilterProps<TData, TValue> {
column: Column<TData, TValue>;
}
export const DataTable = () => {
const intl = useIntl();
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 10
});
const [search, setSearch] = React.useState<string>('');
const { handleChange } = useDebounceSearch({
minQueryNumber: 3,
delay: 400,
initialQuery: search,
onTimeOut: setSearch
});
const [sorting, setSorting] = React.useState<SortingState>([]);
const { list, total, isFetching, isLoading, pagesCount } = useGetList(
mapTableStateToListQueryParams(pagination, sorting, search)
);
const columns = React.useMemo<ColumnDef<Entity>[]>(
() => [
{
id: 'id',
accessorFn: (row) => row.id,
header: ({ column }) => <DataGridColumnHeader title="id" column={column} />,
enableSorting: true,
size: 80,
meta: {
cellClassName: '',
skeleton: <Skeleton className="h-4" />
}
},
{
id: 'name',
accessorFn: (row) => row.name,
header: ({ column }) => (
<DataGridColumnHeader
title="name"
filter={<ColumnInputFilter column={column} />}
column={column}
/>
),
cell: (info) => info.getValue(),
enableSorting: true,
meta: {
cellClassName: '',
skeleton: <Skeleton className="h-4" />
}
},
{
id: 'name2',
accessorFn: (row) => row.name2,
header: ({ column }) => (
<DataGridColumnHeader
title="name2"
filter={<ColumnInputFilter column={column} />}
column={column}
/>
),
cell: (info) => info.getValue(),
enableSorting: true,
meta: {
cellClassName: '',
skeleton: <Skeleton className="h-4" />
}
},
{
id: 'order',
accessorFn: (row) => row.order,
header: ({ column }) => <DataGridColumnHeader title="order" column={column} />,
cell: (info) => info.getValue(),
enableSorting: true,
meta: {
cellClassName: '',
skeleton: <Skeleton className="h-4" />
}
},
{
id: 'number',
accessorFn: (row) => row.number,
header: ({ column }) => <DataGridColumnHeader title="number" column={column} />,
cell: (info) => info.getValue(),
enableSorting: true,
meta: {
cellClassName: '',
skeleton: <Skeleton className="h-4" />
}
},
{
id: 'action',
header: () => intl.formatMessage({ id: 'ACTIONS' }),
cell: (info) => {
const data = info.row.original;
const id = data.id;
return (
<div className="flex items-center justify-end gap-2">
<PermissionGate requiredPermissions={MODULE_PERMISSION_UPDATE}>
<ManageTrigger title={`Edit ${MODULE_NAME_LABEL}`} id={id}>
<Button variant="secondary" size="sm" shape="default" mode="icon">
<SquarePen />
</Button>
</ManageTrigger>
</PermissionGate>
<PermissionGate requiredPermissions={MODULE_PERMISSION_DELETE}>
<DeleteTrigger id={id} deleteTarget={id} />
</PermissionGate>
</div>
);
},
enableSorting: false,
size: 96,
meta: {
headerClassName: '',
skeleton: <Skeleton className="h-4" />
}
}
],
[intl]
);
const table = useReactTable({
columns,
autoResetPageIndex: false,
enableMultiSort: false,
data: list,
manualPagination: true,
pageCount: pagesCount,
getRowId: (row) => String(row.id),
state: {
pagination,
sorting
},
onPaginationChange: setPagination,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel()
});
return (
<DataGrid
table={table}
recordCount={total || 0}
emptyMessage={intl.formatMessage({
id: 'TABLE.COMMON.NO_DATA_AVAILABLE'
})}
loadingMode="skeleton"
isLoading={isLoading}
tableLayout={{
columnsDraggable: false,
columnsResizable: false,
width: 'auto',
columnsPinnable: true,
columnsMovable: true,
columnsVisibility: true,
cellBorder: true
}}
>
<Card>
<CardHeader>
<CardTitle>
<InputWrapper>
{search.length > 0 && isFetching ? (
<LoaderCircleIcon className="size-4 animate-spin" />
) : (
<SearchIcon className="size-4" />
)}
<Input
type="text"
placeholder={intl.formatMessage({ id: 'ACTION.SEARCH' })}
onChange={(e) => handleChange(e.target.value)}
/>
</InputWrapper>
</CardTitle>
<Toolbar />
</CardHeader>
<CardTable>
<ScrollArea>
<DataGridTable />
<ScrollBar orientation="horizontal" />
</ScrollArea>
</CardTable>
<CardFooter>
<DataGridPagination />
</CardFooter>
</Card>
</DataGrid>
);
};
const Toolbar = () => {
const { table } = useDataGrid();
const intl = useIntl();
return (
<CardToolbar>
<div className="flex flex-wrap items-center gap-2.5">
<PermissionGate requiredPermissions={MODULE_PERMISSION_CREATE}>
<ManageTrigger
id={null}
title={intl.formatMessage(
{ id: 'MODULE.COMMON.CREATE_NEW_ENTITY' },
{ entity: MODULE_NAME_LABEL }
)}
>
<Button>{intl.formatMessage({ id: 'ACTION.CREATE' })}</Button>
</ManageTrigger>
</PermissionGate>
<DataGridColumnVisibility
table={table}
trigger={
<Button variant="outline">
<Settings2 />
{intl.formatMessage({ id: 'TABLE.COMMON.COLUMNS' })}
</Button>
}
/>
</div>
</CardToolbar>
);
};
const ColumnInputFilter = <TData, TValue>({ column }: IColumnFilterProps<TData, TValue>) => {
const intl = useIntl();
return (
<Input
placeholder={intl.formatMessage({ id: 'FILTER' })}
value={(column.getFilterValue() as string) ?? ''}
onChange={(event) => column.setFilterValue(event.target.value)}
className="h-9 w-full max-w-40"
/>
);
};
@@ -0,0 +1,86 @@
import { LoaderCircleIcon, X } from 'lucide-react';
import React from 'react';
import { useIntl } from 'react-intl';
import { Button } from '#shared/ui/Button';
import { Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '#shared/ui/Dialog';
import { useDelete } from '../../api/delete';
import { invalidateList } from '../../api/list';
export const DeleteTrigger: React.FC<
React.PropsWithChildren<{
id: number;
deleteTarget?: React.ReactNode;
}>
> = ({ id, deleteTarget, children }) => {
const intl = useIntl();
const mutationDelete = useDelete();
const handleDelete = () => {
mutationDelete.mutate(
{ id: id },
{
onSuccess() {
invalidateList();
}
}
);
};
return (
<Dialog>
<DialogTrigger asChild>
{children ? (
children
) : (
<Button variant="destructive" size="sm" shape="default" mode="icon">
<X />
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{intl.formatMessage({ id: 'ACTION.CONFIRM' })}</DialogTitle>
<DialogDescription className="sr-only"></DialogDescription>
</DialogHeader>
<DialogBody className="text-sm">
{deleteTarget ? (
<React.Fragment>
{intl.formatMessage(
{ id: 'MODULE.COMMON.REMOVE_ITEM_QUESTION' },
{ target: deleteTarget }
)}
<span className="ml-1">
{intl.formatMessage({
id: 'MODULE.COMMON.REMOVE_ITEM_QUESTION_DESC'
})}
</span>
</React.Fragment>
) : (
intl.formatMessage({ id: 'MODULE.COMMON.ARE_YOU_SURE?' }) +
' ' +
intl.formatMessage({
id: 'MODULE.COMMON.REMOVE_ITEM_QUESTION_DESC'
})
)}
</DialogBody>
<DialogFooter>
<Button variant="destructive" disabled={mutationDelete.isPending} onClick={handleDelete}>
{mutationDelete.isPending ? (
<span className="flex items-center gap-2">
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
{intl.formatMessage({ id: 'STATE.REMOVING' })}...
</span>
) : (
intl.formatMessage({ id: 'ACTION.REMOVE' })
)}
</Button>
<DialogClose asChild>
<Button variant="outline">{intl.formatMessage({ id: 'ACTION.CANCEL' })}</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,69 @@
import { LoaderCircleIcon } from 'lucide-react';
import React from 'react';
import { useIntl } from 'react-intl';
import { useDisclosure } from '@crux-kit/hooks';
import { Button } from '#shared/ui/Button';
import { Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '#shared/ui/Dialog';
import { Skeleton } from '#shared/ui/Skeleton';
import { useGetDetailed } from '../../api/detailed';
import { FORM_ID, initialValues, MainForm, prepareInitialValues, useSubmit } from '../forms/MainForm';
export const ManageTrigger: React.FC<
React.PropsWithChildren<{
title: string;
id: number | null;
}>
> = ({ id, children, title }) => {
const intl = useIntl();
const dialog = useDisclosure(false);
const { data, isLoading } = useGetDetailed(
id == null || dialog.isOpen === false ? undefined : { id: String(id) }
);
const { isSubmitting, submit } = useSubmit({ onSuccess: dialog.close });
return (
<Dialog open={dialog.isOpen} onOpenChange={dialog.toggle}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent variant="fullscreen">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="sr-only"></DialogDescription>
</DialogHeader>
<DialogBody>
{isLoading ? (
<div className="grid grid-cols-2 gap-5">
<Skeleton className="h-15" />
<Skeleton className="h-15" />
<Skeleton className="h-15" />
<Skeleton className="h-15" />
</div>
) : (
<MainForm
initialValues={data ? prepareInitialValues(data) : initialValues}
onSubmit={submit}
/>
)}
</DialogBody>
<DialogFooter>
<Button type="submit" form={FORM_ID} disabled={isSubmitting}>
{isSubmitting ? (
<span className="flex items-center gap-2">
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
{intl.formatMessage({ id: 'STATE.SAVING' })}...
</span>
) : (
intl.formatMessage({ id: 'ACTION.SAVE' })
)}
</Button>
<DialogClose asChild>
<Button variant="outline">{intl.formatMessage({ id: 'ACTION.CANCEL' })}</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,26 @@
import vine from '@vinejs/vine'
function isValidJsonValue(value: any): boolean {
if (value === null) return true
if (['string', 'number', 'boolean'].includes(typeof value)) return true
if (Array.isArray(value)) {
return value.every(isValidJsonValue)
}
if (typeof value === 'object' && Object.prototype.toString.call(value) === '[object Object]') {
return Object.values(value).every(isValidJsonValue)
}
return false
}
export const anyJsonRule = vine.createRule((value, _options, field) => {
if (value === undefined || value === null) {
return
}
if (!isValidJsonValue(value)) {
field.report('Not valid JSON in {{ field }}', 'anyJson', field)
}
})
@@ -0,0 +1,13 @@
import vine from '@vinejs/vine'
export type JsonValue =
| string
| number
| boolean
| null
| JsonValue[]
| { [key: string]: JsonValue }
export type JsonObject = Record<string, JsonValue>
export const jsonObjectSchema = vine.object({}).allowUnknownProperties<JsonValue>()