domma-cms 0.18.0 → 0.21.0

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.
Files changed (110) hide show
  1. package/CLAUDE.md +37 -3
  2. package/admin/css/admin.css +1 -1
  3. package/admin/js/api.js +1 -1
  4. package/admin/js/app.js +4 -4
  5. package/admin/js/config/sidebar-config.js +1 -1
  6. package/admin/js/lib/crud-tutorial.js +1 -0
  7. package/admin/js/lib/markdown-toolbar.js +5 -5
  8. package/admin/js/lib/project-context.js +1 -0
  9. package/admin/js/lib/sidebar-renderer.js +4 -0
  10. package/admin/js/templates/action-editor.html +7 -0
  11. package/admin/js/templates/block-editor.html +7 -0
  12. package/admin/js/templates/collection-editor.html +9 -0
  13. package/admin/js/templates/form-editor.html +9 -0
  14. package/admin/js/templates/menu-editor.html +98 -0
  15. package/admin/js/templates/menu-locations.html +14 -0
  16. package/admin/js/templates/menus.html +14 -0
  17. package/admin/js/templates/page-editor.html +9 -2
  18. package/admin/js/templates/project-detail.html +50 -0
  19. package/admin/js/templates/project-editor.html +45 -0
  20. package/admin/js/templates/project-settings.html +60 -0
  21. package/admin/js/templates/projects.html +13 -0
  22. package/admin/js/templates/role-editor.html +11 -0
  23. package/admin/js/templates/tutorials.html +335 -2
  24. package/admin/js/templates/view-editor.html +7 -0
  25. package/admin/js/views/action-editor.js +1 -1
  26. package/admin/js/views/actions-list.js +1 -1
  27. package/admin/js/views/block-editor.js +8 -8
  28. package/admin/js/views/blocks.js +2 -2
  29. package/admin/js/views/collection-editor.js +4 -4
  30. package/admin/js/views/collections.js +1 -1
  31. package/admin/js/views/form-editor.js +5 -5
  32. package/admin/js/views/forms.js +1 -1
  33. package/admin/js/views/index.js +1 -1
  34. package/admin/js/views/menu-editor.js +19 -0
  35. package/admin/js/views/menu-locations.js +1 -0
  36. package/admin/js/views/menus.js +5 -0
  37. package/admin/js/views/page-editor.js +24 -24
  38. package/admin/js/views/pages.js +3 -3
  39. package/admin/js/views/project-detail.js +4 -0
  40. package/admin/js/views/project-editor.js +1 -0
  41. package/admin/js/views/project-settings.js +1 -0
  42. package/admin/js/views/projects.js +7 -0
  43. package/admin/js/views/role-editor.js +1 -1
  44. package/admin/js/views/roles.js +3 -3
  45. package/admin/js/views/tutorials.js +1 -1
  46. package/admin/js/views/user-editor.js +1 -1
  47. package/admin/js/views/users.js +3 -3
  48. package/admin/js/views/view-editor.js +1 -1
  49. package/admin/js/views/views-list.js +1 -1
  50. package/config/menu-locations.json +5 -0
  51. package/config/menus/admin-sidebar.json +185 -0
  52. package/config/menus/footer.json +33 -0
  53. package/config/menus/main.json +35 -0
  54. package/config/menus/sproj-1779696558011-menu.json +17 -0
  55. package/config/menus/sproj-1779696960337-menu.json +18 -0
  56. package/config/menus/sproj-1779696985353-menu.json +18 -0
  57. package/config/site.json +6 -22
  58. package/package.json +4 -3
  59. package/plugins/analytics/daily.json +3 -0
  60. package/plugins/analytics/journeys.json +8 -0
  61. package/plugins/analytics/lifetime.json +1 -1
  62. package/public/css/site.css +1 -1
  63. package/public/js/collection-browser.js +4 -0
  64. package/public/js/forms.js +1 -1
  65. package/public/js/site.js +1 -1
  66. package/server/middleware/auth.js +88 -22
  67. package/server/routes/api/actions.js +58 -5
  68. package/server/routes/api/auth.js +2 -2
  69. package/server/routes/api/blocks.js +18 -3
  70. package/server/routes/api/collections.js +201 -8
  71. package/server/routes/api/forms.js +266 -21
  72. package/server/routes/api/menu-locations.js +46 -0
  73. package/server/routes/api/menus.js +115 -0
  74. package/server/routes/api/pages.js +1 -1
  75. package/server/routes/api/projects.js +107 -0
  76. package/server/routes/api/scaffold.js +86 -0
  77. package/server/routes/api/sidebar.js +23 -0
  78. package/server/routes/api/users.js +32 -7
  79. package/server/routes/api/views.js +10 -2
  80. package/server/routes/public.js +79 -6
  81. package/server/server.js +38 -0
  82. package/server/services/actions.js +137 -8
  83. package/server/services/adapters/FileAdapter.js +23 -8
  84. package/server/services/adapters/MongoAdapter.js +36 -18
  85. package/server/services/blocks.js +20 -8
  86. package/server/services/collections.js +85 -8
  87. package/server/services/content.js +23 -9
  88. package/server/services/filterEngine.js +281 -0
  89. package/server/services/hooks.js +48 -0
  90. package/server/services/markdown.js +686 -109
  91. package/server/services/menus-migration.js +107 -0
  92. package/server/services/menus.js +422 -0
  93. package/server/services/permissionRegistry.js +26 -0
  94. package/server/services/plugins.js +9 -2
  95. package/server/services/presetCollections.js +22 -0
  96. package/server/services/projects.js +429 -0
  97. package/server/services/recipes/contact-list.json +78 -0
  98. package/server/services/recipes/onboarding.json +426 -0
  99. package/server/services/references.js +174 -0
  100. package/server/services/renderer.js +237 -40
  101. package/server/services/roles.js +6 -1
  102. package/server/services/rowAccess.js +86 -13
  103. package/server/services/scaffolder.js +465 -0
  104. package/server/services/sidebar-migration.js +117 -0
  105. package/server/services/sitemap.js +112 -0
  106. package/server/services/userRoles.js +86 -0
  107. package/server/services/users.js +23 -2
  108. package/server/services/views.js +15 -4
  109. package/server/templates/page.html +7 -2
  110. /package/config/{navigation.json → navigation.json.bak} +0 -0
@@ -0,0 +1,45 @@
1
+ <div class="page-header">
2
+ <h2><span data-icon="folder"></span> <span id="pe-title">Edit project</span></h2>
3
+ <div class="header-actions">
4
+ <a class="btn btn-secondary" href="#/projects"><span data-icon="arrow-left"></span> Back</a>
5
+ <button class="btn btn-primary" id="pe-save"><span data-icon="check"></span> Save</button>
6
+ </div>
7
+ </div>
8
+
9
+ <div class="card">
10
+ <div class="card-header">
11
+ <span data-icon="info"></span> Project details
12
+ </div>
13
+ <div class="card-body">
14
+ <div class="grid grid-cols-2 dm-pe-grid">
15
+ <div class="form-group">
16
+ <label for="pe-slug">Slug <span class="required">*</span></label>
17
+ <input type="text" id="pe-slug" name="slug" class="form-input" required>
18
+ <small class="form-hint">Lowercase alphanumeric + hyphen; immutable after create</small>
19
+ </div>
20
+ <div class="form-group">
21
+ <label for="pe-name">Name <span class="required">*</span></label>
22
+ <input type="text" id="pe-name" name="name" class="form-input" required>
23
+ </div>
24
+ <div class="form-group col-span-2">
25
+ <label for="pe-description">Description</label>
26
+ <textarea id="pe-description" name="description" class="form-input" rows="2"></textarea>
27
+ </div>
28
+ <div class="form-group">
29
+ <label for="pe-icon">Icon</label>
30
+ <div id="pe-icon-mount"></div>
31
+ <small class="form-hint">Click <code>⊞</code> to browse Domma icons; defaults to <code>folder</code></small>
32
+ </div>
33
+ <div class="form-group">
34
+ <label for="pe-rootUrl">Root URL</label>
35
+ <input type="text" id="pe-rootUrl" name="rootUrl" class="form-input" placeholder="/members">
36
+ <small class="form-hint">URL prefix for auto-inheritance (must start with /)</small>
37
+ </div>
38
+ <div class="form-group">
39
+ <label for="pe-sortOrder">Sort order</label>
40
+ <input type="number" id="pe-sortOrder" name="sortOrder" class="form-input" value="0">
41
+ <small class="form-hint">Lower = higher in the sidebar</small>
42
+ </div>
43
+ </div>
44
+ </div>
45
+ </div>
@@ -0,0 +1,60 @@
1
+ <div class="page-header">
2
+ <h2><span id="ps-icon" data-icon="settings"></span> <span id="ps-title">Project settings</span></h2>
3
+ <div class="header-actions">
4
+ <a class="btn btn-secondary" id="ps-back"><span data-icon="arrow-left"></span> Back to project</a>
5
+ <button class="btn btn-primary" id="ps-save"><span data-icon="check"></span> Save</button>
6
+ </div>
7
+ </div>
8
+
9
+ <div class="card">
10
+ <div class="card-header">
11
+ <span data-icon="info"></span> Project details
12
+ </div>
13
+ <div class="card-body">
14
+ <div class="grid grid-cols-2 dm-pe-grid">
15
+ <div class="form-group">
16
+ <label for="ps-slug">Slug</label>
17
+ <input type="text" id="ps-slug" name="slug" class="form-input" disabled>
18
+ <small class="form-hint">Immutable after create</small>
19
+ </div>
20
+ <div class="form-group">
21
+ <label for="ps-name">Name <span class="required">*</span></label>
22
+ <input type="text" id="ps-name" name="name" class="form-input" required>
23
+ </div>
24
+ <div class="form-group col-span-2">
25
+ <label for="ps-description">Description</label>
26
+ <textarea id="ps-description" name="description" class="form-input" rows="2"></textarea>
27
+ </div>
28
+ <div class="form-group">
29
+ <label for="ps-icon">Icon</label>
30
+ <div id="ps-icon-mount"></div>
31
+ <small class="form-hint">Click <code>⊞</code> to browse Domma icons</small>
32
+ </div>
33
+ <div class="form-group">
34
+ <label for="ps-rootUrl">Root URL</label>
35
+ <input type="text" id="ps-rootUrl" name="rootUrl" class="form-input" placeholder="/members">
36
+ <small class="form-hint">URL prefix for auto-inheritance (must start with /)</small>
37
+ </div>
38
+ <div class="form-group">
39
+ <label for="ps-sortOrder">Sort order</label>
40
+ <input type="number" id="ps-sortOrder" name="sortOrder" class="form-input" value="0">
41
+ <small class="form-hint">Lower = higher in the sidebar</small>
42
+ </div>
43
+ </div>
44
+ </div>
45
+ </div>
46
+
47
+ <div class="card" style="border-color:#c33;">
48
+ <div class="card-header"><h3>Danger zone</h3></div>
49
+ <div class="card-body">
50
+ <p>Untagging removes <code>meta.project</code> from every artefact currently tagged with this project. The artefacts remain (untagged); the project record is unchanged.</p>
51
+ <p>
52
+ <button class="btn btn-warning" id="ps-untag-all"><span data-icon="tag"></span> Untag all artefacts</button>
53
+ </p>
54
+ <hr>
55
+ <p>Deleting a project is only possible when no artefacts are tagged. The server returns an explanatory error otherwise.</p>
56
+ <p>
57
+ <button class="btn btn-danger" id="ps-delete"><span data-icon="trash"></span> Delete project</button>
58
+ </p>
59
+ </div>
60
+ </div>
@@ -0,0 +1,13 @@
1
+ <div class="page-header">
2
+ <h2><span data-icon="folder"></span> Projects</h2>
3
+ <div class="header-actions">
4
+ <a class="btn btn-primary" href="#/projects/new"><span data-icon="plus"></span> New project</a>
5
+ </div>
6
+ </div>
7
+ <p class="text-muted mb-4">Group related artefacts under a project for navigation and per-user access scoping.</p>
8
+
9
+ <div class="card">
10
+ <div class="card-body">
11
+ <div id="projects-table"></div>
12
+ </div>
13
+ </div>
@@ -47,6 +47,17 @@
47
47
  </div>
48
48
  </div>
49
49
  </div>
50
+ <div class="row">
51
+ <div class="col-6">
52
+ <div class="mb-3">
53
+ <label class="form-label">Project</label>
54
+ <select id="role-project" class="form-input">
55
+ <option value="">— none —</option>
56
+ </select>
57
+ <p class="form-hint">Tag this role to a project for grouping.</p>
58
+ </div>
59
+ </div>
60
+ </div>
50
61
  </div>
51
62
  </div>
52
63
  </div>
@@ -7,13 +7,346 @@
7
7
 
8
8
  <div class="tabs" id="tutorials-tabs">
9
9
  <div class="tab-list" style="flex-wrap: wrap;">
10
- <button class="tab-item active">Writing a Plugin</button>
10
+ <button class="tab-item active">Building a CRUD App</button>
11
+ <button class="tab-item">Writing a Plugin</button>
11
12
  <button class="tab-item">Form Follow-Up</button>
12
13
  </div>
13
14
  <div class="tab-content">
14
15
 
16
+ <!-- Building a CRUD App — the headline tutorial -->
17
+ <div class="tab-panel active docs-body" id="tutorial-crud">
18
+
19
+ <p class="lead">
20
+ A "CRUD app" — Create, Read, Update, Delete — is the shape of almost every business
21
+ tool you'll ever build. Onboarding, bookings, contacts, tickets, orders, RSVPs,
22
+ classified ads. Domma CMS gives you everything to build one without writing
23
+ JavaScript, but the trick is understanding <em>why</em> the pieces compose the way
24
+ they do. That's what this tutorial is for.
25
+ </p>
26
+
27
+ <hr>
28
+
29
+ <h2>The mental model</h2>
30
+
31
+ <p>Before we build anything, the picture you want in your head:</p>
32
+
33
+ <p>A CRUD app is just <strong>somewhere to put data</strong>, <strong>a way for people to give it to you</strong>,
34
+ <strong>buttons that change it once it's there</strong>, and <strong>a page that shows it back</strong>.
35
+ Domma CMS gives each of those a name:</p>
36
+
37
+ <ul>
38
+ <li><strong>Collection</strong> — the data store. Think "spreadsheet": rows are records, columns are fields with types.</li>
39
+ <li><strong>Form</strong> — how new records get added. It writes to a Collection on submit.</li>
40
+ <li><strong>Action</strong> — a server-side button that changes existing records. "Approve", "Withdraw", "Send invoice".</li>
41
+ <li><strong>Page</strong> — a Markdown page with <em>shortcodes</em> that render forms and lists from your collections.</li>
42
+ </ul>
43
+
44
+ <p>These are deliberately separate. A single Collection might be written to by three different Forms
45
+ (one for staff, one for the public, one for an import job) and read by four different Pages (a
46
+ dashboard, a public listing, a per-user "my entries" view, a printable report). Keeping them
47
+ separate is what lets the same data drive multiple experiences.</p>
48
+
49
+ <div class="alert alert-info">
50
+ <strong>Why not a database?</strong> Collections store as flat JSON files on disk by default —
51
+ no database to install, no SQL to learn, no migrations. When you outgrow that, switch
52
+ individual collections to MongoDB without changing any of your pages or forms. The
53
+ compatibility layer is the point.
54
+ </div>
55
+
56
+ <hr>
57
+
58
+ <h2>Step 1 — Create the Collection (your data store)</h2>
59
+
60
+ <p>Open <a href="#/collections">Collections</a> → <strong>New collection</strong>. You'll give it a
61
+ slug (the URL-safe name we use in shortcodes), a title, and a list of fields.</p>
62
+
63
+ <p><strong>The slug matters more than you'd think.</strong> It's what every Form, Action, and shortcode
64
+ uses to refer to this collection — so pick a noun and stick with it. <code>jobs</code>,
65
+ <code>applications</code>, <code>contacts</code>, <code>events</code>. Don't pluralise inconsistently
66
+ and don't include the word "data" or "collection" in the slug — that's redundant.</p>
67
+
68
+ <p><strong>Fields are where the design lives.</strong> Each field has a <em>type</em>, and the type isn't
69
+ just for show — it drives every downstream behaviour:</p>
70
+
71
+ <ul>
72
+ <li><code>text</code> renders a text input on forms and is searchable in the Browser</li>
73
+ <li><code>number</code> renders a number input, gets a min/max range filter automatically</li>
74
+ <li><code>select</code> with options becomes a dropdown both on forms AND in the filter rail</li>
75
+ <li><code>multiselect</code> becomes a checkbox group AND a filterable tag chip set</li>
76
+ <li><code>date</code> renders a date picker AND gets a from/to range filter</li>
77
+ <li><code>file</code> renders a file upload with mime/size validation</li>
78
+ <li><code>reference</code> stores a link to another collection's entry — auto-renders as a populated dropdown</li>
79
+ </ul>
80
+
81
+ <p>Pick the right type at design time and you get the right form input, the right filter UI, and the
82
+ right validation, all for free. Pick <code>text</code> for everything and you have to recreate all
83
+ that yourself.</p>
84
+
85
+ <p><strong>Tip:</strong> always include a <code>status</code> field as a <code>select</code> with values
86
+ like <em>pending</em>, <em>reviewing</em>, <em>approved</em>, <em>rejected</em>. This is what makes
87
+ state-machine transitions possible later. You don't need it until you do, but adding it later means
88
+ back-filling every existing record. Add it on day one.</p>
89
+
90
+ <hr>
91
+
92
+ <h2>Step 2 — Read the data (display it on a page)</h2>
93
+
94
+ <p>You don't need code to list a Collection on a public page. Drop this in any Markdown page:</p>
95
+
96
+ <pre class="code-block"><code>[collection slug="jobs" display="cards" columns="3"
97
+ title-field="title" sort="postedAt" order="desc"
98
+ fields="company,location,salary,type" /]</code></pre>
99
+
100
+ <p>That single shortcode gives you a server-rendered, cached, SEO-friendly grid of cards. The server
101
+ reads the collection at request time, filters it, sorts it, and produces static HTML — every
102
+ visitor gets the same instant render. The page is cached per role, so a million visitors viewing
103
+ a public page hit the cache and never touch the data store.</p>
104
+
105
+ <p><strong>Five display modes are built in:</strong></p>
106
+
107
+ <ul>
108
+ <li><code>display="table"</code> — sortable, paginated, with a search box</li>
109
+ <li><code>display="cards"</code> — visual grid (default 3 columns; configurable)</li>
110
+ <li><code>display="list"</code> — vertical stack with title + meta</li>
111
+ <li><code>display="accordion"</code> — expandable rows, title visible, body hidden until clicked</li>
112
+ <li><code>display="timeline"</code> — chronological with dates and statuses</li>
113
+ </ul>
114
+
115
+ <p>All five display the same data; you pick the right shape for the page. A directory might use
116
+ <code>cards</code>; an admin overview might use <code>table</code>; a history page might use
117
+ <code>timeline</code>.</p>
118
+
119
+ <h3>Make it interactive — flip on the Browser</h3>
120
+
121
+ <p>Adding any of <code>searchable</code>, <code>filterable</code>, or <code>sortable</code> upgrades the
122
+ static list to a full <strong>Collection Browser</strong>:</p>
123
+
124
+ <pre class="code-block"><code>[collection slug="jobs" display="cards" columns="3"
125
+ searchable
126
+ filterable="location,type,salary,tags"
127
+ sortable
128
+ page-size="9"
129
+ empty="No matching jobs." /]</code></pre>
130
+
131
+ <p>Now the page has a search box, a filter rail down the left side, a sort dropdown, and pagination —
132
+ all generated automatically from the schema you wrote in Step 1. The Browser is the moment your
133
+ app starts to feel like an app rather than a brochure.</p>
134
+
135
+ <p><strong>Why the filter rail is automatic:</strong> remember each field has a type? The Browser
136
+ reads those types and builds the appropriate control. <code>location</code> (text) becomes a
137
+ dropdown of distinct values harvested from the data. <code>salary</code> (number) becomes a
138
+ min/max range with a slider. <code>tags</code> (multiselect) becomes toggleable chips with counts.
139
+ You authored zero filter logic.</p>
140
+
141
+ <hr>
142
+
143
+ <h2>Step 3 — Create new records (the Form)</h2>
144
+
145
+ <p>Open <a href="#/forms">Forms</a> → <strong>New form</strong>. The slug here is the form's identity —
146
+ what you embed on a page with <code>[form name="..." /]</code>.</p>
147
+
148
+ <p>Form fields can mirror the collection fields exactly, or they can be a subset. <strong>You almost
149
+ always want a subset.</strong> Fields like <code>status</code> shouldn't appear on a public-facing
150
+ submission form — the form should set status to <em>"pending"</em> automatically. Fields like
151
+ <code>internalNotes</code> shouldn't be visible to the submitter at all. The form is your
152
+ audience-facing slice of the collection; design it for who's filling it in.</p>
153
+
154
+ <p>Wire the form to the collection in <strong>Actions → Collection</strong>: enable it and pick the
155
+ target collection slug. Every submission becomes one new entry in that collection. Done.</p>
156
+
157
+ <h3>Identity capture (the small thing that makes everything work)</h3>
158
+
159
+ <p>When a signed-in user submits a form, the CMS automatically stamps the new entry with their user
160
+ id (in the entry's <code>meta.createdBy</code>) AND passes their identity into any actions the
161
+ form fires. That's how the "My applications" view later works without any extra config: you ask
162
+ for "entries created by the current user" and the platform already has that data.</p>
163
+
164
+ <p>Anonymous submissions still work — <code>createdBy</code> is null and the action receives an empty
165
+ user. So one form can serve both anonymous contact-us submissions AND authenticated apply-for-this-job
166
+ submissions; the difference shows up in what the action templates can resolve.</p>
167
+
168
+ <hr>
169
+
170
+ <h2>Step 4 — Change records over time (the Action)</h2>
171
+
172
+ <p>This is where most no-code platforms top out. An Action is a server-side button: visitors click it
173
+ on a page, the server runs a sequence of steps against the entry they clicked on, and the page
174
+ refreshes.</p>
175
+
176
+ <p>Steps include: <code>updateField</code> (set one field to a new value), <code>deleteEntry</code>
177
+ (remove the entry), <code>createInCollection</code> (write a related entry to another collection
178
+ — this is how "apply for this job" creates an application without losing the job), <code>email</code>
179
+ (send a notification using the entry's fields as template variables), and <code>webhook</code>
180
+ (POST to an external service).</p>
181
+
182
+ <h3>The transition is the magic word</h3>
183
+
184
+ <p>Actions can declare a <strong>transition</strong> — "this action moves the entry's status from
185
+ <em>pending</em> to <em>reviewing</em>". That single addition unlocks two huge things:</p>
186
+
187
+ <ol>
188
+ <li><strong>Server-side guard.</strong> Try to run "withdraw" on an already-rejected application
189
+ and the server refuses with HTTP 409. The illegal move can't happen, even if someone copies
190
+ and edits the URL.
191
+ </li>
192
+ <li><strong>Per-row UI.</strong> Add <code>transitions</code> to a <code>[collection]</code> block
193
+ and each row sprouts buttons for exactly the transitions legally available given that row's
194
+ current status AND the viewer's role. A candidate sees "Withdraw" on their pending application;
195
+ an admin sees "Move to reviewing"; a candidate viewing a rejected application sees nothing
196
+ to click. No client-side conditionals; the rules live entirely in the action definitions.
197
+ </li>
198
+ </ol>
199
+
200
+ <pre class="code-block"><code>{
201
+ "slug": "withdraw-application",
202
+ "title": "Withdraw",
203
+ "collection": "applications",
204
+ "transition": { "field": "status", "from": ["submitted", "reviewing"], "to": "withdrawn" },
205
+ "access": { "roles": ["candidate"], "rowLevel": { "mode": "owner" } },
206
+ "steps": [
207
+ { "type": "updateField", "config": { "field": "status", "value": "withdrawn" } }
208
+ ]
209
+ }</code></pre>
210
+
211
+ <p>Read that JSON like a sentence: <em>"Anyone with the candidate role can withdraw their own
212
+ application as long as it's currently submitted or reviewing."</em> The platform enforces every
213
+ clause: <code>access.roles</code> for the role check, <code>rowLevel.mode: 'owner'</code> so
214
+ candidates can only touch their own entries, <code>transition.from</code> for the status guard.
215
+ Together they're the whole authorisation policy for this one button.</p>
216
+
217
+ <hr>
218
+
219
+ <h2>Step 5 — Who sees what (Visibility + scope)</h2>
220
+
221
+ <p>Pages have a <code>visibility</code> frontmatter field that decides who can load the page at all.
222
+ It accepts a single role (<em>"editor and everyone above"</em>) or an array of roles
223
+ (<em>"candidates OR employers"</em>). Roles are hierarchical — higher-privilege roles inherit
224
+ access to lower-privilege gated pages without you adding them explicitly.</p>
225
+
226
+ <p>For per-user data <em>within</em> a page that mixed-role users share — like a "My applications"
227
+ block on a dashboard that both candidates and employers visit — use <code>scope="mine"</code>:</p>
228
+
229
+ <pre class="code-block"><code>[collection slug="applications" scope="mine"
230
+ display="cards" title-field="jobId"
231
+ fields="jobId,status,submittedAt"
232
+ empty="You haven't applied yet." /]</code></pre>
233
+
234
+ <p>The page itself stays cached per role; the per-user block renders client-side via a small hydration
235
+ request that injects <code>createdBy = current-user-id</code> server-side (the client can never
236
+ tamper with which user's data they see). Anonymous visitors see a sign-in prompt where the block
237
+ would render.</p>
238
+
239
+ <p>For cross-collection scoping — <em>"recruiter sees only applications for jobs they posted"</em> —
240
+ use the <code>reference</code> row-access mode in your action's <code>access.rowLevel</code>. The
241
+ platform resolves the reference to check ownership on the target. <a
242
+ href="/docs/configuration.md" target="_blank">Full row-access reference</a> covers all three
243
+ modes (<code>owner</code>, <code>field</code>, <code>reference</code>) with worked examples.</p>
244
+
245
+ <hr>
246
+
247
+ <h2>Step 6 — Files, references, and "feels like a real app"</h2>
248
+
249
+ <p>Three field types deserve their own mention because they're where Domma stops looking like a CMS
250
+ and starts looking like a development platform:</p>
251
+
252
+ <p><strong>File fields</strong> (<code>type: "file"</code>) render a native file picker that uploads
253
+ to <code>/content/media/</code> with mime + size validation, then stores a reference object
254
+ <code>{url, name, size, mime}</code> on the entry. Image mimes auto-display as thumbnails on the
255
+ page; PDFs and docs become "download" links. The form switches to multipart submission
256
+ automatically the moment any file input has a selection — no extra config.</p>
257
+
258
+ <p><strong>Reference fields</strong> (<code>type: "reference"</code>) store the id of another
259
+ collection's entry. The form picker becomes a populated dropdown of the target collection's
260
+ entries (showing the <code>displayField</code>); the page display resolves to the readable label
261
+ everywhere; with a <code>linkTemplate</code> the label becomes a clickable link to the target's
262
+ detail page. Validation refuses to save an entry pointing at a non-existent target. Dangling
263
+ references (target deleted later) render as "<em>id</em> (missing)" instead of crashing the page.</p>
264
+
265
+ <p><strong>Status fields</strong> with <code>options</code> + paired <code>transition</code>-bearing
266
+ actions = a workflow. We covered this in Step 4 but it deserves repeating: the combination of a
267
+ typed status field, a few actions with <code>transition.from</code>, and the <code>transitions</code>
268
+ attribute on a <code>[collection]</code> shortcode gives you a real state machine that's enforced
269
+ everywhere (UI, API, audit) with no JavaScript.</p>
270
+
271
+ <hr>
272
+
273
+ <h2>Step 7 — The Browser tour (advanced reading UI)</h2>
274
+
275
+ <p>We touched on the Browser in Step 2. Here's the rest of what it does for free once you turn it
276
+ on with <code>searchable filterable=... sortable</code>:</p>
277
+
278
+ <ul>
279
+ <li><strong>Faceted filter counts</strong> — every filter chip shows the count of entries that
280
+ would match if that option were toggled with current other filters. Standard ecommerce-style
281
+ faceted browsing.</li>
282
+ <li><strong>Empty state with "Clear filters"</strong> — when the user narrows to zero results,
283
+ one click resets and gets them un-stuck.</li>
284
+ <li><strong>Saved searches</strong> — dropdown in the header lets users name and recall their
285
+ favourite filter combinations.</li>
286
+ <li><strong>CSV export</strong> — add <code>exportable</code> and a button generates a CSV of the
287
+ current filtered set.</li>
288
+ <li><strong>Mobile filter drawer</strong> — narrow viewports get a "Filters (n)" button that
289
+ slides the rail in from the left as an overlay.</li>
290
+ <li><strong>URL state sync</strong> — every change writes to the URL query string, so deep-links
291
+ and the browser back button work.</li>
292
+ <li><strong>Relevance sort during search</strong> — when the search box has a term, results
293
+ re-order by match count with a 3× boost on matches in the title field.</li>
294
+ <li><strong>Keyboard shortcuts</strong> — <code>/</code> focuses search, <code>←</code>/<code>→</code>
295
+ pages.</li>
296
+ <li><strong>Infinite scroll</strong> — <code>pagination="scroll"</code> replaces the pager with
297
+ a sentinel that loads more on scroll.</li>
298
+ <li><strong>Server-mode for huge datasets</strong> — <code>mode="server"</code> makes every
299
+ change round-trip to the API; the storage adapter (Mongo or File) handles the query;
300
+ authoring stays identical.</li>
301
+ </ul>
302
+
303
+ <p>None of this requires any JavaScript on your part. You add attribute names to a shortcode.</p>
304
+
305
+ <hr>
306
+
307
+ <h2>Pulling it together — the worked example</h2>
308
+
309
+ <p>A <strong>job board</strong> uses every primitive in this tutorial:</p>
310
+
311
+ <ol>
312
+ <li>Two collections: <code>jobs</code> (employer posts roles) and <code>applications</code> (candidates apply)</li>
313
+ <li><code>applications</code> has a <code>reference</code> field <code>jobId</code> pointing at <code>jobs</code></li>
314
+ <li><code>applications</code> has a <code>file</code> field <code>resume</code> for the candidate's PDF</li>
315
+ <li>A public <code>[collection slug="jobs"]</code> on <code>/jobs</code> with the Browser turned on</li>
316
+ <li>An apply form that writes to <code>applications</code> + an action that emails HR</li>
317
+ <li>A dashboard at <code>/dashboard</code> with <code>visibility: [candidate, employer]</code> and
318
+ two <code>[collection scope="mine"]</code> blocks (one per role)</li>
319
+ <li>Transition actions: <em>start-review</em>, <em>invite-to-interview</em>, <em>make-offer</em>,
320
+ <em>reject</em>, <em>withdraw</em> — each with its own role + state guards</li>
321
+ <li>The candidate dashboard adds <code>transitions</code> and each row gets the right buttons
322
+ for its current status</li>
323
+ <li>Recruiters get <code>access.rowLevel: { mode: 'reference', field: 'jobId', targetCollection: 'jobs' }</code>
324
+ on their transition actions so they only ever see applications for jobs they posted</li>
325
+ </ol>
326
+
327
+ <p>That's a complete recruitment platform built in pure JSON + Markdown. The full annotated build
328
+ lives at <a href="/docs/recruitment-recipe.md" target="_blank">docs/recruitment-recipe.md</a>.</p>
329
+
330
+ <hr>
331
+
332
+ <h2>Skip ahead — scaffold a working CRUD system in one click</h2>
333
+
334
+ <p>Reading is one thing; having a real working subsystem to poke at is another. The CMS ships with
335
+ starter <em>recipes</em> that scaffold a complete Collection + Form + Actions in a single
336
+ operation. Try one now:</p>
337
+
338
+ <div id="tutorial-scaffolder-mount" style="margin-top:1rem;"></div>
339
+
340
+ <p class="text-muted" style="margin-top:1rem;font-size:.9rem;">
341
+ Recipes are JSON files under <code>server/services/recipes/</code> — copy one as a starting
342
+ point for your own. The <a href="/docs/scaffolding.md" target="_blank">scaffolding docs</a>
343
+ cover the recipe format in full.
344
+ </p>
345
+
346
+ </div>
347
+
15
348
  <!-- Writing a Plugin -->
16
- <div class="tab-panel active docs-body">
349
+ <div class="tab-panel docs-body">
17
350
  <p>Plugins live in the <code>plugins/</code> directory. Each plugin is a self-contained folder with
18
351
  three
19
352
  required files and optional <code>admin/</code> and <code>public/</code> subdirectories.</p>
@@ -44,6 +44,13 @@
44
44
  <small class="text-muted">Changing the collection will refresh field lists in Pipeline and
45
45
  Display tabs.</small>
46
46
  </div>
47
+ <div>
48
+ <label class="form-label">Project</label>
49
+ <select id="view-project" class="form-input">
50
+ <option value="">— none —</option>
51
+ </select>
52
+ <small class="text-muted">Tag this view to a project for grouping.</small>
53
+ </div>
47
54
  <div>
48
55
  <label class="form-check-label" title="Included in fresh installs via the seed script">
49
56
  <input id="view-bundled" type="checkbox" class="form-check"> Bundled
@@ -1 +1 @@
1
- import{api as T}from"../api.js";let _=null;const $={deleteEntry:[],moveToCollection:[{name:"targetCollection",label:"Target Collection Slug",placeholder:"e.g. archived-applications"}],webhook:[{name:"url",label:"URL",placeholder:"https://hooks.example.com/notify"},{name:"method",label:"Method",placeholder:"POST"},{name:"body",label:"Body (JSON)",placeholder:'{"email": "{{entry.data.email}}"}',multiline:!0}],email:[{name:"to",label:"To",placeholder:"{{entry.data.email}}"},{name:"subject",label:"Subject",placeholder:"Your application update"},{name:"template",label:"Body",placeholder:"Your application has been approved.",multiline:!0}]};export const actionEditorView={templateUrl:"/admin/js/templates/action-editor.html",async onMount(e){_=null;const t=window.location.hash.match(/\/actions\/edit\/([^/?#]+)/);t&&(_=t[1]),E.tabs(e.find("#action-editor-tabs").get(0)),await j(e),await D(e),_&&(e.find("#action-editor-title").text("Edit Action"),await I(e,_)),e.find("#add-step-btn").off("click").on("click",()=>{const l=e.find("#add-step-type").val()||"updateField";J(e,{type:l,config:{}})}),e.find("#save-action-btn").off("click").on("click",async()=>{await G(e)}),B(e),Domma.icons.scan()}};async function j(e){const t=e.find("#action-collection").get(0);if(t)try{(await T.collections.list()).forEach(l=>{const i=document.createElement("option");i.value=l.slug,i.textContent=`${l.title} (${l.slug})`,t.appendChild(i)})}catch{}}async function D(e){const t=e.find("#action-roles-checkboxes").get(0);t&&["admin","manager","editor","subscriber"].forEach(l=>{const i=document.createElement("label");i.style.cssText="display:flex;align-items:center;gap:.5rem;cursor:pointer;";const n=document.createElement("input");n.type="checkbox",n.value=l,n.dataset.role=l,n.className="action-role-cb",n.checked=l==="admin",i.appendChild(n),i.appendChild(document.createTextNode(l)),t.appendChild(i)})}async function I(e,t){try{const l=await T.actions.get(t);if(!l){E.toast("Action not found.",{type:"error"}),R.navigate("/actions");return}P(e,l)}catch(l){E.toast(l.message||"Failed to load action.",{type:"error"}),R.navigate("/actions")}}function B(e){const t=e.find("#action-rowlevel-enabled").get(0),l=e.find("#action-rowlevel-config").get(0),i=e.find("#action-rowlevel-mode").get(0),n=e.find("#action-rowlevel-field-group").get(0);t&&(t.addEventListener("change",()=>{l&&(l.style.display=t.checked?"flex":"none")}),i&&i.addEventListener("change",()=>{n&&(n.style.display=i.value==="field"?"":"none")}))}function P(e,t){e.find("#action-title").val(t.title||""),e.find("#action-slug").val(t.slug||""),e.find("#action-description").val(t.description||""),e.find("#action-collection").val(t.collection||""),e.find("#action-bundled").prop("checked",!!t.bundled),e.find("#action-trigger-type").val(t.trigger?.type||"manual"),e.find("#action-trigger-label").val(t.trigger?.label||"Run"),e.find("#action-trigger-icon").val(t.trigger?.icon||"zap"),e.find("#action-trigger-confirm").val(t.trigger?.confirmMessage||"");const l=t.access?.roles||["admin"];e.find(".action-role-cb").each(function(){this.checked=l.includes(this.value)});const i=t.access?.rowLevel;i&&(e.find("#action-rowlevel-enabled").prop("checked",!0),e.find("#action-rowlevel-config").css("display","flex"),e.find("#action-rowlevel-mode").val(i.mode||"owner"),e.find("#action-rowlevel-userkey").val(i.userKey||"id"),i.mode==="field"&&(e.find("#action-rowlevel-field-group").css("display",""),e.find("#action-rowlevel-field").val(i.field||"")));const n=e.find("#action-steps-list").get(0);if(n){const c=n.querySelector(".steps-empty-placeholder");for(c&&c.remove();n.firstChild;)n.removeChild(n.firstChild);const a=document.createElement("p");a.className="text-muted steps-empty-placeholder",a.textContent="No steps yet. Add a step to define what this action does.",a.style.cssText="text-align:center;padding:2rem 0;",n.appendChild(a)}(t.steps||[]).forEach(c=>J(e,c))}function J(e,t){const l=e.find("#action-steps-list").get(0);if(!l)return;const i=l.querySelector(".steps-empty-placeholder");i&&i.remove();const n=$[t.type]||[],c=document.createElement("div");c.className="card mb-2 step-card",c.dataset.stepType=t.type;const a=document.createElement("div");a.className="card-header",a.style.cssText="display:flex;align-items:center;gap:.5rem;";const p=document.createElement("code");p.textContent=t.type,p.style.cssText="flex:1;font-size:.85rem;";const r=document.createElement("button");r.type="button",r.className="btn btn-sm btn-danger";const v=document.createElement("span");v.setAttribute("data-icon","trash-2"),r.appendChild(v),r.addEventListener("click",()=>{if(c.remove(),!l.querySelector(".step-card")){const o=document.createElement("p");o.className="text-muted steps-empty-placeholder",o.textContent="No steps yet. Add a step to define what this action does.",o.style.cssText="text-align:center;padding:2rem 0;",l.appendChild(o)}}),a.appendChild(p),a.appendChild(r);const u=document.createElement("div");if(u.className="card-body",n.length===0){const o=document.createElement("p");o.className="text-muted",o.textContent=t.type==="deleteEntry"?"This step deletes the entry. No configuration required.":"No additional configuration required.",o.style.margin="0",u.appendChild(o)}t.type==="updateField"?U(u,t,e):n.forEach(o=>{const y=document.createElement("div");y.style.cssText="margin-bottom:.75rem;";const b=document.createElement("label");b.className="form-label",b.textContent=o.label,y.appendChild(b);let m;o.multiline?(m=document.createElement("textarea"),m.rows=3,m.style.cssText="font-family:monospace;font-size:.8rem;resize:vertical;",m.value=typeof t.config?.[o.name]=="object"?JSON.stringify(t.config[o.name],null,2):t.config?.[o.name]??""):(m=document.createElement("input"),m.type="text",m.value=t.config?.[o.name]??""),m.className=`form-input step-field-${o.name}`,m.placeholder=o.placeholder||"",m.dataset.field=o.name,y.appendChild(m),u.appendChild(y)}),c.appendChild(a),c.appendChild(u),l.appendChild(c),Domma.icons.scan(c)}async function U(e,t,l){const i=l.find("#action-collection").val(),n=document.createElement("div");n.style.cssText="margin-bottom:.75rem;";const c=document.createElement("label");c.className="form-label",c.textContent="Field",n.appendChild(c);const a=document.createElement("select");a.className="form-input step-field-field",a.dataset.field="field";const p=document.createElement("input");p.type="text",p.className="form-input mt-2 step-field-field-custom",p.placeholder="Field name, e.g. status",p.style.display="none";const r=document.createElement("div");r.style.cssText="margin-bottom:.75rem;";const v=document.createElement("label");v.className="form-label",v.textContent="New Value",r.appendChild(v);const u=document.createElement("div");r.appendChild(u);const o=document.createElement("button");o.type="button",o.className="btn btn-ghost btn-sm mt-2",o.style.cssText="font-size:.75rem;padding:.2rem .5rem;",o.textContent="Template Variables";const y=document.createElement("div");y.style.cssText="display:none;background:var(--dm-surface-2,#1e1e2e);border-radius:.4rem;padding:.75rem;margin-top:.5rem;font-size:.8rem;line-height:1.7;";const b=document.createElement("strong");b.textContent="Available template variables:",y.appendChild(b);const m=document.createElement("table");m.style.cssText="width:100%;border-collapse:collapse;margin-top:.4rem;",[["{{now}}","Current ISO timestamp"],["{{user.id}}","Executing user's ID"],["{{user.name}}","Executing user's name"],["{{user.email}}","Executing user's email"],["{{entry.id}}","Entry's unique ID"],["{{entry.data.fieldName}}","Any field value from the entry"],["{{env.CMS_PUBLIC_*}}","Public environment variables"]].forEach(([d,f])=>{const x=document.createElement("tr"),w=document.createElement("td");w.style.cssText="padding:.2rem .5rem .2rem 0;opacity:.7;white-space:nowrap;";const N=document.createElement("code");N.textContent=d,w.appendChild(N);const s=document.createElement("td");s.textContent=f,x.appendChild(w),x.appendChild(s),m.appendChild(x)}),y.appendChild(m),o.addEventListener("click",()=>{const d=y.style.display!=="none";y.style.display=d?"none":"",o.textContent=d?"Template Variables":"Hide Variables"}),r.appendChild(o),r.appendChild(y),e.appendChild(n),e.appendChild(r);let S=[];if(i)try{S=(await T.collections.get(i)).fields||[]}catch{}const q=document.createElement("option");q.value="",q.textContent=S.length?"\u2014 select a field \u2014":"\u2014 no fields available \u2014",a.appendChild(q),S.forEach(d=>{const f=document.createElement("option");f.value=d.name,f.textContent=`${d.label} (${d.name})`,f.dataset.fieldType=d.type,f.dataset.fieldOptions=d.type==="select"?JSON.stringify(d.options||[]):"",a.appendChild(f)});const L=document.createElement("option");L.value="__custom__",L.textContent="\u2014 enter manually \u2014",a.appendChild(L);const C=t.config?.field||"",z=C&&[...a.options].find(d=>d.value===C);z?a.value=C:C&&(a.value="__custom__",p.value=C,p.style.display=""),n.appendChild(a),n.appendChild(p);function M(d,f){u.textContent="";const x=d?[...a.options].find(s=>s.value===d):null,w=x?.dataset.fieldType==="select",N=w?JSON.parse(x.dataset.fieldOptions||"[]"):[];if(w&&N.length){const s=document.createElement("select");s.className="form-input step-field-value",s.dataset.field="value";const A=document.createElement("option");A.value="",A.textContent="\u2014 select a value \u2014",s.appendChild(A),N.forEach(h=>{const k=typeof h=="string"?h:h.value??"",V=typeof h=="string"?h:h.label||h.value||k;if(!k||k==="undefined")return;const O=document.createElement("option");O.value=k,O.textContent=V,s.appendChild(O)});const F=document.createElement("option");F.value="__custom__",F.textContent="\u2014 enter manually \u2014",s.appendChild(F);const g=document.createElement("input");g.type="text",g.className="form-input mt-2",g.placeholder="e.g. approved or {{now}}",g.style.display="none",f&&[...s.options].find(h=>h.value===f&&h.value!=="__custom__")?s.value=f:f&&(s.value="__custom__",g.value=f,g.style.display=""),s.addEventListener("change",()=>{const h=s.value==="__custom__";g.style.display=h?"":"none",h||(g.value="")}),u.appendChild(s),u.appendChild(g)}else{const s=document.createElement("input");s.type="text",s.className="form-input step-field-value",s.dataset.field="value",s.placeholder="e.g. approved or {{now}}",s.value=f||"",u.appendChild(s)}}M(z?C:null,t.config?.value||""),a.addEventListener("change",()=>{const d=a.value==="__custom__";p.style.display=d?"":"none",d||(p.value=""),M(d?null:a.value,"")})}function K(e){const t=[];return e.find(".step-card").each(function(){const l=this.dataset.stepType,i={};if(l==="updateField"){let n=this.querySelector(".step-field-field")?.value?.trim()||"";n==="__custom__"&&(n=this.querySelector(".step-field-field-custom")?.value?.trim()||""),i.field=n;const c=this.querySelector(".step-field-value");let a=c?.value?.trim()||"";a==="__custom__"&&(a=c?.nextElementSibling?.value?.trim()||""),i.value=a}else($[l]||[]).forEach(n=>{const c=this.querySelector(`.step-field-${n.name}`);if(!c)return;const a=c.value.trim();if(n.multiline&&a)try{i[n.name]=JSON.parse(a)}catch{i[n.name]=a}else i[n.name]=a});t.push({type:l,config:i})}),t}async function G(e){const t=e.find("#action-title").val().trim();if(!t){E.toast("Title is required.",{type:"warning"});return}const l=e.find("#action-collection").val();if(!l){E.toast("Target collection is required (General tab).",{type:"warning"});return}const i=[];e.find(".action-role-cb:checked").each(function(){i.push(this.value)});const n=e.find("#action-rowlevel-enabled").is(":checked");let c=null;if(n){const r=e.find("#action-rowlevel-mode").val()||"owner",v=e.find("#action-rowlevel-userkey").val()||"id";if(c={mode:r,userKey:v},r==="field"){const u=e.find("#action-rowlevel-field").val().trim();if(!u){E.toast("Field name is required for Field Match mode.",{type:"warning"});return}c.field=u}}const a={title:t,slug:e.find("#action-slug").val().trim()||void 0,description:e.find("#action-description").val().trim(),collection:l,...e.find("#action-bundled").is(":checked")?{bundled:!0}:{},trigger:{type:e.find("#action-trigger-type").val()||"manual",label:e.find("#action-trigger-label").val().trim()||"Run",icon:e.find("#action-trigger-icon").val().trim()||"zap",confirmMessage:e.find("#action-trigger-confirm").val().trim()||null},steps:K(e),access:{roles:i,rowLevel:c}},p=e.find("#save-action-btn").get(0);p&&(p.disabled=!0);try{if(_)await T.actions.update(_,a),E.toast("Action updated.",{type:"success"});else{const r=await T.actions.create(a);E.toast("Action created.",{type:"success"}),R.navigate(`/actions/edit/${r.slug}`)}}catch(r){E.toast(r.message||"Failed to save action.",{type:"error"})}finally{p&&(p.disabled=!1)}}
1
+ import{api as _}from"../api.js";let N=null,O={};async function D(e){const t=e.find("#action-project").get(0);if(t)try{(await _.projects.list()).forEach(a=>{const n=document.createElement("option");n.value=a.slug,n.textContent=a.name||a.slug,t.appendChild(n)})}catch{}}const $={deleteEntry:[],moveToCollection:[{name:"targetCollection",label:"Target Collection Slug",placeholder:"e.g. archived-applications"}],webhook:[{name:"url",label:"URL",placeholder:"https://hooks.example.com/notify"},{name:"method",label:"Method",placeholder:"POST"},{name:"body",label:"Body (JSON)",placeholder:'{"email": "{{entry.data.email}}"}',multiline:!0}],email:[{name:"to",label:"To",placeholder:"{{entry.data.email}}"},{name:"subject",label:"Subject",placeholder:"Your application update"},{name:"template",label:"Body",placeholder:"Your application has been approved.",multiline:!0}]};export const actionEditorView={templateUrl:"/admin/js/templates/action-editor.html",async onMount(e){N=null,O={};const t=window.location.hash.match(/\/actions\/edit\/([^/?#]+)/);t&&(N=t[1]),E.tabs(e.find("#action-editor-tabs").get(0)),await I(e),await P(e),await D(e),N&&(e.find("#action-editor-title").text("Edit Action"),await B(e,N)),e.find("#add-step-btn").off("click").on("click",()=>{const l=e.find("#add-step-type").val()||"updateField";J(e,{type:l,config:{}})}),e.find("#save-action-btn").off("click").on("click",async()=>{await Y(e)}),U(e),Domma.icons.scan()}};async function I(e){const t=e.find("#action-collection").get(0);if(t)try{(await _.collections.list()).forEach(l=>{const a=document.createElement("option");a.value=l.slug,a.textContent=`${l.title} (${l.slug})`,t.appendChild(a)})}catch{}}async function P(e){const t=e.find("#action-roles-checkboxes").get(0);t&&["admin","manager","editor","subscriber"].forEach(l=>{const a=document.createElement("label");a.style.cssText="display:flex;align-items:center;gap:.5rem;cursor:pointer;";const n=document.createElement("input");n.type="checkbox",n.value=l,n.dataset.role=l,n.className="action-role-cb",n.checked=l==="admin",a.appendChild(n),a.appendChild(document.createTextNode(l)),t.appendChild(a)})}async function B(e,t){try{const l=await _.actions.get(t);if(!l){E.toast("Action not found.",{type:"error"}),R.navigate("/actions");return}K(e,l)}catch(l){E.toast(l.message||"Failed to load action.",{type:"error"}),R.navigate("/actions")}}function U(e){const t=e.find("#action-rowlevel-enabled").get(0),l=e.find("#action-rowlevel-config").get(0),a=e.find("#action-rowlevel-mode").get(0),n=e.find("#action-rowlevel-field-group").get(0);t&&(t.addEventListener("change",()=>{l&&(l.style.display=t.checked?"flex":"none")}),a&&a.addEventListener("change",()=>{n&&(n.style.display=a.value==="field"?"":"none")}))}function K(e,t){e.find("#action-title").val(t.title||""),e.find("#action-slug").val(t.slug||""),e.find("#action-description").val(t.description||""),e.find("#action-collection").val(t.collection||""),O=t.meta||{},e.find("#action-project").val(t.meta?.project||""),e.find("#action-bundled").prop("checked",!!t.bundled),e.find("#action-trigger-type").val(t.trigger?.type||"manual"),e.find("#action-trigger-label").val(t.trigger?.label||"Run"),e.find("#action-trigger-icon").val(t.trigger?.icon||"zap"),e.find("#action-trigger-confirm").val(t.trigger?.confirmMessage||"");const l=t.access?.roles||["admin"];e.find(".action-role-cb").each(function(){this.checked=l.includes(this.value)});const a=t.access?.rowLevel;a&&(e.find("#action-rowlevel-enabled").prop("checked",!0),e.find("#action-rowlevel-config").css("display","flex"),e.find("#action-rowlevel-mode").val(a.mode||"owner"),e.find("#action-rowlevel-userkey").val(a.userKey||"id"),a.mode==="field"&&(e.find("#action-rowlevel-field-group").css("display",""),e.find("#action-rowlevel-field").val(a.field||"")));const n=e.find("#action-steps-list").get(0);if(n){const c=n.querySelector(".steps-empty-placeholder");for(c&&c.remove();n.firstChild;)n.removeChild(n.firstChild);const i=document.createElement("p");i.className="text-muted steps-empty-placeholder",i.textContent="No steps yet. Add a step to define what this action does.",i.style.cssText="text-align:center;padding:2rem 0;",n.appendChild(i)}(t.steps||[]).forEach(c=>J(e,c))}function J(e,t){const l=e.find("#action-steps-list").get(0);if(!l)return;const a=l.querySelector(".steps-empty-placeholder");a&&a.remove();const n=$[t.type]||[],c=document.createElement("div");c.className="card mb-2 step-card",c.dataset.stepType=t.type;const i=document.createElement("div");i.className="card-header",i.style.cssText="display:flex;align-items:center;gap:.5rem;";const p=document.createElement("code");p.textContent=t.type,p.style.cssText="flex:1;font-size:.85rem;";const r=document.createElement("button");r.type="button",r.className="btn btn-sm btn-danger";const f=document.createElement("span");f.setAttribute("data-icon","trash-2"),r.appendChild(f),r.addEventListener("click",()=>{if(c.remove(),!l.querySelector(".step-card")){const o=document.createElement("p");o.className="text-muted steps-empty-placeholder",o.textContent="No steps yet. Add a step to define what this action does.",o.style.cssText="text-align:center;padding:2rem 0;",l.appendChild(o)}}),i.appendChild(p),i.appendChild(r);const h=document.createElement("div");if(h.className="card-body",n.length===0){const o=document.createElement("p");o.className="text-muted",o.textContent=t.type==="deleteEntry"?"This step deletes the entry. No configuration required.":"No additional configuration required.",o.style.margin="0",h.appendChild(o)}t.type==="updateField"?G(h,t,e):n.forEach(o=>{const g=document.createElement("div");g.style.cssText="margin-bottom:.75rem;";const b=document.createElement("label");b.className="form-label",b.textContent=o.label,g.appendChild(b);let m;o.multiline?(m=document.createElement("textarea"),m.rows=3,m.style.cssText="font-family:monospace;font-size:.8rem;resize:vertical;",m.value=typeof t.config?.[o.name]=="object"?JSON.stringify(t.config[o.name],null,2):t.config?.[o.name]??""):(m=document.createElement("input"),m.type="text",m.value=t.config?.[o.name]??""),m.className=`form-input step-field-${o.name}`,m.placeholder=o.placeholder||"",m.dataset.field=o.name,g.appendChild(m),h.appendChild(g)}),c.appendChild(i),c.appendChild(h),l.appendChild(c),Domma.icons.scan(c)}async function G(e,t,l){const a=l.find("#action-collection").val(),n=document.createElement("div");n.style.cssText="margin-bottom:.75rem;";const c=document.createElement("label");c.className="form-label",c.textContent="Field",n.appendChild(c);const i=document.createElement("select");i.className="form-input step-field-field",i.dataset.field="field";const p=document.createElement("input");p.type="text",p.className="form-input mt-2 step-field-field-custom",p.placeholder="Field name, e.g. status",p.style.display="none";const r=document.createElement("div");r.style.cssText="margin-bottom:.75rem;";const f=document.createElement("label");f.className="form-label",f.textContent="New Value",r.appendChild(f);const h=document.createElement("div");r.appendChild(h);const o=document.createElement("button");o.type="button",o.className="btn btn-ghost btn-sm mt-2",o.style.cssText="font-size:.75rem;padding:.2rem .5rem;",o.textContent="Template Variables";const g=document.createElement("div");g.style.cssText="display:none;background:var(--dm-surface-2,#1e1e2e);border-radius:.4rem;padding:.75rem;margin-top:.5rem;font-size:.8rem;line-height:1.7;";const b=document.createElement("strong");b.textContent="Available template variables:",g.appendChild(b);const m=document.createElement("table");m.style.cssText="width:100%;border-collapse:collapse;margin-top:.4rem;",[["{{now}}","Current ISO timestamp"],["{{user.id}}","Executing user's ID"],["{{user.name}}","Executing user's name"],["{{user.email}}","Executing user's email"],["{{entry.id}}","Entry's unique ID"],["{{entry.data.fieldName}}","Any field value from the entry"],["{{env.CMS_PUBLIC_*}}","Public environment variables"]].forEach(([d,u])=>{const x=document.createElement("tr"),w=document.createElement("td");w.style.cssText="padding:.2rem .5rem .2rem 0;opacity:.7;white-space:nowrap;";const T=document.createElement("code");T.textContent=d,w.appendChild(T);const s=document.createElement("td");s.textContent=u,x.appendChild(w),x.appendChild(s),m.appendChild(x)}),g.appendChild(m),o.addEventListener("click",()=>{const d=g.style.display!=="none";g.style.display=d?"none":"",o.textContent=d?"Template Variables":"Hide Variables"}),r.appendChild(o),r.appendChild(g),e.appendChild(n),e.appendChild(r);let S=[];if(a)try{S=(await _.collections.get(a)).fields||[]}catch{}const j=document.createElement("option");j.value="",j.textContent=S.length?"\u2014 select a field \u2014":"\u2014 no fields available \u2014",i.appendChild(j),S.forEach(d=>{const u=document.createElement("option");u.value=d.name,u.textContent=`${d.label} (${d.name})`,u.dataset.fieldType=d.type,u.dataset.fieldOptions=d.type==="select"?JSON.stringify(d.options||[]):"",i.appendChild(u)});const q=document.createElement("option");q.value="__custom__",q.textContent="\u2014 enter manually \u2014",i.appendChild(q);const C=t.config?.field||"",z=C&&[...i.options].find(d=>d.value===C);z?i.value=C:C&&(i.value="__custom__",p.value=C,p.style.display=""),n.appendChild(i),n.appendChild(p);function M(d,u){h.textContent="";const x=d?[...i.options].find(s=>s.value===d):null,w=x?.dataset.fieldType==="select",T=w?JSON.parse(x.dataset.fieldOptions||"[]"):[];if(w&&T.length){const s=document.createElement("select");s.className="form-input step-field-value",s.dataset.field="value";const A=document.createElement("option");A.value="",A.textContent="\u2014 select a value \u2014",s.appendChild(A),T.forEach(y=>{const k=typeof y=="string"?y:y.value??"",V=typeof y=="string"?y:y.label||y.value||k;if(!k||k==="undefined")return;const F=document.createElement("option");F.value=k,F.textContent=V,s.appendChild(F)});const L=document.createElement("option");L.value="__custom__",L.textContent="\u2014 enter manually \u2014",s.appendChild(L);const v=document.createElement("input");v.type="text",v.className="form-input mt-2",v.placeholder="e.g. approved or {{now}}",v.style.display="none",u&&[...s.options].find(y=>y.value===u&&y.value!=="__custom__")?s.value=u:u&&(s.value="__custom__",v.value=u,v.style.display=""),s.addEventListener("change",()=>{const y=s.value==="__custom__";v.style.display=y?"":"none",y||(v.value="")}),h.appendChild(s),h.appendChild(v)}else{const s=document.createElement("input");s.type="text",s.className="form-input step-field-value",s.dataset.field="value",s.placeholder="e.g. approved or {{now}}",s.value=u||"",h.appendChild(s)}}M(z?C:null,t.config?.value||""),i.addEventListener("change",()=>{const d=i.value==="__custom__";p.style.display=d?"":"none",d||(p.value=""),M(d?null:i.value,"")})}function H(e){const t=[];return e.find(".step-card").each(function(){const l=this.dataset.stepType,a={};if(l==="updateField"){let n=this.querySelector(".step-field-field")?.value?.trim()||"";n==="__custom__"&&(n=this.querySelector(".step-field-field-custom")?.value?.trim()||""),a.field=n;const c=this.querySelector(".step-field-value");let i=c?.value?.trim()||"";i==="__custom__"&&(i=c?.nextElementSibling?.value?.trim()||""),a.value=i}else($[l]||[]).forEach(n=>{const c=this.querySelector(`.step-field-${n.name}`);if(!c)return;const i=c.value.trim();if(n.multiline&&i)try{a[n.name]=JSON.parse(i)}catch{a[n.name]=i}else a[n.name]=i});t.push({type:l,config:a})}),t}async function Y(e){const t=e.find("#action-title").val().trim();if(!t){E.toast("Title is required.",{type:"warning"});return}const l=e.find("#action-collection").val();if(!l){E.toast("Target collection is required (General tab).",{type:"warning"});return}const a=[];e.find(".action-role-cb:checked").each(function(){a.push(this.value)});const n=e.find("#action-rowlevel-enabled").is(":checked");let c=null;if(n){const f=e.find("#action-rowlevel-mode").val()||"owner",h=e.find("#action-rowlevel-userkey").val()||"id";if(c={mode:f,userKey:h},f==="field"){const o=e.find("#action-rowlevel-field").val().trim();if(!o){E.toast("Field name is required for Field Match mode.",{type:"warning"});return}c.field=o}}const i=e.find("#action-project").val()||"",p={title:t,slug:e.find("#action-slug").val().trim()||void 0,description:e.find("#action-description").val().trim(),collection:l,...e.find("#action-bundled").is(":checked")?{bundled:!0}:{},trigger:{type:e.find("#action-trigger-type").val()||"manual",label:e.find("#action-trigger-label").val().trim()||"Run",icon:e.find("#action-trigger-icon").val().trim()||"zap",confirmMessage:e.find("#action-trigger-confirm").val().trim()||null},steps:H(e),access:{roles:a,rowLevel:c},meta:{...O||{},project:i||null}},r=e.find("#save-action-btn").get(0);r&&(r.disabled=!0);try{if(N)await _.actions.update(N,p),E.toast("Action updated.",{type:"success"});else{const f=await _.actions.create(p);E.toast("Action created.",{type:"success"}),R.navigate(`/actions/edit/${f.slug}`)}}catch(f){E.toast(f.message||"Failed to save action.",{type:"error"})}finally{r&&(r.disabled=!1)}}
@@ -1 +1 @@
1
- import{api as i}from"../api.js";function a(o){return String(o).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}export const actionsListView={templateUrl:"/admin/js/templates/actions-list.html",async onMount(o){await r(o),o.find("#create-action-btn").off("click").on("click",()=>{R.navigate("/actions/new")}),Domma.icons.scan()}};async function r(o){let s=[];try{s=await i.actions.list(),o.find("#actions-pro-notice").hide()}catch(e){e.message?.includes("MongoDB")||e.message?.includes("pro mode")||e.message?.includes("connection")?o.find("#actions-pro-notice").show():E.toast("Could not load actions.",{type:"error"})}T.create("#actions-table",{data:s,columns:[{key:"title",title:"Title",render:(e,t)=>{const n=document.createElement("a");return n.href=`#/actions/edit/${a(t.slug)}`,n.textContent=e,n.style.fontWeight="600",n.outerHTML}},{key:"slug",title:"Slug",render:e=>`<code>${a(e)}</code>`},{key:"collection",title:"Collection",render:e=>`<code>${a(e||"\u2014")}</code>`},{key:"trigger",title:"Trigger",render:e=>a(e?.label||e?.type||"manual")},{key:"steps",title:"Steps",render:e=>String(Array.isArray(e)?e.length:0)},{key:"access",title:"Roles",render:e=>(e?.roles||[]).map(t=>`<span class="badge badge-secondary">${a(t)}</span>`).join(" ")},{key:"slug",title:"Actions",render:e=>{const t=document.createElement("div");t.style.cssText="display:flex;gap:.4rem;justify-content:flex-end;";const n=document.createElement("a");n.href=`#/actions/edit/${a(e)}`,n.className="btn btn-sm btn-primary",n.textContent="Edit";const c=document.createElement("button");return c.className="btn btn-sm btn-danger js-delete-action",c.dataset.slug=e,c.textContent="Delete",t.appendChild(n),t.appendChild(c),t.outerHTML}}],emptyMessage:'No actions yet. Click "New Action" to create your first action.'}),document.querySelectorAll(".js-delete-action").forEach(e=>{e.addEventListener("click",async()=>{const t=e.dataset.slug;if(await E.confirm(`Delete action "${t}"? This cannot be undone.`))try{await i.actions.delete(t),E.toast("Action deleted.",{type:"success"}),await r(o)}catch{E.toast("Failed to delete action.",{type:"error"})}})}),Domma.icons.scan()}
1
+ import{api as r}from"../api.js";import{filterByProject as d,getProjectFromHash as m}from"../lib/project-context.js";function c(o){return String(o).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}export const actionsListView={templateUrl:"/admin/js/templates/actions-list.html",async onMount(o){await l(o),o.find("#create-action-btn").off("click").on("click",()=>{R.navigate("/actions/new")}),Domma.icons.scan()}};async function l(o){let a=[];try{a=await r.actions.list(),o.find("#actions-pro-notice").hide()}catch(e){e.message?.includes("MongoDB")||e.message?.includes("pro mode")||e.message?.includes("connection")?o.find("#actions-pro-notice").show():E.toast("Could not load actions.",{type:"error"})}const i=m();i&&(a=d(a,i)),T.create("#actions-table",{data:a,columns:[{key:"title",title:"Title",render:(e,t)=>{const n=document.createElement("a");return n.href=`#/actions/edit/${c(t.slug)}`,n.textContent=e,n.style.fontWeight="600",n.outerHTML}},{key:"slug",title:"Slug",render:e=>`<code>${c(e)}</code>`},{key:"collection",title:"Collection",render:e=>`<code>${c(e||"\u2014")}</code>`},{key:"trigger",title:"Trigger",render:e=>c(e?.label||e?.type||"manual")},{key:"steps",title:"Steps",render:e=>String(Array.isArray(e)?e.length:0)},{key:"access",title:"Roles",render:e=>(e?.roles||[]).map(t=>`<span class="badge badge-secondary">${c(t)}</span>`).join(" ")},{key:"slug",title:"Actions",render:e=>{const t=document.createElement("div");t.style.cssText="display:flex;gap:.4rem;justify-content:flex-end;";const n=document.createElement("a");n.href=`#/actions/edit/${c(e)}`,n.className="btn btn-sm btn-primary",n.textContent="Edit";const s=document.createElement("button");return s.className="btn btn-sm btn-danger js-delete-action",s.dataset.slug=e,s.textContent="Delete",t.appendChild(n),t.appendChild(s),t.outerHTML}}],emptyMessage:'No actions yet. Click "New Action" to create your first action.'}),document.querySelectorAll(".js-delete-action").forEach(e=>{e.addEventListener("click",async()=>{const t=e.dataset.slug;if(await E.confirm(`Delete action "${t}"? This cannot be undone.`))try{await r.actions.delete(t),E.toast("Action deleted.",{type:"success"}),await l(o)}catch{E.toast("Failed to delete action.",{type:"error"})}})}),Domma.icons.scan()}