TASKS-1823 WebManager module
This commit is contained in:
+148
@@ -0,0 +1,148 @@
|
||||
import '@adonisjs/core/types/http'
|
||||
|
||||
type ParamValue = string | number | bigint | boolean
|
||||
|
||||
export type ScannedRoutes = {
|
||||
ALL: {
|
||||
'drive.fs.serve': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
|
||||
'admin.update_profile': { paramsTuple?: []; params?: {} }
|
||||
'admin.index': { paramsTuple?: []; params?: {} }
|
||||
'admin.store': { paramsTuple?: []; params?: {} }
|
||||
'admin.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'admin.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'admin.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'auth.login': { paramsTuple?: []; params?: {} }
|
||||
'auth.logout': { paramsTuple?: []; params?: {} }
|
||||
'auth.user': { paramsTuple?: []; params?: {} }
|
||||
'root': { paramsTuple?: []; params?: {} }
|
||||
'file_manager.rename': { paramsTuple?: []; params?: {} }
|
||||
'file_manager.create_folder': { paramsTuple?: []; params?: {} }
|
||||
'file_manager.raw': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
|
||||
'file_manager.show': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
|
||||
'file_manager.upload': { paramsTuple?: []; params?: {} }
|
||||
'file_manager.duplicate': { paramsTuple?: []; params?: {} }
|
||||
'file_manager.search': { paramsTuple?: []; params?: {} }
|
||||
'file_manager.destroy': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
|
||||
'nested': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
|
||||
'permission.index': { paramsTuple?: []; params?: {} }
|
||||
'permission.store': { paramsTuple?: []; params?: {} }
|
||||
'permission.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'permission.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'permission.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'role.index': { paramsTuple?: []; params?: {} }
|
||||
'role.store': { paramsTuple?: []; params?: {} }
|
||||
'role.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'role.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'role.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'public_web_manager.blog_list': { paramsTuple?: []; params?: {} }
|
||||
'public_web_manager.sitemap': { paramsTuple?: []; params?: {} }
|
||||
'public_web_manager.resolve': { paramsTuple?: []; params?: {} }
|
||||
'public_web_manager.preview': { paramsTuple?: []; params?: {} }
|
||||
'public_web_manager.settings': { paramsTuple?: []; params?: {} }
|
||||
'web_manager.index': { paramsTuple?: []; params?: {} }
|
||||
'web_manager.group_keys': { paramsTuple?: []; params?: {} }
|
||||
'web_manager.store': { paramsTuple?: []; params?: {} }
|
||||
'web_manager.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'web_manager.save_draft': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'web_manager.publish': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'web_manager.unpublish': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'web_manager.create_preview_token': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'web_manager.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'web_manager.get_settings': { paramsTuple?: []; params?: {} }
|
||||
'web_manager.save_settings': { paramsTuple?: []; params?: {} }
|
||||
'module.tables': { paramsTuple?: []; params?: {} }
|
||||
'module.columns': { paramsTuple: [ParamValue]; params: {'table': ParamValue} }
|
||||
'module.generate': { paramsTuple?: []; params?: {} }
|
||||
}
|
||||
GET: {
|
||||
'drive.fs.serve': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
|
||||
'admin.index': { paramsTuple?: []; params?: {} }
|
||||
'admin.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'auth.user': { paramsTuple?: []; params?: {} }
|
||||
'root': { paramsTuple?: []; params?: {} }
|
||||
'file_manager.raw': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
|
||||
'file_manager.show': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
|
||||
'file_manager.search': { paramsTuple?: []; params?: {} }
|
||||
'nested': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
|
||||
'permission.index': { paramsTuple?: []; params?: {} }
|
||||
'permission.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'role.index': { paramsTuple?: []; params?: {} }
|
||||
'role.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'public_web_manager.blog_list': { paramsTuple?: []; params?: {} }
|
||||
'public_web_manager.sitemap': { paramsTuple?: []; params?: {} }
|
||||
'public_web_manager.resolve': { paramsTuple?: []; params?: {} }
|
||||
'public_web_manager.preview': { paramsTuple?: []; params?: {} }
|
||||
'public_web_manager.settings': { paramsTuple?: []; params?: {} }
|
||||
'web_manager.index': { paramsTuple?: []; params?: {} }
|
||||
'web_manager.group_keys': { paramsTuple?: []; params?: {} }
|
||||
'web_manager.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'web_manager.get_settings': { paramsTuple?: []; params?: {} }
|
||||
'module.tables': { paramsTuple?: []; params?: {} }
|
||||
'module.columns': { paramsTuple: [ParamValue]; params: {'table': ParamValue} }
|
||||
}
|
||||
HEAD: {
|
||||
'drive.fs.serve': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
|
||||
'admin.index': { paramsTuple?: []; params?: {} }
|
||||
'admin.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'auth.user': { paramsTuple?: []; params?: {} }
|
||||
'root': { paramsTuple?: []; params?: {} }
|
||||
'file_manager.raw': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
|
||||
'file_manager.show': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
|
||||
'file_manager.search': { paramsTuple?: []; params?: {} }
|
||||
'nested': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
|
||||
'permission.index': { paramsTuple?: []; params?: {} }
|
||||
'permission.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'role.index': { paramsTuple?: []; params?: {} }
|
||||
'role.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'public_web_manager.blog_list': { paramsTuple?: []; params?: {} }
|
||||
'public_web_manager.sitemap': { paramsTuple?: []; params?: {} }
|
||||
'public_web_manager.resolve': { paramsTuple?: []; params?: {} }
|
||||
'public_web_manager.preview': { paramsTuple?: []; params?: {} }
|
||||
'public_web_manager.settings': { paramsTuple?: []; params?: {} }
|
||||
'web_manager.index': { paramsTuple?: []; params?: {} }
|
||||
'web_manager.group_keys': { paramsTuple?: []; params?: {} }
|
||||
'web_manager.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'web_manager.get_settings': { paramsTuple?: []; params?: {} }
|
||||
'module.tables': { paramsTuple?: []; params?: {} }
|
||||
'module.columns': { paramsTuple: [ParamValue]; params: {'table': ParamValue} }
|
||||
}
|
||||
PUT: {
|
||||
'admin.update_profile': { paramsTuple?: []; params?: {} }
|
||||
'admin.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'file_manager.rename': { paramsTuple?: []; params?: {} }
|
||||
'permission.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'role.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'web_manager.save_draft': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'web_manager.save_settings': { paramsTuple?: []; params?: {} }
|
||||
}
|
||||
POST: {
|
||||
'admin.store': { paramsTuple?: []; params?: {} }
|
||||
'auth.login': { paramsTuple?: []; params?: {} }
|
||||
'auth.logout': { paramsTuple?: []; params?: {} }
|
||||
'file_manager.create_folder': { paramsTuple?: []; params?: {} }
|
||||
'file_manager.upload': { paramsTuple?: []; params?: {} }
|
||||
'file_manager.duplicate': { paramsTuple?: []; params?: {} }
|
||||
'permission.store': { paramsTuple?: []; params?: {} }
|
||||
'role.store': { paramsTuple?: []; params?: {} }
|
||||
'web_manager.store': { paramsTuple?: []; params?: {} }
|
||||
'web_manager.publish': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'web_manager.unpublish': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'web_manager.create_preview_token': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'module.generate': { paramsTuple?: []; params?: {} }
|
||||
}
|
||||
PATCH: {
|
||||
'admin.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'permission.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'role.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
}
|
||||
DELETE: {
|
||||
'admin.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'file_manager.destroy': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
|
||||
'permission.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'role.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'web_manager.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
}
|
||||
}
|
||||
declare module '@adonisjs/core/types/http' {
|
||||
export interface RoutesList extends ScannedRoutes {}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ DB_PORT=5432
|
||||
DB_USER=root
|
||||
DB_PASSWORD=root
|
||||
DB_DATABASE=app
|
||||
WEB_APP_URL=http://localhost:3000
|
||||
WEB_REVALIDATE_SECRET=
|
||||
DRIVE_DISK=fs
|
||||
AWS_ACCESS_KEY_ID=minioadmin
|
||||
AWS_SECRET_ACCESS_KEY=minioadmin
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Test overrides, merged on top of .env when NODE_ENV=test (bin/test.ts forces it).
|
||||
# Tests run against a dedicated database so they never touch the seeded dev data.
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=5432
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=postgres
|
||||
DB_DATABASE=adonis_test
|
||||
LOG_LEVEL=error
|
||||
# Empty secret disables outbound cache purges: WebRevalidationService reports
|
||||
# status "disabled" and still returns the computed tags, which specs assert on.
|
||||
WEB_REVALIDATE_SECRET=
|
||||
# Keep file storage local; specs do not upload files.
|
||||
DRIVE_DISK=fs
|
||||
@@ -0,0 +1,8 @@
|
||||
build
|
||||
dist
|
||||
public/**
|
||||
storage/**
|
||||
|
||||
app/templates/**
|
||||
database/schema.ts
|
||||
.adonisjs
|
||||
@@ -63,7 +63,11 @@ export default defineConfig({
|
||||
| List of modules to import before starting the application.
|
||||
|
|
||||
*/
|
||||
preloads: [() => import('#start/routes'), () => import('#start/kernel')],
|
||||
preloads: [
|
||||
() => import('#start/vine'),
|
||||
() => import('#start/routes'),
|
||||
() => import('#start/kernel'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@@ -76,9 +80,17 @@ export default defineConfig({
|
||||
*/
|
||||
tests: {
|
||||
suites: [
|
||||
{
|
||||
name: 'unit',
|
||||
files: ['tests/unit/**/*.spec.@(ts|js)'],
|
||||
},
|
||||
{
|
||||
name: 'functional',
|
||||
files: ['tests/**/*.spec.(ts|js)', 'modules/**/tests/**/*.spec.(ts|js)'],
|
||||
files: [
|
||||
'tests/functional/**/*.spec.@(ts|js)',
|
||||
'tests/modules/**/*.spec.@(ts|js)',
|
||||
'modules/**/tests/**/*.spec.@(ts|js)',
|
||||
],
|
||||
},
|
||||
],
|
||||
forceExit: false,
|
||||
|
||||
@@ -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()
|
||||
|
||||
+1
-1
@@ -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'
|
||||
|
||||
+1
-1
@@ -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)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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
@@ -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 };
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
+38
@@ -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>()
|
||||
@@ -0,0 +1,57 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'web_pages'
|
||||
|
||||
async up() {
|
||||
this.schema.raw('DROP TYPE IF EXISTS web_page_status_enum')
|
||||
|
||||
this.schema.createTable(this.tableName, (table) => {
|
||||
table.increments('id')
|
||||
table
|
||||
.enu('status', ['draft', 'published', 'unpublished'], {
|
||||
useNative: true,
|
||||
enumName: 'web_page_status_enum',
|
||||
})
|
||||
.notNullable()
|
||||
.defaultTo('draft')
|
||||
table
|
||||
.integer('draft_of_id')
|
||||
.unsigned()
|
||||
.nullable()
|
||||
.references('id')
|
||||
.inTable(this.tableName)
|
||||
.onDelete('CASCADE')
|
||||
table.string('locale', 16).notNullable()
|
||||
table.string('path').notNullable()
|
||||
table.string('group_key').notNullable()
|
||||
table.string('page_type').notNullable()
|
||||
table.string('title').notNullable()
|
||||
table.jsonb('hero').nullable()
|
||||
table.string('seo_title').notNullable().defaultTo('')
|
||||
table.text('seo_description').notNullable().defaultTo('')
|
||||
table.timestamp('published_at', { useTz: true }).nullable()
|
||||
table.timestamp('created_at', { useTz: true }).notNullable()
|
||||
table.timestamp('updated_at', { useTz: true }).notNullable()
|
||||
|
||||
table.index(['group_key', 'status'])
|
||||
table.index(['locale', 'path', 'status'])
|
||||
})
|
||||
|
||||
this.schema.raw(`
|
||||
CREATE UNIQUE INDEX web_pages_published_route_unique
|
||||
ON ${this.tableName} (locale, path)
|
||||
WHERE status = 'published'
|
||||
`)
|
||||
this.schema.raw(`
|
||||
CREATE UNIQUE INDEX web_pages_one_draft_per_page_unique
|
||||
ON ${this.tableName} (draft_of_id)
|
||||
WHERE draft_of_id IS NOT NULL
|
||||
`)
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable(this.tableName)
|
||||
this.schema.raw('DROP TYPE IF EXISTS web_page_status_enum')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'web_components'
|
||||
|
||||
async up() {
|
||||
this.schema.createTable(this.tableName, (table) => {
|
||||
table.increments('id')
|
||||
table
|
||||
.integer('page_id')
|
||||
.unsigned()
|
||||
.notNullable()
|
||||
.references('id')
|
||||
.inTable('web_pages')
|
||||
.onDelete('CASCADE')
|
||||
table.string('type').notNullable()
|
||||
table.jsonb('data').notNullable()
|
||||
table.integer('sort_order').unsigned().notNullable().defaultTo(0)
|
||||
table.timestamp('created_at', { useTz: true }).notNullable()
|
||||
table.timestamp('updated_at', { useTz: true }).notNullable()
|
||||
|
||||
table.index(['page_id', 'sort_order'])
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable(this.tableName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'web_settings'
|
||||
|
||||
async up() {
|
||||
this.schema.createTable(this.tableName, (table) => {
|
||||
table.increments('id')
|
||||
table.string('locale', 16).notNullable().unique()
|
||||
table.jsonb('header').notNullable().defaultTo('{}')
|
||||
table.jsonb('footer').notNullable().defaultTo('{}')
|
||||
table.jsonb('legal').notNullable().defaultTo('{}')
|
||||
table.timestamp('created_at', { useTz: true }).notNullable()
|
||||
table.timestamp('updated_at', { useTz: true }).notNullable()
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable(this.tableName)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'testings'
|
||||
|
||||
async up() {
|
||||
this.schema.createTable(this.tableName, (table) => {
|
||||
table.increments('id')
|
||||
table.string('name').notNullable().unique()
|
||||
table.timestamp('created_at')
|
||||
table.timestamp('updated_at')
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable(this.tableName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* This file is automatically generated
|
||||
* DO NOT EDIT manually
|
||||
* Run "node ace migration:run" command to re-generate this file
|
||||
*/
|
||||
|
||||
import { BaseModel, column } from '@adonisjs/lucid/orm'
|
||||
import { DateTime } from 'luxon'
|
||||
|
||||
export class AdminRoleSchema extends BaseModel {
|
||||
static $columns = ['adminId', 'createdAt', 'id', 'roleId', 'updatedAt'] as const
|
||||
$columns = AdminRoleSchema.$columns
|
||||
@column()
|
||||
declare adminId: number | null
|
||||
@column.dateTime({ autoCreate: true })
|
||||
declare createdAt: DateTime | null
|
||||
@column({ isPrimary: true })
|
||||
declare id: number
|
||||
@column()
|
||||
declare roleId: number | null
|
||||
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
||||
declare updatedAt: DateTime | null
|
||||
}
|
||||
|
||||
export class AdminSchema extends BaseModel {
|
||||
static $columns = ['createdAt', 'email', 'firstName', 'id', 'lastName', 'password', 'status', 'updatedAt'] as const
|
||||
$columns = AdminSchema.$columns
|
||||
@column.dateTime({ autoCreate: true })
|
||||
declare createdAt: DateTime
|
||||
@column()
|
||||
declare email: string
|
||||
@column()
|
||||
declare firstName: string | null
|
||||
@column({ isPrimary: true })
|
||||
declare id: number
|
||||
@column()
|
||||
declare lastName: string | null
|
||||
@column({ serializeAs: null })
|
||||
declare password: string
|
||||
@column()
|
||||
declare status: any
|
||||
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
||||
declare updatedAt: DateTime | null
|
||||
}
|
||||
|
||||
export class AuthAccessTokenSchema extends BaseModel {
|
||||
static $columns = ['abilities', 'createdAt', 'expiresAt', 'hash', 'id', 'lastUsedAt', 'name', 'tokenableId', 'type', 'updatedAt'] as const
|
||||
$columns = AuthAccessTokenSchema.$columns
|
||||
@column()
|
||||
declare abilities: string
|
||||
@column.dateTime({ autoCreate: true })
|
||||
declare createdAt: DateTime | null
|
||||
@column.dateTime()
|
||||
declare expiresAt: DateTime | null
|
||||
@column()
|
||||
declare hash: string
|
||||
@column({ isPrimary: true })
|
||||
declare id: number
|
||||
@column.dateTime()
|
||||
declare lastUsedAt: DateTime | null
|
||||
@column()
|
||||
declare name: string | null
|
||||
@column()
|
||||
declare tokenableId: number
|
||||
@column()
|
||||
declare type: string
|
||||
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
||||
declare updatedAt: DateTime | null
|
||||
}
|
||||
|
||||
export class PermissionSchema extends BaseModel {
|
||||
static $columns = ['createdAt', 'id', 'name', 'slug', 'updatedAt'] as const
|
||||
$columns = PermissionSchema.$columns
|
||||
@column.dateTime({ autoCreate: true })
|
||||
declare createdAt: DateTime | null
|
||||
@column({ isPrimary: true })
|
||||
declare id: number
|
||||
@column()
|
||||
declare name: string
|
||||
@column()
|
||||
declare slug: string
|
||||
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
||||
declare updatedAt: DateTime | null
|
||||
}
|
||||
|
||||
export class RolePermissionSchema extends BaseModel {
|
||||
static $columns = ['createdAt', 'id', 'permissionSlug', 'roleId', 'updatedAt'] as const
|
||||
$columns = RolePermissionSchema.$columns
|
||||
@column.dateTime({ autoCreate: true })
|
||||
declare createdAt: DateTime | null
|
||||
@column({ isPrimary: true })
|
||||
declare id: number
|
||||
@column()
|
||||
declare permissionSlug: string | null
|
||||
@column()
|
||||
declare roleId: number | null
|
||||
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
||||
declare updatedAt: DateTime | null
|
||||
}
|
||||
|
||||
export class RoleSchema extends BaseModel {
|
||||
static $columns = ['createdAt', 'id', 'name', 'updatedAt'] as const
|
||||
$columns = RoleSchema.$columns
|
||||
@column.dateTime({ autoCreate: true })
|
||||
declare createdAt: DateTime | null
|
||||
@column({ isPrimary: true })
|
||||
declare id: number
|
||||
@column()
|
||||
declare name: string
|
||||
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
||||
declare updatedAt: DateTime | null
|
||||
}
|
||||
|
||||
export class WebComponentSchema extends BaseModel {
|
||||
static $columns = ['createdAt', 'data', 'id', 'pageId', 'sortOrder', 'type', 'updatedAt'] as const
|
||||
$columns = WebComponentSchema.$columns
|
||||
@column.dateTime({ autoCreate: true })
|
||||
declare createdAt: DateTime
|
||||
@column()
|
||||
declare data: any
|
||||
@column({ isPrimary: true })
|
||||
declare id: number
|
||||
@column()
|
||||
declare pageId: number
|
||||
@column()
|
||||
declare sortOrder: number
|
||||
@column()
|
||||
declare type: string
|
||||
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
||||
declare updatedAt: DateTime
|
||||
}
|
||||
|
||||
export class WebPageSchema extends BaseModel {
|
||||
static $columns = ['createdAt', 'draftOfId', 'groupKey', 'hero', 'id', 'locale', 'pageType', 'path', 'publishedAt', 'seoDescription', 'seoTitle', 'status', 'title', 'updatedAt'] as const
|
||||
$columns = WebPageSchema.$columns
|
||||
@column.dateTime({ autoCreate: true })
|
||||
declare createdAt: DateTime
|
||||
@column()
|
||||
declare draftOfId: number | null
|
||||
@column()
|
||||
declare groupKey: string
|
||||
@column()
|
||||
declare hero: any | null
|
||||
@column({ isPrimary: true })
|
||||
declare id: number
|
||||
@column()
|
||||
declare locale: string
|
||||
@column()
|
||||
declare pageType: string
|
||||
@column()
|
||||
declare path: string
|
||||
@column.dateTime()
|
||||
declare publishedAt: DateTime | null
|
||||
@column()
|
||||
declare seoDescription: string
|
||||
@column()
|
||||
declare seoTitle: string
|
||||
@column()
|
||||
declare status: any
|
||||
@column()
|
||||
declare title: string
|
||||
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
||||
declare updatedAt: DateTime
|
||||
}
|
||||
|
||||
export class WebSettingSchema extends BaseModel {
|
||||
static $columns = ['createdAt', 'footer', 'header', 'id', 'legal', 'locale', 'updatedAt'] as const
|
||||
$columns = WebSettingSchema.$columns
|
||||
@column.dateTime({ autoCreate: true })
|
||||
declare createdAt: DateTime
|
||||
@column()
|
||||
declare footer: any
|
||||
@column()
|
||||
declare header: any
|
||||
@column({ isPrimary: true })
|
||||
declare id: number
|
||||
@column()
|
||||
declare legal: any
|
||||
@column()
|
||||
declare locale: string
|
||||
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
||||
declare updatedAt: DateTime
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { configApp } from '@adonisjs/eslint-config'
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['.adonisjs/**', 'database/schema.ts'],
|
||||
ignores: ['.adonisjs/**', 'database/schema.ts', 'app/templates/**'],
|
||||
},
|
||||
...configApp(),
|
||||
]
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"build": "node ace build",
|
||||
"dev": "node ace serve --hmr",
|
||||
"test": "node ace test",
|
||||
"lint": "node ../scripts/eslint-with-typescript6.cjs .",
|
||||
"lint": "node ../../scripts/eslint-with-typescript6.cjs .",
|
||||
"format": "prettier --write .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
@@ -43,10 +43,11 @@
|
||||
"@japa/assert": "^4.2.0",
|
||||
"@japa/plugin-adonisjs": "^5.2.0",
|
||||
"@japa/runner": "^5.3.0",
|
||||
"@japa/spec-reporter": "^1.3.3",
|
||||
"@poppinss/ts-exec": "^1.4.4",
|
||||
"@swc/core": "1.11.24",
|
||||
"@types/luxon": "^3.6.2",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/sanitize-html": "^2.16.1",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"hot-hook": "^0.4.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
@@ -68,7 +69,8 @@
|
||||
"luxon": "^3.6.1",
|
||||
"patch-package": "^8.0.1",
|
||||
"pg": "^8.16.0",
|
||||
"reflect-metadata": "^0.2.2"
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"sanitize-html": "^2.17.6"
|
||||
},
|
||||
"hotHook": {
|
||||
"boundaries": [
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
import { Env } from '@adonisjs/core/env'
|
||||
|
||||
export default await Env.create(new URL('../../', import.meta.url), {
|
||||
export default await Env.create(new URL('../', import.meta.url), {
|
||||
NODE_ENV: Env.schema.enum(['development', 'production', 'test'] as const),
|
||||
PORT: Env.schema.number(),
|
||||
APP_KEY: Env.schema.string(),
|
||||
@@ -28,6 +28,14 @@ export default await Env.create(new URL('../../', import.meta.url), {
|
||||
DB_USER: Env.schema.string(),
|
||||
DB_PASSWORD: Env.schema.string.optional(),
|
||||
DB_DATABASE: Env.schema.string(),
|
||||
/*
|
||||
|----------------------------------------------------------
|
||||
| Variables for public web app cache revalidation
|
||||
|----------------------------------------------------------
|
||||
*/
|
||||
WEB_APP_URL: Env.schema.string.optional({ format: 'url', tld: false }),
|
||||
WEB_REVALIDATE_SECRET: Env.schema.string.optional(),
|
||||
|
||||
DRIVE_DISK: Env.schema.enum(['fs', 's3'] as const),
|
||||
AWS_ACCESS_KEY_ID: Env.schema.string.optional(),
|
||||
AWS_SECRET_ACCESS_KEY: Env.schema.string.optional(),
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { anyJsonRule } from '#validators/rules/any_json_rule'
|
||||
import { VineAny } from '@vinejs/vine'
|
||||
|
||||
VineAny.macro('anyJson', function (this: VineAny) {
|
||||
return this.use(anyJsonRule())
|
||||
})
|
||||
|
||||
declare module '@vinejs/vine' {
|
||||
interface VineAny {
|
||||
anyJson(): this
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
import { assert } from '@japa/assert'
|
||||
import { apiClient } from '@japa/api-client'
|
||||
import { pluginAdonisJS } from '@japa/plugin-adonisjs'
|
||||
import { specReporter } from '@japa/spec-reporter'
|
||||
import type { Config } from '@japa/runner/types'
|
||||
|
||||
import app from '@adonisjs/core/services/app'
|
||||
@@ -18,22 +17,54 @@ export const plugins: Config['plugins'] = [assert(), apiClient(), pluginAdonisJS
|
||||
*/
|
||||
export const reporters: Config['reporters'] = {
|
||||
activated: ['spec'],
|
||||
list: [
|
||||
{
|
||||
name: 'spec',
|
||||
handler: specReporter(),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Global setup/teardown (for ALL suites)
|
||||
*
|
||||
* Runs migrations against the test database (`.env.test` → adonis_test) before
|
||||
* the suites and rolls them back afterwards. DB-backed specs additionally wrap
|
||||
* each test in a global transaction via `testUtils.db().wrapInGlobalTransaction()`
|
||||
* in their own group hooks.
|
||||
*/
|
||||
export const runnerHooks: Required<Pick<Config, 'setup' | 'teardown'>> = {
|
||||
setup: [],
|
||||
setup: [() => ensureTestDatabase(), () => testUtils.db().migrate()],
|
||||
teardown: [],
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the test database on first run so a fresh checkout only needs the
|
||||
* postgres container up. Guarded to `*_test` names so a misconfigured
|
||||
* `.env.test` can never provision anything else.
|
||||
*/
|
||||
async function ensureTestDatabase() {
|
||||
const { default: env } = await import('#start/env')
|
||||
const database = env.get('DB_DATABASE')
|
||||
if (!database.endsWith('_test')) {
|
||||
throw new Error(
|
||||
`Refusing to run tests against "${database}": DB_DATABASE in .env.test must end with "_test"`
|
||||
)
|
||||
}
|
||||
|
||||
const { Client } = await import('pg')
|
||||
const client = new Client({
|
||||
host: env.get('DB_HOST'),
|
||||
port: env.get('DB_PORT'),
|
||||
user: env.get('DB_USER'),
|
||||
password: env.get('DB_PASSWORD'),
|
||||
database: 'postgres',
|
||||
})
|
||||
await client.connect()
|
||||
try {
|
||||
const exists = await client.query('SELECT 1 FROM pg_database WHERE datname = $1', [database])
|
||||
if (exists.rowCount === 0) {
|
||||
await client.query(`CREATE DATABASE "${database}"`)
|
||||
}
|
||||
} finally {
|
||||
await client.end()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-suite configuration (start HTTP server for HTTP tests)
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { test } from '@japa/runner'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import { FileNotFoundException } from '#exceptions/file_manager_exceptions'
|
||||
import { PageRouteConflictException } from '#exceptions/web_manager_exceptions'
|
||||
|
||||
function captureContext() {
|
||||
const captured: { status?: number; body?: unknown } = {}
|
||||
const response = {
|
||||
status(code: number) {
|
||||
captured.status = code
|
||||
return response
|
||||
},
|
||||
send(body: unknown) {
|
||||
captured.body = body
|
||||
},
|
||||
}
|
||||
return { captured, ctx: { response } as unknown as HttpContext }
|
||||
}
|
||||
|
||||
test.group('API exceptions', () => {
|
||||
test('page manager exceptions self-render as message and code', async ({ assert }) => {
|
||||
const { captured, ctx } = captureContext()
|
||||
const error = new PageRouteConflictException('en', 'about')
|
||||
await error.handle(error, ctx)
|
||||
|
||||
assert.equal(captured.status, 409)
|
||||
assert.deepEqual(captured.body, {
|
||||
message: 'A page already uses /en/about',
|
||||
code: 'E_PAGE_ROUTE_CONFLICT',
|
||||
})
|
||||
})
|
||||
|
||||
test('file manager exceptions self-render as message and code', async ({ assert }) => {
|
||||
const { captured, ctx } = captureContext()
|
||||
const error = new FileNotFoundException()
|
||||
await error.handle(error, ctx)
|
||||
|
||||
assert.equal(captured.status, 404)
|
||||
assert.deepEqual(captured.body, { message: 'File not found', code: 'E_FILE_NOT_FOUND' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { test } from '@japa/runner'
|
||||
import {
|
||||
ancestorPaths,
|
||||
ancestorsAlreadyLoaded,
|
||||
BREADCRUMB_PARENT_GROUP_KEYS,
|
||||
} from '#base_modules/WebManager/services/breadcrumb_paths'
|
||||
import { BREADCRUMB_PAGE_TYPE_PARENTS } from '#base_modules/WebManager/config/general'
|
||||
|
||||
/**
|
||||
* These decide which rows the breadcrumb query asks for. Miss a path here and the trail
|
||||
* silently loses a level, because the walk can only choose from what was loaded.
|
||||
*/
|
||||
test.group('Web Manager breadcrumb candidate paths', () => {
|
||||
test('climbs every shorter prefix, longest first, then home', ({ assert }) => {
|
||||
assert.deepEqual(ancestorPaths('blog/post/hello'), ['blog/post', 'blog', ''])
|
||||
})
|
||||
|
||||
test('never offers the page itself as its own parent', ({ assert }) => {
|
||||
assert.notInclude(ancestorPaths('about'), 'about')
|
||||
assert.notInclude(ancestorPaths('blog/post'), 'blog/post')
|
||||
})
|
||||
|
||||
test('a top-level page and the home page both fall back to home', ({ assert }) => {
|
||||
assert.deepEqual(ancestorPaths('about'), [''])
|
||||
assert.deepEqual(ancestorPaths(''), [''])
|
||||
})
|
||||
|
||||
test('tolerates stray slashes in a stored path', ({ assert }) => {
|
||||
assert.deepEqual(ancestorPaths('/blog/post/'), ancestorPaths('blog/post'))
|
||||
assert.deepEqual(ancestorPaths('/blog/post/'), ['blog', ''])
|
||||
})
|
||||
|
||||
test('covers the group keys a page type can be re-parented onto', ({ assert }) => {
|
||||
assert.includeMembers(BREADCRUMB_PARENT_GROUP_KEYS, Object.values(BREADCRUMB_PAGE_TYPE_PARENTS))
|
||||
assert.equal(
|
||||
BREADCRUMB_PARENT_GROUP_KEYS.length,
|
||||
new Set(BREADCRUMB_PARENT_GROUP_KEYS).size,
|
||||
'duplicate group keys would widen the query for nothing'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.group('Web Manager breadcrumb candidate reuse', () => {
|
||||
test('walking up a prefix reuses the rows already loaded', ({ assert }) => {
|
||||
assert.isTrue(ancestorsAlreadyLoaded('blog/post', ['blog/post/hello']))
|
||||
assert.isTrue(ancestorsAlreadyLoaded('blog', ['blog/post/hello']))
|
||||
assert.isTrue(ancestorsAlreadyLoaded('', ['blog/post/hello']))
|
||||
assert.isTrue(ancestorsAlreadyLoaded('blog/post/hello', ['blog/post/hello']))
|
||||
})
|
||||
|
||||
test('a jump to an unrelated branch needs its own query', ({ assert }) => {
|
||||
assert.isFalse(ancestorsAlreadyLoaded('company/blog', ['news/hello']))
|
||||
assert.isFalse(ancestorsAlreadyLoaded('blog', []))
|
||||
})
|
||||
|
||||
test('does not mistake a shared name prefix for an ancestor', ({ assert }) => {
|
||||
assert.isFalse(ancestorsAlreadyLoaded('blogging', ['blog/post']))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { test } from '@japa/runner'
|
||||
import {
|
||||
sanitizePayload,
|
||||
sanitizeRichText,
|
||||
sanitizeUrl,
|
||||
} from '#base_modules/WebManager/services/content_sanitizer'
|
||||
|
||||
test.group('Web Manager content sanitizer', () => {
|
||||
test('rich text keeps allowed markup and drops event handlers', ({ assert }) => {
|
||||
assert.equal(
|
||||
sanitizeRichText('<p class="lead" onclick="alert(1)">Hi <em>there</em></p>'),
|
||||
'<p class="lead">Hi <em>there</em></p>'
|
||||
)
|
||||
})
|
||||
|
||||
test('rich text drops dangerous subtrees entirely', ({ assert }) => {
|
||||
assert.equal(
|
||||
sanitizeRichText('before<script>alert(1)</script><svg><a onload="alert(1)"></a></svg>after'),
|
||||
'beforeafter'
|
||||
)
|
||||
})
|
||||
|
||||
test('rich text rejects unsafe link schemes and normalizes rel', ({ assert }) => {
|
||||
assert.equal(
|
||||
sanitizeRichText('<a href="javascript:alert(1)" target="_blank" rel="me nofollow">x</a>'),
|
||||
'<a target="_blank" rel="noopener noreferrer">x</a>'
|
||||
)
|
||||
assert.equal(
|
||||
sanitizeRichText('<a href="https://example.com" target="_top" rel="me nofollow">x</a>'),
|
||||
'<a href="https://example.com" rel="nofollow">x</a>'
|
||||
)
|
||||
})
|
||||
|
||||
test('url fields allow relative and https urls, reject script schemes', ({ assert }) => {
|
||||
assert.equal(sanitizeUrl('/en/about'), '/en/about')
|
||||
assert.equal(sanitizeUrl('https://example.com/?a=1&b=2'), 'https://example.com/?a=1&b=2')
|
||||
assert.equal(sanitizeUrl('mailto:hi@example.com'), 'mailto:hi@example.com')
|
||||
assert.equal(sanitizeUrl('javascript:alert(1)'), '')
|
||||
assert.equal(sanitizeUrl(' jav\tascript:alert(1)'), '')
|
||||
})
|
||||
|
||||
test('payload pass applies the url policy to known url keys only', ({ assert }) => {
|
||||
assert.deepEqual(
|
||||
sanitizePayload({
|
||||
title: '<b>Hello</b>',
|
||||
externalUrl: 'javascript:alert(1)',
|
||||
items: [{ url: 'https://example.com/?a=1&b=2' }],
|
||||
}),
|
||||
{
|
||||
title: '<b>Hello</b>',
|
||||
externalUrl: '',
|
||||
items: [{ url: 'https://example.com/?a=1&b=2' }],
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,311 @@
|
||||
import { test } from '@japa/runner'
|
||||
import testUtils from '@adonisjs/core/services/test_utils'
|
||||
import WebManagerService from '#base_modules/WebManager/services/web_manager_service'
|
||||
import { createAdminWithToken } from '#tests/utils/auth'
|
||||
import { contentComponent, pageInput } from '#tests/utils/web_manager'
|
||||
|
||||
const ALL_PERMISSIONS = [
|
||||
'web_manager.view',
|
||||
'web_manager.create',
|
||||
'web_manager.update',
|
||||
'web_manager.publish',
|
||||
'web_manager.delete',
|
||||
'web_manager.settings',
|
||||
]
|
||||
|
||||
/** [method, path, permission the action authorizes against] */
|
||||
const ADMIN_ROUTES = [
|
||||
['get', '/web-manager/pages', 'web_manager.view'],
|
||||
['get', '/web-manager/pages/group-keys', 'web_manager.view'],
|
||||
['post', '/web-manager/pages', 'web_manager.create'],
|
||||
['get', '/web-manager/pages/1', 'web_manager.view'],
|
||||
['put', '/web-manager/pages/1/draft', 'web_manager.update'],
|
||||
['post', '/web-manager/pages/1/publish', 'web_manager.publish'],
|
||||
['post', '/web-manager/pages/1/unpublish', 'web_manager.publish'],
|
||||
['post', '/web-manager/pages/1/preview-token', 'web_manager.view'],
|
||||
['delete', '/web-manager/pages/1', 'web_manager.delete'],
|
||||
['get', '/web-manager/settings', 'web_manager.view'],
|
||||
['put', '/web-manager/settings', 'web_manager.settings'],
|
||||
] as const
|
||||
|
||||
test.group('WebManager admin API auth', (group) => {
|
||||
group.each.setup(() => testUtils.db().wrapInGlobalTransaction())
|
||||
|
||||
test('every admin route rejects an unauthenticated request', async ({ client }) => {
|
||||
for (const [method, url] of ADMIN_ROUTES) {
|
||||
const response = await client[method](url)
|
||||
response.assertStatus(401)
|
||||
}
|
||||
})
|
||||
|
||||
test('every action rejects a token that lacks its permission', async ({ client }) => {
|
||||
// holds every permission except the one each action checks
|
||||
for (const [method, url, permission] of ADMIN_ROUTES) {
|
||||
const { token } = await createAdminWithToken(
|
||||
ALL_PERMISSIONS.filter((slug) => slug !== permission)
|
||||
)
|
||||
const response = await client[method](url).bearerToken(token)
|
||||
response.assertStatus(403)
|
||||
}
|
||||
})
|
||||
|
||||
test('a viewer can read but not mutate', async ({ client }) => {
|
||||
const { token } = await createAdminWithToken(['web_manager.view'])
|
||||
|
||||
const list = await client.get('/web-manager/pages').bearerToken(token)
|
||||
list.assertStatus(200)
|
||||
|
||||
const create = await client.post('/web-manager/pages').bearerToken(token).json(pageInput())
|
||||
create.assertStatus(403)
|
||||
})
|
||||
})
|
||||
|
||||
test.group('WebManager admin API', (group) => {
|
||||
group.each.setup(() => testUtils.db().wrapInGlobalTransaction())
|
||||
|
||||
test('create → publish → unpublish round trip reports revalidation', async ({
|
||||
assert,
|
||||
client,
|
||||
}) => {
|
||||
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
|
||||
|
||||
const created = await client
|
||||
.post('/web-manager/pages')
|
||||
.bearerToken(token)
|
||||
.json(pageInput({ components: [contentComponent()] }))
|
||||
created.assertStatus(201)
|
||||
const pageId = created.body().data.page.id
|
||||
assert.equal(created.body().data.page.status, 'draft')
|
||||
|
||||
const published = await client.post(`/web-manager/pages/${pageId}/publish`).bearerToken(token)
|
||||
published.assertStatus(200)
|
||||
assert.equal(published.body().data.page.status, 'published')
|
||||
assert.equal(published.body().meta.revalidation.status, 'disabled')
|
||||
assert.isNotEmpty(published.body().meta.revalidation.tags)
|
||||
|
||||
const unpublished = await client
|
||||
.post(`/web-manager/pages/${pageId}/unpublish`)
|
||||
.bearerToken(token)
|
||||
unpublished.assertStatus(200)
|
||||
assert.equal(unpublished.body().data.page.status, 'unpublished')
|
||||
|
||||
const again = await client.post(`/web-manager/pages/${pageId}/unpublish`).bearerToken(token)
|
||||
again.assertStatus(409)
|
||||
assert.equal(again.body().code, 'E_PAGE_NOT_PUBLISHED')
|
||||
})
|
||||
|
||||
test('page list filters by status and search', async ({ assert, client }) => {
|
||||
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
|
||||
const service = new WebManagerService()
|
||||
const { page: draft } = await service.createDraft(pageInput({ title: 'Needle draft' }))
|
||||
const { page: live } = await service.createDraft(pageInput({ title: 'Other page' }))
|
||||
await service.publish(live.id)
|
||||
|
||||
const drafts = await client
|
||||
.get('/web-manager/pages')
|
||||
.qs({ status: 'draft', search: 'needle' })
|
||||
.bearerToken(token)
|
||||
drafts.assertStatus(200)
|
||||
assert.deepEqual(
|
||||
drafts.body().data.map((row: { id: number }) => row.id),
|
||||
[draft.id]
|
||||
)
|
||||
})
|
||||
|
||||
test('validation rejects a malformed path with 422', async ({ client }) => {
|
||||
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
|
||||
|
||||
const response = await client
|
||||
.post('/web-manager/pages')
|
||||
.bearerToken(token)
|
||||
.json(pageInput({ path: 'About/Us' }))
|
||||
response.assertStatus(422)
|
||||
})
|
||||
|
||||
test('an unknown block type is rejected with 422', async ({ assert, client }) => {
|
||||
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
|
||||
|
||||
const response = await client
|
||||
.post('/web-manager/pages')
|
||||
.bearerToken(token)
|
||||
.json(pageInput({ components: [{ type: 'carousel', data: {} }] }))
|
||||
response.assertStatus(422)
|
||||
assert.equal(response.body().code, 'E_INVALID_PAGE_PAYLOAD')
|
||||
})
|
||||
|
||||
test('a multipart payload field is parsed as JSON', async ({ assert, client }) => {
|
||||
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
|
||||
|
||||
const response = await client
|
||||
.post('/web-manager/pages')
|
||||
.bearerToken(token)
|
||||
.field('payload', JSON.stringify(pageInput({ components: [contentComponent()] })))
|
||||
response.assertStatus(201)
|
||||
assert.equal(response.body().data.components.length, 1)
|
||||
})
|
||||
|
||||
test('empty strings survive the multipart payload but not a JSON body', async ({
|
||||
assert,
|
||||
client,
|
||||
}) => {
|
||||
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
|
||||
const input = pageInput({
|
||||
components: [
|
||||
{
|
||||
type: 'content',
|
||||
data: { title: 'Section', subtitle: '', columns: [] },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// The JSON bodyparser converts '' to null, so the strict block validator
|
||||
// rejects it — this is why the admin sends the page as one JSON string.
|
||||
const asJson = await client.post('/web-manager/pages').bearerToken(token).json(input)
|
||||
asJson.assertStatus(422)
|
||||
assert.equal(asJson.body().code, 'E_INVALID_PAGE_PAYLOAD')
|
||||
|
||||
const asPayload = await client
|
||||
.post('/web-manager/pages')
|
||||
.bearerToken(token)
|
||||
.field('payload', JSON.stringify(input))
|
||||
asPayload.assertStatus(201)
|
||||
})
|
||||
|
||||
test('a malformed multipart payload is rejected with 422', async ({ assert, client }) => {
|
||||
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
|
||||
|
||||
const response = await client
|
||||
.post('/web-manager/pages')
|
||||
.bearerToken(token)
|
||||
.field('payload', 'not-json')
|
||||
response.assertStatus(422)
|
||||
assert.equal(response.body().code, 'E_INVALID_PAGE_PAYLOAD')
|
||||
})
|
||||
|
||||
test('a media value referencing a missing upload is rejected with 422', async ({
|
||||
assert,
|
||||
client,
|
||||
}) => {
|
||||
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
|
||||
|
||||
const input = pageInput({
|
||||
hero: { type: 'middle', ctaItems: [], media: { name: 'x.png', fileKey: 'file-0' } },
|
||||
})
|
||||
const response = await client
|
||||
.post('/web-manager/pages')
|
||||
.bearerToken(token)
|
||||
.field('payload', JSON.stringify(input))
|
||||
response.assertStatus(422)
|
||||
assert.equal(response.body().code, 'E_PAGE_MEDIA_FILE_MISSING')
|
||||
})
|
||||
|
||||
test('settings upsert requires its own permission and reports revalidation', async ({
|
||||
assert,
|
||||
client,
|
||||
}) => {
|
||||
const settings = {
|
||||
locale: 'en',
|
||||
header: { menus: [], ctaItems: [] },
|
||||
footer: { copyright: '© Spec', menus: [] },
|
||||
legal: { privacyPath: '/privacy', termsPath: '/terms' },
|
||||
}
|
||||
|
||||
const { token: viewer } = await createAdminWithToken(['web_manager.view'])
|
||||
const denied = await client.put('/web-manager/settings').bearerToken(viewer).json(settings)
|
||||
denied.assertStatus(403)
|
||||
|
||||
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
|
||||
const saved = await client.put('/web-manager/settings').bearerToken(token).json(settings)
|
||||
saved.assertStatus(200)
|
||||
assert.equal(saved.body().meta.revalidation.status, 'disabled')
|
||||
assert.deepEqual(saved.body().meta.revalidation.tags, ['web-manager:settings:en'])
|
||||
})
|
||||
})
|
||||
|
||||
test.group('WebManager public API', (group) => {
|
||||
group.each.setup(() => testUtils.db().wrapInGlobalTransaction())
|
||||
|
||||
test('resolve serves published pages only', async ({ assert, client }) => {
|
||||
const service = new WebManagerService()
|
||||
const { page } = await service.createDraft(pageInput({ title: 'Public page' }))
|
||||
|
||||
const asDraft = await client
|
||||
.get('/web-manager/public/pages/resolve')
|
||||
.qs({ locale: 'en', path: page.path })
|
||||
asDraft.assertStatus(404)
|
||||
|
||||
await service.publish(page.id)
|
||||
|
||||
const asPublished = await client
|
||||
.get('/web-manager/public/pages/resolve')
|
||||
.qs({ locale: 'en', path: page.path })
|
||||
asPublished.assertStatus(200)
|
||||
assert.equal(asPublished.body().page.title, 'Public page')
|
||||
assert.equal(asPublished.body().page.status, 'published')
|
||||
assert.isArray(asPublished.body().breadcrumbs)
|
||||
assert.isArray(asPublished.body().languageAlternatives)
|
||||
})
|
||||
|
||||
test('preview serves a draft through a signed token and rejects tampering', async ({
|
||||
assert,
|
||||
client,
|
||||
}) => {
|
||||
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
|
||||
const service = new WebManagerService()
|
||||
const { page } = await service.createDraft(pageInput({ title: 'Draft preview' }))
|
||||
|
||||
const minted = await client
|
||||
.post(`/web-manager/pages/${page.id}/preview-token`)
|
||||
.bearerToken(token)
|
||||
.json({})
|
||||
minted.assertStatus(201)
|
||||
const previewToken: string = minted.body().data.token
|
||||
|
||||
const preview = await client
|
||||
.get('/web-manager/public/pages/preview')
|
||||
.qs({ token: previewToken })
|
||||
preview.assertStatus(200)
|
||||
assert.equal(preview.body().page.title, 'Draft preview')
|
||||
assert.equal(preview.body().page.status, 'draft')
|
||||
|
||||
const tampered = await client
|
||||
.get('/web-manager/public/pages/preview')
|
||||
.qs({ token: `${previewToken}x` })
|
||||
tampered.assertStatus(404)
|
||||
})
|
||||
|
||||
test('sitemap lists published root pages with pagination', async ({ assert, client }) => {
|
||||
const service = new WebManagerService()
|
||||
const { page } = await service.createDraft(pageInput())
|
||||
await service.publish(page.id)
|
||||
await service.createDraft(pageInput()) // stays draft, must not appear
|
||||
|
||||
const response = await client.get('/web-manager/public/pages/sitemap')
|
||||
response.assertStatus(200)
|
||||
const items = response.body().items as Array<{ id: number }>
|
||||
assert.deepEqual(
|
||||
items.map((item) => item.id),
|
||||
[page.id]
|
||||
)
|
||||
assert.properties(response.body().pagination, ['page', 'perPage', 'total', 'lastPage'])
|
||||
})
|
||||
|
||||
test('blog listing returns published blog items, newest first', async ({ assert, client }) => {
|
||||
const service = new WebManagerService()
|
||||
const { page: older } = await service.createDraft(
|
||||
pageInput({ pageType: 'blog-item', title: 'Older post' })
|
||||
)
|
||||
await service.publish(older.id)
|
||||
const { page: newer } = await service.createDraft(
|
||||
pageInput({ pageType: 'blog-item', title: 'Newer post' })
|
||||
)
|
||||
await service.publish(newer.id)
|
||||
const { page: landing } = await service.createDraft(pageInput({ title: 'Not a post' }))
|
||||
await service.publish(landing.id)
|
||||
|
||||
const response = await client.get('/web-manager/public/blog/pages').qs({ locale: 'en' })
|
||||
response.assertStatus(200)
|
||||
const titles = (response.body().items as Array<{ title: string }>).map((item) => item.title)
|
||||
assert.deepEqual(titles, ['Newer post', 'Older post'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { test } from '@japa/runner'
|
||||
import PreviewTokenService from '#base_modules/WebManager/services/preview_token_service'
|
||||
|
||||
test.group('Web Manager preview tokens', () => {
|
||||
test('creates a verifiable token and rejects tampering', ({ assert }) => {
|
||||
const service = new PreviewTokenService()
|
||||
const { token } = service.create(42, 900)
|
||||
|
||||
assert.equal(service.verify(token)?.pageId, 42)
|
||||
assert.isNull(service.verify(`${token}tampered`))
|
||||
})
|
||||
|
||||
test('rejects expired tokens', ({ assert }) => {
|
||||
const service = new PreviewTokenService()
|
||||
const { token } = service.create(42, -1)
|
||||
|
||||
assert.isNull(service.verify(token))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import { test } from '@japa/runner'
|
||||
import PublicContentService from '#base_modules/WebManager/services/public_content_service'
|
||||
|
||||
test.group('Web Manager public content', () => {
|
||||
test('keeps safe rich text and removes executable markup', async ({ assert }) => {
|
||||
const service = new PublicContentService()
|
||||
const content = await service.prepare(
|
||||
{
|
||||
content:
|
||||
'<p class="lead" onclick="alert(1)">Hello <strong>world</strong></p><script>alert(1)</script><svg><a onload="alert(1)"></a></svg>',
|
||||
},
|
||||
new Map()
|
||||
)
|
||||
|
||||
assert.deepEqual(content, {
|
||||
content: '<p class="lead">Hello <strong>world</strong></p>',
|
||||
})
|
||||
})
|
||||
|
||||
test('rejects encoded unsafe links and resolves internal page links', async ({ assert }) => {
|
||||
const service = new PublicContentService()
|
||||
const content = await service.prepare(
|
||||
{
|
||||
html: '<a href="javascript:alert(1)" target="_blank">Unsafe</a>',
|
||||
ctaItems: [{ linkType: 'internal', pageId: 42, label: 'About' }],
|
||||
externalUrl: 'javascript:alert(1)',
|
||||
},
|
||||
new Map([['42', '/en/about']])
|
||||
)
|
||||
|
||||
assert.deepEqual(content, {
|
||||
html: '<a target="_blank" rel="noopener noreferrer">Unsafe</a>',
|
||||
ctaItems: [{ linkType: 'internal', pageId: 42, label: 'About', href: '/en/about' }],
|
||||
externalUrl: '',
|
||||
})
|
||||
})
|
||||
|
||||
test('survives out-of-range numeric character references', async ({ assert }) => {
|
||||
const service = new PublicContentService()
|
||||
const content = await service.prepare({ html: '<a href="�">Link</a>' }, new Map())
|
||||
|
||||
assert.deepEqual(content, { html: '<a href="�">Link</a>' })
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The link map is fetched by id, so anything this collector misses renders as an empty href
|
||||
* on the public site.
|
||||
*/
|
||||
test.group('Web Manager internal link collection', () => {
|
||||
test('finds internal page ids at any depth and normalizes them to strings', ({ assert }) => {
|
||||
const service = new PublicContentService()
|
||||
|
||||
const ids = service.collectInternalPageIds({
|
||||
menus: [
|
||||
{
|
||||
items: [
|
||||
{ linkType: 'internal', pageId: 13 },
|
||||
{ linkType: 'external', url: '/x' },
|
||||
],
|
||||
},
|
||||
{ items: [{ linkType: 'internal', pageId: '16' }] },
|
||||
],
|
||||
nested: { deep: { cta: { linkType: 'internal', pageId: 7 } } },
|
||||
})
|
||||
|
||||
assert.deepEqual([...ids].sort(), ['13', '16', '7'])
|
||||
})
|
||||
|
||||
test('ignores links that are not internal and empty content', ({ assert }) => {
|
||||
const service = new PublicContentService()
|
||||
|
||||
assert.deepEqual([...service.collectInternalPageIds({ linkType: 'external', pageId: 5 })], [])
|
||||
assert.deepEqual([...service.collectInternalPageIds(null)], [])
|
||||
assert.deepEqual([...service.collectInternalPageIds({ title: 'No links here' })], [])
|
||||
})
|
||||
|
||||
test('accumulates across several content sections into one set', ({ assert }) => {
|
||||
const service = new PublicContentService()
|
||||
const ids = new Set<string>()
|
||||
|
||||
service.collectInternalPageIds({ cta: { linkType: 'internal', pageId: 3 } }, ids)
|
||||
service.collectInternalPageIds({ cta: { linkType: 'internal', pageId: 3 } }, ids)
|
||||
service.collectInternalPageIds({ cta: { linkType: 'internal', pageId: 4 } }, ids)
|
||||
|
||||
assert.deepEqual([...ids].sort(), ['3', '4'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,297 @@
|
||||
import { test } from '@japa/runner'
|
||||
import testUtils from '@adonisjs/core/services/test_utils'
|
||||
import WebManagerService from '#base_modules/WebManager/services/web_manager_service'
|
||||
import WebPage from '#base_modules/WebManager/model/web_page'
|
||||
import WebComponent from '#base_modules/WebManager/model/web_component'
|
||||
import { WEB_MANAGER_CACHE_TAG } from '#base_modules/WebManager/services/web_revalidation_service'
|
||||
import {
|
||||
PageNotPublishedException,
|
||||
PageRouteConflictException,
|
||||
} from '#exceptions/web_manager_exceptions'
|
||||
import { bannerComponent, contentComponent, pageInput } from '#tests/utils/web_manager'
|
||||
|
||||
/**
|
||||
* Lifecycle specs for the draft → published → unpublished state machine.
|
||||
*
|
||||
* `.env.test` leaves WEB_REVALIDATE_SECRET empty, so revalidation reports
|
||||
* `disabled` but still returns the tags it would have purged — the assertions
|
||||
* below use those tags to tell a full-site purge from a targeted one.
|
||||
*/
|
||||
test.group('WebManager page lifecycle', (group) => {
|
||||
group.each.setup(() => testUtils.db().wrapInGlobalTransaction())
|
||||
|
||||
const service = () => new WebManagerService()
|
||||
|
||||
test('createDraft stores a root draft with components in submitted order', async ({ assert }) => {
|
||||
const { page } = await service().createDraft(
|
||||
pageInput({
|
||||
path: 'about-us',
|
||||
components: [contentComponent('first'), bannerComponent('second')],
|
||||
})
|
||||
)
|
||||
|
||||
const row = await WebPage.findOrFail(page.id)
|
||||
assert.equal(row.status, 'draft')
|
||||
assert.isNull(row.draftOfId)
|
||||
assert.isNull(row.publishedAt)
|
||||
|
||||
const components = await WebComponent.query().where('page_id', row.id).orderBy('sort_order')
|
||||
assert.deepEqual(
|
||||
components.map((component) => [component.type, component.sortOrder]),
|
||||
[
|
||||
['content', 0],
|
||||
['banner', 1],
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
test('createDraft normalizes the path before storing it', async ({ assert }) => {
|
||||
const { page } = await service().createDraft(pageInput({ path: '/about//team/' }))
|
||||
assert.equal(page.path, 'about/team')
|
||||
})
|
||||
|
||||
test('createDraft rejects a route held by any root row, whatever its status', async ({
|
||||
assert,
|
||||
}) => {
|
||||
await service().createDraft(pageInput({ path: 'taken', groupKey: 'taken-one' }))
|
||||
|
||||
await assert.rejects(
|
||||
() => service().createDraft(pageInput({ path: 'taken', groupKey: 'taken-two' })),
|
||||
PageRouteConflictException
|
||||
)
|
||||
})
|
||||
|
||||
test('saveDraft edits a draft in place and replaces components wholesale', async ({ assert }) => {
|
||||
const { page } = await service().createDraft(
|
||||
pageInput({ components: [contentComponent(), bannerComponent()] })
|
||||
)
|
||||
|
||||
const { page: saved } = await service().saveDraft(
|
||||
page.id,
|
||||
pageInput({
|
||||
path: page.path,
|
||||
groupKey: page.groupKey,
|
||||
title: 'Renamed',
|
||||
components: [bannerComponent('only one now')],
|
||||
})
|
||||
)
|
||||
|
||||
assert.equal(saved.id, page.id)
|
||||
assert.equal(saved.title, 'Renamed')
|
||||
assert.lengthOf(await WebPage.query(), 1, 'no clone was created for an unpublished draft')
|
||||
|
||||
const components = await WebComponent.query().where('page_id', page.id)
|
||||
assert.lengthOf(components, 1)
|
||||
assert.equal(components[0].type, 'banner')
|
||||
assert.equal(components[0].sortOrder, 0)
|
||||
})
|
||||
|
||||
test('saveDraft on a published page creates one clone and reuses it', async ({ assert }) => {
|
||||
const { page } = await service().createDraft(pageInput({ components: [contentComponent()] }))
|
||||
await service().publish(page.id)
|
||||
|
||||
const input = pageInput({
|
||||
path: page.path,
|
||||
groupKey: page.groupKey,
|
||||
title: 'Edited copy',
|
||||
components: [bannerComponent()],
|
||||
})
|
||||
const { page: firstClone } = await service().saveDraft(page.id, input)
|
||||
|
||||
assert.notEqual(firstClone.id, page.id)
|
||||
assert.equal(firstClone.draftOfId, page.id)
|
||||
assert.equal(firstClone.status, 'draft')
|
||||
|
||||
const original = await WebPage.findOrFail(page.id)
|
||||
assert.equal(original.status, 'published')
|
||||
assert.equal(original.title, 'Spec page')
|
||||
|
||||
const { page: secondClone } = await service().saveDraft(page.id, input)
|
||||
assert.equal(secondClone.id, firstClone.id)
|
||||
|
||||
const clones = await WebPage.query().where('draft_of_id', page.id)
|
||||
assert.lengthOf(clones, 1)
|
||||
})
|
||||
|
||||
test('publish flips a root draft live and purges the whole site', async ({ assert }) => {
|
||||
const { page } = await service().createDraft(pageInput())
|
||||
|
||||
const result = await service().publish(page.id)
|
||||
|
||||
assert.equal(result.page.page.status, 'published')
|
||||
assert.isNotNull(result.page.page.publishedAt)
|
||||
assert.equal(result.revalidation.status, 'disabled')
|
||||
assert.deepEqual(result.revalidation.tags, [WEB_MANAGER_CACHE_TAG])
|
||||
})
|
||||
|
||||
test('publish merges a pending clone; a content-only change purges only page tags', async ({
|
||||
assert,
|
||||
}) => {
|
||||
const { page } = await service().createDraft(pageInput({ components: [contentComponent()] }))
|
||||
await service().publish(page.id)
|
||||
|
||||
const { page: clone } = await service().saveDraft(
|
||||
page.id,
|
||||
pageInput({
|
||||
path: page.path,
|
||||
groupKey: page.groupKey,
|
||||
title: page.title,
|
||||
components: [bannerComponent('new content')],
|
||||
})
|
||||
)
|
||||
|
||||
const result = await service().publish(page.id)
|
||||
|
||||
assert.notInclude(result.revalidation.tags, WEB_MANAGER_CACHE_TAG)
|
||||
assert.include(result.revalidation.tags, `${WEB_MANAGER_CACHE_TAG}:page:en:${page.path}`)
|
||||
|
||||
assert.isNull(await WebPage.find(clone.id))
|
||||
const components = await WebComponent.query().where('page_id', page.id)
|
||||
assert.lengthOf(components, 1)
|
||||
assert.equal(components[0].type, 'banner')
|
||||
})
|
||||
|
||||
test('publish treats a changed title as structural and purges the whole site', async ({
|
||||
assert,
|
||||
}) => {
|
||||
const { page } = await service().createDraft(pageInput())
|
||||
await service().publish(page.id)
|
||||
|
||||
await service().saveDraft(
|
||||
page.id,
|
||||
pageInput({ path: page.path, groupKey: page.groupKey, title: 'New title' })
|
||||
)
|
||||
const result = await service().publish(page.id)
|
||||
|
||||
assert.deepEqual(result.revalidation.tags, [WEB_MANAGER_CACHE_TAG])
|
||||
assert.equal(result.page.page.title, 'New title')
|
||||
})
|
||||
|
||||
test('publishing an already-published page with no clone only re-purges its cache', async ({
|
||||
assert,
|
||||
}) => {
|
||||
const { page } = await service().createDraft(pageInput())
|
||||
const first = await service().publish(page.id)
|
||||
|
||||
const again = await service().publish(page.id)
|
||||
|
||||
assert.equal(again.page.page.publishedAt, first.page.page.publishedAt)
|
||||
assert.notInclude(again.revalidation.tags, WEB_MANAGER_CACHE_TAG)
|
||||
assert.include(again.revalidation.tags, `${WEB_MANAGER_CACHE_TAG}:page:en:${page.path}`)
|
||||
})
|
||||
|
||||
test('publish refuses when another published page holds the address', async ({ assert }) => {
|
||||
// Two root rows on the same route can only exist when one is not published
|
||||
// (assertNewRouteAvailable blocks the service path), so build them directly.
|
||||
await WebPage.create({
|
||||
status: 'published',
|
||||
draftOfId: null,
|
||||
locale: 'en',
|
||||
path: 'clash',
|
||||
groupKey: 'clash-live',
|
||||
pageType: 'landing',
|
||||
title: 'Live page',
|
||||
hero: null,
|
||||
seoTitle: '',
|
||||
seoDescription: '',
|
||||
})
|
||||
const contender = await WebPage.create({
|
||||
status: 'draft',
|
||||
draftOfId: null,
|
||||
locale: 'en',
|
||||
path: 'clash',
|
||||
groupKey: 'clash-draft',
|
||||
pageType: 'landing',
|
||||
title: 'Contender',
|
||||
hero: null,
|
||||
seoTitle: '',
|
||||
seoDescription: '',
|
||||
})
|
||||
|
||||
await assert.rejects(() => service().publish(contender.id), PageRouteConflictException)
|
||||
})
|
||||
|
||||
test('unpublish takes the page down but keeps content, date, and address', async ({ assert }) => {
|
||||
const { page } = await service().createDraft(pageInput({ components: [contentComponent()] }))
|
||||
const published = await service().publish(page.id)
|
||||
|
||||
const result = await service().unpublish(page.id)
|
||||
|
||||
assert.equal(result.page.page.status, 'unpublished')
|
||||
assert.equal(result.page.page.publishedAt, published.page.page.publishedAt)
|
||||
assert.deepEqual(result.revalidation.tags, [WEB_MANAGER_CACHE_TAG])
|
||||
assert.lengthOf(await WebComponent.query().where('page_id', page.id), 1)
|
||||
|
||||
// the address stays reserved for the unpublished page
|
||||
await assert.rejects(
|
||||
() => service().createDraft(pageInput({ path: page.path, groupKey: 'squatter' })),
|
||||
PageRouteConflictException
|
||||
)
|
||||
})
|
||||
|
||||
test('unpublish rejects anything that is not currently published', async ({ assert }) => {
|
||||
const { page } = await service().createDraft(pageInput())
|
||||
await assert.rejects(() => service().unpublish(page.id), PageNotPublishedException)
|
||||
})
|
||||
|
||||
test('an unpublished page is edited in place and can be republished', async ({ assert }) => {
|
||||
const { page } = await service().createDraft(pageInput())
|
||||
await service().publish(page.id)
|
||||
await service().unpublish(page.id)
|
||||
|
||||
const { page: edited } = await service().saveDraft(
|
||||
page.id,
|
||||
pageInput({ path: page.path, groupKey: page.groupKey, title: 'Back soon' })
|
||||
)
|
||||
assert.equal(edited.id, page.id, 'no clone for a page that is not serving')
|
||||
|
||||
const republished = await service().publish(page.id)
|
||||
assert.equal(republished.page.page.status, 'published')
|
||||
assert.deepEqual(republished.revalidation.tags, [WEB_MANAGER_CACHE_TAG])
|
||||
})
|
||||
|
||||
test('discarding a clone leaves the published original untouched', async ({ assert }) => {
|
||||
const { page } = await service().createDraft(pageInput({ components: [contentComponent()] }))
|
||||
await service().publish(page.id)
|
||||
const { page: clone } = await service().saveDraft(
|
||||
page.id,
|
||||
pageInput({ path: page.path, groupKey: page.groupKey, title: 'Scrapped edit' })
|
||||
)
|
||||
|
||||
await service().discard(clone.id)
|
||||
|
||||
assert.isNull(await WebPage.find(clone.id))
|
||||
const original = await WebPage.findOrFail(page.id)
|
||||
assert.equal(original.status, 'published')
|
||||
assert.lengthOf(await WebComponent.query().where('page_id', page.id), 1)
|
||||
})
|
||||
|
||||
test('deleting a published page cascades its clone and components', async ({ assert }) => {
|
||||
const { page } = await service().createDraft(pageInput({ components: [contentComponent()] }))
|
||||
await service().publish(page.id)
|
||||
const { page: clone } = await service().saveDraft(
|
||||
page.id,
|
||||
pageInput({ path: page.path, groupKey: page.groupKey, title: 'Open draft' })
|
||||
)
|
||||
|
||||
await service().discard(page.id)
|
||||
|
||||
assert.isNull(await WebPage.find(page.id))
|
||||
assert.isNull(await WebPage.find(clone.id))
|
||||
assert.lengthOf(await WebComponent.query().whereIn('page_id', [page.id, clone.id]), 0)
|
||||
})
|
||||
|
||||
test('resolvePublicPage serves published pages only and normalizes the path', async ({
|
||||
assert,
|
||||
}) => {
|
||||
const { page } = await service().createDraft(pageInput({ title: 'Public page' }))
|
||||
assert.isNull(await service().resolvePublicPage('en', page.path))
|
||||
|
||||
await service().publish(page.id)
|
||||
|
||||
const resolved = await service().resolvePublicPage('en', `/${page.path}/`)
|
||||
assert.isNotNull(resolved)
|
||||
assert.equal(resolved!.page.title, 'Public page')
|
||||
assert.equal(resolved!.page.seo.title, 'Public page', 'seo title falls back to the page title')
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user