sh3-core 0.13.1 → 0.13.2
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/dist/BrandSlot.svelte +62 -13
- package/dist/__test__/setup-dom.js +5 -0
- package/dist/api.d.ts +3 -0
- package/dist/api.js +3 -0
- package/dist/apps/lifecycle.js +10 -2
- package/dist/apps/types.d.ts +11 -4
- package/dist/apps/workspace-rekey.d.ts +1 -0
- package/dist/apps/workspace-rekey.js +35 -0
- package/dist/apps/workspace-rekey.test.js +23 -0
- package/dist/auth/admin-users.svelte.d.ts +9 -0
- package/dist/auth/admin-users.svelte.js +42 -0
- package/dist/auth/admin-users.test.d.ts +1 -0
- package/dist/auth/admin-users.test.js +52 -0
- package/dist/createShell.js +5 -5
- package/dist/documents/config.d.ts +5 -1
- package/dist/documents/config.js +16 -8
- package/dist/documents/index.d.ts +1 -1
- package/dist/documents/index.js +1 -1
- package/dist/host-entry.d.ts +1 -1
- package/dist/host-entry.js +1 -1
- package/dist/host.d.ts +1 -1
- package/dist/host.js +8 -2
- package/dist/primitives/Button.svelte +50 -4
- package/dist/primitives/Button.svelte.d.ts +3 -1
- package/dist/primitives/Collapsible.svelte +110 -0
- package/dist/primitives/Collapsible.svelte.d.ts +14 -0
- package/dist/primitives/widgets/AppPicker.svelte +41 -0
- package/dist/primitives/widgets/AppPicker.svelte.d.ts +9 -0
- package/dist/primitives/widgets/AppPicker.svelte.test.d.ts +1 -0
- package/dist/primitives/widgets/AppPicker.svelte.test.js +26 -0
- package/dist/primitives/widgets/AppPicker.test.d.ts +1 -0
- package/dist/primitives/widgets/AppPicker.test.js +74 -0
- package/dist/primitives/widgets/ColorSwatch.svelte +7 -2
- package/dist/primitives/widgets/ColorSwatch.svelte.d.ts +2 -1
- package/dist/primitives/widgets/ColorSwatch.svelte.test.d.ts +1 -0
- package/dist/primitives/widgets/ColorSwatch.svelte.test.js +31 -0
- package/dist/primitives/widgets/Field.svelte +4 -2
- package/dist/primitives/widgets/Field.svelte.d.ts +2 -2
- package/dist/primitives/widgets/Field.svelte.test.d.ts +1 -0
- package/dist/primitives/widgets/Field.svelte.test.js +33 -0
- package/dist/primitives/widgets/FilePicker.svelte +2 -2
- package/dist/primitives/widgets/FilePicker.svelte.d.ts +2 -2
- package/dist/primitives/widgets/FilePicker.svelte.test.d.ts +1 -0
- package/dist/primitives/widgets/FilePicker.svelte.test.js +31 -0
- package/dist/primitives/widgets/IconToggleGroup.svelte +4 -4
- package/dist/primitives/widgets/IconToggleGroup.svelte.d.ts +3 -3
- package/dist/primitives/widgets/IconToggleGroup.svelte.test.d.ts +1 -0
- package/dist/primitives/widgets/IconToggleGroup.svelte.test.js +40 -0
- package/dist/primitives/widgets/NumberInput.svelte +19 -9
- package/dist/primitives/widgets/NumberInput.svelte.d.ts +2 -2
- package/dist/primitives/widgets/NumberInput.svelte.test.d.ts +1 -0
- package/dist/primitives/widgets/NumberInput.svelte.test.js +48 -0
- package/dist/primitives/widgets/PickerList.d.ts +24 -0
- package/dist/primitives/widgets/PickerList.js +21 -0
- package/dist/primitives/widgets/PickerList.svelte +150 -0
- package/dist/primitives/widgets/PickerList.svelte.d.ts +16 -0
- package/dist/primitives/widgets/PickerList.svelte.test.d.ts +1 -0
- package/dist/primitives/widgets/PickerList.svelte.test.js +31 -0
- package/dist/primitives/widgets/PickerList.test.d.ts +1 -0
- package/dist/primitives/widgets/PickerList.test.js +218 -0
- package/dist/primitives/widgets/RangeSlider.svelte +11 -4
- package/dist/primitives/widgets/RangeSlider.svelte.d.ts +2 -2
- package/dist/primitives/widgets/RangeSlider.svelte.test.d.ts +1 -0
- package/dist/primitives/widgets/RangeSlider.svelte.test.js +38 -0
- package/dist/primitives/widgets/Segmented.svelte +4 -4
- package/dist/primitives/widgets/Segmented.svelte.d.ts +3 -3
- package/dist/primitives/widgets/Segmented.svelte.test.d.ts +1 -0
- package/dist/primitives/widgets/Segmented.svelte.test.js +25 -0
- package/dist/primitives/widgets/Select.svelte +4 -4
- package/dist/primitives/widgets/Select.svelte.d.ts +3 -3
- package/dist/primitives/widgets/Select.svelte.test.d.ts +1 -0
- package/dist/primitives/widgets/Select.svelte.test.js +37 -0
- package/dist/primitives/widgets/Slider.svelte +4 -2
- package/dist/primitives/widgets/Slider.svelte.d.ts +2 -2
- package/dist/primitives/widgets/Slider.svelte.test.d.ts +1 -0
- package/dist/primitives/widgets/Slider.svelte.test.js +22 -0
- package/dist/primitives/widgets/SliderGroup.svelte +4 -2
- package/dist/primitives/widgets/SliderGroup.svelte.d.ts +2 -2
- package/dist/primitives/widgets/SliderGroup.svelte.test.d.ts +1 -0
- package/dist/primitives/widgets/SliderGroup.svelte.test.js +34 -0
- package/dist/primitives/widgets/Textarea.svelte +5 -2
- package/dist/primitives/widgets/Textarea.svelte.d.ts +2 -2
- package/dist/primitives/widgets/Textarea.svelte.test.d.ts +1 -0
- package/dist/primitives/widgets/Textarea.svelte.test.js +29 -0
- package/dist/primitives/widgets/UserPicker.svelte +53 -0
- package/dist/primitives/widgets/UserPicker.svelte.d.ts +9 -0
- package/dist/primitives/widgets/UserPicker.svelte.test.d.ts +1 -0
- package/dist/primitives/widgets/UserPicker.svelte.test.js +30 -0
- package/dist/primitives/widgets/UserPicker.test.d.ts +1 -0
- package/dist/primitives/widgets/UserPicker.test.js +115 -0
- package/dist/primitives/widgets/_contract.d.ts +27 -0
- package/dist/primitives/widgets/_contract.js +10 -0
- package/dist/projects/session-state.svelte.d.ts +17 -0
- package/dist/projects/session-state.svelte.js +39 -0
- package/dist/projects/session-state.test.d.ts +1 -0
- package/dist/projects/session-state.test.js +55 -0
- package/dist/projects-shard/DeleteProjectDialog.svelte +150 -0
- package/dist/projects-shard/DeleteProjectDialog.svelte.d.ts +12 -0
- package/dist/projects-shard/DeleteProjectDialog.test.d.ts +1 -0
- package/dist/projects-shard/DeleteProjectDialog.test.js +120 -0
- package/dist/projects-shard/ProjectManage.svelte +209 -0
- package/dist/projects-shard/ProjectManage.svelte.d.ts +8 -0
- package/dist/projects-shard/ProjectsSection.svelte +120 -0
- package/dist/projects-shard/ProjectsSection.svelte.d.ts +3 -0
- package/dist/projects-shard/index.d.ts +4 -0
- package/dist/projects-shard/index.js +4 -0
- package/dist/projects-shard/projectsApi.d.ts +20 -0
- package/dist/projects-shard/projectsApi.js +44 -0
- package/dist/projects-shard/projectsApi.test.d.ts +1 -0
- package/dist/projects-shard/projectsApi.test.js +71 -0
- package/dist/projects-shard/projectsShard.svelte.d.ts +10 -0
- package/dist/projects-shard/projectsShard.svelte.js +148 -0
- package/dist/sh3core-shard/ShellHome.svelte +19 -1
- package/dist/shards/activate-scopeid.test.d.ts +1 -0
- package/dist/shards/{activate-tenantid.test.js → activate-scopeid.test.js} +6 -6
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- /package/dist/{shards/activate-tenantid.test.d.ts → apps/workspace-rekey.test.d.ts} +0 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { mount, unmount, tick } from 'svelte';
|
|
3
|
+
import UserPicker from './UserPicker.svelte';
|
|
4
|
+
import { usersAdminState, __resetAdminUsersForTest, } from '../../auth/admin-users.svelte';
|
|
5
|
+
let host;
|
|
6
|
+
let cmp = null;
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
host = document.createElement('div');
|
|
9
|
+
document.body.appendChild(host);
|
|
10
|
+
__resetAdminUsersForTest();
|
|
11
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
12
|
+
ok: true,
|
|
13
|
+
json: async () => [
|
|
14
|
+
{ id: 'u-1', username: 'alice', displayName: 'Alice', role: 'user', createdAt: '', updatedAt: '' },
|
|
15
|
+
{ id: 'u-2', username: 'bob', displayName: 'Bob', role: 'admin', createdAt: '', updatedAt: '' },
|
|
16
|
+
],
|
|
17
|
+
}));
|
|
18
|
+
});
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
if (cmp) {
|
|
21
|
+
unmount(cmp);
|
|
22
|
+
cmp = null;
|
|
23
|
+
}
|
|
24
|
+
host.remove();
|
|
25
|
+
__resetAdminUsersForTest();
|
|
26
|
+
});
|
|
27
|
+
describe('UserPicker', () => {
|
|
28
|
+
it('triggers a fetch on first mount and renders rows once loaded', async () => {
|
|
29
|
+
cmp = mount(UserPicker, { target: host, props: { value: [] } });
|
|
30
|
+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
|
31
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
32
|
+
await tick();
|
|
33
|
+
const rows = host.querySelectorAll('.sh3-picker__row');
|
|
34
|
+
expect(rows.length).toBe(2);
|
|
35
|
+
});
|
|
36
|
+
it('reflects selected user ids as checked rows', async () => {
|
|
37
|
+
// Pre-populate the cache so the mount can render synchronously.
|
|
38
|
+
usersAdminState.users = [
|
|
39
|
+
{ id: 'u-1', username: 'alice', displayName: 'Alice', role: 'user', createdAt: '', updatedAt: '' },
|
|
40
|
+
{ id: 'u-2', username: 'bob', displayName: 'Bob', role: 'admin', createdAt: '', updatedAt: '' },
|
|
41
|
+
];
|
|
42
|
+
cmp = mount(UserPicker, { target: host, props: { value: ['u-2'] } });
|
|
43
|
+
await tick();
|
|
44
|
+
const checks = host.querySelectorAll('input[type="checkbox"]');
|
|
45
|
+
const aliceChecked = Array.from(checks).find((c) => { var _a, _b; return (_b = (_a = c.closest('.sh3-picker__row')) === null || _a === void 0 ? void 0 : _a.textContent) === null || _b === void 0 ? void 0 : _b.includes('alice'); }).checked;
|
|
46
|
+
const bobChecked = Array.from(checks).find((c) => { var _a, _b; return (_b = (_a = c.closest('.sh3-picker__row')) === null || _a === void 0 ? void 0 : _a.textContent) === null || _b === void 0 ? void 0 : _b.includes('bob'); }).checked;
|
|
47
|
+
expect(aliceChecked).toBe(false);
|
|
48
|
+
expect(bobChecked).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
it('subsequent mounts reuse the cached state and skip the fetch', async () => {
|
|
51
|
+
cmp = mount(UserPicker, { target: host, props: { value: [] } });
|
|
52
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
53
|
+
await tick();
|
|
54
|
+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
|
55
|
+
const host2 = document.createElement('div');
|
|
56
|
+
document.body.appendChild(host2);
|
|
57
|
+
const cmp2 = mount(UserPicker, { target: host2, props: { value: [] } });
|
|
58
|
+
await tick();
|
|
59
|
+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
|
60
|
+
unmount(cmp2);
|
|
61
|
+
host2.remove();
|
|
62
|
+
});
|
|
63
|
+
it('uses displayName for label and username for sublabel', async () => {
|
|
64
|
+
usersAdminState.users = [
|
|
65
|
+
{ id: 'u-1', username: 'alice', displayName: 'Alice', role: 'user', createdAt: '', updatedAt: '' },
|
|
66
|
+
{ id: 'u-2', username: 'bob', displayName: 'Bob', role: 'admin', createdAt: '', updatedAt: '' },
|
|
67
|
+
];
|
|
68
|
+
cmp = mount(UserPicker, { target: host, props: { value: [] } });
|
|
69
|
+
await tick();
|
|
70
|
+
const labels = host.querySelectorAll('.sh3-picker__row-label');
|
|
71
|
+
const subs = host.querySelectorAll('.sh3-picker__row-sub');
|
|
72
|
+
const labelTexts = Array.from(labels).map((l) => l.textContent).sort();
|
|
73
|
+
const subTexts = Array.from(subs).map((l) => l.textContent).sort();
|
|
74
|
+
expect(labelTexts).toEqual(['Alice', 'Bob']);
|
|
75
|
+
expect(subTexts).toEqual(['alice', 'bob']);
|
|
76
|
+
});
|
|
77
|
+
it('fires onchange with the new value array on row click', async () => {
|
|
78
|
+
usersAdminState.users = [
|
|
79
|
+
{ id: 'u-1', username: 'alice', displayName: 'Alice', role: 'user', createdAt: '', updatedAt: '' },
|
|
80
|
+
{ id: 'u-2', username: 'bob', displayName: 'Bob', role: 'admin', createdAt: '', updatedAt: '' },
|
|
81
|
+
];
|
|
82
|
+
let received = null;
|
|
83
|
+
cmp = mount(UserPicker, {
|
|
84
|
+
target: host,
|
|
85
|
+
props: {
|
|
86
|
+
value: [],
|
|
87
|
+
onchange: (next) => { received = next; },
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
await tick();
|
|
91
|
+
const aliceRow = Array.from(host.querySelectorAll('.sh3-picker__row'))
|
|
92
|
+
.find((r) => { var _a; return (_a = r.textContent) === null || _a === void 0 ? void 0 : _a.includes('alice'); });
|
|
93
|
+
aliceRow.querySelector('input[type="checkbox"]').click();
|
|
94
|
+
await tick();
|
|
95
|
+
expect(received).toEqual(['u-1']);
|
|
96
|
+
});
|
|
97
|
+
it('shows the loading state before the first response', async () => {
|
|
98
|
+
let resolveFetch = null;
|
|
99
|
+
globalThis.fetch.mockReset();
|
|
100
|
+
globalThis.fetch.mockImplementationOnce(() => new Promise((res) => { resolveFetch = res; }));
|
|
101
|
+
cmp = mount(UserPicker, { target: host, props: { value: [] } });
|
|
102
|
+
await tick();
|
|
103
|
+
expect(host.textContent).toContain('Loading');
|
|
104
|
+
resolveFetch({ ok: true, json: async () => [] });
|
|
105
|
+
});
|
|
106
|
+
it('shows an error and a Retry button on fetch failure', async () => {
|
|
107
|
+
globalThis.fetch.mockReset();
|
|
108
|
+
globalThis.fetch.mockResolvedValueOnce({ ok: false, status: 500 });
|
|
109
|
+
cmp = mount(UserPicker, { target: host, props: { value: [] } });
|
|
110
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
111
|
+
await tick();
|
|
112
|
+
expect(host.textContent).toMatch(/500/);
|
|
113
|
+
expect(host.querySelector('.sh3-picker__retry')).not.toBeNull();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Widget event-contract types — codifies which widgets expose `oninput`
|
|
3
|
+
* (live, during-interaction) vs `onchange`-only (commit-only).
|
|
4
|
+
*
|
|
5
|
+
* Each widget under `widgets/` MUST compose one of these into its prop
|
|
6
|
+
* type. Adding a third bucket requires a new ADR-022 amendment.
|
|
7
|
+
*
|
|
8
|
+
* Internal — not exported from `api.ts`.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Widgets with a continuous interaction phase (typing, dragging).
|
|
12
|
+
* - oninput fires on every internal value change (keystroke, drag tick).
|
|
13
|
+
* - onchange fires once at commit (blur for text inputs, mouseup / native
|
|
14
|
+
* change for sliders).
|
|
15
|
+
* Mirrors HTML <input>'s input vs change distinction.
|
|
16
|
+
*/
|
|
17
|
+
export type LiveInputEvents<T> = {
|
|
18
|
+
oninput?: (next: T) => void;
|
|
19
|
+
onchange?: (next: T) => void;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Widgets whose interaction is a single discrete commit (click, pick).
|
|
23
|
+
* - onchange fires once when the value changes. No oninput.
|
|
24
|
+
*/
|
|
25
|
+
export type CommitOnlyEvents<T> = {
|
|
26
|
+
onchange?: (next: T) => void;
|
|
27
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Widget event-contract types — codifies which widgets expose `oninput`
|
|
3
|
+
* (live, during-interaction) vs `onchange`-only (commit-only).
|
|
4
|
+
*
|
|
5
|
+
* Each widget under `widgets/` MUST compose one of these into its prop
|
|
6
|
+
* type. Adding a third bucket requires a new ADR-022 amendment.
|
|
7
|
+
*
|
|
8
|
+
* Internal — not exported from `api.ts`.
|
|
9
|
+
*/
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare const sessionState: {
|
|
2
|
+
activeProjectId: string | null;
|
|
3
|
+
};
|
|
4
|
+
/**
|
|
5
|
+
* Set the active project id. Side-effects when the value actually changes:
|
|
6
|
+
* - the active app (if any) is unloaded — its document handles and zone
|
|
7
|
+
* state are bound to the previous scope and cannot follow.
|
|
8
|
+
* - the breadcrumb pointer is cleared so re-entering an app cannot point
|
|
9
|
+
* at the wrong namespace.
|
|
10
|
+
*
|
|
11
|
+
* Setting the same value is a no-op (no app unload, no breadcrumb clear).
|
|
12
|
+
*
|
|
13
|
+
* The unloadApp call is deferred to a dynamic import so this module stays
|
|
14
|
+
* importable from circular-dependency hot-paths (lifecycle imports
|
|
15
|
+
* sessionState; sessionState here would otherwise import lifecycle eagerly).
|
|
16
|
+
*/
|
|
17
|
+
export declare function setActiveProjectId(id: string | null): void;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Session-zone state for the active project scope.
|
|
3
|
+
*
|
|
4
|
+
* `null` means the user is in their personal scope. Otherwise it carries
|
|
5
|
+
* the active project's id. Read at app launch by `launchApp` to bind
|
|
6
|
+
* `ctx.scopeId`, and read by ShellHome / BrandSlot for UX rendering.
|
|
7
|
+
*
|
|
8
|
+
* Setting the slot through `setActiveProjectId` is the only supported
|
|
9
|
+
* mutation path: it clears the breadcrumb pointer (so resuming an app
|
|
10
|
+
* after a scope change can't open a stale doc handle) and unloads any
|
|
11
|
+
* active app (whose scopeId is bound to the old scope).
|
|
12
|
+
*/
|
|
13
|
+
import { activeApp, breadcrumbApp } from '../apps/registry.svelte';
|
|
14
|
+
export const sessionState = $state({
|
|
15
|
+
activeProjectId: null,
|
|
16
|
+
});
|
|
17
|
+
/**
|
|
18
|
+
* Set the active project id. Side-effects when the value actually changes:
|
|
19
|
+
* - the active app (if any) is unloaded — its document handles and zone
|
|
20
|
+
* state are bound to the previous scope and cannot follow.
|
|
21
|
+
* - the breadcrumb pointer is cleared so re-entering an app cannot point
|
|
22
|
+
* at the wrong namespace.
|
|
23
|
+
*
|
|
24
|
+
* Setting the same value is a no-op (no app unload, no breadcrumb clear).
|
|
25
|
+
*
|
|
26
|
+
* The unloadApp call is deferred to a dynamic import so this module stays
|
|
27
|
+
* importable from circular-dependency hot-paths (lifecycle imports
|
|
28
|
+
* sessionState; sessionState here would otherwise import lifecycle eagerly).
|
|
29
|
+
*/
|
|
30
|
+
export function setActiveProjectId(id) {
|
|
31
|
+
if (sessionState.activeProjectId === id)
|
|
32
|
+
return;
|
|
33
|
+
const previousActive = activeApp.id;
|
|
34
|
+
sessionState.activeProjectId = id;
|
|
35
|
+
breadcrumbApp.id = null;
|
|
36
|
+
if (previousActive) {
|
|
37
|
+
void import('../apps/lifecycle').then((m) => m.unloadApp(previousActive));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { sessionState, setActiveProjectId } from './session-state.svelte';
|
|
3
|
+
import { breadcrumbApp, activeApp } from '../apps/registry.svelte';
|
|
4
|
+
vi.mock('../apps/lifecycle', () => ({
|
|
5
|
+
unloadApp: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
describe('sessionState.activeProjectId', () => {
|
|
8
|
+
beforeEach(async () => {
|
|
9
|
+
sessionState.activeProjectId = null;
|
|
10
|
+
breadcrumbApp.id = null;
|
|
11
|
+
activeApp.id = null;
|
|
12
|
+
const lifecycle = await import('../apps/lifecycle');
|
|
13
|
+
lifecycle.unloadApp.mockClear();
|
|
14
|
+
});
|
|
15
|
+
it('starts as null', () => {
|
|
16
|
+
expect(sessionState.activeProjectId).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
it('setActiveProjectId updates the slot', () => {
|
|
19
|
+
setActiveProjectId('acme-abcd');
|
|
20
|
+
expect(sessionState.activeProjectId).toBe('acme-abcd');
|
|
21
|
+
});
|
|
22
|
+
it('setActiveProjectId clears breadcrumbApp.id when the value actually changes', () => {
|
|
23
|
+
breadcrumbApp.id = 'notes';
|
|
24
|
+
setActiveProjectId('acme-abcd');
|
|
25
|
+
expect(breadcrumbApp.id).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
it('setActiveProjectId(null) also clears breadcrumb', () => {
|
|
28
|
+
sessionState.activeProjectId = 'acme-abcd';
|
|
29
|
+
breadcrumbApp.id = 'notes';
|
|
30
|
+
setActiveProjectId(null);
|
|
31
|
+
expect(breadcrumbApp.id).toBeNull();
|
|
32
|
+
expect(sessionState.activeProjectId).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
it('setting the same value is a no-op (does not clear breadcrumb)', () => {
|
|
35
|
+
setActiveProjectId('acme-abcd');
|
|
36
|
+
breadcrumbApp.id = 'notes';
|
|
37
|
+
setActiveProjectId('acme-abcd');
|
|
38
|
+
expect(breadcrumbApp.id).toBe('notes');
|
|
39
|
+
});
|
|
40
|
+
it('unloads the active app on scope change (its doc handle is scope-bound)', async () => {
|
|
41
|
+
const lifecycle = await import('../apps/lifecycle');
|
|
42
|
+
activeApp.id = 'notes';
|
|
43
|
+
setActiveProjectId('acme-abcd');
|
|
44
|
+
// unloadApp is called via a dynamic import — wait for it to resolve.
|
|
45
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
46
|
+
expect(lifecycle.unloadApp).toHaveBeenCalledWith('notes');
|
|
47
|
+
});
|
|
48
|
+
it('does not call unloadApp when no app is active', async () => {
|
|
49
|
+
const lifecycle = await import('../apps/lifecycle');
|
|
50
|
+
activeApp.id = null;
|
|
51
|
+
setActiveProjectId('acme-abcd');
|
|
52
|
+
await Promise.resolve();
|
|
53
|
+
expect(lifecycle.unloadApp).not.toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* Confirmation modal for project deletion. Mirrors ConfirmDialog's
|
|
4
|
+
* shape (title + body + Cancel/Confirm) and adds a single checkbox
|
|
5
|
+
* that opts in to also wiping the on-disk docs directory.
|
|
6
|
+
*
|
|
7
|
+
* Mounted via modalManager.open(); `close` is injected by the modal
|
|
8
|
+
* manager. Cancel is the default-focused button so a stray Enter
|
|
9
|
+
* does not trigger the destructive path.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
let {
|
|
13
|
+
projectName,
|
|
14
|
+
projectId,
|
|
15
|
+
onConfirm,
|
|
16
|
+
onCancel,
|
|
17
|
+
close,
|
|
18
|
+
}: {
|
|
19
|
+
projectName: string;
|
|
20
|
+
projectId: string;
|
|
21
|
+
onConfirm: (opts: { wipeData: boolean }) => void | Promise<void>;
|
|
22
|
+
onCancel?: () => void;
|
|
23
|
+
close: () => void;
|
|
24
|
+
} = $props();
|
|
25
|
+
|
|
26
|
+
let cancelBtn: HTMLButtonElement | undefined = $state();
|
|
27
|
+
let busy = $state(false);
|
|
28
|
+
let wipeData = $state(false);
|
|
29
|
+
|
|
30
|
+
$effect(() => {
|
|
31
|
+
cancelBtn?.focus();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
async function handleConfirm(): Promise<void> {
|
|
35
|
+
if (busy) return;
|
|
36
|
+
busy = true;
|
|
37
|
+
try {
|
|
38
|
+
await onConfirm({ wipeData });
|
|
39
|
+
} finally {
|
|
40
|
+
close();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function handleCancel(): void {
|
|
45
|
+
if (busy) return;
|
|
46
|
+
onCancel?.();
|
|
47
|
+
close();
|
|
48
|
+
}
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<div class="delete-project-dialog">
|
|
52
|
+
<div class="delete-project-dialog__title">Delete project '{projectName}'?</div>
|
|
53
|
+
<div class="delete-project-dialog__body">
|
|
54
|
+
The project record will be removed and any members will lose access.
|
|
55
|
+
Documents already written under the project's namespace remain on disk
|
|
56
|
+
unless you tick the option below.
|
|
57
|
+
</div>
|
|
58
|
+
<label class="delete-project-dialog__opt">
|
|
59
|
+
<input type="checkbox" bind:checked={wipeData} disabled={busy} />
|
|
60
|
+
<span>
|
|
61
|
+
Also delete project files
|
|
62
|
+
<code class="delete-project-dialog__path"><dataDir>/docs/{projectId}/</code>
|
|
63
|
+
</span>
|
|
64
|
+
</label>
|
|
65
|
+
<div class="delete-project-dialog__actions">
|
|
66
|
+
<button
|
|
67
|
+
type="button"
|
|
68
|
+
class="delete-project-dialog__btn"
|
|
69
|
+
data-delete-project-cancel
|
|
70
|
+
bind:this={cancelBtn}
|
|
71
|
+
onclick={handleCancel}
|
|
72
|
+
disabled={busy}
|
|
73
|
+
>
|
|
74
|
+
Cancel
|
|
75
|
+
</button>
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
class="delete-project-dialog__btn delete-project-dialog__btn--danger"
|
|
79
|
+
data-delete-project-confirm
|
|
80
|
+
onclick={handleConfirm}
|
|
81
|
+
disabled={busy}
|
|
82
|
+
>
|
|
83
|
+
Delete
|
|
84
|
+
</button>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<style>
|
|
89
|
+
.delete-project-dialog {
|
|
90
|
+
display: flex;
|
|
91
|
+
flex-direction: column;
|
|
92
|
+
gap: 16px;
|
|
93
|
+
padding: 20px 24px;
|
|
94
|
+
min-width: 400px;
|
|
95
|
+
max-width: 520px;
|
|
96
|
+
}
|
|
97
|
+
.delete-project-dialog__title {
|
|
98
|
+
font-size: 16px;
|
|
99
|
+
font-weight: 600;
|
|
100
|
+
color: var(--shell-fg);
|
|
101
|
+
}
|
|
102
|
+
.delete-project-dialog__body {
|
|
103
|
+
font-size: 13px;
|
|
104
|
+
color: var(--shell-fg-muted, var(--shell-fg));
|
|
105
|
+
line-height: 1.5;
|
|
106
|
+
}
|
|
107
|
+
.delete-project-dialog__opt {
|
|
108
|
+
display: flex;
|
|
109
|
+
align-items: flex-start;
|
|
110
|
+
gap: 8px;
|
|
111
|
+
font-size: 13px;
|
|
112
|
+
color: var(--shell-fg);
|
|
113
|
+
cursor: pointer;
|
|
114
|
+
}
|
|
115
|
+
.delete-project-dialog__opt input { margin-top: 3px; }
|
|
116
|
+
.delete-project-dialog__path {
|
|
117
|
+
display: block;
|
|
118
|
+
font-family: var(--shell-font-mono, monospace);
|
|
119
|
+
font-size: 11px;
|
|
120
|
+
color: var(--shell-fg-muted);
|
|
121
|
+
background: var(--shell-bg-elevated);
|
|
122
|
+
padding: 2px 6px;
|
|
123
|
+
border-radius: var(--shell-radius-sm, 3px);
|
|
124
|
+
margin-top: 2px;
|
|
125
|
+
word-break: break-all;
|
|
126
|
+
}
|
|
127
|
+
.delete-project-dialog__actions {
|
|
128
|
+
display: flex;
|
|
129
|
+
justify-content: flex-end;
|
|
130
|
+
gap: 8px;
|
|
131
|
+
margin-top: 4px;
|
|
132
|
+
}
|
|
133
|
+
.delete-project-dialog__btn {
|
|
134
|
+
font-size: 13px;
|
|
135
|
+
padding: 6px 14px;
|
|
136
|
+
border-radius: var(--shell-radius-sm, 4px);
|
|
137
|
+
border: 1px solid var(--shell-border-strong);
|
|
138
|
+
background: transparent;
|
|
139
|
+
color: var(--shell-fg);
|
|
140
|
+
cursor: pointer;
|
|
141
|
+
}
|
|
142
|
+
.delete-project-dialog__btn:disabled {
|
|
143
|
+
opacity: 0.6;
|
|
144
|
+
cursor: not-allowed;
|
|
145
|
+
}
|
|
146
|
+
.delete-project-dialog__btn--danger {
|
|
147
|
+
color: var(--shell-error, #d32f2f);
|
|
148
|
+
border-color: var(--shell-error, #d32f2f);
|
|
149
|
+
}
|
|
150
|
+
</style>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
type $$ComponentProps = {
|
|
2
|
+
projectName: string;
|
|
3
|
+
projectId: string;
|
|
4
|
+
onConfirm: (opts: {
|
|
5
|
+
wipeData: boolean;
|
|
6
|
+
}) => void | Promise<void>;
|
|
7
|
+
onCancel?: () => void;
|
|
8
|
+
close: () => void;
|
|
9
|
+
};
|
|
10
|
+
declare const DeleteProjectDialog: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
11
|
+
type DeleteProjectDialog = ReturnType<typeof DeleteProjectDialog>;
|
|
12
|
+
export default DeleteProjectDialog;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mount, unmount, tick } from 'svelte';
|
|
3
|
+
import DeleteProjectDialog from './DeleteProjectDialog.svelte';
|
|
4
|
+
let host;
|
|
5
|
+
let cmp = null;
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
host = document.createElement('div');
|
|
8
|
+
document.body.appendChild(host);
|
|
9
|
+
});
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
if (cmp) {
|
|
12
|
+
unmount(cmp);
|
|
13
|
+
cmp = null;
|
|
14
|
+
}
|
|
15
|
+
host.remove();
|
|
16
|
+
});
|
|
17
|
+
describe('DeleteProjectDialog', () => {
|
|
18
|
+
it('renders the project name in the title and the docs path in the checkbox label', async () => {
|
|
19
|
+
cmp = mount(DeleteProjectDialog, {
|
|
20
|
+
target: host,
|
|
21
|
+
props: {
|
|
22
|
+
projectName: 'Acme Website',
|
|
23
|
+
projectId: 'acme-website-abcd',
|
|
24
|
+
onConfirm: () => { },
|
|
25
|
+
onCancel: () => { },
|
|
26
|
+
close: () => { },
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
await tick();
|
|
30
|
+
expect(host.textContent).toContain("Delete project 'Acme Website'");
|
|
31
|
+
expect(host.textContent).toContain('docs/acme-website-abcd');
|
|
32
|
+
});
|
|
33
|
+
it('the checkbox starts unchecked', async () => {
|
|
34
|
+
cmp = mount(DeleteProjectDialog, {
|
|
35
|
+
target: host,
|
|
36
|
+
props: {
|
|
37
|
+
projectName: 'A', projectId: 'a-1234',
|
|
38
|
+
onConfirm: () => { }, close: () => { },
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
await tick();
|
|
42
|
+
const cb = host.querySelector('input[type="checkbox"]');
|
|
43
|
+
expect(cb).not.toBeNull();
|
|
44
|
+
expect(cb.checked).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
it('Confirm fires onConfirm({ wipeData: false }) when the box is unchecked', async () => {
|
|
47
|
+
let received = null;
|
|
48
|
+
let closed = false;
|
|
49
|
+
cmp = mount(DeleteProjectDialog, {
|
|
50
|
+
target: host,
|
|
51
|
+
props: {
|
|
52
|
+
projectName: 'A', projectId: 'a-1234',
|
|
53
|
+
onConfirm: (o) => { received = o; },
|
|
54
|
+
close: () => { closed = true; },
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
await tick();
|
|
58
|
+
const confirm = host.querySelector('[data-delete-project-confirm]');
|
|
59
|
+
confirm.click();
|
|
60
|
+
await tick();
|
|
61
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
62
|
+
expect(received).toEqual({ wipeData: false });
|
|
63
|
+
expect(closed).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
it('Confirm fires onConfirm({ wipeData: true }) when the box is checked', async () => {
|
|
66
|
+
let received = null;
|
|
67
|
+
cmp = mount(DeleteProjectDialog, {
|
|
68
|
+
target: host,
|
|
69
|
+
props: {
|
|
70
|
+
projectName: 'A', projectId: 'a-1234',
|
|
71
|
+
onConfirm: (o) => { received = o; },
|
|
72
|
+
close: () => { },
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
await tick();
|
|
76
|
+
const cb = host.querySelector('input[type="checkbox"]');
|
|
77
|
+
cb.click();
|
|
78
|
+
await tick();
|
|
79
|
+
const confirm = host.querySelector('[data-delete-project-confirm]');
|
|
80
|
+
confirm.click();
|
|
81
|
+
await tick();
|
|
82
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
83
|
+
expect(received).toEqual({ wipeData: true });
|
|
84
|
+
});
|
|
85
|
+
it('Cancel does NOT fire onConfirm and does call close', async () => {
|
|
86
|
+
let confirmFired = false;
|
|
87
|
+
let closed = false;
|
|
88
|
+
cmp = mount(DeleteProjectDialog, {
|
|
89
|
+
target: host,
|
|
90
|
+
props: {
|
|
91
|
+
projectName: 'A', projectId: 'a-1234',
|
|
92
|
+
onConfirm: () => { confirmFired = true; },
|
|
93
|
+
close: () => { closed = true; },
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
await tick();
|
|
97
|
+
const cancel = host.querySelector('[data-delete-project-cancel]');
|
|
98
|
+
cancel.click();
|
|
99
|
+
await tick();
|
|
100
|
+
expect(confirmFired).toBe(false);
|
|
101
|
+
expect(closed).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
it('Confirm button is disabled while onConfirm is in flight', async () => {
|
|
104
|
+
let release = null;
|
|
105
|
+
cmp = mount(DeleteProjectDialog, {
|
|
106
|
+
target: host,
|
|
107
|
+
props: {
|
|
108
|
+
projectName: 'A', projectId: 'a-1234',
|
|
109
|
+
onConfirm: () => new Promise((res) => { release = res; }),
|
|
110
|
+
close: () => { },
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
await tick();
|
|
114
|
+
const confirm = host.querySelector('[data-delete-project-confirm]');
|
|
115
|
+
confirm.click();
|
|
116
|
+
await tick();
|
|
117
|
+
expect(confirm.disabled).toBe(true);
|
|
118
|
+
release();
|
|
119
|
+
});
|
|
120
|
+
});
|