@shardworks/clerk-apparatus 0.1.162 → 0.1.163
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shardworks/clerk-apparatus",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.163",
|
|
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.
|
|
21
|
-
"@shardworks/tools-apparatus": "0.1.
|
|
22
|
-
"@shardworks/stacks-apparatus": "0.1.
|
|
20
|
+
"@shardworks/nexus-core": "0.1.163",
|
|
21
|
+
"@shardworks/tools-apparatus": "0.1.163",
|
|
22
|
+
"@shardworks/stacks-apparatus": "0.1.163"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"@types/node": "25.5.0"
|
package/pages/writs/index.html
CHANGED
|
@@ -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
|
+
});
|