Add IndexedDB-backed draft persistence for CreateTeamDialog so that navigating away (e.g. to Dashboard to install CLI) and back preserves all form fields: team name, members, paths, solo/launch flags, color. - New createTeamDraftStorage service (IDB + in-memory fallback) - New useCreateTeamDraft hook (debounced save, flush on unmount, race-safe) - Gate useEffect for defaults/initialData on draftLoaded to prevent race - Block submit button until draft is loaded - Improve CLI-missing error in LaunchTeamDialog with Dashboard navigation - Remove notification bell button from MessagesPanel
176 lines
5 KiB
TypeScript
176 lines
5 KiB
TypeScript
/**
|
|
* Atomic draft storage for CreateTeamDialog form snapshots.
|
|
*
|
|
* Stores the full form state (team name, members, paths, flags) under a single
|
|
* IndexedDB key so that navigating away from the Teams tab and back preserves
|
|
* user input. No TTL — drafts persist until explicitly cleared on successful
|
|
* team creation.
|
|
*
|
|
* Pattern mirrors `composerDraftStorage.ts`.
|
|
*/
|
|
|
|
import { del, get, set } from 'idb-keyval';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Current snapshot schema version. Bump when shape changes. */
|
|
const SNAPSHOT_VERSION = 1;
|
|
|
|
/** Serializable subset of MemberDraft — excludes transient `workflowChips`. */
|
|
export interface SerializedMemberDraft {
|
|
id: string;
|
|
name: string;
|
|
roleSelection: string;
|
|
customRole: string;
|
|
workflow?: string;
|
|
}
|
|
|
|
export interface CreateTeamDraftSnapshot {
|
|
version: number;
|
|
teamName: string;
|
|
members: SerializedMemberDraft[];
|
|
cwdMode: 'project' | 'custom';
|
|
selectedProjectPath: string;
|
|
customCwd: string;
|
|
soloTeam: boolean;
|
|
launchTeam: boolean;
|
|
teamColor: string;
|
|
updatedAt: number;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Key
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const STORAGE_KEY = 'createTeamDraft:form';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Validation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function isValidMember(m: unknown): m is SerializedMemberDraft {
|
|
if (typeof m !== 'object' || m === null) return false;
|
|
const obj = m as Record<string, unknown>;
|
|
return (
|
|
typeof obj.id === 'string' &&
|
|
typeof obj.name === 'string' &&
|
|
typeof obj.roleSelection === 'string' &&
|
|
typeof obj.customRole === 'string'
|
|
);
|
|
}
|
|
|
|
function isValidSnapshot(data: unknown): data is CreateTeamDraftSnapshot {
|
|
if (typeof data !== 'object' || data === null) return false;
|
|
const obj = data as Record<string, unknown>;
|
|
return (
|
|
typeof obj.version === 'number' &&
|
|
obj.version === SNAPSHOT_VERSION &&
|
|
typeof obj.teamName === 'string' &&
|
|
Array.isArray(obj.members) &&
|
|
obj.members.every(isValidMember) &&
|
|
(obj.cwdMode === 'project' || obj.cwdMode === 'custom') &&
|
|
typeof obj.selectedProjectPath === 'string' &&
|
|
typeof obj.customCwd === 'string' &&
|
|
typeof obj.soloTeam === 'boolean' &&
|
|
typeof obj.launchTeam === 'boolean' &&
|
|
typeof obj.teamColor === 'string' &&
|
|
typeof obj.updatedAt === 'number'
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// IDB availability tracking
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let idbUnavailable = false;
|
|
let idbUnavailableLogged = false;
|
|
const fallbackStore = new Map<string, CreateTeamDraftSnapshot>();
|
|
|
|
function markIdbUnavailable(): void {
|
|
if (!idbUnavailableLogged) {
|
|
idbUnavailableLogged = true;
|
|
console.warn(
|
|
'[createTeamDraftStorage] IndexedDB unavailable, using in-memory storage for this session.'
|
|
);
|
|
}
|
|
idbUnavailable = true;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Core API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function saveSnapshot(snapshot: CreateTeamDraftSnapshot): Promise<void> {
|
|
if (idbUnavailable) {
|
|
fallbackStore.set(STORAGE_KEY, snapshot);
|
|
return;
|
|
}
|
|
try {
|
|
await set(STORAGE_KEY, snapshot);
|
|
} catch {
|
|
markIdbUnavailable();
|
|
fallbackStore.set(STORAGE_KEY, snapshot);
|
|
}
|
|
}
|
|
|
|
async function loadSnapshot(): Promise<CreateTeamDraftSnapshot | null> {
|
|
if (idbUnavailable) {
|
|
return fallbackStore.get(STORAGE_KEY) ?? null;
|
|
}
|
|
try {
|
|
const data = await get<unknown>(STORAGE_KEY);
|
|
if (data == null) return null;
|
|
if (isValidSnapshot(data)) return data;
|
|
// Invalid shape — discard silently
|
|
void del(STORAGE_KEY);
|
|
return null;
|
|
} catch {
|
|
markIdbUnavailable();
|
|
return fallbackStore.get(STORAGE_KEY) ?? null;
|
|
}
|
|
}
|
|
|
|
async function deleteSnapshot(): Promise<void> {
|
|
if (idbUnavailable) {
|
|
fallbackStore.delete(STORAGE_KEY);
|
|
return;
|
|
}
|
|
try {
|
|
await del(STORAGE_KEY);
|
|
} catch {
|
|
markIdbUnavailable();
|
|
fallbackStore.delete(STORAGE_KEY);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Factory
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function emptySnapshot(): CreateTeamDraftSnapshot {
|
|
return {
|
|
version: SNAPSHOT_VERSION,
|
|
teamName: '',
|
|
members: [],
|
|
cwdMode: 'project',
|
|
selectedProjectPath: '',
|
|
customCwd: '',
|
|
soloTeam: false,
|
|
launchTeam: true,
|
|
teamColor: '',
|
|
updatedAt: Date.now(),
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Export
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const createTeamDraftStorage = {
|
|
saveSnapshot,
|
|
loadSnapshot,
|
|
deleteSnapshot,
|
|
emptySnapshot,
|
|
};
|