@portaki/module-sections 1.2.7 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,79 @@
1
+ import type { SectionsEditorDesign } from './sections-host.types'
2
+
3
+ export const sectionsEditorDesign = {
4
+ id: 'sections-editor-v1',
5
+ moduleId: 'sections',
6
+ layout: 'sections-split-panel',
7
+ gateway: {
8
+ listQuery: 'sections.list',
9
+ saveCommand: 'sections.section.save',
10
+ deleteCommand: 'sections.section.delete',
11
+ reorderCommand: 'sections.reorder',
12
+ },
13
+ createModal: {
14
+ titleFr: 'Nouvelle section',
15
+ subtitleFr: 'Choisissez un modèle pour partir vite, ou créez une section vide.',
16
+ titleFieldLabelFr: 'Titre',
17
+ titleFieldHintFr: 'Visible par le voyageur, en haut de la section.',
18
+ footerHintFr: 'Vous pourrez renommer et réordonner ensuite.',
19
+ cancelLabelFr: 'Annuler',
20
+ submitLabelFr: 'Créer la section',
21
+ defaultTemplateId: 'welcome',
22
+ templates: [
23
+ {
24
+ id: 'welcome',
25
+ icon: '👋',
26
+ titleFr: 'Mot de bienvenue',
27
+ titleEn: 'Welcome message',
28
+ descriptionFr: "Un paragraphe d'accueil personnalisé",
29
+ contentPreset: 'welcome',
30
+ },
31
+ {
32
+ id: 'beaches',
33
+ icon: '🏖️',
34
+ titleFr: 'Plages & criques',
35
+ titleEn: 'Beaches & coves',
36
+ descriptionFr: 'Liste de plages avec accès & distance',
37
+ contentPreset: 'beaches',
38
+ },
39
+ {
40
+ id: 'restaurants',
41
+ icon: '🍝',
42
+ titleFr: 'Nos restaurants préférés',
43
+ titleEn: 'Our favourite restaurants',
44
+ descriptionFr: 'Recommandations par budget',
45
+ contentPreset: 'restaurants',
46
+ },
47
+ {
48
+ id: 'transport',
49
+ icon: '🚂',
50
+ titleFr: 'Comment venir & repartir',
51
+ titleEn: 'Getting here & leaving',
52
+ descriptionFr: 'Train, bus, voiture, taxi',
53
+ contentPreset: 'transport',
54
+ },
55
+ {
56
+ id: 'emergencies',
57
+ icon: '🆘',
58
+ titleFr: 'Urgences & numéros utiles',
59
+ titleEn: 'Emergencies & useful numbers',
60
+ descriptionFr: '112, médecin, police',
61
+ contentPreset: 'emergencies',
62
+ },
63
+ {
64
+ id: 'empty',
65
+ icon: '📋',
66
+ titleFr: 'Vide · personnalisée',
67
+ titleEn: 'Empty · custom',
68
+ descriptionFr: "Démarrer d'une page blanche",
69
+ contentPreset: 'empty',
70
+ },
71
+ ],
72
+ },
73
+ emptyState: {
74
+ titleFr: 'Aucune section pour l’instant',
75
+ descriptionFr:
76
+ 'Structurez le carnet d’accueil en blocs (plages, restaurants, consignes…) visibles par vos voyageurs dans le livret.',
77
+ primaryActionLabelFr: 'Créer une section',
78
+ },
79
+ } satisfies SectionsEditorDesign
@@ -0,0 +1,53 @@
1
+ export const SECTION_CONTENT_PRESET_IDS = [
2
+ 'welcome',
3
+ 'beaches',
4
+ 'restaurants',
5
+ 'transport',
6
+ 'emergencies',
7
+ 'empty',
8
+ ] as const
9
+
10
+ export type SectionContentPresetId = (typeof SECTION_CONTENT_PRESET_IDS)[number]
11
+
12
+ export type SectionTemplateId = SectionContentPresetId
13
+
14
+ export type SectionsEditorEmptyStateDesign = {
15
+ titleFr: string
16
+ descriptionFr: string
17
+ primaryActionLabelFr: string
18
+ }
19
+
20
+ export type SectionCreateModalDesign = {
21
+ titleFr: string
22
+ subtitleFr: string
23
+ titleFieldLabelFr: string
24
+ titleFieldHintFr: string
25
+ footerHintFr: string
26
+ cancelLabelFr: string
27
+ submitLabelFr: string
28
+ defaultTemplateId: SectionTemplateId
29
+ templates: readonly SectionTemplateDesign[]
30
+ }
31
+
32
+ export type SectionTemplateDesign = {
33
+ id: SectionTemplateId
34
+ icon: string
35
+ titleFr: string
36
+ titleEn: string
37
+ descriptionFr: string
38
+ contentPreset: SectionContentPresetId
39
+ }
40
+
41
+ export type SectionsEditorDesign = {
42
+ id: 'sections-editor-v1'
43
+ moduleId: 'sections'
44
+ layout: 'sections-split-panel'
45
+ gateway: {
46
+ listQuery: 'sections.list'
47
+ saveCommand: 'sections.section.save'
48
+ deleteCommand: 'sections.section.delete'
49
+ reorderCommand: 'sections.reorder'
50
+ }
51
+ createModal: SectionCreateModalDesign
52
+ emptyState: SectionsEditorEmptyStateDesign
53
+ }
@@ -0,0 +1,9 @@
1
+ export const sectionsToastCopy = {
2
+ loadError: 'Impossible de charger les sections.',
3
+ saveError: 'Enregistrement impossible.',
4
+ reorderError: 'Réordonnancement impossible.',
5
+ createSuccess: 'Section créée.',
6
+ createError: 'Création impossible.',
7
+ deleteSuccess: 'Section supprimée.',
8
+ deleteError: 'Suppression impossible.',
9
+ } as const
@@ -1,17 +1,32 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { waitFor } from '@testing-library/react'
3
- import { assertGuestSurface, renderGuestModule } from '@portaki/sdk-test-support'
3
+ import {
4
+ assertGuestSurface,
5
+ renderGuestModule,
6
+ renderHostModule,
7
+ } from '@portaki/sdk-test-support'
4
8
 
5
9
  import moduleDef from './index'
10
+ import { createSectionsTestHostContext } from './test/mock-host-module-context'
6
11
 
7
12
  describe('@portaki/module-sections', () => {
8
13
  it('exposes a valid guest module definition', () => {
9
14
  assertGuestSurface(moduleDef)
10
15
  })
11
16
 
12
- it('renders without crashing', async () => {
13
- const view =
14
- moduleDef.surface === 'host' ? renderHostModule(moduleDef) : renderGuestModule(moduleDef)
17
+ it('renders guest surface without crashing', async () => {
18
+ const view = renderGuestModule(moduleDef)
19
+ await waitFor(() => {
20
+ expect(view.container).toBeTruthy()
21
+ })
22
+ view.unmount()
23
+ })
24
+
25
+ it('renders host surface without crashing', async () => {
26
+ if (moduleDef.renderHost == null) {
27
+ return
28
+ }
29
+ const view = renderHostModule(moduleDef, createSectionsTestHostContext())
15
30
  await waitFor(() => {
16
31
  expect(view.container).toBeTruthy()
17
32
  })
@@ -1,6 +1,5 @@
1
1
  import {
2
2
  defineModule,
3
- index,
4
3
  int,
5
4
  jsonb,
6
5
  moduleSchema,
@@ -11,11 +10,14 @@ import {
11
10
  timestamptz,
12
11
  uuid,
13
12
  uuidPrimaryKey,
13
+ type HostModuleContext,
14
14
  type ModuleContext,
15
15
  type PortakiFullModule,
16
16
  } from '@portaki/sdk'
17
17
  import type { ReactNode } from 'react'
18
18
 
19
+ import { SectionsHostWorkspace } from './host/SectionsHostWorkspace'
20
+
19
21
  const sectionsSchema = moduleSchema([
20
22
  table('items', 't_e_module_sections_item', {
21
23
  columns: [
@@ -23,17 +25,28 @@ const sectionsSchema = moduleSchema([
23
25
  uuid('propertyId'),
24
26
  workspaceId(),
25
27
  int('sortOrder', { defaultSql: '0' }),
26
- text('titleFr', { defaultSql: "''" }),
27
- text('titleEn', { defaultSql: "''" }),
28
- jsonb('contentFr', { nullable: true }),
29
- jsonb('contentEn', { nullable: true }),
30
28
  timestamptz('createdAt', { nullable: false, defaultSql: 'now()' }),
31
29
  timestamptz('updatedAt', { nullable: false, defaultSql: 'now()' }),
32
30
  ],
33
31
  indexes: [workspacePropertyIndex()],
34
32
  }),
33
+ table('itemLocales', 't_e_module_sections_item_locale', {
34
+ columns: [
35
+ uuid('sectionId', { primaryKey: true }),
36
+ text('lang', { primaryKey: true }),
37
+ workspaceId(),
38
+ uuid('propertyId'),
39
+ text('title', { defaultSql: "''" }),
40
+ jsonb('content', { nullable: true }),
41
+ ],
42
+ indexes: [workspacePropertyIndex()],
43
+ }),
35
44
  ])
36
45
 
46
+ const SECTION_LANGS = ['fr', 'en'] as const
47
+
48
+ type SectionLang = (typeof SECTION_LANGS)[number]
49
+
37
50
  function stringParam(params: Record<string, unknown>, key: string): string | null {
38
51
  const value = params[key]
39
52
  if (value == null) {
@@ -53,6 +66,75 @@ function intParam(params: Record<string, unknown>, key: string, defaultValue: nu
53
66
  return Number.parseInt(String(value), 10)
54
67
  }
55
68
 
69
+ function localeTitleForLang(
70
+ lang: SectionLang,
71
+ titleFr: string,
72
+ titleEn: string,
73
+ ): string {
74
+ if (lang === 'fr') {
75
+ return titleFr
76
+ }
77
+ return titleEn.trim() === '' ? titleFr : titleEn
78
+ }
79
+
80
+ function localeContentForLang(
81
+ lang: SectionLang,
82
+ contentFr: unknown,
83
+ contentEn: unknown,
84
+ ): unknown {
85
+ if (lang === 'fr') {
86
+ return contentFr ?? null
87
+ }
88
+ return contentEn ?? null
89
+ }
90
+
91
+ async function upsertSectionLocale(
92
+ ctx: ModuleContext,
93
+ filters: { workspaceId: string; propertyId: string },
94
+ sectionId: string,
95
+ lang: SectionLang,
96
+ title: string,
97
+ content: unknown,
98
+ ): Promise<void> {
99
+ const where = { ...filters, sectionId, lang }
100
+ const existing = await ctx.db.from('itemLocales').where(where).one()
101
+ const payload = { title, content }
102
+ if (existing != null) {
103
+ await ctx.db.from('itemLocales').where(where).update(payload)
104
+ return
105
+ }
106
+ await ctx.db.from('itemLocales').insert({
107
+ ...where,
108
+ ...payload,
109
+ })
110
+ }
111
+
112
+ function sectionDtoFromLocales(
113
+ item: Record<string, unknown>,
114
+ locales: Record<string, unknown>[],
115
+ ): Record<string, unknown> {
116
+ const byLang = new Map<string, Record<string, unknown>>()
117
+ for (const row of locales) {
118
+ byLang.set(String(row.lang), row)
119
+ }
120
+ const fr = byLang.get('fr')
121
+ const en = byLang.get('en')
122
+ const titleFr = String(fr?.title ?? '')
123
+ const titleEnRaw = String(en?.title ?? '')
124
+ const section: Record<string, unknown> = {
125
+ id: item.id,
126
+ sortOrder: item.sortOrder,
127
+ titleFr,
128
+ titleEn: titleEnRaw.trim() === '' ? titleFr : titleEnRaw,
129
+ contentFr: fr?.content ?? null,
130
+ contentEn: en?.content ?? null,
131
+ }
132
+ if (item.updatedAt != null) {
133
+ section.updatedAt = item.updatedAt
134
+ }
135
+ return section
136
+ }
137
+
56
138
  export function defineSectionsModule(render: (ctx: ModuleContext) => ReactNode): PortakiFullModule {
57
139
  return defineModule({
58
140
  id: 'sections',
@@ -62,8 +144,8 @@ export function defineSectionsModule(render: (ctx: ModuleContext) => ReactNode):
62
144
  en: 'Welcome book editorial content.',
63
145
  },
64
146
  icon: 'file-text',
65
- version: '1.2.4',
66
- schemaVersion: '1.0.1',
147
+ version: '1.2.7',
148
+ schemaVersion: '1.0.2',
67
149
  schema: sectionsSchema,
68
150
  navSlot: 'section',
69
151
 
@@ -75,10 +157,16 @@ export function defineSectionsModule(render: (ctx: ModuleContext) => ReactNode):
75
157
  en: 'Property editorial sections.',
76
158
  },
77
159
  async handler(ctx) {
78
- const rows = await ctx.db
79
- .from('items')
80
- .where({ workspaceId: ctx.workspaceId, propertyId: ctx.propertyId })
81
- .many()
160
+ const filters = { workspaceId: ctx.workspaceId, propertyId: ctx.propertyId }
161
+ const rows = await ctx.db.from('items').where(filters).many()
162
+ const locales = await ctx.db.from('itemLocales').where(filters).many()
163
+ const localesBySection = new Map<string, Record<string, unknown>[]>()
164
+ for (const locale of locales) {
165
+ const sectionId = String(locale.sectionId)
166
+ const bucket = localesBySection.get(sectionId) ?? []
167
+ bucket.push(locale)
168
+ localesBySection.set(sectionId, bucket)
169
+ }
82
170
  rows.sort((a, b) => {
83
171
  const orderA = Number(a.sortOrder ?? 0)
84
172
  const orderB = Number(b.sortOrder ?? 0)
@@ -89,21 +177,9 @@ export function defineSectionsModule(render: (ctx: ModuleContext) => ReactNode):
89
177
  const createdB = String(b.createdAt ?? '')
90
178
  return createdA.localeCompare(createdB)
91
179
  })
92
- const sections = rows.map((row) => {
93
- const section: Record<string, unknown> = {
94
- id: row.id,
95
- sortOrder: row.sortOrder,
96
- titleFr: row.titleFr,
97
- titleEn: row.titleEn,
98
- contentFr: row.contentFr ?? null,
99
- contentEn: row.contentEn ?? null,
100
- }
101
- if (row.updatedAt != null) {
102
- section.updatedAt = row.updatedAt
103
- }
104
- return section
105
- })
106
- return { sections }
180
+ return rows.map((row) =>
181
+ sectionDtoFromLocales(row, localesBySection.get(String(row.id)) ?? []),
182
+ )
107
183
  },
108
184
  },
109
185
  },
@@ -146,24 +222,30 @@ export function defineSectionsModule(render: (ctx: ModuleContext) => ReactNode):
146
222
  sortOrder = max + 1
147
223
  }
148
224
  const now = new Date().toISOString()
149
- const payload = {
150
- titleFr,
151
- titleEn,
152
- contentFr,
153
- contentEn,
225
+ const itemPayload = {
154
226
  sortOrder,
155
227
  updatedAt: now,
156
228
  }
157
229
  if (existing != null) {
158
- await ctx.db.from('items').where({ ...filters, id }).update(payload)
159
- return
230
+ await ctx.db.from('items').where({ ...filters, id }).update(itemPayload)
231
+ } else {
232
+ await ctx.db.from('items').insert({
233
+ id,
234
+ ...filters,
235
+ ...itemPayload,
236
+ createdAt: now,
237
+ })
238
+ }
239
+ for (const lang of SECTION_LANGS) {
240
+ await upsertSectionLocale(
241
+ ctx,
242
+ filters,
243
+ id,
244
+ lang,
245
+ localeTitleForLang(lang, titleFr, titleEn),
246
+ localeContentForLang(lang, contentFr, contentEn),
247
+ )
160
248
  }
161
- await ctx.db.from('items').insert({
162
- id,
163
- ...filters,
164
- ...payload,
165
- createdAt: now,
166
- })
167
249
  },
168
250
  },
169
251
  'sections.section.delete': {
@@ -177,14 +259,13 @@ export function defineSectionsModule(render: (ctx: ModuleContext) => ReactNode):
177
259
  if (idRaw == null || idRaw.trim() === '') {
178
260
  throw new Error('id_required')
179
261
  }
180
- const deleted = await ctx.db
181
- .from('items')
182
- .where({
183
- id: idRaw.trim(),
184
- workspaceId: ctx.workspaceId,
185
- propertyId: ctx.propertyId,
186
- })
187
- .delete()
262
+ const id = idRaw.trim()
263
+ const filters = {
264
+ id,
265
+ workspaceId: ctx.workspaceId,
266
+ propertyId: ctx.propertyId,
267
+ }
268
+ const deleted = await ctx.db.from('items').where(filters).delete()
188
269
  if (deleted === 0) {
189
270
  throw new Error('section_not_found')
190
271
  }
@@ -208,10 +289,10 @@ export function defineSectionsModule(render: (ctx: ModuleContext) => ReactNode):
208
289
  if (item == null) {
209
290
  continue
210
291
  }
211
- const id = String(item)
292
+ const sectionId = String(item)
212
293
  await ctx.db
213
294
  .from('items')
214
- .where({ ...filters, id })
295
+ .where({ ...filters, id: sectionId })
215
296
  .update({ sortOrder: order++, updatedAt: now })
216
297
  }
217
298
  },
@@ -219,5 +300,6 @@ export function defineSectionsModule(render: (ctx: ModuleContext) => ReactNode):
219
300
  },
220
301
 
221
302
  render,
303
+ renderHost: (ctx: HostModuleContext) => <SectionsHostWorkspace ctx={ctx} />,
222
304
  })
223
305
  }
@@ -0,0 +1,35 @@
1
+ import type { HostModuleContext, HostModuleShellComponents } from '@portaki/sdk'
2
+ import { createMockHostModuleContext } from '@portaki/sdk-test-support'
3
+
4
+ /** Stubs until published `@portaki/sdk-test-support` includes `shell` / `gateway`. */
5
+ export const mockHostShell: HostModuleShellComponents = {
6
+ NotionLikeEditor: () => null,
7
+ HostEmptyPlaceholder: () => null,
8
+ HostUnderlineTabs: () => null,
9
+ Button: () => null,
10
+ Input: () => null,
11
+ Dialog: ({ children }) => children ?? null,
12
+ DialogContent: ({ children }) => children ?? null,
13
+ DialogHeader: ({ children }) => children ?? null,
14
+ DialogTitle: ({ children }) => children ?? null,
15
+ DialogDescription: ({ children }) => children ?? null,
16
+ DialogFooter: ({ children }) => children ?? null,
17
+ cn: (...parts) => parts.filter(Boolean).join(' '),
18
+ toast: { success: () => {}, error: () => {} },
19
+ parseTipTapDoc: () => ({ type: 'doc', content: [{ type: 'paragraph' }] }),
20
+ serializeTipTapDoc: (doc) => JSON.stringify(doc),
21
+ }
22
+
23
+ export function createSectionsTestHostContext(
24
+ overrides: Partial<HostModuleContext> = {},
25
+ ): HostModuleContext {
26
+ const base = createMockHostModuleContext(overrides)
27
+ return {
28
+ ...base,
29
+ gateway: overrides.gateway ?? base.gateway ?? {
30
+ query: async <T,>() => [] as T,
31
+ command: async () => {},
32
+ },
33
+ shell: overrides.shell ?? base.shell ?? mockHostShell,
34
+ }
35
+ }