modal_stack 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +37 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +748 -0
- data/Rakefile +12 -0
- data/app/assets/javascripts/modal_stack.js +756 -0
- data/app/assets/stylesheets/modal_stack/bootstrap.css +232 -0
- data/app/assets/stylesheets/modal_stack/tailwind.css +303 -0
- data/app/assets/stylesheets/modal_stack/vanilla.css +219 -0
- data/app/javascript/modal_stack/controllers/modal_stack_controller.js +149 -0
- data/app/javascript/modal_stack/controllers/modal_stack_link_controller.js +34 -0
- data/app/javascript/modal_stack/index.js +15 -0
- data/app/javascript/modal_stack/install.js +15 -0
- data/app/javascript/modal_stack/orchestrator.js +98 -0
- data/app/javascript/modal_stack/orchestrator.test.js +260 -0
- data/app/javascript/modal_stack/runtime.js +217 -0
- data/app/javascript/modal_stack/runtime.test.js +134 -0
- data/app/javascript/modal_stack/state.js +315 -0
- data/app/javascript/modal_stack/state.test.js +508 -0
- data/app/views/layouts/modal.html.erb +6 -0
- data/lib/generators/modal_stack/install/install_generator.rb +224 -0
- data/lib/generators/modal_stack/install/templates/initializer.rb +57 -0
- data/lib/modal_stack/capybara/minitest.rb +9 -0
- data/lib/modal_stack/capybara/rspec.rb +9 -0
- data/lib/modal_stack/capybara.rb +85 -0
- data/lib/modal_stack/configuration.rb +90 -0
- data/lib/modal_stack/controller_extensions.rb +73 -0
- data/lib/modal_stack/engine.rb +44 -0
- data/lib/modal_stack/helpers/modal_link_helper.rb +65 -0
- data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +45 -0
- data/lib/modal_stack/helpers/modal_stack_container_helper.rb +36 -0
- data/lib/modal_stack/initializer_version_check.rb +33 -0
- data/lib/modal_stack/turbo_streams_extension.rb +73 -0
- data/lib/modal_stack/version.rb +5 -0
- data/lib/modal_stack.rb +36 -0
- metadata +130 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
closeAll,
|
|
4
|
+
createStack,
|
|
5
|
+
handlePopstate,
|
|
6
|
+
pop,
|
|
7
|
+
push,
|
|
8
|
+
replaceTop,
|
|
9
|
+
restore,
|
|
10
|
+
snapshot,
|
|
11
|
+
topLayer,
|
|
12
|
+
} from "./state.js";
|
|
13
|
+
|
|
14
|
+
const STACK_ID = "stack-abc";
|
|
15
|
+
const BASE_URL = "/projects";
|
|
16
|
+
|
|
17
|
+
function freshStack() {
|
|
18
|
+
return createStack({ stackId: STACK_ID, baseUrl: BASE_URL });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function pushed(state, overrides = {}) {
|
|
22
|
+
return push(state, {
|
|
23
|
+
id: "L1",
|
|
24
|
+
url: "/projects/42/edit",
|
|
25
|
+
variant: "modal",
|
|
26
|
+
dismissible: true,
|
|
27
|
+
...overrides,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("createStack", () => {
|
|
32
|
+
test("returns frozen empty stack", () => {
|
|
33
|
+
const s = freshStack();
|
|
34
|
+
expect(s.stackId).toBe(STACK_ID);
|
|
35
|
+
expect(s.baseUrl).toBe(BASE_URL);
|
|
36
|
+
expect(s.layers).toEqual([]);
|
|
37
|
+
expect(Object.isFrozen(s)).toBe(true);
|
|
38
|
+
expect(Object.isFrozen(s.layers)).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("requires stackId and baseUrl", () => {
|
|
42
|
+
expect(() => createStack({ baseUrl: "/" })).toThrow(/stackId/);
|
|
43
|
+
expect(() => createStack({ stackId: "x" })).toThrow(/baseUrl/);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("topLayer", () => {
|
|
48
|
+
test("null when empty", () => {
|
|
49
|
+
expect(topLayer(freshStack())).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("returns the last layer", () => {
|
|
53
|
+
const { state } = pushed(freshStack());
|
|
54
|
+
expect(topLayer(state).id).toBe("L1");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("push", () => {
|
|
59
|
+
test("first layer emits showDialog + lockScroll + mount + pushHistory + snapshot", () => {
|
|
60
|
+
const { state, commands } = pushed(freshStack());
|
|
61
|
+
expect(state.layers).toHaveLength(1);
|
|
62
|
+
expect(state.layers[0]).toMatchObject({
|
|
63
|
+
id: "L1",
|
|
64
|
+
url: "/projects/42/edit",
|
|
65
|
+
variant: "modal",
|
|
66
|
+
dismissible: true,
|
|
67
|
+
});
|
|
68
|
+
expect(commands).toEqual([
|
|
69
|
+
{
|
|
70
|
+
type: "mountLayer",
|
|
71
|
+
layerId: "L1",
|
|
72
|
+
url: "/projects/42/edit",
|
|
73
|
+
depth: 1,
|
|
74
|
+
variant: "modal",
|
|
75
|
+
dismissible: true,
|
|
76
|
+
},
|
|
77
|
+
{ type: "showDialog" },
|
|
78
|
+
{ type: "lockScroll" },
|
|
79
|
+
{
|
|
80
|
+
type: "pushHistory",
|
|
81
|
+
url: "/projects/42/edit",
|
|
82
|
+
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1 },
|
|
83
|
+
},
|
|
84
|
+
{ type: "persistSnapshot" },
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("preserves side and size for drawer layers", () => {
|
|
89
|
+
const { state, commands } = push(freshStack(), {
|
|
90
|
+
id: "L1",
|
|
91
|
+
url: "/modal_stack/details",
|
|
92
|
+
variant: "drawer",
|
|
93
|
+
side: "right",
|
|
94
|
+
size: "md",
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(state.layers[0]).toMatchObject({
|
|
98
|
+
id: "L1",
|
|
99
|
+
variant: "drawer",
|
|
100
|
+
side: "right",
|
|
101
|
+
size: "md",
|
|
102
|
+
});
|
|
103
|
+
expect(commands[0]).toMatchObject({
|
|
104
|
+
type: "mountLayer",
|
|
105
|
+
layerId: "L1",
|
|
106
|
+
variant: "drawer",
|
|
107
|
+
side: "right",
|
|
108
|
+
size: "md",
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("defaults drawer side to right when missing", () => {
|
|
113
|
+
const { state, commands } = push(freshStack(), {
|
|
114
|
+
id: "L1",
|
|
115
|
+
url: "/modal_stack/details",
|
|
116
|
+
variant: "drawer",
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(state.layers[0]).toMatchObject({
|
|
120
|
+
variant: "drawer",
|
|
121
|
+
side: "right",
|
|
122
|
+
});
|
|
123
|
+
expect(commands[0]).toMatchObject({
|
|
124
|
+
type: "mountLayer",
|
|
125
|
+
variant: "drawer",
|
|
126
|
+
side: "right",
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("supports top and bottom drawer sides", () => {
|
|
131
|
+
const topLayerResult = push(freshStack(), {
|
|
132
|
+
id: "L1",
|
|
133
|
+
url: "/modal_stack/details",
|
|
134
|
+
variant: "drawer",
|
|
135
|
+
side: "top",
|
|
136
|
+
});
|
|
137
|
+
expect(topLayerResult.state.layers[0].side).toBe("top");
|
|
138
|
+
|
|
139
|
+
const bottomLayerResult = push(freshStack(), {
|
|
140
|
+
id: "L1",
|
|
141
|
+
url: "/modal_stack/details",
|
|
142
|
+
variant: "drawer",
|
|
143
|
+
side: "bottom",
|
|
144
|
+
});
|
|
145
|
+
expect(bottomLayerResult.state.layers[0].side).toBe("bottom");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("rejects unknown drawer side", () => {
|
|
149
|
+
expect(() =>
|
|
150
|
+
push(freshStack(), {
|
|
151
|
+
id: "L1",
|
|
152
|
+
url: "/modal_stack/details",
|
|
153
|
+
variant: "drawer",
|
|
154
|
+
side: "middle",
|
|
155
|
+
}),
|
|
156
|
+
).toThrow(/unknown drawer side/);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("passes custom width and height to mount command", () => {
|
|
160
|
+
const { state, commands } = push(freshStack(), {
|
|
161
|
+
id: "L1",
|
|
162
|
+
url: "/modal_stack/details",
|
|
163
|
+
variant: "drawer",
|
|
164
|
+
width: "80vw",
|
|
165
|
+
height: "24rem",
|
|
166
|
+
});
|
|
167
|
+
expect(state.layers[0]).toMatchObject({
|
|
168
|
+
width: "80vw",
|
|
169
|
+
height: "24rem",
|
|
170
|
+
});
|
|
171
|
+
expect(commands[0]).toMatchObject({
|
|
172
|
+
width: "80vw",
|
|
173
|
+
height: "24rem",
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("second layer mounts before inerting the previous top, no showDialog", () => {
|
|
178
|
+
const first = pushed(freshStack()).state;
|
|
179
|
+
const { state, commands } = push(first, { id: "L2", url: "/clients/new" });
|
|
180
|
+
expect(state.layers).toHaveLength(2);
|
|
181
|
+
expect(commands.map((c) => c.type)).toEqual([
|
|
182
|
+
"mountLayer",
|
|
183
|
+
"inertLayer",
|
|
184
|
+
"pushHistory",
|
|
185
|
+
"persistSnapshot",
|
|
186
|
+
]);
|
|
187
|
+
expect(commands).not.toContainEqual({ type: "showDialog" });
|
|
188
|
+
expect(commands).toContainEqual({
|
|
189
|
+
type: "pushHistory",
|
|
190
|
+
url: "/clients/new",
|
|
191
|
+
historyState: { stackId: STACK_ID, layerId: "L2", depth: 2 },
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("rejects unknown variant", () => {
|
|
196
|
+
expect(() =>
|
|
197
|
+
push(freshStack(), { id: "L1", url: "/x", variant: "popover" }),
|
|
198
|
+
).toThrow(/unknown variant/);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("rejects missing id or url", () => {
|
|
202
|
+
expect(() => push(freshStack(), { url: "/x" })).toThrow(/id/);
|
|
203
|
+
expect(() => push(freshStack(), { id: "L" })).toThrow(/url/);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("dismissible defaults to true", () => {
|
|
207
|
+
const { state } = push(freshStack(), { id: "L1", url: "/x" });
|
|
208
|
+
expect(state.layers[0].dismissible).toBe(true);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("variant defaults to modal", () => {
|
|
212
|
+
const { state } = push(freshStack(), { id: "L1", url: "/x" });
|
|
213
|
+
expect(state.layers[0].variant).toBe("modal");
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("pop", () => {
|
|
218
|
+
test("noop on empty stack", () => {
|
|
219
|
+
const s = freshStack();
|
|
220
|
+
expect(pop(s)).toEqual({ state: s, commands: [] });
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("popping last layer closes dialog and clears snapshot", () => {
|
|
224
|
+
const first = pushed(freshStack()).state;
|
|
225
|
+
const { state, commands } = pop(first);
|
|
226
|
+
expect(state.layers).toEqual([]);
|
|
227
|
+
expect(commands).toEqual([
|
|
228
|
+
{ type: "unmountTopLayer" },
|
|
229
|
+
{ type: "historyBack", n: 1 },
|
|
230
|
+
{ type: "closeDialog" },
|
|
231
|
+
{ type: "unlockScroll" },
|
|
232
|
+
{ type: "clearSnapshot" },
|
|
233
|
+
]);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("popping middle layer un-inerts new top", () => {
|
|
237
|
+
let s = pushed(freshStack()).state;
|
|
238
|
+
s = push(s, { id: "L2", url: "/clients/new" }).state;
|
|
239
|
+
const { state, commands } = pop(s);
|
|
240
|
+
expect(state.layers).toHaveLength(1);
|
|
241
|
+
expect(commands).toEqual([
|
|
242
|
+
{ type: "unmountTopLayer" },
|
|
243
|
+
{ type: "historyBack", n: 1 },
|
|
244
|
+
{ type: "inertLayer", layerId: "L1", value: false },
|
|
245
|
+
{ type: "persistSnapshot" },
|
|
246
|
+
]);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe("replaceTop", () => {
|
|
251
|
+
test("throws on empty stack", () => {
|
|
252
|
+
expect(() => replaceTop(freshStack(), { url: "/x" })).toThrow(
|
|
253
|
+
/at least one layer/,
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("default historyMode is replace, keeps id", () => {
|
|
258
|
+
const first = pushed(freshStack()).state;
|
|
259
|
+
const { state, commands } = replaceTop(first, { url: "/projects/42/edit/billing" });
|
|
260
|
+
expect(state.layers[0].id).toBe("L1");
|
|
261
|
+
expect(state.layers[0].url).toBe("/projects/42/edit/billing");
|
|
262
|
+
expect(commands).toEqual([
|
|
263
|
+
{
|
|
264
|
+
type: "morphTopLayer",
|
|
265
|
+
layerId: "L1",
|
|
266
|
+
url: "/projects/42/edit/billing",
|
|
267
|
+
depth: 1,
|
|
268
|
+
variant: "modal",
|
|
269
|
+
dismissible: true,
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
type: "replaceHistory",
|
|
273
|
+
url: "/projects/42/edit/billing",
|
|
274
|
+
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1 },
|
|
275
|
+
},
|
|
276
|
+
{ type: "persistSnapshot" },
|
|
277
|
+
]);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("replaceTop keeps side and size when not patched", () => {
|
|
281
|
+
const first = push(freshStack(), {
|
|
282
|
+
id: "L1",
|
|
283
|
+
url: "/modal_stack/details",
|
|
284
|
+
variant: "drawer",
|
|
285
|
+
side: "right",
|
|
286
|
+
size: "md",
|
|
287
|
+
}).state;
|
|
288
|
+
|
|
289
|
+
const { state, commands } = replaceTop(first, { url: "/modal_stack/details?x=1" });
|
|
290
|
+
|
|
291
|
+
expect(state.layers[0]).toMatchObject({
|
|
292
|
+
id: "L1",
|
|
293
|
+
variant: "drawer",
|
|
294
|
+
side: "right",
|
|
295
|
+
size: "md",
|
|
296
|
+
url: "/modal_stack/details?x=1",
|
|
297
|
+
});
|
|
298
|
+
expect(commands[0]).toMatchObject({
|
|
299
|
+
type: "morphTopLayer",
|
|
300
|
+
layerId: "L1",
|
|
301
|
+
variant: "drawer",
|
|
302
|
+
side: "right",
|
|
303
|
+
size: "md",
|
|
304
|
+
url: "/modal_stack/details?x=1",
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("historyMode push assigns a new layerId", () => {
|
|
309
|
+
const first = pushed(freshStack()).state;
|
|
310
|
+
const { state, commands } = replaceTop(
|
|
311
|
+
first,
|
|
312
|
+
{ id: "L1b", url: "/onboarding/step2" },
|
|
313
|
+
{ historyMode: "push" },
|
|
314
|
+
);
|
|
315
|
+
expect(state.layers[0].id).toBe("L1b");
|
|
316
|
+
expect(commands[1]).toEqual({
|
|
317
|
+
type: "pushHistory",
|
|
318
|
+
url: "/onboarding/step2",
|
|
319
|
+
historyState: { stackId: STACK_ID, layerId: "L1b", depth: 1 },
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("rejects unknown historyMode", () => {
|
|
324
|
+
const s = pushed(freshStack()).state;
|
|
325
|
+
expect(() => replaceTop(s, { url: "/x" }, { historyMode: "wat" })).toThrow(
|
|
326
|
+
/historyMode/,
|
|
327
|
+
);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe("closeAll", () => {
|
|
332
|
+
test("noop on empty stack", () => {
|
|
333
|
+
const s = freshStack();
|
|
334
|
+
expect(closeAll(s)).toEqual({ state: s, commands: [] });
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("clears layers and walks history back N entries", () => {
|
|
338
|
+
let s = pushed(freshStack()).state;
|
|
339
|
+
s = push(s, { id: "L2", url: "/clients/new" }).state;
|
|
340
|
+
s = push(s, { id: "L3", url: "/clients/new/contact" }).state;
|
|
341
|
+
const { state, commands } = closeAll(s);
|
|
342
|
+
expect(state.layers).toEqual([]);
|
|
343
|
+
expect(commands).toEqual([
|
|
344
|
+
{ type: "unmountAllLayers" },
|
|
345
|
+
{ type: "closeDialog" },
|
|
346
|
+
{ type: "unlockScroll" },
|
|
347
|
+
{ type: "historyBack", n: 3 },
|
|
348
|
+
{ type: "clearSnapshot" },
|
|
349
|
+
]);
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
describe("handlePopstate", () => {
|
|
354
|
+
function buildTwoLayer() {
|
|
355
|
+
let s = pushed(freshStack()).state;
|
|
356
|
+
s = push(s, { id: "L2", url: "/clients/new" }).state;
|
|
357
|
+
return s;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
test("ignores popstate from a different stackId, tearing down silently", () => {
|
|
361
|
+
const s = buildTwoLayer();
|
|
362
|
+
const { state, commands } = handlePopstate(s, {
|
|
363
|
+
historyState: { stackId: "other-stack", layerId: "X", depth: 5 },
|
|
364
|
+
locationHref: "/elsewhere",
|
|
365
|
+
});
|
|
366
|
+
expect(state.layers).toEqual([]);
|
|
367
|
+
expect(commands).toEqual([
|
|
368
|
+
{ type: "unmountAllLayers" },
|
|
369
|
+
{ type: "closeDialog" },
|
|
370
|
+
{ type: "unlockScroll" },
|
|
371
|
+
{ type: "clearSnapshot" },
|
|
372
|
+
]);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("popstate with no historyState while empty is a true noop", () => {
|
|
376
|
+
const s = freshStack();
|
|
377
|
+
expect(handlePopstate(s, { historyState: null })).toEqual({
|
|
378
|
+
state: s,
|
|
379
|
+
commands: [],
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("back: targetDepth < current pops layers and un-inerts new top (no historyBack)", () => {
|
|
384
|
+
const s = buildTwoLayer();
|
|
385
|
+
const { state, commands } = handlePopstate(s, {
|
|
386
|
+
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1 },
|
|
387
|
+
locationHref: "/projects/42/edit",
|
|
388
|
+
});
|
|
389
|
+
expect(state.layers.map((l) => l.id)).toEqual(["L1"]);
|
|
390
|
+
expect(commands).toEqual([
|
|
391
|
+
{ type: "unmountTopLayer" },
|
|
392
|
+
{ type: "inertLayer", layerId: "L1", value: false },
|
|
393
|
+
{ type: "persistSnapshot" },
|
|
394
|
+
]);
|
|
395
|
+
expect(commands).not.toContainEqual({ type: "historyBack", n: 1 });
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("back: targetDepth 0 closes dialog and clears snapshot", () => {
|
|
399
|
+
const s = buildTwoLayer();
|
|
400
|
+
const { state, commands } = handlePopstate(s, {
|
|
401
|
+
historyState: { stackId: STACK_ID, depth: 0 },
|
|
402
|
+
locationHref: BASE_URL,
|
|
403
|
+
});
|
|
404
|
+
expect(state.layers).toEqual([]);
|
|
405
|
+
expect(commands).toEqual([
|
|
406
|
+
{ type: "unmountTopLayer" },
|
|
407
|
+
{ type: "unmountTopLayer" },
|
|
408
|
+
{ type: "closeDialog" },
|
|
409
|
+
{ type: "unlockScroll" },
|
|
410
|
+
{ type: "clearSnapshot" },
|
|
411
|
+
]);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("forward: targetDepth > current asks controller to rebuild from snapshot", () => {
|
|
415
|
+
const s = pushed(freshStack()).state;
|
|
416
|
+
const { state, commands } = handlePopstate(s, {
|
|
417
|
+
historyState: { stackId: STACK_ID, layerId: "L2", depth: 2 },
|
|
418
|
+
locationHref: "/clients/new",
|
|
419
|
+
});
|
|
420
|
+
expect(state).toEqual(s);
|
|
421
|
+
expect(commands).toEqual([
|
|
422
|
+
{ type: "rebuildFromSnapshot", targetDepth: 2, targetLayerId: "L2" },
|
|
423
|
+
]);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("same depth, different layerId morphs top (wizard step back)", () => {
|
|
427
|
+
const after = replaceTop(
|
|
428
|
+
pushed(freshStack()).state,
|
|
429
|
+
{ id: "L1b", url: "/onboarding/step2" },
|
|
430
|
+
{ historyMode: "push" },
|
|
431
|
+
).state;
|
|
432
|
+
const { state, commands } = handlePopstate(after, {
|
|
433
|
+
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1 },
|
|
434
|
+
locationHref: "/onboarding/step1",
|
|
435
|
+
});
|
|
436
|
+
expect(state.layers[0]).toMatchObject({
|
|
437
|
+
id: "L1",
|
|
438
|
+
url: "/onboarding/step1",
|
|
439
|
+
});
|
|
440
|
+
expect(commands).toEqual([
|
|
441
|
+
{
|
|
442
|
+
type: "morphTopLayer",
|
|
443
|
+
layerId: "L1",
|
|
444
|
+
url: "/onboarding/step1",
|
|
445
|
+
depth: 1,
|
|
446
|
+
variant: "modal",
|
|
447
|
+
dismissible: true,
|
|
448
|
+
},
|
|
449
|
+
{ type: "persistSnapshot" },
|
|
450
|
+
]);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test("same depth, same layerId is a noop", () => {
|
|
454
|
+
const s = pushed(freshStack()).state;
|
|
455
|
+
const { state, commands } = handlePopstate(s, {
|
|
456
|
+
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1 },
|
|
457
|
+
locationHref: "/projects/42/edit",
|
|
458
|
+
});
|
|
459
|
+
expect(state).toEqual(s);
|
|
460
|
+
expect(commands).toEqual([]);
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
describe("snapshot / restore", () => {
|
|
465
|
+
test("round-trips state", () => {
|
|
466
|
+
let s = pushed(freshStack()).state;
|
|
467
|
+
s = push(s, { id: "L2", url: "/clients/new", variant: "drawer" }).state;
|
|
468
|
+
const json = snapshot(s);
|
|
469
|
+
const restored = restore(json, { stackId: STACK_ID });
|
|
470
|
+
expect(restored).toEqual(s);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("returns null for wrong stackId", () => {
|
|
474
|
+
const s = pushed(freshStack()).state;
|
|
475
|
+
const json = snapshot(s);
|
|
476
|
+
expect(restore(json, { stackId: "other" })).toBeNull();
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test("returns null when expired", () => {
|
|
480
|
+
const s = pushed(freshStack()).state;
|
|
481
|
+
const json = snapshot(s, { now: () => 0 });
|
|
482
|
+
const restored = restore(json, {
|
|
483
|
+
stackId: STACK_ID,
|
|
484
|
+
maxAgeMs: 1000,
|
|
485
|
+
now: () => 5000,
|
|
486
|
+
});
|
|
487
|
+
expect(restored).toBeNull();
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test("returns null on corrupt payload", () => {
|
|
491
|
+
expect(restore("not-json", { stackId: STACK_ID })).toBeNull();
|
|
492
|
+
expect(restore("", { stackId: STACK_ID })).toBeNull();
|
|
493
|
+
expect(restore(null, { stackId: STACK_ID })).toBeNull();
|
|
494
|
+
expect(restore('{"v":2}', { stackId: STACK_ID })).toBeNull();
|
|
495
|
+
expect(restore('{"v":1}', { stackId: STACK_ID })).toBeNull();
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test("returns null when a layer has unknown variant", () => {
|
|
499
|
+
const malicious = JSON.stringify({
|
|
500
|
+
v: 1,
|
|
501
|
+
stackId: STACK_ID,
|
|
502
|
+
baseUrl: "/",
|
|
503
|
+
layers: [{ id: "L1", url: "/", variant: "popover", dismissible: true }],
|
|
504
|
+
savedAt: Date.now(),
|
|
505
|
+
});
|
|
506
|
+
expect(restore(malicious, { stackId: STACK_ID })).toBeNull();
|
|
507
|
+
});
|
|
508
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<%# modal_stack panel layout — minimal by design.
|
|
2
|
+
Each modal action's view is responsible for calling
|
|
3
|
+
`modal_stack_container` with its own size / variant / dismissible
|
|
4
|
+
options. That keeps per-panel options at the call site instead of
|
|
5
|
+
forcing them through the layout. %>
|
|
6
|
+
<%= yield %>
|