@semiont/react-ui 0.5.1 → 0.5.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/README.md +13 -0
- package/dist/{chunk-4NOUO3W6.mjs → chunk-7VWNZ5YX.mjs} +5032 -2876
- package/dist/chunk-7VWNZ5YX.mjs.map +1 -0
- package/dist/index.d.mts +292 -25
- package/dist/index.mjs +1021 -332
- package/dist/index.mjs.map +1 -1
- package/dist/test-utils.d.mts +1 -1
- package/dist/test-utils.mjs +4 -2352
- package/dist/test-utils.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/StatusDisplay.tsx +1 -1
- package/src/components/modals/PermissionDeniedModal.tsx +2 -2
- package/src/components/modals/SessionExpiredModal.tsx +4 -4
- package/src/components/resource/panels/AssessmentPanel.tsx +4 -0
- package/src/components/resource/panels/AssistSection.tsx +10 -1
- package/src/components/resource/panels/CollaborationPanel.tsx +1 -1
- package/src/components/resource/panels/CommentsPanel.tsx +4 -0
- package/src/components/resource/panels/HighlightPanel.tsx +4 -0
- package/src/components/resource/panels/ReferencesPanel.tsx +11 -0
- package/src/components/resource/panels/TaggingPanel.tsx +10 -0
- package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +11 -1
- package/src/components/resource/panels/__tests__/ReferencesPanel.observable-flow.test.tsx +2 -2
- package/src/features/admin-devops/components/AdminDevOpsPage.tsx +1 -1
- package/src/features/admin-exchange/components/AdminExchangePage.tsx +1 -1
- package/src/features/admin-exchange/components/ImportCard.tsx +1 -1
- package/src/features/admin-exchange/state/__tests__/exchange-state-unit.test.ts +171 -0
- package/src/features/admin-exchange/state/exchange-state-unit.ts +131 -0
- package/src/features/admin-security/components/AdminSecurityPage.tsx +1 -1
- package/src/features/admin-security/state/__tests__/admin-security-state-unit.test.ts +68 -0
- package/src/features/admin-security/state/admin-security-state-unit.ts +46 -0
- package/src/features/admin-users/components/AdminUsersPage.tsx +1 -1
- package/src/features/admin-users/state/__tests__/admin-users-state-unit.test.ts +86 -0
- package/src/features/admin-users/state/admin-users-state-unit.ts +73 -0
- package/src/features/auth-welcome/state/__tests__/welcome-state-unit.test.ts +86 -0
- package/src/features/auth-welcome/state/welcome-state-unit.ts +44 -0
- package/src/features/moderate-entity-tags/components/EntityTagsPage.tsx +1 -1
- package/src/features/moderate-entity-tags/state/__tests__/entity-tags-state-unit.test.ts +102 -0
- package/src/features/moderate-entity-tags/state/entity-tags-state-unit.ts +64 -0
- package/src/features/moderate-recent/components/RecentDocumentsPage.tsx +1 -1
- package/src/features/moderate-tag-schemas/components/TagSchemasPage.tsx +1 -1
- package/src/features/moderation-linked-data/components/LinkedDataPage.tsx +1 -1
- package/src/features/resource-compose/__tests__/UploadProgressBar.test.tsx +225 -0
- package/src/features/resource-compose/components/ResourceComposePage.tsx +19 -4
- package/src/features/resource-compose/components/UploadProgressBar.tsx +94 -0
- package/src/features/resource-compose/state/__tests__/compose-page-state-unit.test.ts +187 -0
- package/src/features/resource-compose/state/compose-page-state-unit.ts +209 -0
- package/src/features/resource-discovery/components/ResourceDiscoveryPage.tsx +1 -1
- package/src/features/resource-discovery/state/__tests__/discover-state-unit.test.ts +76 -0
- package/src/features/resource-discovery/state/discover-state-unit.ts +54 -0
- package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +4 -2
- package/src/features/resource-viewer/components/ResourceViewerPage.tsx +36 -32
- package/src/features/resource-viewer/state/__tests__/resource-loader-state-unit.test.ts +46 -0
- package/src/features/resource-viewer/state/__tests__/resource-viewer-page-state-unit.test.ts +203 -0
- package/src/features/resource-viewer/state/resource-loader-state-unit.ts +26 -0
- package/src/features/resource-viewer/state/resource-viewer-page-state-unit.ts +180 -0
- package/dist/chunk-4NOUO3W6.mjs.map +0 -1
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { firstValueFrom } from 'rxjs';
|
|
3
|
+
import { filter } from 'rxjs/operators';
|
|
4
|
+
import type { SemiontClient } from '@semiont/sdk';
|
|
5
|
+
import type { ShellStateUnit } from '../../../../state/shell-state-unit';
|
|
6
|
+
import { createAdminSecurityStateUnit } from '../admin-security-state-unit';
|
|
7
|
+
|
|
8
|
+
function mockBrowse(): ShellStateUnit {
|
|
9
|
+
return { dispose: vi.fn() } as unknown as ShellStateUnit;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function mockClient(oauthConfig: ReturnType<typeof vi.fn>): SemiontClient {
|
|
13
|
+
return { admin: { oauthConfig } } as unknown as SemiontClient;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('createAdminSecurityStateUnit', () => {
|
|
17
|
+
it('fetches OAuth config on creation', async () => {
|
|
18
|
+
const getOAuthConfig = vi.fn().mockResolvedValue({
|
|
19
|
+
providers: [{ name: 'google' }],
|
|
20
|
+
allowedDomains: ['example.com'],
|
|
21
|
+
});
|
|
22
|
+
const stateUnit = createAdminSecurityStateUnit(mockClient(getOAuthConfig), mockBrowse());
|
|
23
|
+
|
|
24
|
+
const providers = await firstValueFrom(stateUnit.providers$.pipe(filter((p) => p.length > 0)));
|
|
25
|
+
expect(providers).toEqual([{ name: 'google' }]);
|
|
26
|
+
|
|
27
|
+
const domains = await firstValueFrom(stateUnit.allowedDomains$.pipe(filter((d) => d.length > 0)));
|
|
28
|
+
expect(domains).toEqual(['example.com']);
|
|
29
|
+
|
|
30
|
+
stateUnit.dispose();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('starts loading, resolves to false', async () => {
|
|
34
|
+
const stateUnit = createAdminSecurityStateUnit(
|
|
35
|
+
mockClient(vi.fn().mockResolvedValue({ providers: [], allowedDomains: [] })),
|
|
36
|
+
mockBrowse(),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
await firstValueFrom(stateUnit.isLoading$.pipe(filter((l) => !l)));
|
|
40
|
+
stateUnit.dispose();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('sets loading false on error', async () => {
|
|
44
|
+
const stateUnit = createAdminSecurityStateUnit(
|
|
45
|
+
mockClient(vi.fn().mockRejectedValue(new Error('fail'))),
|
|
46
|
+
mockBrowse(),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
await firstValueFrom(stateUnit.isLoading$.pipe(filter((l) => !l)));
|
|
50
|
+
stateUnit.dispose();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('defaults to empty arrays when response has no providers/domains', async () => {
|
|
54
|
+
const stateUnit = createAdminSecurityStateUnit(
|
|
55
|
+
mockClient(vi.fn().mockResolvedValue({})),
|
|
56
|
+
mockBrowse(),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
await firstValueFrom(stateUnit.isLoading$.pipe(filter((l) => !l)));
|
|
60
|
+
|
|
61
|
+
const providers = await firstValueFrom(stateUnit.providers$);
|
|
62
|
+
const domains = await firstValueFrom(stateUnit.allowedDomains$);
|
|
63
|
+
expect(providers).toEqual([]);
|
|
64
|
+
expect(domains).toEqual([]);
|
|
65
|
+
|
|
66
|
+
stateUnit.dispose();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { BehaviorSubject, type Observable } from 'rxjs';
|
|
2
|
+
import { createDisposer } from '@semiont/sdk';
|
|
3
|
+
import type { StateUnit } from '@semiont/sdk';
|
|
4
|
+
import type { ShellStateUnit } from '../../../state/shell-state-unit';
|
|
5
|
+
import type { SemiontClient } from '@semiont/sdk';
|
|
6
|
+
|
|
7
|
+
export interface AdminSecurityStateUnit extends StateUnit {
|
|
8
|
+
browse: ShellStateUnit;
|
|
9
|
+
providers$: Observable<unknown[]>;
|
|
10
|
+
allowedDomains$: Observable<string[]>;
|
|
11
|
+
isLoading$: Observable<boolean>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createAdminSecurityStateUnit(
|
|
15
|
+
client: SemiontClient,
|
|
16
|
+
browse: ShellStateUnit,
|
|
17
|
+
): AdminSecurityStateUnit {
|
|
18
|
+
const disposer = createDisposer();
|
|
19
|
+
disposer.add(browse);
|
|
20
|
+
|
|
21
|
+
const providers$ = new BehaviorSubject<unknown[]>([]);
|
|
22
|
+
const allowedDomains$ = new BehaviorSubject<string[]>([]);
|
|
23
|
+
const isLoading$ = new BehaviorSubject<boolean>(true);
|
|
24
|
+
|
|
25
|
+
client.admin!.oauthConfig()
|
|
26
|
+
.then((data) => {
|
|
27
|
+
const config = data as { providers?: unknown[]; allowedDomains?: string[] };
|
|
28
|
+
providers$.next(config.providers ?? []);
|
|
29
|
+
allowedDomains$.next(config.allowedDomains ?? []);
|
|
30
|
+
isLoading$.next(false);
|
|
31
|
+
})
|
|
32
|
+
.catch(() => isLoading$.next(false));
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
browse,
|
|
36
|
+
providers$: providers$.asObservable(),
|
|
37
|
+
allowedDomains$: allowedDomains$.asObservable(),
|
|
38
|
+
isLoading$: isLoading$.asObservable(),
|
|
39
|
+
dispose: () => {
|
|
40
|
+
providers$.complete();
|
|
41
|
+
allowedDomains$.complete();
|
|
42
|
+
isLoading$.complete();
|
|
43
|
+
disposer.dispose();
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { firstValueFrom } from 'rxjs';
|
|
3
|
+
import { filter } from 'rxjs/operators';
|
|
4
|
+
import type { SemiontClient } from '@semiont/sdk';
|
|
5
|
+
import type { ShellStateUnit } from '../../../../state/shell-state-unit';
|
|
6
|
+
import { createAdminUsersStateUnit } from '../admin-users-state-unit';
|
|
7
|
+
|
|
8
|
+
function mockBrowse(): ShellStateUnit {
|
|
9
|
+
return { dispose: vi.fn() } as unknown as ShellStateUnit;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function mockClient(overrides: {
|
|
13
|
+
listUsers?: ReturnType<typeof vi.fn>;
|
|
14
|
+
getUserStats?: ReturnType<typeof vi.fn>;
|
|
15
|
+
updateUser?: ReturnType<typeof vi.fn>;
|
|
16
|
+
} = {}): SemiontClient {
|
|
17
|
+
return {
|
|
18
|
+
admin: {
|
|
19
|
+
users: overrides.listUsers ?? vi.fn().mockResolvedValue({ users: [] }),
|
|
20
|
+
userStats: overrides.getUserStats ?? vi.fn().mockResolvedValue({ stats: null }),
|
|
21
|
+
updateUser: overrides.updateUser ?? vi.fn().mockResolvedValue(undefined),
|
|
22
|
+
},
|
|
23
|
+
} as unknown as SemiontClient;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('createAdminUsersStateUnit', () => {
|
|
27
|
+
it('fetches users and stats on creation', async () => {
|
|
28
|
+
const listUsers = vi.fn().mockResolvedValue({ users: [{ id: 'u1' }] });
|
|
29
|
+
const getUserStats = vi.fn().mockResolvedValue({ stats: { total: 1 } });
|
|
30
|
+
const stateUnit = createAdminUsersStateUnit(mockClient({ listUsers, getUserStats }), mockBrowse());
|
|
31
|
+
|
|
32
|
+
const users = await firstValueFrom(stateUnit.users$.pipe(filter((u) => u.length > 0)));
|
|
33
|
+
expect(users).toEqual([{ id: 'u1' }]);
|
|
34
|
+
|
|
35
|
+
const stats = await firstValueFrom(stateUnit.stats$.pipe(filter((s) => s !== null)));
|
|
36
|
+
expect(stats).toEqual({ total: 1 });
|
|
37
|
+
|
|
38
|
+
stateUnit.dispose();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('starts with loading true, resolves to false', async () => {
|
|
42
|
+
const stateUnit = createAdminUsersStateUnit(mockClient(), mockBrowse());
|
|
43
|
+
|
|
44
|
+
await firstValueFrom(stateUnit.usersLoading$.pipe(filter((l) => !l)));
|
|
45
|
+
await firstValueFrom(stateUnit.statsLoading$.pipe(filter((l) => !l)));
|
|
46
|
+
|
|
47
|
+
stateUnit.dispose();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('sets loading false on fetch error', async () => {
|
|
51
|
+
const listUsers = vi.fn().mockRejectedValue(new Error('fail'));
|
|
52
|
+
const getUserStats = vi.fn().mockRejectedValue(new Error('fail'));
|
|
53
|
+
const stateUnit = createAdminUsersStateUnit(mockClient({ listUsers, getUserStats }), mockBrowse());
|
|
54
|
+
|
|
55
|
+
await firstValueFrom(stateUnit.usersLoading$.pipe(filter((l) => !l)));
|
|
56
|
+
await firstValueFrom(stateUnit.statsLoading$.pipe(filter((l) => !l)));
|
|
57
|
+
|
|
58
|
+
stateUnit.dispose();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('updateUser calls client and refetches', async () => {
|
|
62
|
+
const listUsers = vi.fn().mockResolvedValue({ users: [] });
|
|
63
|
+
const getUserStats = vi.fn().mockResolvedValue({ stats: null });
|
|
64
|
+
const updateUser = vi.fn().mockResolvedValue(undefined);
|
|
65
|
+
const stateUnit = createAdminUsersStateUnit(mockClient({ listUsers, getUserStats, updateUser }), mockBrowse());
|
|
66
|
+
|
|
67
|
+
await firstValueFrom(stateUnit.usersLoading$.pipe(filter((l) => !l)));
|
|
68
|
+
listUsers.mockClear();
|
|
69
|
+
getUserStats.mockClear();
|
|
70
|
+
|
|
71
|
+
await stateUnit.updateUser('u1', { isAdmin: true });
|
|
72
|
+
|
|
73
|
+
expect(updateUser).toHaveBeenCalledOnce();
|
|
74
|
+
expect(listUsers).toHaveBeenCalledOnce();
|
|
75
|
+
expect(getUserStats).toHaveBeenCalledOnce();
|
|
76
|
+
|
|
77
|
+
stateUnit.dispose();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('disposes browse on dispose', () => {
|
|
81
|
+
const browse = mockBrowse();
|
|
82
|
+
const stateUnit = createAdminUsersStateUnit(mockClient(), browse);
|
|
83
|
+
stateUnit.dispose();
|
|
84
|
+
expect(browse.dispose).toHaveBeenCalled();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { BehaviorSubject, type Observable } from 'rxjs';
|
|
2
|
+
import { userDID } from '@semiont/core';
|
|
3
|
+
import { createDisposer } from '@semiont/sdk';
|
|
4
|
+
import type { StateUnit } from '@semiont/sdk';
|
|
5
|
+
import type { ShellStateUnit } from '../../../state/shell-state-unit';
|
|
6
|
+
import type { SemiontClient } from '@semiont/sdk';
|
|
7
|
+
|
|
8
|
+
export interface AdminUsersStateUnit extends StateUnit {
|
|
9
|
+
browse: ShellStateUnit;
|
|
10
|
+
users$: Observable<unknown[]>;
|
|
11
|
+
stats$: Observable<unknown | null>;
|
|
12
|
+
usersLoading$: Observable<boolean>;
|
|
13
|
+
statsLoading$: Observable<boolean>;
|
|
14
|
+
updateUser(id: string, data: { isAdmin?: boolean; isActive?: boolean }): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createAdminUsersStateUnit(
|
|
18
|
+
client: SemiontClient,
|
|
19
|
+
browse: ShellStateUnit,
|
|
20
|
+
): AdminUsersStateUnit {
|
|
21
|
+
const disposer = createDisposer();
|
|
22
|
+
disposer.add(browse);
|
|
23
|
+
|
|
24
|
+
const users$ = new BehaviorSubject<unknown[]>([]);
|
|
25
|
+
const stats$ = new BehaviorSubject<unknown | null>(null);
|
|
26
|
+
const usersLoading$ = new BehaviorSubject<boolean>(true);
|
|
27
|
+
const statsLoading$ = new BehaviorSubject<boolean>(true);
|
|
28
|
+
|
|
29
|
+
const fetchUsers = () => {
|
|
30
|
+
usersLoading$.next(true);
|
|
31
|
+
client.admin!.users()
|
|
32
|
+
.then((data) => {
|
|
33
|
+
users$.next((data as { users?: unknown[] }).users ?? []);
|
|
34
|
+
usersLoading$.next(false);
|
|
35
|
+
})
|
|
36
|
+
.catch(() => usersLoading$.next(false));
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const fetchStats = () => {
|
|
40
|
+
statsLoading$.next(true);
|
|
41
|
+
client.admin!.userStats()
|
|
42
|
+
.then((data) => {
|
|
43
|
+
stats$.next((data as { stats?: unknown }).stats ?? null);
|
|
44
|
+
statsLoading$.next(false);
|
|
45
|
+
})
|
|
46
|
+
.catch(() => statsLoading$.next(false));
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
fetchUsers();
|
|
50
|
+
fetchStats();
|
|
51
|
+
|
|
52
|
+
const updateUser = async (id: string, data: { isAdmin?: boolean; isActive?: boolean }): Promise<void> => {
|
|
53
|
+
await client.admin!.updateUser(userDID(id), data);
|
|
54
|
+
fetchUsers();
|
|
55
|
+
fetchStats();
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
browse,
|
|
60
|
+
users$: users$.asObservable(),
|
|
61
|
+
stats$: stats$.asObservable(),
|
|
62
|
+
usersLoading$: usersLoading$.asObservable(),
|
|
63
|
+
statsLoading$: statsLoading$.asObservable(),
|
|
64
|
+
updateUser,
|
|
65
|
+
dispose: () => {
|
|
66
|
+
users$.complete();
|
|
67
|
+
stats$.complete();
|
|
68
|
+
usersLoading$.complete();
|
|
69
|
+
statsLoading$.complete();
|
|
70
|
+
disposer.dispose();
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { firstValueFrom } from 'rxjs';
|
|
3
|
+
import { filter } from 'rxjs/operators';
|
|
4
|
+
import type { SemiontClient } from '@semiont/sdk';
|
|
5
|
+
import { createWelcomeStateUnit } from '../welcome-state-unit';
|
|
6
|
+
|
|
7
|
+
function mockClient(overrides: {
|
|
8
|
+
getMe?: ReturnType<typeof vi.fn>;
|
|
9
|
+
acceptTerms?: ReturnType<typeof vi.fn>;
|
|
10
|
+
} = {}): SemiontClient {
|
|
11
|
+
return {
|
|
12
|
+
auth: {
|
|
13
|
+
me: overrides.getMe ?? vi.fn().mockResolvedValue({ termsAcceptedAt: undefined }),
|
|
14
|
+
acceptTerms: overrides.acceptTerms ?? vi.fn().mockResolvedValue(undefined),
|
|
15
|
+
},
|
|
16
|
+
} as unknown as SemiontClient;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('createWelcomeStateUnit', () => {
|
|
20
|
+
it('fetches user data on creation', async () => {
|
|
21
|
+
const getMe = vi.fn().mockResolvedValue({ termsAcceptedAt: '2026-01-01' });
|
|
22
|
+
const stateUnit = createWelcomeStateUnit(mockClient({ getMe }));
|
|
23
|
+
|
|
24
|
+
const data = await firstValueFrom(stateUnit.userData$.pipe(filter((d) => d !== null)));
|
|
25
|
+
expect(data).toEqual({ termsAcceptedAt: '2026-01-01' });
|
|
26
|
+
|
|
27
|
+
stateUnit.dispose();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('initializes with null userData and not processing', async () => {
|
|
31
|
+
const getMe = vi.fn().mockReturnValue(new Promise(() => {}));
|
|
32
|
+
const stateUnit = createWelcomeStateUnit(mockClient({ getMe }));
|
|
33
|
+
|
|
34
|
+
const data = await firstValueFrom(stateUnit.userData$);
|
|
35
|
+
const processing = await firstValueFrom(stateUnit.isProcessing$);
|
|
36
|
+
expect(data).toBeNull();
|
|
37
|
+
expect(processing).toBe(false);
|
|
38
|
+
|
|
39
|
+
stateUnit.dispose();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('acceptTerms sets isProcessing and updates userData', async () => {
|
|
43
|
+
const acceptTerms = vi.fn().mockResolvedValue(undefined);
|
|
44
|
+
const stateUnit = createWelcomeStateUnit(mockClient({ acceptTerms }));
|
|
45
|
+
|
|
46
|
+
await firstValueFrom(stateUnit.userData$.pipe(filter((d) => d !== null)));
|
|
47
|
+
|
|
48
|
+
await stateUnit.acceptTerms();
|
|
49
|
+
|
|
50
|
+
expect(acceptTerms).toHaveBeenCalledOnce();
|
|
51
|
+
|
|
52
|
+
const data = await firstValueFrom(stateUnit.userData$);
|
|
53
|
+
expect(data?.termsAcceptedAt).toBeDefined();
|
|
54
|
+
|
|
55
|
+
const processing = await firstValueFrom(stateUnit.isProcessing$);
|
|
56
|
+
expect(processing).toBe(false);
|
|
57
|
+
|
|
58
|
+
stateUnit.dispose();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('acceptTerms resets isProcessing on error', async () => {
|
|
62
|
+
const acceptTerms = vi.fn().mockRejectedValue(new Error('fail'));
|
|
63
|
+
const stateUnit = createWelcomeStateUnit(mockClient({ acceptTerms }));
|
|
64
|
+
|
|
65
|
+
await firstValueFrom(stateUnit.userData$.pipe(filter((d) => d !== null)));
|
|
66
|
+
|
|
67
|
+
await expect(stateUnit.acceptTerms()).rejects.toThrow('fail');
|
|
68
|
+
|
|
69
|
+
const processing = await firstValueFrom(stateUnit.isProcessing$);
|
|
70
|
+
expect(processing).toBe(false);
|
|
71
|
+
|
|
72
|
+
stateUnit.dispose();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('handles getMe failure gracefully', async () => {
|
|
76
|
+
const getMe = vi.fn().mockRejectedValue(new Error('unauthorized'));
|
|
77
|
+
const stateUnit = createWelcomeStateUnit(mockClient({ getMe }));
|
|
78
|
+
|
|
79
|
+
await vi.waitFor(() => expect(getMe).toHaveBeenCalled());
|
|
80
|
+
|
|
81
|
+
const data = await firstValueFrom(stateUnit.userData$);
|
|
82
|
+
expect(data).toBeNull();
|
|
83
|
+
|
|
84
|
+
stateUnit.dispose();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { BehaviorSubject, type Observable } from 'rxjs';
|
|
2
|
+
import { createDisposer } from '@semiont/sdk';
|
|
3
|
+
import type { StateUnit } from '@semiont/sdk';
|
|
4
|
+
import type { SemiontClient } from '@semiont/sdk';
|
|
5
|
+
|
|
6
|
+
export interface WelcomeStateUnit extends StateUnit {
|
|
7
|
+
userData$: Observable<{ termsAcceptedAt?: string } | null>;
|
|
8
|
+
isProcessing$: Observable<boolean>;
|
|
9
|
+
acceptTerms(): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createWelcomeStateUnit(
|
|
13
|
+
client: SemiontClient,
|
|
14
|
+
): WelcomeStateUnit {
|
|
15
|
+
const disposer = createDisposer();
|
|
16
|
+
|
|
17
|
+
const userData$ = new BehaviorSubject<{ termsAcceptedAt?: string } | null>(null);
|
|
18
|
+
const isProcessing$ = new BehaviorSubject<boolean>(false);
|
|
19
|
+
|
|
20
|
+
client.auth!.me()
|
|
21
|
+
.then((data) => userData$.next(data as { termsAcceptedAt?: string }))
|
|
22
|
+
.catch(() => {});
|
|
23
|
+
|
|
24
|
+
const acceptTerms = async (): Promise<void> => {
|
|
25
|
+
isProcessing$.next(true);
|
|
26
|
+
try {
|
|
27
|
+
await client.auth!.acceptTerms();
|
|
28
|
+
userData$.next({ ...userData$.getValue(), termsAcceptedAt: new Date().toISOString() });
|
|
29
|
+
} finally {
|
|
30
|
+
isProcessing$.next(false);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
userData$: userData$.asObservable(),
|
|
36
|
+
isProcessing$: isProcessing$.asObservable(),
|
|
37
|
+
acceptTerms,
|
|
38
|
+
dispose: () => {
|
|
39
|
+
userData$.complete();
|
|
40
|
+
isProcessing$.complete();
|
|
41
|
+
disposer.dispose();
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
PlusIcon,
|
|
12
12
|
ExclamationCircleIcon
|
|
13
13
|
} from '@heroicons/react/24/outline';
|
|
14
|
-
import { COMMON_PANELS, type ToolbarPanelType } from '
|
|
14
|
+
import { COMMON_PANELS, type ToolbarPanelType } from '../../../state/shell-state-unit';
|
|
15
15
|
export interface EntityTagsPageProps {
|
|
16
16
|
// Data props
|
|
17
17
|
entityTypes: string[];
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { BehaviorSubject, firstValueFrom } from 'rxjs';
|
|
3
|
+
import { filter } from 'rxjs/operators';
|
|
4
|
+
import type { SemiontClient } from '@semiont/sdk';
|
|
5
|
+
import type { ShellStateUnit } from '../../../../state/shell-state-unit';
|
|
6
|
+
import { createEntityTagsStateUnit } from '../entity-tags-state-unit';
|
|
7
|
+
|
|
8
|
+
function mockBrowse(): ShellStateUnit {
|
|
9
|
+
return { dispose: vi.fn() } as unknown as ShellStateUnit;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function mockClient(overrides: {
|
|
13
|
+
entityTypes$?: BehaviorSubject<string[] | undefined>;
|
|
14
|
+
addEntityType?: ReturnType<typeof vi.fn>;
|
|
15
|
+
} = {}): SemiontClient {
|
|
16
|
+
const entityTypes$ = overrides.entityTypes$ ?? new BehaviorSubject<string[] | undefined>(['Person', 'Place']);
|
|
17
|
+
return {
|
|
18
|
+
browse: {
|
|
19
|
+
entityTypes: () => entityTypes$.asObservable(),
|
|
20
|
+
},
|
|
21
|
+
frame: {
|
|
22
|
+
addEntityType: overrides.addEntityType ?? vi.fn().mockResolvedValue(undefined),
|
|
23
|
+
},
|
|
24
|
+
} as unknown as SemiontClient;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('createEntityTagsStateUnit', () => {
|
|
28
|
+
it('exposes entity types from browse namespace', async () => {
|
|
29
|
+
const stateUnit = createEntityTagsStateUnit(mockClient(), mockBrowse());
|
|
30
|
+
|
|
31
|
+
const types = await firstValueFrom(stateUnit.entityTypes$);
|
|
32
|
+
expect(types).toEqual(['Person', 'Place']);
|
|
33
|
+
|
|
34
|
+
stateUnit.dispose();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('reports loading when entity types are undefined', async () => {
|
|
38
|
+
const entityTypes$ = new BehaviorSubject<string[] | undefined>(undefined);
|
|
39
|
+
const stateUnit = createEntityTagsStateUnit(mockClient({ entityTypes$ }), mockBrowse());
|
|
40
|
+
|
|
41
|
+
const loading = await firstValueFrom(stateUnit.isLoading$);
|
|
42
|
+
expect(loading).toBe(true);
|
|
43
|
+
|
|
44
|
+
entityTypes$.next(['Tag']);
|
|
45
|
+
const loaded = await firstValueFrom(stateUnit.isLoading$.pipe(filter((l) => !l)));
|
|
46
|
+
expect(loaded).toBe(false);
|
|
47
|
+
|
|
48
|
+
stateUnit.dispose();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('setNewTag updates newTag$', async () => {
|
|
52
|
+
const stateUnit = createEntityTagsStateUnit(mockClient(), mockBrowse());
|
|
53
|
+
|
|
54
|
+
stateUnit.setNewTag('Organization');
|
|
55
|
+
const tag = await firstValueFrom(stateUnit.newTag$);
|
|
56
|
+
expect(tag).toBe('Organization');
|
|
57
|
+
|
|
58
|
+
stateUnit.dispose();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('addTag calls client and clears newTag$', async () => {
|
|
62
|
+
const addEntityType = vi.fn().mockResolvedValue(undefined);
|
|
63
|
+
const stateUnit = createEntityTagsStateUnit(mockClient({ addEntityType }), mockBrowse());
|
|
64
|
+
|
|
65
|
+
stateUnit.setNewTag('Event');
|
|
66
|
+
await stateUnit.addTag();
|
|
67
|
+
|
|
68
|
+
expect(addEntityType).toHaveBeenCalledWith('Event');
|
|
69
|
+
const tag = await firstValueFrom(stateUnit.newTag$);
|
|
70
|
+
expect(tag).toBe('');
|
|
71
|
+
|
|
72
|
+
stateUnit.dispose();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('addTag ignores empty/whitespace input', async () => {
|
|
76
|
+
const addEntityType = vi.fn();
|
|
77
|
+
const stateUnit = createEntityTagsStateUnit(mockClient({ addEntityType }), mockBrowse());
|
|
78
|
+
|
|
79
|
+
stateUnit.setNewTag(' ');
|
|
80
|
+
await stateUnit.addTag();
|
|
81
|
+
|
|
82
|
+
expect(addEntityType).not.toHaveBeenCalled();
|
|
83
|
+
|
|
84
|
+
stateUnit.dispose();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('addTag sets error on failure', async () => {
|
|
88
|
+
const addEntityType = vi.fn().mockRejectedValue(new Error('duplicate'));
|
|
89
|
+
const stateUnit = createEntityTagsStateUnit(mockClient({ addEntityType }), mockBrowse());
|
|
90
|
+
|
|
91
|
+
stateUnit.setNewTag('Person');
|
|
92
|
+
await stateUnit.addTag();
|
|
93
|
+
|
|
94
|
+
const error = await firstValueFrom(stateUnit.error$);
|
|
95
|
+
expect(error).toBe('duplicate');
|
|
96
|
+
|
|
97
|
+
const adding = await firstValueFrom(stateUnit.isAdding$);
|
|
98
|
+
expect(adding).toBe(false);
|
|
99
|
+
|
|
100
|
+
stateUnit.dispose();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { BehaviorSubject, type Observable, map } from 'rxjs';
|
|
2
|
+
import { createDisposer } from '@semiont/sdk';
|
|
3
|
+
import type { StateUnit } from '@semiont/sdk';
|
|
4
|
+
import type { ShellStateUnit } from '../../../state/shell-state-unit';
|
|
5
|
+
import type { SemiontClient } from '@semiont/sdk';
|
|
6
|
+
|
|
7
|
+
export interface EntityTagsStateUnit extends StateUnit {
|
|
8
|
+
browse: ShellStateUnit;
|
|
9
|
+
entityTypes$: Observable<string[]>;
|
|
10
|
+
isLoading$: Observable<boolean>;
|
|
11
|
+
newTag$: Observable<string>;
|
|
12
|
+
error$: Observable<string>;
|
|
13
|
+
isAdding$: Observable<boolean>;
|
|
14
|
+
setNewTag(value: string): void;
|
|
15
|
+
addTag(): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createEntityTagsStateUnit(
|
|
19
|
+
client: SemiontClient,
|
|
20
|
+
browse: ShellStateUnit,
|
|
21
|
+
): EntityTagsStateUnit {
|
|
22
|
+
const disposer = createDisposer();
|
|
23
|
+
disposer.add(browse);
|
|
24
|
+
|
|
25
|
+
const newTag$ = new BehaviorSubject<string>('');
|
|
26
|
+
const error$ = new BehaviorSubject<string>('');
|
|
27
|
+
const isAdding$ = new BehaviorSubject<boolean>(false);
|
|
28
|
+
|
|
29
|
+
const raw$ = client.browse.entityTypes();
|
|
30
|
+
const entityTypes$: Observable<string[]> = raw$.pipe(map((e) => e ?? []));
|
|
31
|
+
const isLoading$: Observable<boolean> = raw$.pipe(map((e) => e === undefined));
|
|
32
|
+
|
|
33
|
+
const addTag = async (): Promise<void> => {
|
|
34
|
+
const tag = newTag$.getValue().trim();
|
|
35
|
+
if (!tag) return;
|
|
36
|
+
error$.next('');
|
|
37
|
+
isAdding$.next(true);
|
|
38
|
+
try {
|
|
39
|
+
await client.frame.addEntityType(tag);
|
|
40
|
+
newTag$.next('');
|
|
41
|
+
} catch (err) {
|
|
42
|
+
error$.next(err instanceof Error ? err.message : 'Failed to add entity type');
|
|
43
|
+
} finally {
|
|
44
|
+
isAdding$.next(false);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
browse,
|
|
50
|
+
entityTypes$,
|
|
51
|
+
isLoading$,
|
|
52
|
+
newTag$: newTag$.asObservable(),
|
|
53
|
+
error$: error$.asObservable(),
|
|
54
|
+
isAdding$: isAdding$.asObservable(),
|
|
55
|
+
setNewTag: (v) => newTag$.next(v),
|
|
56
|
+
addTag,
|
|
57
|
+
dispose: () => {
|
|
58
|
+
newTag$.complete();
|
|
59
|
+
error$.complete();
|
|
60
|
+
isAdding$.complete();
|
|
61
|
+
disposer.dispose();
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import React from 'react';
|
|
9
9
|
import { ClockIcon } from '@heroicons/react/24/outline';
|
|
10
|
-
import { COMMON_PANELS, type ToolbarPanelType } from '
|
|
10
|
+
import { COMMON_PANELS, type ToolbarPanelType } from '../../../state/shell-state-unit';
|
|
11
11
|
export interface RecentDocumentsPageProps {
|
|
12
12
|
// Data props
|
|
13
13
|
hasDocuments: boolean;
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
ScaleIcon,
|
|
12
12
|
LightBulbIcon
|
|
13
13
|
} from '@heroicons/react/24/outline';
|
|
14
|
-
import { COMMON_PANELS, type ToolbarPanelType } from '
|
|
14
|
+
import { COMMON_PANELS, type ToolbarPanelType } from '../../../state/shell-state-unit';
|
|
15
15
|
import type { TagSchema } from '@semiont/react-ui';
|
|
16
16
|
|
|
17
17
|
export interface TagSchemasPageProps {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import React from 'react';
|
|
9
|
-
import { COMMON_PANELS, type ToolbarPanelType } from '
|
|
9
|
+
import { COMMON_PANELS, type ToolbarPanelType } from '../../../state/shell-state-unit';
|
|
10
10
|
import { ExportCard, type ExportCardTranslations } from '../../admin-exchange/components/ExportCard';
|
|
11
11
|
import { ImportCard, type ImportCardProps, type ImportCardTranslations } from '../../admin-exchange/components/ImportCard';
|
|
12
12
|
import { ImportProgress, type ImportProgressTranslations } from '../../admin-exchange/components/ImportProgress';
|