@shardworks/clerk-apparatus 0.1.162 → 0.1.164

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/README.md CHANGED
@@ -91,7 +91,7 @@ const total = await clerk.count({ status: 'ready' });
91
91
 
92
92
  ### `edit(request): Promise<WritDoc>`
93
93
 
94
- Edit a draft writ (status: `new`). Only the provided fields are updated. Throws if the writ is not in `new` status.
94
+ Edit a writ, updating one or more fields. Only the provided fields are updated.
95
95
 
96
96
  ```typescript
97
97
  const edited = await clerk.edit({
@@ -266,7 +266,7 @@ interface PostCommissionRequest {
266
266
  }
267
267
 
268
268
  interface EditWritRequest {
269
- id: string; // writ to edit (must be in 'new' status)
269
+ id: string; // writ to edit
270
270
  title?: string; // new title
271
271
  body?: string; // new body text
272
272
  type?: string; // new type (must be valid)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shardworks/clerk-apparatus",
3
- "version": "0.1.162",
3
+ "version": "0.1.164",
4
4
  "license": "ISC",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,9 +17,9 @@
17
17
  },
18
18
  "dependencies": {
19
19
  "zod": "4.3.6",
20
- "@shardworks/nexus-core": "0.1.162",
21
- "@shardworks/tools-apparatus": "0.1.162",
22
- "@shardworks/stacks-apparatus": "0.1.162"
20
+ "@shardworks/nexus-core": "0.1.164",
21
+ "@shardworks/stacks-apparatus": "0.1.164",
22
+ "@shardworks/tools-apparatus": "0.1.164"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/node": "25.5.0"
@@ -61,6 +61,10 @@
61
61
  <button class="btn filter-btn" data-status="failed">failed</button>
62
62
  <button class="btn filter-btn" data-status="cancelled">cancelled</button>
63
63
  </span>
64
+ <span id="type-filter-bar" style="display:inline-flex;gap:0.4rem;flex-wrap:wrap;align-items:center">
65
+ <span style="font-size:0.875rem;opacity:0.6">Type:</span>
66
+ <button class="btn type-filter-btn active-filter" data-type="">All</button>
67
+ </span>
64
68
  <input type="text" id="search-input" placeholder="Search title...">
65
69
  </div>
66
70
  </div>
@@ -130,6 +134,7 @@
130
134
  let writs = []; // currently loaded writs array
131
135
  let offset = 0;
132
136
  let currentStatus = ''; // '' = all
137
+ let currentType = ''; // '' = all
133
138
  let searchText = '';
134
139
  let sortCol = 'createdAt';
135
140
  let sortDir = 'desc';
@@ -813,6 +818,7 @@
813
818
 
814
819
  const params = new URLSearchParams({ limit: String(LIMIT), offset: String(offset) });
815
820
  if (currentStatus) params.set('status', currentStatus);
821
+ if (currentType) params.set('type', currentType);
816
822
 
817
823
  let result;
818
824
  try {
@@ -858,6 +864,45 @@
858
864
  if (t.default) opt.selected = true;
859
865
  sel.appendChild(opt);
860
866
  }
867
+
868
+ // Build type-filter buttons
869
+ buildTypeFilterBar(types);
870
+ }
871
+
872
+ function buildTypeFilterBar(types) {
873
+ const bar = document.getElementById('type-filter-bar');
874
+ // Clear existing buttons (keep the label span)
875
+ bar.innerHTML = '<span style="font-size:0.875rem;opacity:0.6">Type:</span>';
876
+
877
+ // "All" button
878
+ const allBtn = document.createElement('button');
879
+ allBtn.className = 'btn type-filter-btn';
880
+ allBtn.dataset.type = '';
881
+ allBtn.textContent = 'All';
882
+ bar.appendChild(allBtn);
883
+
884
+ // One button per known type
885
+ for (const t of types) {
886
+ const btn = document.createElement('button');
887
+ btn.className = 'btn type-filter-btn';
888
+ btn.dataset.type = t.name;
889
+ btn.textContent = t.name;
890
+ if (t.description) btn.title = t.description;
891
+ bar.appendChild(btn);
892
+ }
893
+
894
+ // Wire click handlers
895
+ bar.querySelectorAll('.type-filter-btn').forEach(btn => {
896
+ btn.addEventListener('click', () => setTypeFilter(btn.dataset.type));
897
+ });
898
+
899
+ // Default: select 'mandate' if present, otherwise 'All'
900
+ const hasMandateType = types.some(t => t.name === 'mandate');
901
+ const defaultType = hasMandateType ? 'mandate' : '';
902
+ currentType = defaultType;
903
+ bar.querySelectorAll('.type-filter-btn').forEach(btn => {
904
+ btn.classList.toggle('active-filter', btn.dataset.type === defaultType);
905
+ });
861
906
  }
862
907
 
863
908
  async function loadCodexes() {
@@ -1035,6 +1080,14 @@
1035
1080
  loadWrits(true);
1036
1081
  }
1037
1082
 
1083
+ function setTypeFilter(type) {
1084
+ currentType = type;
1085
+ document.querySelectorAll('.type-filter-btn').forEach(btn => {
1086
+ btn.classList.toggle('active-filter', btn.dataset.type === type);
1087
+ });
1088
+ loadWrits(true);
1089
+ }
1090
+
1038
1091
  // ── Event wiring ────────────────────────────────────────────────────
1039
1092
 
1040
1093
  document.getElementById('btn-new-writ').addEventListener('click', () => {
@@ -1075,11 +1128,13 @@
1075
1128
  });
1076
1129
 
1077
1130
  // ── Init ────────────────────────────────────────────────────────────
1078
- loadWritTypes();
1079
1131
  loadCodexes();
1080
1132
 
1081
1133
  // Deep-link: ?writ=ID
1082
1134
  (async function () {
1135
+ // Load writ types first so currentType default is set before fetching writs
1136
+ await loadWritTypes();
1137
+
1083
1138
  var params = new URLSearchParams(window.location.search);
1084
1139
  var writId = params.get('writ');
1085
1140
 
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Unit tests for writ type filter logic in writs/index.html.
3
+ *
4
+ * Extracts and tests the pure logic behind the type filter bar:
5
+ * - Button generation from writ types
6
+ * - Default type selection (mandate if present, else All)
7
+ * - Filter toggling via setTypeFilter
8
+ *
9
+ * Uses a minimal DOM shim to validate element creation without a browser.
10
+ */
11
+
12
+ import { describe, it, beforeEach } from 'node:test';
13
+ import assert from 'node:assert/strict';
14
+
15
+ // ── Minimal DOM shim ────────────────────────────────────────────────
16
+
17
+ /** Tiny element stub sufficient for the filter bar logic. */
18
+ class FakeElement {
19
+ constructor(tag) {
20
+ this.tagName = tag.toUpperCase();
21
+ this.className = '';
22
+ this.textContent = '';
23
+ this.title = '';
24
+ this.innerHTML = '';
25
+ this.dataset = {};
26
+ this.children = [];
27
+ this._listeners = {};
28
+ }
29
+
30
+ appendChild(child) {
31
+ this.children.push(child);
32
+ return child;
33
+ }
34
+
35
+ querySelectorAll(selector) {
36
+ // Only supports '.type-filter-btn'
37
+ return this.children.filter(c =>
38
+ c.className.includes('type-filter-btn'),
39
+ );
40
+ }
41
+
42
+ addEventListener(event, fn) {
43
+ if (!this._listeners[event]) this._listeners[event] = [];
44
+ this._listeners[event].push(fn);
45
+ }
46
+
47
+ click() {
48
+ for (const fn of this._listeners.click ?? []) fn();
49
+ }
50
+
51
+ get classList() {
52
+ const self = this;
53
+ return {
54
+ toggle(cls, force) {
55
+ const classes = self.className.split(/\s+/).filter(Boolean);
56
+ const idx = classes.indexOf(cls);
57
+ if (force && idx === -1) classes.push(cls);
58
+ if (!force && idx !== -1) classes.splice(idx, 1);
59
+ self.className = classes.join(' ');
60
+ },
61
+ contains(cls) {
62
+ return self.className.split(/\s+/).includes(cls);
63
+ },
64
+ };
65
+ }
66
+ }
67
+
68
+ function createElement(tag) {
69
+ return new FakeElement(tag);
70
+ }
71
+
72
+ // ── Extracted logic (mirrors index.html) ────────────────────────────
73
+
74
+ /**
75
+ * Builds type filter buttons from a types array into a bar element.
76
+ * Returns { bar, defaultType } for testability.
77
+ */
78
+ function buildTypeFilterBar(types, bar) {
79
+ // Clear existing buttons (keep label span)
80
+ bar.innerHTML = '';
81
+ bar.children = [];
82
+
83
+ // Label
84
+ const label = createElement('span');
85
+ label.textContent = 'Type:';
86
+ bar.appendChild(label);
87
+
88
+ // "All" button
89
+ const allBtn = createElement('button');
90
+ allBtn.className = 'btn type-filter-btn';
91
+ allBtn.dataset.type = '';
92
+ allBtn.textContent = 'All';
93
+ bar.appendChild(allBtn);
94
+
95
+ // One button per known type
96
+ for (const t of types) {
97
+ const btn = createElement('button');
98
+ btn.className = 'btn type-filter-btn';
99
+ btn.dataset.type = t.name;
100
+ btn.textContent = t.name;
101
+ if (t.description) btn.title = t.description;
102
+ bar.appendChild(btn);
103
+ }
104
+
105
+ // Default: select 'mandate' if present, otherwise 'All'
106
+ const hasMandateType = types.some(t => t.name === 'mandate');
107
+ const defaultType = hasMandateType ? 'mandate' : '';
108
+
109
+ bar.querySelectorAll('.type-filter-btn').forEach(btn => {
110
+ btn.classList.toggle('active-filter', btn.dataset.type === defaultType);
111
+ });
112
+
113
+ return defaultType;
114
+ }
115
+
116
+ /**
117
+ * Applies a type filter selection to the bar (mirrors setTypeFilter).
118
+ */
119
+ function applyTypeFilter(bar, type) {
120
+ bar.querySelectorAll('.type-filter-btn').forEach(btn => {
121
+ btn.classList.toggle('active-filter', btn.dataset.type === type);
122
+ });
123
+ }
124
+
125
+ // ── Tests ────────────────────────────────────────────────────────────
126
+
127
+ describe('buildTypeFilterBar()', () => {
128
+ let bar;
129
+
130
+ beforeEach(() => {
131
+ bar = createElement('span');
132
+ });
133
+
134
+ it('creates "All" button plus one per type', () => {
135
+ const types = [
136
+ { name: 'mandate' },
137
+ { name: 'task' },
138
+ ];
139
+ buildTypeFilterBar(types, bar);
140
+
141
+ const btns = bar.querySelectorAll('.type-filter-btn');
142
+ assert.equal(btns.length, 3); // All + mandate + task
143
+ assert.equal(btns[0].textContent, 'All');
144
+ assert.equal(btns[0].dataset.type, '');
145
+ assert.equal(btns[1].textContent, 'mandate');
146
+ assert.equal(btns[1].dataset.type, 'mandate');
147
+ assert.equal(btns[2].textContent, 'task');
148
+ assert.equal(btns[2].dataset.type, 'task');
149
+ });
150
+
151
+ it('defaults to mandate when mandate is present', () => {
152
+ const types = [
153
+ { name: 'task' },
154
+ { name: 'mandate' },
155
+ { name: 'bug' },
156
+ ];
157
+ const defaultType = buildTypeFilterBar(types, bar);
158
+
159
+ assert.equal(defaultType, 'mandate');
160
+
161
+ const btns = bar.querySelectorAll('.type-filter-btn');
162
+ // "All" should NOT be active
163
+ const allBtn = btns.find(b => b.dataset.type === '');
164
+ assert.ok(!allBtn.classList.contains('active-filter'), 'All should not be active');
165
+ // "mandate" should be active
166
+ const mandateBtn = btns.find(b => b.dataset.type === 'mandate');
167
+ assert.ok(mandateBtn.classList.contains('active-filter'), 'mandate should be active');
168
+ // others should not be active
169
+ const taskBtn = btns.find(b => b.dataset.type === 'task');
170
+ assert.ok(!taskBtn.classList.contains('active-filter'), 'task should not be active');
171
+ });
172
+
173
+ it('defaults to All when mandate is not present', () => {
174
+ const types = [
175
+ { name: 'task' },
176
+ { name: 'bug' },
177
+ ];
178
+ const defaultType = buildTypeFilterBar(types, bar);
179
+
180
+ assert.equal(defaultType, '');
181
+
182
+ const btns = bar.querySelectorAll('.type-filter-btn');
183
+ const allBtn = btns.find(b => b.dataset.type === '');
184
+ assert.ok(allBtn.classList.contains('active-filter'), 'All should be active');
185
+ const taskBtn = btns.find(b => b.dataset.type === 'task');
186
+ assert.ok(!taskBtn.classList.contains('active-filter'), 'task should not be active');
187
+ });
188
+
189
+ it('handles empty types array — only All button', () => {
190
+ const types = [];
191
+ const defaultType = buildTypeFilterBar(types, bar);
192
+
193
+ assert.equal(defaultType, '');
194
+
195
+ const btns = bar.querySelectorAll('.type-filter-btn');
196
+ assert.equal(btns.length, 1);
197
+ assert.equal(btns[0].textContent, 'All');
198
+ assert.ok(btns[0].classList.contains('active-filter'));
199
+ });
200
+
201
+ it('handles single mandate type', () => {
202
+ const types = [{ name: 'mandate' }];
203
+ const defaultType = buildTypeFilterBar(types, bar);
204
+
205
+ assert.equal(defaultType, 'mandate');
206
+ const btns = bar.querySelectorAll('.type-filter-btn');
207
+ assert.equal(btns.length, 2); // All + mandate
208
+ });
209
+
210
+ it('sets title from description', () => {
211
+ const types = [
212
+ { name: 'task', description: 'A work item' },
213
+ ];
214
+ buildTypeFilterBar(types, bar);
215
+
216
+ const btns = bar.querySelectorAll('.type-filter-btn');
217
+ const taskBtn = btns.find(b => b.dataset.type === 'task');
218
+ assert.equal(taskBtn.title, 'A work item');
219
+ });
220
+
221
+ it('does not set title when no description', () => {
222
+ const types = [{ name: 'task' }];
223
+ buildTypeFilterBar(types, bar);
224
+
225
+ const btns = bar.querySelectorAll('.type-filter-btn');
226
+ const taskBtn = btns.find(b => b.dataset.type === 'task');
227
+ assert.equal(taskBtn.title, '');
228
+ });
229
+
230
+ it('includes label span as first child', () => {
231
+ buildTypeFilterBar([{ name: 'mandate' }], bar);
232
+ assert.equal(bar.children[0].textContent, 'Type:');
233
+ });
234
+ });
235
+
236
+ describe('applyTypeFilter()', () => {
237
+ let bar;
238
+
239
+ beforeEach(() => {
240
+ bar = createElement('span');
241
+ buildTypeFilterBar([
242
+ { name: 'mandate' },
243
+ { name: 'task' },
244
+ { name: 'bug' },
245
+ ], bar);
246
+ });
247
+
248
+ it('switches active state to selected type', () => {
249
+ applyTypeFilter(bar, 'task');
250
+
251
+ const btns = bar.querySelectorAll('.type-filter-btn');
252
+ const taskBtn = btns.find(b => b.dataset.type === 'task');
253
+ const mandateBtn = btns.find(b => b.dataset.type === 'mandate');
254
+ const allBtn = btns.find(b => b.dataset.type === '');
255
+
256
+ assert.ok(taskBtn.classList.contains('active-filter'));
257
+ assert.ok(!mandateBtn.classList.contains('active-filter'));
258
+ assert.ok(!allBtn.classList.contains('active-filter'));
259
+ });
260
+
261
+ it('switches active state to All', () => {
262
+ applyTypeFilter(bar, '');
263
+
264
+ const btns = bar.querySelectorAll('.type-filter-btn');
265
+ const allBtn = btns.find(b => b.dataset.type === '');
266
+ assert.ok(allBtn.classList.contains('active-filter'));
267
+
268
+ // All others should be inactive
269
+ for (const btn of btns) {
270
+ if (btn.dataset.type !== '') {
271
+ assert.ok(!btn.classList.contains('active-filter'), `${btn.dataset.type} should not be active`);
272
+ }
273
+ }
274
+ });
275
+
276
+ it('only one button is active at a time', () => {
277
+ applyTypeFilter(bar, 'bug');
278
+
279
+ const btns = bar.querySelectorAll('.type-filter-btn');
280
+ const activeCount = btns.filter(b => b.classList.contains('active-filter')).length;
281
+ assert.equal(activeCount, 1);
282
+ });
283
+ });