@exegia/corpora-ui 0.20.0 → 0.21.0
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-lib/components/blocks/auth/__tests__/auth-state-atom.test.d.ts +1 -0
- package/dist-lib/components/blocks/auth/auth-flow-atom.d.ts +77 -0
- package/dist-lib/components/blocks/auth/auth-session-atom.d.ts +28 -0
- package/dist-lib/components/blocks/auth/auth-state-type.d.ts +71 -0
- package/dist-lib/components/blocks/auth/auth-state.d.ts +9 -0
- package/dist-lib/components/blocks/auth/use-auth-state.d.ts +52 -0
- package/dist-lib/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.d.ts +1 -0
- package/dist-lib/components/blocks/nav/sidebar/ai-sidebar-atom.d.ts +217 -0
- package/dist-lib/components/blocks/nav/sidebar/index.d.ts +3 -1
- package/dist-lib/components/blocks/nav/sidebar/sidebar-context.d.ts +3 -0
- package/dist-lib/components/blocks/nav/sidebar/sidebar-row.d.ts +3 -1
- package/dist-lib/components/blocks/nav/sidebar/type.d.ts +109 -29
- package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar-state.d.ts +29 -0
- package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar.d.ts +6 -1
- package/dist-lib/components/blocks/nav/sidebar/utils.d.ts +1 -1
- package/dist-lib/components/blocks/profile/index.d.ts +7 -0
- package/dist-lib/components/blocks/profile/profile-card-atom.d.ts +68 -0
- package/dist-lib/components/blocks/profile/profile-card-block.d.ts +28 -39
- package/dist-lib/components/blocks/profile/type.d.ts +80 -0
- package/dist-lib/components/blocks/profile/use-profile-card-state.d.ts +24 -0
- package/dist-lib/components/blocks/profile/use-profile-card.d.ts +34 -0
- package/dist-lib/components/blocks/shell/__tests__/shell-fit-atom.test.d.ts +1 -0
- package/dist-lib/components/blocks/shell/__tests__/shell-metrics.test.d.ts +1 -0
- package/dist-lib/components/blocks/shell/animated-panel-provider.d.ts +1 -1
- package/dist-lib/components/blocks/shell/animated-panel.d.ts +1 -1
- package/dist-lib/components/blocks/shell/index.d.ts +4 -0
- package/dist-lib/components/blocks/shell/shell-fit-atom.d.ts +68 -0
- package/dist-lib/components/blocks/shell/shell-metrics.d.ts +54 -0
- package/dist-lib/components/blocks/shell/type.d.ts +115 -3
- package/dist-lib/components/blocks/shell/use-shell-fit-state.d.ts +25 -0
- package/dist-lib/components/blocks/shell/use-shell-fit.d.ts +14 -0
- package/dist-lib/components/blocks/shell/use-shell-panels.d.ts +12 -1
- package/dist-lib/components/blocks/shell/utils.d.ts +32 -0
- package/dist-lib/components/composed/tree/__tests__/tree-atom.test.d.ts +1 -0
- package/dist-lib/components/composed/tree/constants.d.ts +4 -4
- package/dist-lib/components/composed/tree/index.d.ts +3 -1
- package/dist-lib/components/composed/tree/tree-atom.d.ts +160 -0
- package/dist-lib/components/composed/tree/tree-node.d.ts +6 -1
- package/dist-lib/components/composed/tree/type.d.ts +116 -18
- package/dist-lib/components/composed/tree/use-tree-dnd.d.ts +18 -7
- package/dist-lib/components/composed/tree/use-tree-state.d.ts +25 -0
- package/dist-lib/components/composed/tree/use-tree.d.ts +4 -0
- package/dist-lib/components/composed/user-avatar.d.ts +5 -28
- package/dist-lib/components/user-avatar/__tests__/user-avatar.test.d.ts +1 -0
- package/dist-lib/components/user-avatar/component.d.ts +1 -8
- package/dist-lib/components/user-avatar/index.d.ts +5 -0
- package/dist-lib/components/user-avatar/presence-badge.d.ts +16 -0
- package/dist-lib/components/user-avatar/type.d.ts +45 -0
- package/dist-lib/components/user-avatar/use-user-avatar-state.d.ts +26 -0
- package/dist-lib/components/user-avatar/use-user-avatar.d.ts +32 -0
- package/dist-lib/components/user-avatar/user-avatar-atom.d.ts +55 -0
- package/dist-lib/index.d.ts +5 -2
- package/dist-lib/index.js +3004 -1796
- package/dist-lib/index.js.map +1 -1
- package/dist-lib/state/exegia-provider.d.ts +51 -0
- package/dist-lib/state/index.d.ts +4 -0
- package/dist-lib/state/store.d.ts +17 -0
- package/package.json +15 -12
- package/src/components/blocks/auth/__tests__/auth-state-atom.test.tsx +247 -0
- package/src/components/blocks/auth/auth-flow-atom.ts +238 -0
- package/src/components/blocks/auth/auth-session-atom.ts +82 -0
- package/src/components/blocks/auth/auth-state-type.ts +97 -0
- package/src/components/blocks/auth/auth-state.ts +52 -0
- package/src/components/blocks/auth/use-auth-state.ts +127 -0
- package/src/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.tsx +325 -0
- package/src/components/blocks/nav/sidebar/ai-sidebar-atom.ts +698 -0
- package/src/components/blocks/nav/sidebar/ai-sidebar.tsx +48 -42
- package/src/components/blocks/nav/sidebar/index.ts +53 -1
- package/src/components/blocks/nav/sidebar/sidebar-context.ts +15 -0
- package/src/components/blocks/nav/sidebar/sidebar-row.tsx +119 -50
- package/src/components/blocks/nav/sidebar/type.ts +125 -30
- package/src/components/blocks/nav/sidebar/use-ai-sidebar-state.ts +123 -0
- package/src/components/blocks/nav/sidebar/use-ai-sidebar.ts +301 -248
- package/src/components/blocks/nav/sidebar/utils.ts +1 -1
- package/src/components/blocks/profile/__tests__/profile-card-block.test.tsx +194 -0
- package/src/components/blocks/profile/index.ts +28 -0
- package/src/components/blocks/profile/profile-card-atom.ts +247 -0
- package/src/components/blocks/profile/profile-card-block.tsx +153 -74
- package/src/components/blocks/profile/type.ts +95 -0
- package/src/components/blocks/profile/use-profile-card-state.ts +67 -0
- package/src/components/blocks/profile/use-profile-card.ts +126 -0
- package/src/components/blocks/shell/__tests__/shell-fit-atom.test.tsx +360 -0
- package/src/components/blocks/shell/__tests__/shell-layout.test.tsx +192 -3
- package/src/components/blocks/shell/__tests__/shell-metrics.test.ts +108 -0
- package/src/components/blocks/shell/animated-panel-inset.tsx +5 -1
- package/src/components/blocks/shell/animated-panel-provider.tsx +77 -11
- package/src/components/blocks/shell/animated-panel-trigger.tsx +4 -0
- package/src/components/blocks/shell/animated-panel.tsx +126 -29
- package/src/components/blocks/shell/index.ts +18 -0
- package/src/components/blocks/shell/shell-fit-atom.ts +243 -0
- package/src/components/blocks/shell/shell-layout.tsx +55 -53
- package/src/components/blocks/shell/shell-metrics.ts +79 -0
- package/src/components/blocks/shell/type.ts +130 -3
- package/src/components/blocks/shell/use-shell-fit-state.ts +49 -0
- package/src/components/blocks/shell/use-shell-fit.ts +135 -0
- package/src/components/blocks/shell/use-shell-panels.ts +57 -4
- package/src/components/blocks/shell/utils.ts +44 -4
- package/src/components/composed/tree/CLAUDE.md +132 -0
- package/src/components/composed/tree/__tests__/tree-atom.test.tsx +217 -0
- package/src/components/composed/tree/__tests__/tree.test.tsx +162 -37
- package/src/components/composed/tree/__tests__/use-tree.test.tsx +2 -1
- package/src/components/composed/tree/constants.ts +4 -4
- package/src/components/composed/tree/index.ts +38 -0
- package/src/components/composed/tree/tree-atom.ts +590 -0
- package/src/components/composed/tree/tree-node.tsx +117 -78
- package/src/components/composed/tree/tree.tsx +24 -16
- package/src/components/composed/tree/type.ts +125 -15
- package/src/components/composed/tree/use-tree-dnd.ts +82 -51
- package/src/components/composed/tree/use-tree-state.ts +105 -0
- package/src/components/composed/tree/use-tree.ts +185 -184
- package/src/components/composed/user-avatar.tsx +11 -99
- package/src/components/user-avatar/__tests__/user-avatar.test.tsx +245 -0
- package/src/components/user-avatar/component.tsx +121 -22
- package/src/components/user-avatar/index.ts +19 -0
- package/src/components/user-avatar/presence-badge.tsx +56 -0
- package/src/components/user-avatar/type.ts +50 -0
- package/src/components/user-avatar/use-user-avatar-state.ts +60 -0
- package/src/components/user-avatar/use-user-avatar.ts +144 -0
- package/src/components/user-avatar/user-avatar-atom.ts +198 -0
- package/src/index.ts +34 -2
- package/src/state/exegia-provider.tsx +79 -0
- package/src/state/index.ts +4 -0
- package/src/state/store.ts +19 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session state for the auth blocks — singleton atoms, deliberately NOT a
|
|
3
|
+
* family.
|
|
4
|
+
*
|
|
5
|
+
* A session is one-per-store: one signed-in user per app (per
|
|
6
|
+
* `ExegiaProvider` store; SSR isolation comes from passing a fresh store).
|
|
7
|
+
* Instance keying is for multi-instance components; a family here would
|
|
8
|
+
* imply several concurrent users and be a lie.
|
|
9
|
+
*
|
|
10
|
+
* Import direction: this module is the LEAF — it imports nothing from
|
|
11
|
+
* `auth-flow-atom.ts`. The flow module imports `signInAtom` from here (for
|
|
12
|
+
* `completeAuthFlowAtom`) and defines the composite `endAuthSessionAtom`
|
|
13
|
+
* that pairs `signOutAtom` with a flow reset. `signOutAtom` below clears the
|
|
14
|
+
* session only.
|
|
15
|
+
*/
|
|
16
|
+
import { atom } from "jotai"
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
AuthSessionState,
|
|
20
|
+
AuthSessionStatus,
|
|
21
|
+
AuthUser,
|
|
22
|
+
} from "./auth-state-type"
|
|
23
|
+
|
|
24
|
+
// ── primitives ───────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/** `"unknown"` until the app restores or rejects a session at boot. */
|
|
27
|
+
export const authSessionStatusAtom = atom<AuthSessionStatus>("unknown")
|
|
28
|
+
authSessionStatusAtom.debugLabel = "auth-session/status"
|
|
29
|
+
|
|
30
|
+
export const authUserAtom = atom<AuthUser | null>(null)
|
|
31
|
+
authUserAtom.debugLabel = "auth-session/user"
|
|
32
|
+
|
|
33
|
+
// ── derived ──────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
export const isAuthenticatedAtom = atom(
|
|
36
|
+
(get) => get(authSessionStatusAtom) === "authenticated"
|
|
37
|
+
)
|
|
38
|
+
isAuthenticatedAtom.debugLabel = "auth-session/isAuthenticated"
|
|
39
|
+
|
|
40
|
+
export const authSessionStateAtom = atom<AuthSessionState>((get) => ({
|
|
41
|
+
status: get(authSessionStatusAtom),
|
|
42
|
+
user: get(authUserAtom),
|
|
43
|
+
}))
|
|
44
|
+
authSessionStateAtom.debugLabel = "auth-session/state"
|
|
45
|
+
|
|
46
|
+
// ── actions ──────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
export const signInAtom = atom(null, (_get, set, user: AuthUser) => {
|
|
49
|
+
set(authUserAtom, user)
|
|
50
|
+
set(authSessionStatusAtom, "authenticated")
|
|
51
|
+
})
|
|
52
|
+
signInAtom.debugLabel = "auth-session/signIn"
|
|
53
|
+
|
|
54
|
+
export const updateAuthUserAtom = atom(
|
|
55
|
+
null,
|
|
56
|
+
(get, set, patch: Partial<AuthUser>) => {
|
|
57
|
+
const user = get(authUserAtom)
|
|
58
|
+
// No-op when signed out: there is no user to merge into, and inventing
|
|
59
|
+
// one from a partial patch would fabricate an identity.
|
|
60
|
+
if (user === null) return
|
|
61
|
+
set(authUserAtom, { ...user, ...patch })
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
updateAuthUserAtom.debugLabel = "auth-session/updateUser"
|
|
65
|
+
|
|
66
|
+
/** What an app calls at boot when session restore fails. */
|
|
67
|
+
export const markUnauthenticatedAtom = atom(null, (_get, set) => {
|
|
68
|
+
set(authSessionStatusAtom, "unauthenticated")
|
|
69
|
+
set(authUserAtom, null)
|
|
70
|
+
})
|
|
71
|
+
markUnauthenticatedAtom.debugLabel = "auth-session/markUnauthenticated"
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Clears the session only. Most consumers want `endAuthSessionAtom` from
|
|
75
|
+
* `auth-flow-atom.ts` instead, which also returns the default flow to the
|
|
76
|
+
* login step — the hooks expose that one as `signOut`.
|
|
77
|
+
*/
|
|
78
|
+
export const signOutAtom = atom(null, (_get, set) => {
|
|
79
|
+
set(authUserAtom, null)
|
|
80
|
+
set(authSessionStatusAtom, "unauthenticated")
|
|
81
|
+
})
|
|
82
|
+
signOutAtom.debugLabel = "auth-session/signOut"
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for the auth coordination state (`auth-flow-atom.ts` /
|
|
3
|
+
* `auth-session-atom.ts`).
|
|
4
|
+
*
|
|
5
|
+
* This file stays dependency-free of the atom modules: types only, no atom
|
|
6
|
+
* imports. The auth blocks themselves are unaware of this layer — they keep
|
|
7
|
+
* field values in local `useState` — these types describe the coordination
|
|
8
|
+
* state an app wires *around* the blocks.
|
|
9
|
+
*/
|
|
10
|
+
import type { AuthStatus } from "./auth-shell"
|
|
11
|
+
|
|
12
|
+
export type AuthFlowId = string
|
|
13
|
+
|
|
14
|
+
/** Which auth block the app is showing. */
|
|
15
|
+
export type AuthFlowStep =
|
|
16
|
+
| "login"
|
|
17
|
+
| "signup"
|
|
18
|
+
| "verify-code"
|
|
19
|
+
| "forgot-password"
|
|
20
|
+
| "update-password"
|
|
21
|
+
| "onboarding"
|
|
22
|
+
| "success"
|
|
23
|
+
|
|
24
|
+
export type AuthFlowChannel = "email" | "sms"
|
|
25
|
+
|
|
26
|
+
export interface AuthFlowState {
|
|
27
|
+
step: AuthFlowStep
|
|
28
|
+
/** The identifier in flight — an email or phone number. Cleared by reset
|
|
29
|
+
* and by signOut. Never store passwords or codes here. */
|
|
30
|
+
identifier: string | null
|
|
31
|
+
/** `identifier` masked for display — "y•••@example.com", "•••1234". */
|
|
32
|
+
maskedIdentifier: string | null
|
|
33
|
+
channel: AuthFlowChannel
|
|
34
|
+
status: AuthStatus
|
|
35
|
+
error: string | null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Options for `beginAuthVerificationAtom` / `beginVerification`. */
|
|
39
|
+
export interface BeginAuthVerificationOptions {
|
|
40
|
+
identifier: string
|
|
41
|
+
/** @default "email" */
|
|
42
|
+
channel?: AuthFlowChannel
|
|
43
|
+
/** @default "verify-code" */
|
|
44
|
+
step?: AuthFlowStep
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** What `useAuthFlowActions(flowId)` returns. */
|
|
48
|
+
export interface AuthFlowActions {
|
|
49
|
+
/** Show a step; resets status to `"idle"` and clears the error. Keeps the
|
|
50
|
+
* identifier. */
|
|
51
|
+
goToStep: (step: AuthFlowStep) => void
|
|
52
|
+
/** Record the identifier in flight and move to the verification step. */
|
|
53
|
+
beginVerification: (options: BeginAuthVerificationOptions) => void
|
|
54
|
+
/** Mark the flow `"loading"` while an async attempt is in flight. */
|
|
55
|
+
beginAttempt: () => void
|
|
56
|
+
/** Mark the flow `"error"` with a message. */
|
|
57
|
+
fail: (message: string) => void
|
|
58
|
+
/** Mark the flow `"success"`; when a user is given, also sign the session
|
|
59
|
+
* in. */
|
|
60
|
+
complete: (user?: AuthUser) => void
|
|
61
|
+
/** Back to the initial values (login step, no identifier). */
|
|
62
|
+
reset: () => void
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The signed-in identity, kept for the whole session. Structurally
|
|
66
|
+
* assignable to `ProfileCardUser`, so it drops into `ProfileCardBlock`. */
|
|
67
|
+
export interface AuthUser {
|
|
68
|
+
id: string
|
|
69
|
+
name: string
|
|
70
|
+
/** Secondary line — a handle, an email, a role. */
|
|
71
|
+
username?: string
|
|
72
|
+
email?: string
|
|
73
|
+
/** Avatar image URL. */
|
|
74
|
+
avatar?: string
|
|
75
|
+
initials?: string
|
|
76
|
+
/** App-specific extras the library never interprets. */
|
|
77
|
+
metadata?: Record<string, unknown>
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `unknown` until the app restores or rejects a session at boot. */
|
|
81
|
+
export type AuthSessionStatus = "unknown" | "unauthenticated" | "authenticated"
|
|
82
|
+
|
|
83
|
+
export interface AuthSessionState {
|
|
84
|
+
status: AuthSessionStatus
|
|
85
|
+
user: AuthUser | null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** What `useAuthSessionActions()` returns. */
|
|
89
|
+
export interface AuthSessionActions {
|
|
90
|
+
signIn: (user: AuthUser) => void
|
|
91
|
+
/** Shallow-merge a patch into the signed-in user; no-op when signed out. */
|
|
92
|
+
updateUser: (patch: Partial<AuthUser>) => void
|
|
93
|
+
/** What an app calls at boot when session restore fails. */
|
|
94
|
+
markUnauthenticated: () => void
|
|
95
|
+
/** Sign out AND return the default auth flow to the login step. */
|
|
96
|
+
signOut: () => void
|
|
97
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public barrel for the auth coordination state. Explicit named exports
|
|
3
|
+
* only — never `export *` from the atom modules, so internals can move
|
|
4
|
+
* without becoming a breaking-change surface.
|
|
5
|
+
*/
|
|
6
|
+
export {
|
|
7
|
+
DEFAULT_AUTH_FLOW_ID,
|
|
8
|
+
authFlowChannelAtom,
|
|
9
|
+
authFlowErrorAtom,
|
|
10
|
+
authFlowIdentifierAtom,
|
|
11
|
+
authFlowMaskedIdentifierAtom,
|
|
12
|
+
authFlowStateAtom,
|
|
13
|
+
authFlowStatusAtom,
|
|
14
|
+
authFlowStepAtom,
|
|
15
|
+
beginAuthAttemptAtom,
|
|
16
|
+
beginAuthVerificationAtom,
|
|
17
|
+
completeAuthFlowAtom,
|
|
18
|
+
endAuthSessionAtom,
|
|
19
|
+
failAuthFlowAtom,
|
|
20
|
+
goToAuthStepAtom,
|
|
21
|
+
maskAuthIdentifier,
|
|
22
|
+
removeAuthFlowInstance,
|
|
23
|
+
resetAuthFlowAtom,
|
|
24
|
+
} from "./auth-flow-atom"
|
|
25
|
+
export {
|
|
26
|
+
authSessionStateAtom,
|
|
27
|
+
authSessionStatusAtom,
|
|
28
|
+
authUserAtom,
|
|
29
|
+
isAuthenticatedAtom,
|
|
30
|
+
markUnauthenticatedAtom,
|
|
31
|
+
signInAtom,
|
|
32
|
+
signOutAtom,
|
|
33
|
+
updateAuthUserAtom,
|
|
34
|
+
} from "./auth-session-atom"
|
|
35
|
+
export {
|
|
36
|
+
useAuthFlow,
|
|
37
|
+
useAuthFlowActions,
|
|
38
|
+
useAuthSession,
|
|
39
|
+
useAuthSessionActions,
|
|
40
|
+
} from "./use-auth-state"
|
|
41
|
+
export type {
|
|
42
|
+
AuthFlowActions,
|
|
43
|
+
AuthFlowChannel,
|
|
44
|
+
AuthFlowId,
|
|
45
|
+
AuthFlowState,
|
|
46
|
+
AuthFlowStep,
|
|
47
|
+
AuthSessionActions,
|
|
48
|
+
AuthSessionState,
|
|
49
|
+
AuthSessionStatus,
|
|
50
|
+
AuthUser,
|
|
51
|
+
BeginAuthVerificationOptions,
|
|
52
|
+
} from "./auth-state-type"
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { useAtomValue, useSetAtom } from "jotai"
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_AUTH_FLOW_ID,
|
|
8
|
+
authFlowStateAtom,
|
|
9
|
+
beginAuthAttemptAtom,
|
|
10
|
+
beginAuthVerificationAtom,
|
|
11
|
+
completeAuthFlowAtom,
|
|
12
|
+
endAuthSessionAtom,
|
|
13
|
+
failAuthFlowAtom,
|
|
14
|
+
goToAuthStepAtom,
|
|
15
|
+
resetAuthFlowAtom,
|
|
16
|
+
} from "./auth-flow-atom"
|
|
17
|
+
import {
|
|
18
|
+
authSessionStateAtom,
|
|
19
|
+
isAuthenticatedAtom,
|
|
20
|
+
markUnauthenticatedAtom,
|
|
21
|
+
signInAtom,
|
|
22
|
+
updateAuthUserAtom,
|
|
23
|
+
} from "./auth-session-atom"
|
|
24
|
+
import type {
|
|
25
|
+
AuthFlowActions,
|
|
26
|
+
AuthFlowId,
|
|
27
|
+
AuthFlowState,
|
|
28
|
+
AuthSessionActions,
|
|
29
|
+
AuthSessionState,
|
|
30
|
+
} from "./auth-state-type"
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read the auth flow registered under `flowId` from anywhere below
|
|
34
|
+
* `ExegiaProvider`. Pairs with `useAuthFlowActions` to wire the auth blocks
|
|
35
|
+
* into one flow — the blocks keep passwords and codes in local state, the
|
|
36
|
+
* flow tracks which block shows and what identifier is in flight:
|
|
37
|
+
*
|
|
38
|
+
* ```tsx
|
|
39
|
+
* const flow = useAuthFlow()
|
|
40
|
+
* const { beginVerification, complete, fail } = useAuthFlowActions()
|
|
41
|
+
*
|
|
42
|
+
* {flow.step === "login" && (
|
|
43
|
+
* <LoginBlock
|
|
44
|
+
* onSubmit={async ({ email }) => {
|
|
45
|
+
* await api.requestCode(email)
|
|
46
|
+
* beginVerification({ identifier: email })
|
|
47
|
+
* }}
|
|
48
|
+
* />
|
|
49
|
+
* )}
|
|
50
|
+
* {flow.step === "verify-code" && (
|
|
51
|
+
* <CodeAuthBlock
|
|
52
|
+
* destination={flow.maskedIdentifier ?? undefined}
|
|
53
|
+
* onVerify={async (code) => complete(await api.verify(code))}
|
|
54
|
+
* />
|
|
55
|
+
* )}
|
|
56
|
+
* ```
|
|
57
|
+
*
|
|
58
|
+
* This returns the whole state object, so the caller re-renders on any
|
|
59
|
+
* change. A component that reads one field should subscribe to that field's
|
|
60
|
+
* atom instead: `useAtomValue(authFlowStepAtom(flowId))`.
|
|
61
|
+
*/
|
|
62
|
+
export function useAuthFlow(
|
|
63
|
+
flowId: AuthFlowId = DEFAULT_AUTH_FLOW_ID
|
|
64
|
+
): AuthFlowState {
|
|
65
|
+
return useAtomValue(authFlowStateAtom(flowId))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Drive the auth flow registered under `flowId` from anywhere. Writes only —
|
|
70
|
+
* the caller never re-renders when the flow changes. See `useAuthFlow` for
|
|
71
|
+
* the intended wiring; a block's submit handler typically runs
|
|
72
|
+
* `beginAttempt()` → the API call → `complete(user)` or `fail(message)`.
|
|
73
|
+
*/
|
|
74
|
+
export function useAuthFlowActions(
|
|
75
|
+
flowId: AuthFlowId = DEFAULT_AUTH_FLOW_ID
|
|
76
|
+
): AuthFlowActions {
|
|
77
|
+
const goToStep = useSetAtom(goToAuthStepAtom(flowId))
|
|
78
|
+
const beginVerification = useSetAtom(beginAuthVerificationAtom(flowId))
|
|
79
|
+
const beginAttempt = useSetAtom(beginAuthAttemptAtom(flowId))
|
|
80
|
+
const fail = useSetAtom(failAuthFlowAtom(flowId))
|
|
81
|
+
const complete = useSetAtom(completeAuthFlowAtom(flowId))
|
|
82
|
+
const reset = useSetAtom(resetAuthFlowAtom(flowId))
|
|
83
|
+
|
|
84
|
+
return React.useMemo(
|
|
85
|
+
() => ({
|
|
86
|
+
goToStep,
|
|
87
|
+
beginVerification,
|
|
88
|
+
beginAttempt,
|
|
89
|
+
fail,
|
|
90
|
+
complete,
|
|
91
|
+
reset,
|
|
92
|
+
}),
|
|
93
|
+
[goToStep, beginVerification, beginAttempt, fail, complete, reset]
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Read the session: `status` (`"unknown"` until the app restores or rejects
|
|
99
|
+
* a session at boot), the signed-in `user`, and derived `isAuthenticated`.
|
|
100
|
+
*/
|
|
101
|
+
export function useAuthSession(): AuthSessionState & {
|
|
102
|
+
isAuthenticated: boolean
|
|
103
|
+
} {
|
|
104
|
+
const state = useAtomValue(authSessionStateAtom)
|
|
105
|
+
const isAuthenticated = useAtomValue(isAuthenticatedAtom)
|
|
106
|
+
return React.useMemo(
|
|
107
|
+
() => ({ ...state, isAuthenticated }),
|
|
108
|
+
[state, isAuthenticated]
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Drive the session. Writes only. `signOut` is the composite
|
|
114
|
+
* `endAuthSessionAtom`: it clears the session AND returns the default flow
|
|
115
|
+
* to the login step — the one to wire to a logout button.
|
|
116
|
+
*/
|
|
117
|
+
export function useAuthSessionActions(): AuthSessionActions {
|
|
118
|
+
const signIn = useSetAtom(signInAtom)
|
|
119
|
+
const updateUser = useSetAtom(updateAuthUserAtom)
|
|
120
|
+
const markUnauthenticated = useSetAtom(markUnauthenticatedAtom)
|
|
121
|
+
const signOut = useSetAtom(endAuthSessionAtom)
|
|
122
|
+
|
|
123
|
+
return React.useMemo(
|
|
124
|
+
() => ({ signIn, updateUser, markUnauthenticated, signOut }),
|
|
125
|
+
[signIn, updateUser, markUnauthenticated, signOut]
|
|
126
|
+
)
|
|
127
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test"
|
|
2
|
+
import { render, screen } from "@testing-library/react"
|
|
3
|
+
import userEvent from "@testing-library/user-event"
|
|
4
|
+
import { createStore } from "jotai"
|
|
5
|
+
|
|
6
|
+
import { ExegiaProvider } from "@/state"
|
|
7
|
+
import { AISidebar } from "../ai-sidebar"
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_AI_SIDEBAR_CONFIG,
|
|
10
|
+
aiSidebarAnnouncementAtom,
|
|
11
|
+
aiSidebarExpandedIdsAtom,
|
|
12
|
+
aiSidebarItemsAtom,
|
|
13
|
+
aiSidebarMovePendingAtom,
|
|
14
|
+
expandAISidebarRowAtom,
|
|
15
|
+
mountAISidebarAtom,
|
|
16
|
+
moveAISidebarRowAtom,
|
|
17
|
+
removeAISidebarInstance,
|
|
18
|
+
renameAISidebarRowAtom,
|
|
19
|
+
resetAISidebarAtom,
|
|
20
|
+
setAISidebarHandlersAtom,
|
|
21
|
+
} from "../ai-sidebar-atom"
|
|
22
|
+
import type { AISidebarConfig, AISidebarSeed, SidebarResource } from "../type"
|
|
23
|
+
import { useAISidebarActions, useAISidebarState } from "../use-ai-sidebar-state"
|
|
24
|
+
|
|
25
|
+
type Store = ReturnType<typeof createStore>
|
|
26
|
+
|
|
27
|
+
const RESOURCES: SidebarResource[] = [
|
|
28
|
+
{
|
|
29
|
+
id: "corpora",
|
|
30
|
+
label: "Corpora",
|
|
31
|
+
kind: "project",
|
|
32
|
+
children: [
|
|
33
|
+
{ id: "manuscripts", label: "Manuscripts", kind: "folder" },
|
|
34
|
+
{ id: "readme", label: "Field notes", kind: "file" },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
{ id: "reading-list", label: "Reading list", kind: "bookmark" },
|
|
38
|
+
{ id: "archive", label: "Archive", kind: "folder", disabled: true },
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
function mount(
|
|
42
|
+
store: Store,
|
|
43
|
+
id: string,
|
|
44
|
+
config: Partial<AISidebarConfig> = {},
|
|
45
|
+
seed: Partial<AISidebarSeed> = {}
|
|
46
|
+
) {
|
|
47
|
+
store.set(
|
|
48
|
+
mountAISidebarAtom(id),
|
|
49
|
+
{ ...DEFAULT_AI_SIDEBAR_CONFIG, ...config },
|
|
50
|
+
{
|
|
51
|
+
items: RESOURCES,
|
|
52
|
+
activeId: null,
|
|
53
|
+
focusedId: null,
|
|
54
|
+
expandedIds: [],
|
|
55
|
+
...seed,
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const ids = (items: SidebarResource[]) => items.map((item) => item.id)
|
|
61
|
+
|
|
62
|
+
describe("ai-sidebar atoms · instance isolation", () => {
|
|
63
|
+
test("two ids never share state", () => {
|
|
64
|
+
const store = createStore()
|
|
65
|
+
mount(store, "a")
|
|
66
|
+
mount(store, "b")
|
|
67
|
+
|
|
68
|
+
store.set(expandAISidebarRowAtom("a"), "corpora")
|
|
69
|
+
expect(store.get(aiSidebarExpandedIdsAtom("a")).has("corpora")).toBe(true)
|
|
70
|
+
expect(store.get(aiSidebarExpandedIdsAtom("b")).has("corpora")).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test("two stores never share state under the same id", () => {
|
|
74
|
+
const one = createStore()
|
|
75
|
+
const two = createStore()
|
|
76
|
+
mount(one, "shared")
|
|
77
|
+
mount(two, "shared")
|
|
78
|
+
|
|
79
|
+
one.set(expandAISidebarRowAtom("shared"), "corpora")
|
|
80
|
+
expect(one.get(aiSidebarExpandedIdsAtom("shared")).has("corpora")).toBe(
|
|
81
|
+
true
|
|
82
|
+
)
|
|
83
|
+
expect(two.get(aiSidebarExpandedIdsAtom("shared")).has("corpora")).toBe(
|
|
84
|
+
false
|
|
85
|
+
)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test("removeAISidebarInstance drops the slice back to its defaults", () => {
|
|
89
|
+
const store = createStore()
|
|
90
|
+
mount(store, "gone")
|
|
91
|
+
store.set(expandAISidebarRowAtom("gone"), "corpora")
|
|
92
|
+
|
|
93
|
+
removeAISidebarInstance("gone")
|
|
94
|
+
expect(store.get(aiSidebarExpandedIdsAtom("gone")).size).toBe(0)
|
|
95
|
+
expect(store.get(aiSidebarItemsAtom("gone"))).toEqual([])
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe("ai-sidebar atoms · write gates", () => {
|
|
100
|
+
test("hook-owned data renames itself", () => {
|
|
101
|
+
const store = createStore()
|
|
102
|
+
mount(store, "owned")
|
|
103
|
+
|
|
104
|
+
store.set(renameAISidebarRowAtom("owned"), "reading-list", " To read ")
|
|
105
|
+
expect(store.get(aiSidebarItemsAtom("owned"))[1]?.label).toBe("To read")
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test("a controlled items prop is never overwritten, only reported", () => {
|
|
109
|
+
const store = createStore()
|
|
110
|
+
const onItemsChange = mock((items: SidebarResource[]) => void items)
|
|
111
|
+
mount(store, "controlled", { controlsItems: true })
|
|
112
|
+
store.set(setAISidebarHandlersAtom("controlled"), { onItemsChange })
|
|
113
|
+
|
|
114
|
+
store.set(renameAISidebarRowAtom("controlled"), "reading-list", "To read")
|
|
115
|
+
expect(store.get(aiSidebarItemsAtom("controlled"))[1]?.label).toBe(
|
|
116
|
+
"Reading list"
|
|
117
|
+
)
|
|
118
|
+
expect(onItemsChange).toHaveBeenCalledTimes(1)
|
|
119
|
+
expect(onItemsChange.mock.calls[0]![0][1]?.label).toBe("To read")
|
|
120
|
+
|
|
121
|
+
// A move is gated the same way: the atom keeps the prop's data, the
|
|
122
|
+
// consumer gets the next tree to apply themselves.
|
|
123
|
+
void store.set(moveAISidebarRowAtom("controlled"), {
|
|
124
|
+
itemId: "reading-list",
|
|
125
|
+
targetId: "corpora",
|
|
126
|
+
position: "before",
|
|
127
|
+
})
|
|
128
|
+
expect(ids(store.get(aiSidebarItemsAtom("controlled")))).toEqual([
|
|
129
|
+
"corpora",
|
|
130
|
+
"reading-list",
|
|
131
|
+
"archive",
|
|
132
|
+
])
|
|
133
|
+
expect(onItemsChange).toHaveBeenCalledTimes(2)
|
|
134
|
+
expect(ids(onItemsChange.mock.calls[1]![0])).toEqual([
|
|
135
|
+
"reading-list",
|
|
136
|
+
"corpora",
|
|
137
|
+
"archive",
|
|
138
|
+
])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test("reset replays the mount seed", () => {
|
|
142
|
+
const store = createStore()
|
|
143
|
+
mount(store, "replay", {}, { expandedIds: ["corpora"] })
|
|
144
|
+
store.set(expandAISidebarRowAtom("replay"), "manuscripts")
|
|
145
|
+
store.set(renameAISidebarRowAtom("replay"), "reading-list", "To read")
|
|
146
|
+
|
|
147
|
+
store.set(resetAISidebarAtom("replay"))
|
|
148
|
+
expect([...store.get(aiSidebarExpandedIdsAtom("replay"))]).toEqual([
|
|
149
|
+
"corpora",
|
|
150
|
+
])
|
|
151
|
+
expect(store.get(aiSidebarItemsAtom("replay"))[1]?.label).toBe(
|
|
152
|
+
"Reading list"
|
|
153
|
+
)
|
|
154
|
+
expect(store.get(aiSidebarAnnouncementAtom("replay"))).toBe("")
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe("ai-sidebar atoms · optimistic move", () => {
|
|
159
|
+
test("a rejected onMove rolls the data back and announces it", async () => {
|
|
160
|
+
const store = createStore()
|
|
161
|
+
const onMoveError = mock(() => {})
|
|
162
|
+
mount(store, "rollback")
|
|
163
|
+
store.set(setAISidebarHandlersAtom("rollback"), {
|
|
164
|
+
onMove: () => Promise.reject(new Error("409")),
|
|
165
|
+
onMoveError,
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
const pending = store.set(moveAISidebarRowAtom("rollback"), {
|
|
169
|
+
itemId: "reading-list",
|
|
170
|
+
targetId: "corpora",
|
|
171
|
+
position: "before",
|
|
172
|
+
})
|
|
173
|
+
// The move applies optimistically and announces before onMove settles…
|
|
174
|
+
expect(ids(store.get(aiSidebarItemsAtom("rollback")))).toEqual([
|
|
175
|
+
"reading-list",
|
|
176
|
+
"corpora",
|
|
177
|
+
"archive",
|
|
178
|
+
])
|
|
179
|
+
expect(store.get(aiSidebarAnnouncementAtom("rollback"))).toContain(
|
|
180
|
+
"Moved Reading list before Corpora"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
await pending
|
|
184
|
+
// …and the rejection restores the previous order.
|
|
185
|
+
expect(ids(store.get(aiSidebarItemsAtom("rollback")))).toEqual([
|
|
186
|
+
"corpora",
|
|
187
|
+
"reading-list",
|
|
188
|
+
"archive",
|
|
189
|
+
])
|
|
190
|
+
expect(store.get(aiSidebarAnnouncementAtom("rollback"))).toContain(
|
|
191
|
+
"Move failed"
|
|
192
|
+
)
|
|
193
|
+
expect(onMoveError).toHaveBeenCalled()
|
|
194
|
+
expect(store.get(aiSidebarMovePendingAtom("rollback"))).toBe(false)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test("a second move is refused while one is in flight", async () => {
|
|
198
|
+
const store = createStore()
|
|
199
|
+
let release!: () => void
|
|
200
|
+
mount(store, "pending")
|
|
201
|
+
store.set(setAISidebarHandlersAtom("pending"), {
|
|
202
|
+
onMove: () =>
|
|
203
|
+
new Promise<void>((resolve) => {
|
|
204
|
+
release = resolve
|
|
205
|
+
}),
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
const first = store.set(moveAISidebarRowAtom("pending"), {
|
|
209
|
+
itemId: "reading-list",
|
|
210
|
+
targetId: "corpora",
|
|
211
|
+
position: "before",
|
|
212
|
+
})
|
|
213
|
+
const second = store.set(moveAISidebarRowAtom("pending"), {
|
|
214
|
+
itemId: "archive",
|
|
215
|
+
targetId: "corpora",
|
|
216
|
+
position: "before",
|
|
217
|
+
})
|
|
218
|
+
// The second move never touched the data, only the live region.
|
|
219
|
+
expect(store.get(aiSidebarAnnouncementAtom("pending"))).toBe(
|
|
220
|
+
"Wait for the current move to finish."
|
|
221
|
+
)
|
|
222
|
+
expect(ids(store.get(aiSidebarItemsAtom("pending")))).toEqual([
|
|
223
|
+
"reading-list",
|
|
224
|
+
"corpora",
|
|
225
|
+
"archive",
|
|
226
|
+
])
|
|
227
|
+
|
|
228
|
+
release()
|
|
229
|
+
await Promise.all([first, second])
|
|
230
|
+
expect(store.get(aiSidebarMovePendingAtom("pending"))).toBe(false)
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
describe("ExegiaProvider · driving a sidebar by id", () => {
|
|
235
|
+
/** A component with no relationship to the sidebar beyond its id. */
|
|
236
|
+
function Remote() {
|
|
237
|
+
const actions = useAISidebarActions("app-resources")
|
|
238
|
+
const { expandedIds } = useAISidebarState("app-resources")
|
|
239
|
+
return (
|
|
240
|
+
<button onClick={() => actions.expand("corpora")} type="button">
|
|
241
|
+
{`expand (${expandedIds.size})`}
|
|
242
|
+
</button>
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
test("a sibling component expands the sidebar through one provider", async () => {
|
|
247
|
+
const user = userEvent.setup()
|
|
248
|
+
render(
|
|
249
|
+
<ExegiaProvider store={createStore()}>
|
|
250
|
+
<AISidebar defaultItems={RESOURCES} sidebarId="app-resources" />
|
|
251
|
+
<Remote />
|
|
252
|
+
</ExegiaProvider>
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
// corpora starts collapsed, so its children start unmounted.
|
|
256
|
+
expect(screen.queryByRole("treeitem", { name: /Field notes/ })).toBeNull()
|
|
257
|
+
|
|
258
|
+
await user.click(screen.getByRole("button", { name: /^expand/ }))
|
|
259
|
+
expect(
|
|
260
|
+
await screen.findByRole("treeitem", { name: /Field notes/ })
|
|
261
|
+
).toBeDefined()
|
|
262
|
+
// The remote reader saw the change too.
|
|
263
|
+
expect(screen.getByRole("button", { name: "expand (1)" })).toBeDefined()
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
describe("AISidebar · render granularity", () => {
|
|
268
|
+
test("expanding and selecting one row leave a sibling row untouched", async () => {
|
|
269
|
+
const user = userEvent.setup()
|
|
270
|
+
// `renderIcon` runs inside a row's render, so counting calls per id is a
|
|
271
|
+
// proxy for counting that row's renders.
|
|
272
|
+
const renders = new Map<string, number>()
|
|
273
|
+
|
|
274
|
+
function Driver() {
|
|
275
|
+
const actions = useAISidebarActions("granular")
|
|
276
|
+
return (
|
|
277
|
+
<>
|
|
278
|
+
<button onClick={() => actions.expand("corpora")} type="button">
|
|
279
|
+
open
|
|
280
|
+
</button>
|
|
281
|
+
<button onClick={() => actions.select("reading-list")} type="button">
|
|
282
|
+
pick
|
|
283
|
+
</button>
|
|
284
|
+
</>
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
render(
|
|
289
|
+
<ExegiaProvider store={createStore()}>
|
|
290
|
+
<AISidebar
|
|
291
|
+
defaultItems={RESOURCES}
|
|
292
|
+
renderIcon={(item) => {
|
|
293
|
+
renders.set(item.id, (renders.get(item.id) ?? 0) + 1)
|
|
294
|
+
return null
|
|
295
|
+
}}
|
|
296
|
+
sidebarId="granular"
|
|
297
|
+
/>
|
|
298
|
+
<Driver />
|
|
299
|
+
</ExegiaProvider>
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
const beforeCorpora = renders.get("corpora") ?? 0
|
|
303
|
+
const beforeReadingList = renders.get("reading-list") ?? 0
|
|
304
|
+
const beforeArchive = renders.get("archive") ?? 0
|
|
305
|
+
expect(beforeCorpora).toBeGreaterThan(0)
|
|
306
|
+
expect(beforeReadingList).toBeGreaterThan(0)
|
|
307
|
+
|
|
308
|
+
await user.click(screen.getByRole("button", { name: "open" }))
|
|
309
|
+
await screen.findByRole("treeitem", { name: /Field notes/ })
|
|
310
|
+
|
|
311
|
+
// corpora's branch opened, so its row re-rendered…
|
|
312
|
+
expect(renders.get("corpora")).toBeGreaterThan(beforeCorpora)
|
|
313
|
+
// …and its siblings, whose atoms did not change, did not.
|
|
314
|
+
expect(renders.get("reading-list")).toBe(beforeReadingList)
|
|
315
|
+
expect(renders.get("archive")).toBe(beforeArchive)
|
|
316
|
+
|
|
317
|
+
const expandedCorpora = renders.get("corpora") ?? 0
|
|
318
|
+
await user.click(screen.getByRole("button", { name: "pick" }))
|
|
319
|
+
|
|
320
|
+
// Selection flips reading-list's own atom and nobody else's.
|
|
321
|
+
expect(renders.get("reading-list")).toBeGreaterThan(beforeReadingList)
|
|
322
|
+
expect(renders.get("corpora")).toBe(expandedCorpora)
|
|
323
|
+
expect(renders.get("archive")).toBe(beforeArchive)
|
|
324
|
+
})
|
|
325
|
+
})
|