@portaki/module-sections 1.0.1 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,16 +1,12 @@
1
1
  {
2
2
  "name": "@portaki/module-sections",
3
- "version": "1.0.1",
3
+ "version": "1.2.3",
4
4
  "description": "Portaki module — editorial sections (TipTap)",
5
5
  "license": "AGPL-3.0",
6
6
  "type": "module",
7
7
  "main": "./src/index.tsx",
8
- "scripts": {
9
- "lint": "echo \"no lint yet\""
10
- },
11
8
  "dependencies": {
12
- "@portaki/module-sdk": "0.5.0",
13
- "@portaki/sdk": "^0.5.0",
9
+ "@portaki/sdk": "^2.0.4",
14
10
  "@tiptap/core": "^3.0.0",
15
11
  "@tiptap/html": "^3.0.0",
16
12
  "@tiptap/starter-kit": "^3.0.0",
@@ -18,8 +14,14 @@
18
14
  "react-dom": "^19.1.0"
19
15
  },
20
16
  "devDependencies": {
17
+ "@portaki/cli": "^0.1.2",
18
+ "@portaki/sdk-test-support": "^2.0.4",
19
+ "@testing-library/jest-dom": "^6.6.3",
20
+ "@testing-library/react": "^16.3.0",
21
21
  "@types/react": "^19",
22
- "typescript": "^5"
22
+ "jsdom": "^26.0.0",
23
+ "typescript": "^5",
24
+ "vitest": "^3.0.5"
23
25
  },
24
26
  "repository": {
25
27
  "type": "git",
@@ -28,5 +30,11 @@
28
30
  },
29
31
  "publishConfig": {
30
32
  "access": "public"
33
+ },
34
+ "scripts": {
35
+ "build": "portaki build --entry src/portaki.gateway.tsx",
36
+ "lint": "echo \"no lint yet\"",
37
+ "test": "vitest run",
38
+ "test:watch": "vitest"
31
39
  }
32
- }
40
+ }
@@ -0,0 +1,124 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/PortakiApp/portaki-sdk/main/schema/module.v1.json",
3
+ "id": "sections",
4
+ "name": {
5
+ "fr": "Sections éditoriales",
6
+ "en": "Editorial sections"
7
+ },
8
+ "description": {
9
+ "fr": "Blocs de contenu riches (TipTap) pour le carnet d’accueil invité.",
10
+ "en": "Rich content blocks (TipTap) for the guest welcome book."
11
+ },
12
+ "version": "1.2.3",
13
+ "releaseNotesUrl": "https://github.com/PortakiApp/portaki-modules/releases",
14
+ "changelog": [
15
+ {
16
+ "version": "1.2.0",
17
+ "date": "2026-05-18",
18
+ "notes": {
19
+ "fr": "Release catalogue modules 1.2.0.",
20
+ "en": "Module catalog release 1.2.0."
21
+ }
22
+ },
23
+ {
24
+ "version": "1.0.0",
25
+ "date": "2026-05-16",
26
+ "notes": {
27
+ "fr": "Première version : sections TipTap, gateway hôte et invité.",
28
+ "en": "Initial release: TipTap sections, host and guest gateway."
29
+ }
30
+ }
31
+ ],
32
+ "author": {
33
+ "name": "Portaki",
34
+ "url": "https://portaki.app",
35
+ "type": "official"
36
+ },
37
+ "icon": "file-text",
38
+ "type": "official",
39
+ "tags": [
40
+ "editorial",
41
+ "content",
42
+ "sections"
43
+ ],
44
+ "portakiVersionMin": "1.0.0",
45
+ "requiresHostSdk": "2.0.0",
46
+ "database": {
47
+ "schemaVersion": "1.0.1"
48
+ },
49
+ "license": "AGPL-3.0",
50
+ "repository": "https://github.com/PortakiApp/portaki-modules",
51
+ "scopes": [
52
+ "stay:read",
53
+ "property:read",
54
+ "host:property:read",
55
+ "host:property:write"
56
+ ],
57
+ "queries": [
58
+ {
59
+ "name": "sections.list",
60
+ "description": {
61
+ "fr": "Liste des sections du logement.",
62
+ "en": "Property editorial sections."
63
+ },
64
+ "scope": "property:read"
65
+ }
66
+ ],
67
+ "commands": [
68
+ {
69
+ "name": "sections.section.save",
70
+ "description": {
71
+ "fr": "Créer ou mettre à jour une section.",
72
+ "en": "Create or update a section."
73
+ },
74
+ "scope": "host:property:write"
75
+ },
76
+ {
77
+ "name": "sections.section.delete",
78
+ "description": {
79
+ "fr": "Supprimer une section.",
80
+ "en": "Delete a section."
81
+ },
82
+ "scope": "host:property:write"
83
+ },
84
+ {
85
+ "name": "sections.reorder",
86
+ "description": {
87
+ "fr": "Réordonner les sections.",
88
+ "en": "Reorder sections."
89
+ },
90
+ "scope": "host:property:write"
91
+ }
92
+ ],
93
+ "hostSurfaces": [
94
+ {
95
+ "type": "property-workspace-tab",
96
+ "pathSegment": "sections",
97
+ "label": {
98
+ "fr": "Sections",
99
+ "en": "Sections"
100
+ },
101
+ "icon": "file-text"
102
+ }
103
+ ],
104
+ "config": {
105
+ "defaults": {},
106
+ "fields": []
107
+ },
108
+ "catalog": {
109
+ "tagline": {
110
+ "fr": "Composez votre carnet section par section.",
111
+ "en": "Build your welcome book section by section."
112
+ },
113
+ "npmPackage": "@portaki/module-sections",
114
+ "npmUrl": "https://www.npmjs.com/package/@portaki/module-sections"
115
+ },
116
+ "runtime": {
117
+ "backend": "wasm",
118
+ "guest": "remote-esm"
119
+ },
120
+ "artifacts": {
121
+ "wasmUrl": "oci://ghcr.io/portakiapp/portaki-module-sections",
122
+ "guestEsmUrl": "https://esm.sh/@portaki/module-sections@1.2.3"
123
+ }
124
+ }
@@ -9,7 +9,26 @@
9
9
  "fr": "Blocs de contenu riches (TipTap) pour le carnet d’accueil invité.",
10
10
  "en": "Rich content blocks (TipTap) for the guest welcome book."
11
11
  },
12
- "version": "1.0.0",
12
+ "version": "1.2.1",
13
+ "releaseNotesUrl": "https://github.com/PortakiApp/portaki-modules/releases",
14
+ "changelog": [
15
+ {
16
+ "version": "1.2.0",
17
+ "date": "2026-05-18",
18
+ "notes": {
19
+ "fr": "Release catalogue modules 1.2.0.",
20
+ "en": "Module catalog release 1.2.0."
21
+ }
22
+ },
23
+ {
24
+ "version": "1.0.0",
25
+ "date": "2026-05-16",
26
+ "notes": {
27
+ "fr": "Première version : sections TipTap, gateway hôte et invité.",
28
+ "en": "Initial release: TipTap sections, host and guest gateway."
29
+ }
30
+ }
31
+ ],
13
32
  "author": {
14
33
  "name": "Portaki",
15
34
  "url": "https://portaki.app",
@@ -17,58 +36,68 @@
17
36
  },
18
37
  "icon": "file-text",
19
38
  "type": "official",
20
- "tags": ["editorial", "content", "sections"],
39
+ "tags": [
40
+ "editorial",
41
+ "content",
42
+ "sections"
43
+ ],
21
44
  "portakiVersionMin": "1.0.0",
22
- "requiresHostSdk": "0.6.0",
45
+ "requiresHostSdk": "2.0.0",
46
+ "database": {
47
+ "schemaVersion": "1.0.1"
48
+ },
23
49
  "license": "AGPL-3.0",
24
50
  "repository": "https://github.com/PortakiApp/portaki-modules",
25
51
  "scopes": [
26
- "stay:read",
27
- "property:read",
28
52
  "host:property:read",
29
- "host:property:write"
53
+ "host:property:write",
54
+ "property:read",
55
+ "stay:read"
30
56
  ],
31
57
  "queries": [
32
58
  {
33
59
  "name": "sections.list",
60
+ "scope": "property:read",
34
61
  "description": {
35
62
  "fr": "Liste des sections du logement.",
36
63
  "en": "Property editorial sections."
37
- },
38
- "scope": "property:read"
64
+ }
39
65
  }
40
66
  ],
41
67
  "commands": [
42
68
  {
43
69
  "name": "sections.section.save",
70
+ "scope": "host:property:write",
44
71
  "description": {
45
72
  "fr": "Créer ou mettre à jour une section.",
46
73
  "en": "Create or update a section."
47
- },
48
- "scope": "host:property:write"
74
+ }
49
75
  },
50
76
  {
51
77
  "name": "sections.section.delete",
78
+ "scope": "host:property:write",
52
79
  "description": {
53
80
  "fr": "Supprimer une section.",
54
81
  "en": "Delete a section."
55
- },
56
- "scope": "host:property:write"
82
+ }
57
83
  },
58
84
  {
59
85
  "name": "sections.reorder",
86
+ "scope": "host:property:write",
60
87
  "description": {
61
88
  "fr": "Réordonner les sections.",
62
89
  "en": "Reorder sections."
63
- },
64
- "scope": "host:property:write"
90
+ }
65
91
  }
66
92
  ],
67
93
  "hostSurfaces": [
68
94
  {
69
95
  "type": "property-workspace-tab",
70
96
  "pathSegment": "sections",
71
- "label": { "fr": "Sections", "en": "Sections" },
97
+ "label": {
98
+ "fr": "Sections",
99
+ "en": "Sections"
100
+ },
72
101
  "icon": "file-text"
73
102
  }
74
103
  ],
@@ -82,7 +111,14 @@
82
111
  "en": "Build your welcome book section by section."
83
112
  },
84
113
  "npmPackage": "@portaki/module-sections",
85
- "npmUrl": "https://www.npmjs.com/package/@portaki/module-sections",
86
- "javaArtifact": "app.portaki.module:sections-backend:0.1.0"
114
+ "npmUrl": "https://www.npmjs.com/package/@portaki/module-sections"
115
+ },
116
+ "runtime": {
117
+ "backend": "wasm",
118
+ "guest": "remote-esm"
119
+ },
120
+ "artifacts": {
121
+ "wasmUrl": "oci://ghcr.io/portakiapp/portaki-module-sections",
122
+ "guestEsmUrl": "https://esm.sh/@portaki/module-sections@1.2.3"
87
123
  }
88
124
  }
@@ -4,8 +4,8 @@ import type { JSONContent } from '@tiptap/core'
4
4
  import { generateHTML } from '@tiptap/html'
5
5
  import StarterKit from '@tiptap/starter-kit'
6
6
  import { usePortakiQuery } from '@portaki/sdk'
7
- import type { LangCode } from '@portaki/module-sdk'
8
- import { ModuleSection } from '@portaki/module-sdk'
7
+ import type { LangCode } from '@portaki/sdk'
8
+ import { ModuleSection } from '@portaki/sdk'
9
9
 
10
10
  type SectionRow = {
11
11
  id: string
package/src/index.tsx CHANGED
@@ -1,17 +1 @@
1
- import type { ModuleContext } from '@portaki/module-sdk'
2
- import { definePortakiModule } from '@portaki/module-sdk'
3
-
4
- import { SectionsGuestView } from './components/SectionsGuestView'
5
-
6
- export default definePortakiModule({
7
- id: 'sections',
8
- label: { fr: 'Sections', en: 'Sections' },
9
- description: {
10
- fr: 'Contenus éditoriaux du carnet d’accueil.',
11
- en: 'Welcome book editorial content.',
12
- },
13
- version: '1.0.0',
14
- icon: 'file-text',
15
- navSlot: 'section',
16
- render: ({ lang }: ModuleContext) => <SectionsGuestView lang={lang} />,
17
- })
1
+ export { default } from './portaki.module'
@@ -0,0 +1,8 @@
1
+ import { describe, it } from 'vitest'
2
+ import { validateSiblingManifest } from '@portaki/sdk-test-support'
3
+
4
+ describe('portaki.module.json', () => {
5
+ it('matches module.v1 schema', () => {
6
+ validateSiblingManifest(import.meta.url)
7
+ })
8
+ })
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { waitFor } from '@testing-library/react'
3
+ import { assertGuestSurface, renderGuestModule } from '@portaki/sdk-test-support'
4
+
5
+ import moduleDef from './index'
6
+
7
+ describe('@portaki/module-sections', () => {
8
+ it('exposes a valid guest module definition', () => {
9
+ assertGuestSurface(moduleDef)
10
+ })
11
+
12
+ it('renders without crashing', async () => {
13
+ const view =
14
+ moduleDef.surface === 'host' ? renderHostModule(moduleDef) : renderGuestModule(moduleDef)
15
+ await waitFor(() => {
16
+ expect(view.container).toBeTruthy()
17
+ })
18
+ view.unmount()
19
+ })
20
+ })
@@ -0,0 +1,3 @@
1
+ import { defineSectionsModule } from './sections-module-definition'
2
+
3
+ export default defineSectionsModule(() => null)
@@ -0,0 +1,4 @@
1
+ import { SectionsGuestView } from './components/SectionsGuestView'
2
+ import { defineSectionsModule } from './sections-module-definition'
3
+
4
+ export default defineSectionsModule(({ lang }) => <SectionsGuestView lang={lang} />)
@@ -0,0 +1,223 @@
1
+ import {
2
+ defineModule,
3
+ index,
4
+ int,
5
+ jsonb,
6
+ moduleSchema,
7
+ table,
8
+ tenantId,
9
+ tenantPropertyIndex,
10
+ text,
11
+ timestamptz,
12
+ uuid,
13
+ uuidPrimaryKey,
14
+ type ModuleContext,
15
+ type PortakiFullModule,
16
+ } from '@portaki/sdk'
17
+ import type { ReactNode } from 'react'
18
+
19
+ const sectionsSchema = moduleSchema([
20
+ table('items', 't_e_module_sections_item', {
21
+ columns: [
22
+ uuidPrimaryKey(),
23
+ uuid('propertyId'),
24
+ tenantId(),
25
+ int('sortOrder', { defaultSql: '0' }),
26
+ text('titleFr', { defaultSql: "''" }),
27
+ text('titleEn', { defaultSql: "''" }),
28
+ jsonb('contentFr', { nullable: true }),
29
+ jsonb('contentEn', { nullable: true }),
30
+ timestamptz('createdAt', { nullable: false, defaultSql: 'now()' }),
31
+ timestamptz('updatedAt', { nullable: false, defaultSql: 'now()' }),
32
+ ],
33
+ indexes: [tenantPropertyIndex()],
34
+ }),
35
+ ])
36
+
37
+ function stringParam(params: Record<string, unknown>, key: string): string | null {
38
+ const value = params[key]
39
+ if (value == null) {
40
+ return null
41
+ }
42
+ return String(value)
43
+ }
44
+
45
+ function intParam(params: Record<string, unknown>, key: string, defaultValue: number): number {
46
+ const value = params[key]
47
+ if (value == null) {
48
+ return defaultValue
49
+ }
50
+ if (typeof value === 'number') {
51
+ return value
52
+ }
53
+ return Number.parseInt(String(value), 10)
54
+ }
55
+
56
+ export function defineSectionsModule(render: (ctx: ModuleContext) => ReactNode): PortakiFullModule {
57
+ return defineModule({
58
+ id: 'sections',
59
+ label: { fr: 'Sections', en: 'Sections' },
60
+ description: {
61
+ fr: 'Contenus éditoriaux du carnet d’accueil.',
62
+ en: 'Welcome book editorial content.',
63
+ },
64
+ icon: 'file-text',
65
+ version: '1.2.1',
66
+ schemaVersion: '1.0.1',
67
+ schema: sectionsSchema,
68
+ navSlot: 'section',
69
+
70
+ queries: {
71
+ 'sections.list': {
72
+ scope: 'property:read',
73
+ description: {
74
+ fr: 'Liste des sections du logement.',
75
+ en: 'Property editorial sections.',
76
+ },
77
+ async handler(ctx) {
78
+ const rows = await ctx.db
79
+ .from('items')
80
+ .where({ tenantId: ctx.tenantId, propertyId: ctx.propertyId })
81
+ .many()
82
+ rows.sort((a, b) => {
83
+ const orderA = Number(a.sortOrder ?? 0)
84
+ const orderB = Number(b.sortOrder ?? 0)
85
+ if (orderA !== orderB) {
86
+ return orderA - orderB
87
+ }
88
+ const createdA = String(a.createdAt ?? '')
89
+ const createdB = String(b.createdAt ?? '')
90
+ return createdA.localeCompare(createdB)
91
+ })
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 }
107
+ },
108
+ },
109
+ },
110
+
111
+ commands: {
112
+ 'sections.section.save': {
113
+ scope: 'host:property:write',
114
+ description: {
115
+ fr: 'Créer ou mettre à jour une section.',
116
+ en: 'Create or update a section.',
117
+ },
118
+ async handler(ctx, params) {
119
+ const filters = { tenantId: ctx.tenantId, propertyId: ctx.propertyId }
120
+ const idRaw = stringParam(params, 'id')
121
+ const id =
122
+ idRaw == null || idRaw.trim() === '' ? crypto.randomUUID() : idRaw.trim()
123
+ let titleFr = stringParam(params, 'titleFr')
124
+ let titleEn = stringParam(params, 'titleEn')
125
+ if (titleFr == null || titleFr.trim() === '') {
126
+ throw new Error('title_fr_required')
127
+ }
128
+ titleFr = titleFr.trim()
129
+ if (titleEn == null || titleEn.trim() === '') {
130
+ titleEn = titleFr
131
+ } else {
132
+ titleEn = titleEn.trim()
133
+ }
134
+ const contentFr = params.contentFr ?? null
135
+ const contentEn = params.contentEn ?? null
136
+ const existing = await ctx.db
137
+ .from('items')
138
+ .where({ ...filters, id })
139
+ .one()
140
+ let sortOrder = intParam(params, 'sortOrder', -1)
141
+ if (sortOrder < 0) {
142
+ const all = await ctx.db.from('items').where(filters).many()
143
+ const max = all
144
+ .filter((row) => String(row.id) !== id)
145
+ .reduce((acc, row) => Math.max(acc, Number(row.sortOrder ?? 0)), -1)
146
+ sortOrder = max + 1
147
+ }
148
+ const now = new Date().toISOString()
149
+ const payload = {
150
+ titleFr,
151
+ titleEn,
152
+ contentFr,
153
+ contentEn,
154
+ sortOrder,
155
+ updatedAt: now,
156
+ }
157
+ if (existing != null) {
158
+ await ctx.db.from('items').where({ ...filters, id }).update(payload)
159
+ return
160
+ }
161
+ await ctx.db.from('items').insert({
162
+ id,
163
+ ...filters,
164
+ ...payload,
165
+ createdAt: now,
166
+ })
167
+ },
168
+ },
169
+ 'sections.section.delete': {
170
+ scope: 'host:property:write',
171
+ description: {
172
+ fr: 'Supprimer une section.',
173
+ en: 'Delete a section.',
174
+ },
175
+ async handler(ctx, params) {
176
+ const idRaw = stringParam(params, 'id')
177
+ if (idRaw == null || idRaw.trim() === '') {
178
+ throw new Error('id_required')
179
+ }
180
+ const deleted = await ctx.db
181
+ .from('items')
182
+ .where({
183
+ id: idRaw.trim(),
184
+ tenantId: ctx.tenantId,
185
+ propertyId: ctx.propertyId,
186
+ })
187
+ .delete()
188
+ if (deleted === 0) {
189
+ throw new Error('section_not_found')
190
+ }
191
+ },
192
+ },
193
+ 'sections.reorder': {
194
+ scope: 'host:property:write',
195
+ description: {
196
+ fr: 'Réordonner les sections.',
197
+ en: 'Reorder sections.',
198
+ },
199
+ async handler(ctx, params) {
200
+ const raw = params.orderedIds
201
+ if (!Array.isArray(raw) || raw.length === 0) {
202
+ throw new Error('ordered_ids_required')
203
+ }
204
+ const filters = { tenantId: ctx.tenantId, propertyId: ctx.propertyId }
205
+ const now = new Date().toISOString()
206
+ let order = 0
207
+ for (const item of raw) {
208
+ if (item == null) {
209
+ continue
210
+ }
211
+ const id = String(item)
212
+ await ctx.db
213
+ .from('items')
214
+ .where({ ...filters, id })
215
+ .update({ sortOrder: order++, updatedAt: now })
216
+ }
217
+ },
218
+ },
219
+ },
220
+
221
+ render,
222
+ })
223
+ }
@@ -0,0 +1,3 @@
1
+ import { portakiModuleVitestConfig } from '@portaki/sdk-test-support/vitest'
2
+
3
+ export default portakiModuleVitestConfig(import.meta.url)