firebase-os 1.1.3 → 1.1.5

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.
Files changed (124) hide show
  1. package/dist/FirebaseOS.d.ts +15 -0
  2. package/dist/firebase-os.cjs.js +2 -17
  3. package/dist/firebase-os.es.js +63 -72
  4. package/dist/index.d.ts +3 -0
  5. package/dist/lib/ConfigContext.d.ts +12 -0
  6. package/package.json +3 -2
  7. package/scripts/postinstall.js +89 -10
  8. package/src/App.css +184 -0
  9. package/src/App.tsx +214 -0
  10. package/src/FirebaseOS.tsx +80 -0
  11. package/src/assets/hero.png +0 -0
  12. package/src/assets/react.svg +1 -0
  13. package/src/assets/vite.svg +1 -0
  14. package/src/components/AdminNotifications.test.tsx +98 -0
  15. package/src/components/AdminNotifications.tsx +194 -0
  16. package/src/components/Button.test.tsx +22 -0
  17. package/src/components/Button.tsx +53 -0
  18. package/src/components/ConfirmModal.test.tsx +98 -0
  19. package/src/components/ConfirmModal.tsx +73 -0
  20. package/src/components/ContactPopup.test.tsx +98 -0
  21. package/src/components/ContactPopup.tsx +437 -0
  22. package/src/components/CustomSelect.test.tsx +47 -0
  23. package/src/components/CustomSelect.tsx +89 -0
  24. package/src/components/DashboardNav.test.tsx +98 -0
  25. package/src/components/DashboardNav.tsx +281 -0
  26. package/src/components/Input.test.tsx +33 -0
  27. package/src/components/Input.tsx +61 -0
  28. package/src/components/JsonEditor.tsx +579 -0
  29. package/src/components/Navbar.test.tsx +98 -0
  30. package/src/components/Navbar.tsx +563 -0
  31. package/src/configs/forms/contactForm.config.ts +15 -0
  32. package/src/configs/forms/index.ts +29 -0
  33. package/src/configs/forms/pubForm.config.ts +11 -0
  34. package/src/configs/forms/supportForm.config.ts +14 -0
  35. package/src/configs/forms/userForm.config.ts +11 -0
  36. package/src/configs/pages/admin.config.ts +29 -0
  37. package/src/configs/pages/contact.config.ts +6 -0
  38. package/src/configs/pages/home.config.ts +18 -0
  39. package/src/configs/pages/mem.config.ts +2 -0
  40. package/src/configs/pages/menuOrders.config.ts +11 -0
  41. package/src/configs/pages/pub.config.ts +11 -0
  42. package/src/configs/pages/shared.config.ts +29 -0
  43. package/src/configs/pages/support.config.ts +7 -0
  44. package/src/configs/pages/tabOrders.config.ts +33 -0
  45. package/src/configs/pages/user.config.ts +29 -0
  46. package/src/configs/theme.config.ts +93 -0
  47. package/src/index.css +403 -0
  48. package/src/index.ts +22 -0
  49. package/src/lib/AuthContext.test.tsx +88 -0
  50. package/src/lib/AuthContext.tsx +191 -0
  51. package/src/lib/ConfigContext.tsx +45 -0
  52. package/src/lib/ThemeContext.tsx +227 -0
  53. package/src/lib/firebase.ts +91 -0
  54. package/src/main.tsx +22 -0
  55. package/src/microcomponents/AdminExampleContent.tsx +44 -0
  56. package/src/microcomponents/PrivateExampleContent.tsx +39 -0
  57. package/src/microcomponents/Public.tsx +126 -0
  58. package/src/microcomponents/SharedExampleContent.tsx +53 -0
  59. package/src/pages/Dashboard.test.tsx +98 -0
  60. package/src/pages/Dashboard.tsx +60 -0
  61. package/src/pages/DynamicPage.tsx +237 -0
  62. package/src/pages/FormsAdmin.test.tsx +98 -0
  63. package/src/pages/FormsAdmin.tsx +459 -0
  64. package/src/pages/Home.test.tsx +98 -0
  65. package/src/pages/Home.tsx +144 -0
  66. package/src/pages/Login.test.tsx +98 -0
  67. package/src/pages/Login.tsx +108 -0
  68. package/src/pages/PagesAdmin.test.tsx +98 -0
  69. package/src/pages/PagesAdmin.tsx +1022 -0
  70. package/src/pages/Profile.test.tsx +98 -0
  71. package/src/pages/Profile.tsx +319 -0
  72. package/src/pages/Register.test.tsx +98 -0
  73. package/src/pages/Register.tsx +116 -0
  74. package/src/pages/Requests.test.tsx +95 -0
  75. package/src/pages/Requests.tsx +422 -0
  76. package/src/pages/ResetPassword.test.tsx +98 -0
  77. package/src/pages/ResetPassword.tsx +92 -0
  78. package/src/pages/Settings.test.tsx +98 -0
  79. package/src/pages/Settings.tsx +393 -0
  80. package/src/pages/Setup.tsx +401 -0
  81. package/src/pages/StorageAdmin.test.tsx +150 -0
  82. package/src/pages/StorageAdmin.tsx +769 -0
  83. package/src/pages/Submissions.test.tsx +95 -0
  84. package/src/pages/Submissions.tsx +372 -0
  85. package/src/pages/Templates.test.tsx +98 -0
  86. package/src/pages/Templates.tsx +103 -0
  87. package/src/pages/ThemeAdmin.test.tsx +144 -0
  88. package/src/pages/ThemeAdmin.tsx +1000 -0
  89. package/src/pages/Users.test.tsx +95 -0
  90. package/src/pages/Users.tsx +334 -0
  91. package/src/pages/Verify.test.tsx +98 -0
  92. package/src/pages/Verify.tsx +95 -0
  93. package/src/prompts/index.ts +13 -0
  94. package/src/prompts/pages/publicPage.ts +44 -0
  95. package/src/prompts/sharedConstants.ts +12 -0
  96. package/src/prompts/tabs/board/adminboard.ts +32 -0
  97. package/src/prompts/tabs/board/privateboard.ts +36 -0
  98. package/src/prompts/tabs/board/publicboard.ts +36 -0
  99. package/src/prompts/tabs/calendar/admincalendar.ts +32 -0
  100. package/src/prompts/tabs/calendar/privatecalendar.ts +36 -0
  101. package/src/prompts/tabs/calendar/publiccalendar.ts +36 -0
  102. package/src/prompts/tabs/crud/admin.ts +54 -0
  103. package/src/prompts/tabs/crud/private.ts +55 -0
  104. package/src/prompts/tabs/crud/shared.ts +53 -0
  105. package/src/prompts/tabs/table/admintable.ts +32 -0
  106. package/src/prompts/tabs/table/privatetable.ts +36 -0
  107. package/src/prompts/tabs/table/publictable.ts +36 -0
  108. package/src/setupTests.ts +1 -0
  109. package/src/templates/AdminPageTemplate.tsx +678 -0
  110. package/src/templates/PrivatePageTemplate.tsx +594 -0
  111. package/src/templates/PublicPageTemplate.tsx +92 -0
  112. package/src/templates/SharedPageTemplate.tsx +551 -0
  113. package/src/templates/TemplateBoard.test.tsx +106 -0
  114. package/src/templates/TemplateBoard.tsx +642 -0
  115. package/src/templates/TemplateCalendar.test.tsx +106 -0
  116. package/src/templates/TemplateCalendar.tsx +848 -0
  117. package/src/templates/TemplateConfirmation.test.tsx +106 -0
  118. package/src/templates/TemplateConfirmation.tsx +145 -0
  119. package/src/templates/TemplateInlineForm.test.tsx +106 -0
  120. package/src/templates/TemplateInlineForm.tsx +129 -0
  121. package/src/templates/TemplatePopupForm.test.tsx +106 -0
  122. package/src/templates/TemplatePopupForm.tsx +174 -0
  123. package/src/templates/TemplateTable.test.tsx +106 -0
  124. package/src/templates/TemplateTable.tsx +675 -0
@@ -0,0 +1,98 @@
1
+ // @generated-test
2
+ import { describe, it, expect, vi } from 'vitest';
3
+ import { render, screen, act } from '@testing-library/react';
4
+ import { BrowserRouter } from 'react-router-dom';
5
+ import { AuthProvider } from '../lib/AuthContext';
6
+ import { ThemeProvider } from '../lib/ThemeContext';
7
+ import { FormsAdmin } from './FormsAdmin';
8
+
9
+ // Global mocks
10
+ Object.defineProperty(window, 'matchMedia', {
11
+ writable: true,
12
+ value: vi.fn().mockImplementation(query => ({
13
+ matches: false,
14
+ media: query,
15
+ onchange: null,
16
+ addListener: vi.fn(),
17
+ removeListener: vi.fn(),
18
+ addEventListener: vi.fn(),
19
+ removeEventListener: vi.fn(),
20
+ dispatchEvent: vi.fn(),
21
+ })),
22
+ });
23
+
24
+ vi.mock('../lib/AuthContext', () => ({
25
+ AuthProvider: ({ children }: any) => <>{children}</>,
26
+ useAuth: () => ({
27
+ user: { uid: 'mock-user-123', email: 'test@example.com' },
28
+ userWorkspaces: [],
29
+ activeWorkspace: null,
30
+ activeOrg: null,
31
+ loading: false
32
+ })
33
+ }));
34
+
35
+ vi.mock('../lib/ThemeContext', () => ({
36
+ ThemeProvider: ({ children }: any) => <>{children}</>,
37
+ useTheme: () => ({ themeMode: 'light', setThemeMode: vi.fn(), activeConfig: {} })
38
+ }));
39
+
40
+ vi.mock('firebase/auth', () => ({
41
+ getAuth: vi.fn(() => ({})),
42
+ onAuthStateChanged: vi.fn((auth, cb) => { cb({ uid: 'mock-user-123', email: 'test@example.com', getIdToken: vi.fn(() => Promise.resolve('mock-token')) }); return () => {}; })
43
+ }));
44
+ vi.mock('firebase/firestore', () => ({
45
+ getFirestore: vi.fn(() => ({})),
46
+ collection: vi.fn(),
47
+ doc: vi.fn(),
48
+ setDoc: vi.fn(() => Promise.resolve()),
49
+ addDoc: vi.fn(() => Promise.resolve()),
50
+ updateDoc: vi.fn(() => Promise.resolve()),
51
+ deleteDoc: vi.fn(() => Promise.resolve()),
52
+ query: vi.fn(),
53
+ where: vi.fn(),
54
+ orderBy: vi.fn(),
55
+ limit: vi.fn(),
56
+ getDoc: vi.fn(() => Promise.resolve({ exists: () => true, data: () => ({ role: 'super_admin' }) })),
57
+ getDocs: vi.fn(() => Promise.resolve({ docs: [], forEach: vi.fn() })),
58
+ onSnapshot: vi.fn((...args: any[]) => {
59
+ let cb = args[1];
60
+ if (typeof args[2] === 'function') {
61
+ cb = args[2];
62
+ }
63
+ if (typeof cb === 'function') {
64
+ cb({docs: [], forEach: vi.fn(), data: () => ({}), exists: () => true});
65
+ }
66
+ return () => {};
67
+ })
68
+ }));
69
+ vi.mock('firebase/storage', () => ({
70
+ getStorage: vi.fn(() => ({})),
71
+ ref: vi.fn(),
72
+ listAll: vi.fn(() => Promise.resolve({ items: [], prefixes: [] })),
73
+ getDownloadURL: vi.fn(() => Promise.resolve('mock-url')),
74
+ getMetadata: vi.fn(() => Promise.resolve({ size: 1024, timeCreated: new Date().toISOString() }))
75
+ }));
76
+
77
+ describe('FormsAdmin Component', () => {
78
+ it('renders without crashing', async () => {
79
+ // Wrap in standard application providers inside act to process async side effects and prevent warnings
80
+ await act(async () => {
81
+ render(
82
+ <BrowserRouter>
83
+ <AuthProvider>
84
+ <ThemeProvider>
85
+ {/* @ts-ignore */}
86
+ <FormsAdmin />
87
+ </ThemeProvider>
88
+ </AuthProvider>
89
+ </BrowserRouter>
90
+ );
91
+ // Wait a tick to flush background state updates
92
+ await new Promise(resolve => setTimeout(resolve, 0));
93
+ });
94
+
95
+ // Check if the document has anything rendered without throwing
96
+ expect(document.body).toBeDefined();
97
+ });
98
+ });
@@ -0,0 +1,459 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { useAuth } from '../lib/AuthContext';
3
+ import { useNavigate, useLocation } from 'react-router-dom';
4
+ import { DashboardNav } from '../components/DashboardNav';
5
+ import { motion, AnimatePresence } from 'framer-motion';
6
+ import { FormInput, Plus, Trash2, Save, Loader2, Edit2, AlertTriangle, Eye, EyeOff, CheckCircle2, Copy, RotateCcw } from 'lucide-react';
7
+ import { Button } from '../components/Button';
8
+ import { db } from '../lib/firebase';
9
+ import { doc, getDocs, collection, setDoc, deleteDoc } from 'firebase/firestore';
10
+ import { defaultForms, type FormConfig } from '../configs/forms';
11
+ import { JsonNode } from './ThemeAdmin';
12
+ import { ConfirmModal } from '../components/ConfirmModal';
13
+ import { ContactPopup } from '../components/ContactPopup';
14
+ import { pubFormConfig } from '../configs/forms/pubForm.config';
15
+ import { userFormConfig } from '../configs/forms/userForm.config';
16
+
17
+ const Toggle = ({ enabled, onChange, disabled, className = '' }: { enabled: boolean, onChange: (val: boolean) => void, disabled?: boolean, className?: string }) => (
18
+ <button
19
+ disabled={disabled}
20
+ onClick={(e) => { e.stopPropagation(); onChange(!enabled); }}
21
+ title={enabled ? "Form Active" : "Form Hidden"}
22
+ className={`p-1.5 transition-colors rounded-lg flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity ${className} ${enabled ? 'text-accent hover:bg-accent/10' : 'text-foreground/40 hover:bg-foreground/5'} ${disabled ? '!opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
23
+ >
24
+ {enabled ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
25
+ </button>
26
+ );
27
+
28
+ export function FormsAdmin() {
29
+ const { userRole } = useAuth();
30
+ const navigate = useNavigate();
31
+ const location = useLocation();
32
+
33
+ const [forms, setForms] = useState<Record<string, FormConfig>>({});
34
+ const [loading, setLoading] = useState(true);
35
+ const [savingKeys, setSavingKeys] = useState<Record<string, boolean>>({});
36
+
37
+
38
+ const [previewForm, setPreviewForm] = useState<any | null>(null);
39
+
40
+ const [expandedForms, setExpandedForms] = useState<Record<string, boolean>>({});
41
+ const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
42
+ const [isDeleting, setIsDeleting] = useState(false);
43
+
44
+ const [copyStateId, setCopyStateId] = useState<string | null>(null);
45
+
46
+ const handleCopyBtn = (id: string, val: any) => {
47
+ let str = JSON.stringify(val, null, 2);
48
+ navigator.clipboard.writeText(str);
49
+ setCopyStateId(id);
50
+ setTimeout(() => setCopyStateId(null), 2000);
51
+ };
52
+
53
+ useEffect(() => {
54
+ fetchForms();
55
+ }, []);
56
+
57
+
58
+
59
+ const fetchForms = async () => {
60
+ try {
61
+ const snap = await getDocs(collection(db, 'sys_forms'));
62
+ const activeForms: Record<string, FormConfig> = {};
63
+
64
+ snap.forEach(doc => {
65
+ if (doc.id !== 'pubForm' && doc.id !== 'userForm') {
66
+ const data = doc.data() as any;
67
+ if (!data._uiKey) data._uiKey = Math.random().toString(36).substring(7);
68
+ activeForms[doc.id] = data as FormConfig;
69
+ }
70
+ });
71
+
72
+ // Clean up templates if they linger in the db
73
+ try {
74
+ await deleteDoc(doc(db, 'sys_forms', 'pubForm'));
75
+ await deleteDoc(doc(db, 'sys_forms', 'userForm'));
76
+ } catch (e) {}
77
+
78
+ // Ensure system core forms exist identically to configurations
79
+ for (const [id, f] of Object.entries(defaultForms)) {
80
+ if (!activeForms[id]) {
81
+ await setDoc(doc(db, 'sys_forms', id), f);
82
+ const data = { ...f } as any;
83
+ data._uiKey = Math.random().toString(36).substring(7);
84
+ activeForms[id] = data as FormConfig;
85
+ } else {
86
+ // Additionally ensure properties aren't completely wiped if someone accidentally broke fields array
87
+ if (!activeForms[id].fields || activeForms[id].fields.length === 0) {
88
+ const defaultF = defaultForms[id];
89
+ activeForms[id].fields = defaultF.fields || [];
90
+ await setDoc(doc(db, 'sys_forms', id), activeForms[id]);
91
+ }
92
+ }
93
+ }
94
+
95
+ // Sync URL expansion before revealing the list — avoids a second render/reposition
96
+ const pathId = window.location.pathname.split('/forms/')[1]?.split('/')[0];
97
+ if (pathId && activeForms[pathId]) {
98
+ setExpandedForms({ [pathId]: true });
99
+ }
100
+ setForms(activeForms);
101
+ } catch (err) {
102
+ console.error('Failed to load forms:', err);
103
+ } finally {
104
+ setLoading(false);
105
+ }
106
+ };
107
+
108
+ const handleUpdateForm = async (formId: string, newValue: FormConfig) => {
109
+ let guardedValue = { ...newValue } as any;
110
+ if ((forms[formId] as any)?._uiKey) {
111
+ guardedValue._uiKey = (forms[formId] as any)._uiKey;
112
+ }
113
+
114
+ if (formId === 'contact') {
115
+ guardedValue.systemName = 'contact';
116
+ guardedValue.formType = 'public';
117
+ guardedValue.submitPage = 'submissions';
118
+ } else if (formId === 'support') {
119
+ guardedValue.systemName = 'support';
120
+ guardedValue.formType = 'private';
121
+ guardedValue.submitPage = 'requests';
122
+ }
123
+
124
+ const targetId = guardedValue.systemName || formId;
125
+ const isIdMutated = targetId !== formId && targetId.trim() !== '';
126
+
127
+ if (isIdMutated) {
128
+ setForms(prev => {
129
+ const payload: Record<string, FormConfig> = {};
130
+ for (const k of Object.keys(prev)) {
131
+ if (k === formId) {
132
+ payload[targetId] = guardedValue;
133
+ } else {
134
+ payload[k] = prev[k];
135
+ }
136
+ }
137
+ return payload;
138
+ });
139
+
140
+ if (expandedForms[formId]) {
141
+ setExpandedForms(prev => {
142
+ const expandedPayload: Record<string, boolean> = {};
143
+ for (const k of Object.keys(prev)) {
144
+ if (k === formId) {
145
+ expandedPayload[targetId] = prev[k];
146
+ } else {
147
+ expandedPayload[k] = prev[k];
148
+ }
149
+ }
150
+ return expandedPayload;
151
+ });
152
+ }
153
+
154
+ setSavingKeys(prev => ({ ...prev, [targetId]: true }));
155
+ try {
156
+ await setDoc(doc(db, 'sys_forms', targetId), guardedValue);
157
+ await deleteDoc(doc(db, 'sys_forms', formId));
158
+ } catch (err) {
159
+ console.error('Failed to migrate form map ID:', err);
160
+ } finally {
161
+ setTimeout(() => {
162
+ setSavingKeys(prev => ({ ...prev, [targetId]: false }));
163
+ }, 500);
164
+ }
165
+ } else {
166
+ setForms(prev => ({ ...prev, [formId]: guardedValue }));
167
+
168
+ setSavingKeys(prev => ({ ...prev, [formId]: true }));
169
+ try {
170
+ await setDoc(doc(db, 'sys_forms', formId), guardedValue);
171
+ } catch (err) {
172
+ console.error('Failed to save form:', err);
173
+ } finally {
174
+ setTimeout(() => {
175
+ setSavingKeys(prev => ({ ...prev, [formId]: false }));
176
+ }, 500);
177
+ }
178
+ }
179
+ };
180
+
181
+ const toggleFormVisibility = (id: string, currentEnabled: boolean) => {
182
+ const config = forms[id];
183
+ handleUpdateForm(id, { ...config, enabled: !currentEnabled });
184
+ };
185
+
186
+ const [addingForm, setAddingForm] = useState(false);
187
+
188
+ const handleAddNewForm = async () => {
189
+ if (addingForm) return;
190
+ setAddingForm(true);
191
+
192
+ const formattedId = `new_form_${Date.now().toString().slice(-4)}`;
193
+ const newForm: FormConfig = {
194
+ title: 'New Custom Form',
195
+ systemName: formattedId,
196
+ formType: 'public',
197
+ submitPage: 'submissions',
198
+ redirectTo: '',
199
+ submitText: 'Submit',
200
+ fields: [],
201
+ enabled: true,
202
+ _uiKey: Math.random().toString(36).substring(7)
203
+ } as any;
204
+
205
+ try {
206
+ setSavingKeys(prev => ({ ...prev, [formattedId]: true }));
207
+ await setDoc(doc(db, 'sys_forms', formattedId), newForm);
208
+ setForms(prev => ({ [formattedId]: newForm, ...prev }));
209
+ setExpandedForms(prev => ({ [formattedId]: true, ...prev }));
210
+ navigate(`/forms/${formattedId}`);
211
+ } catch (err) {
212
+ console.error("Failed to create form", err);
213
+ } finally {
214
+ setSavingKeys(prev => ({ ...prev, [formattedId]: false }));
215
+ setAddingForm(false);
216
+ }
217
+ };
218
+
219
+ const handleDeleteForm = async (formId: string) => {
220
+ if (formId === 'contact' || formId === 'support') return;
221
+ setIsDeleting(true);
222
+ try {
223
+ await deleteDoc(doc(db, 'sys_forms', formId));
224
+ setForms(prev => {
225
+ const next = { ...prev };
226
+ delete next[formId];
227
+ return next;
228
+ });
229
+ navigate('/forms');
230
+ } catch (err) {
231
+ console.error('Failed to delete form', err);
232
+ } finally {
233
+ setIsDeleting(false);
234
+ setDeleteConfirm(null);
235
+ }
236
+ };
237
+
238
+ if (userRole !== 'admin') {
239
+ return (
240
+ <main className="flex-1 w-full max-w-7xl mx-auto p-4 md:p-8 z-10 flex flex-col pt-12 min-h-screen">
241
+ <div className="flex flex-col items-center justify-center flex-1">
242
+ <AlertTriangle className="w-12 h-12 text-red-500 mb-4" />
243
+ <h2 className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-br from-red-400 to-red-600">Access Restricted</h2>
244
+ <p className="text-foreground/60 max-w-sm text-center mt-2 font-medium leading-relaxed">
245
+ This area is exclusively for system administrators.
246
+ </p>
247
+ </div>
248
+ </main>
249
+ );
250
+ }
251
+
252
+ const toggleExpand = (id: string) => {
253
+ setExpandedForms(prev => {
254
+ const newState = { ...prev, [id]: !prev[id] };
255
+ if (newState[id]) {
256
+ window.history.replaceState(null, '', `/forms/${id}`);
257
+ } else {
258
+ const remaining = Object.keys(newState).filter(k => newState[k]);
259
+ if (remaining.length > 0) {
260
+ window.history.replaceState(null, '', `/forms/${remaining[0]}`);
261
+ } else {
262
+ window.history.replaceState(null, '', `/forms`);
263
+ }
264
+ }
265
+ return newState;
266
+ });
267
+ };
268
+
269
+ const openFormId = Object.keys(expandedForms).find(k => expandedForms[k]);
270
+
271
+ return (
272
+ <main className="flex-1 w-full max-w-7xl mx-auto p-4 md:p-8 z-10 flex flex-col">
273
+ <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="mb-6 flex items-center justify-between">
274
+ <div>
275
+ <h1 className="w-fit inline-block text-3xl md:text-4xl lg:text-5xl font-extrabold tracking-tight mb-3 text-gradient mt-1">
276
+ Forms
277
+ </h1>
278
+ <div className="flex items-center gap-2 text-foreground/40 font-bold uppercase tracking-[0.2em] text-[11px]">
279
+ <span className="w-8 h-[1px] bg-foreground/10" />
280
+ /forms{openFormId ? `/${openFormId}` : ''}
281
+ </div>
282
+ </div>
283
+ </motion.div>
284
+
285
+ <div className="flex flex-col gap-6 flex-1 pb-16">
286
+ <DashboardNav />
287
+
288
+ <div className="flex flex-col glass-panel border border-[var(--panel-border)] rounded-3xl overflow-hidden flex-1 shadow-2xl bg-background">
289
+ <div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-6 mb-6 pb-6 border-b border-[var(--panel-border)]/50 relative z-10 shrink-0 px-6 pt-6 md:px-10 md:pt-10 bg-background/50">
290
+ <div>
291
+ <h2 className="text-xl font-extrabold text-foreground tracking-tight">Custom Forms</h2>
292
+ <p className="text-[14px] font-medium text-foreground/60 mt-2 leading-relaxed">
293
+ This is the central page where you configure and manage your application's forms. Easily add and customize the fields for your users.
294
+ </p>
295
+ </div>
296
+
297
+ <div className="relative z-50 shrink-0 w-full md:w-auto flex justify-end">
298
+ <Button
299
+ variant="secondary"
300
+ onClick={handleAddNewForm}
301
+ title="Add Custom Form"
302
+ disabled={addingForm}
303
+ className="w-10 h-10 p-0 border-transparent bg-transparent hover:bg-foreground/5 rounded-xl flex items-center justify-center shrink-0 transition-colors group cursor-pointer"
304
+ >
305
+ {addingForm ? <Loader2 className="w-4 h-4 text-foreground/60 animate-spin" /> : <Plus className="w-4 h-4 text-foreground/60 group-hover:text-foreground transition-colors" />}
306
+ </Button>
307
+ </div>
308
+ </div>
309
+
310
+ <div className="p-6 md:p-8 flex flex-col gap-4 overflow-y-auto">
311
+ {loading ? (
312
+ <div className="flex items-center justify-center p-12 glass-panel rounded-3xl border border-[var(--panel-border)]">
313
+ <Loader2 className="w-6 h-6 animate-spin text-accent" />
314
+ </div>
315
+ ) : Object.keys(forms).length === 0 ? (
316
+ <div className="flex flex-col items-center justify-center p-12 glass-panel rounded-3xl border border-[var(--panel-border)] border-dashed opacity-50">
317
+ <FormInput className="w-6 h-6 mb-3 text-foreground/40" />
318
+ <p className="text-[13px] font-bold">No forms currently configured.</p>
319
+ </div>
320
+ ) : (
321
+ <AnimatePresence>
322
+ {Object.entries(forms).map(([id, config]) => {
323
+ const isExpanded = expandedForms[id];
324
+ const isSaving = savingKeys[id];
325
+
326
+ return (
327
+ <motion.div
328
+ key={(config as any)._uiKey || id}
329
+ initial={false}
330
+ animate={{ opacity: 1, y: 0 }}
331
+ exit={{ opacity: 0, y: -10 }}
332
+ onClick={() => toggleExpand(id)}
333
+ className={`group flex flex-col p-3 px-4 glass-panel border transition-all rounded-2xl hover:shadow-sm cursor-pointer hover:border-accent/40 ${isExpanded ? 'border-[var(--panel-border)] bg-background/50' : 'border-transparent hover:border-[var(--panel-border)]'} flex-shrink-0 ${config.enabled === false ? 'opacity-40 grayscale bg-foreground/[0.01] border-[var(--panel-border)]/50' : ''}`}
334
+ >
335
+ <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between w-full">
336
+ <div className="flex flex-col gap-1 w-full sm:w-auto pr-4">
337
+ <div className="flex items-center gap-2">
338
+ <h3 className="text-[14px] font-bold text-foreground break-words ml-1 leading-snug">{config.title || "Untitled Form"}</h3>
339
+ {isSaving && <Loader2 className="w-3 h-3 animate-spin text-accent ml-2 shrink-0" />}
340
+ </div>
341
+ <div className="flex items-center gap-2 mt-1.5 ml-1">
342
+ <span className="text-[10px] font-bold uppercase tracking-widest border border-[var(--panel-border)] bg-foreground/5 text-foreground/60 px-2 py-0.5 rounded-md">{config.systemName || id}</span>
343
+ <span className={`text-[10px] font-bold uppercase tracking-widest border px-2 py-0.5 rounded-md shrink-0 ${config.formType === 'private' ? 'text-blue-500 bg-blue-500/10 border-blue-500/20' : 'text-green-500 bg-green-500/10 border-green-500/20'}`}>
344
+ {config.formType || 'public'}
345
+ </span>
346
+ </div>
347
+ </div>
348
+
349
+ <div className="flex items-center gap-4 w-full sm:w-auto justify-end mt-2 sm:mt-0 shrink-0">
350
+ <button onClick={(e) => { e.stopPropagation(); setPreviewForm(config); }} className="p-1.5 hover:bg-accent/10 text-foreground/40 hover:text-accent transition-colors rounded-lg flex items-center justify-center mr-2" title="Preview Form">
351
+ <Eye className="w-4 h-4" />
352
+ </button>
353
+ {(id !== 'contact' && id !== 'support') ? (
354
+ <button
355
+ onClick={(e) => { e.stopPropagation(); setDeleteConfirm(id); }}
356
+ className="p-1.5 hover:bg-red-500/10 text-foreground/30 hover:text-red-500 transition-colors rounded-lg opacity-0 group-hover:opacity-100 flex items-center justify-center mr-2"
357
+ title="Delete Form"
358
+ >
359
+ <Trash2 className="w-4 h-4" />
360
+ </button>
361
+ ) : (
362
+ <div className="w-7 mr-2 shrink-0" />
363
+ )}
364
+ <div className={`p-1.5 transition-transform duration-300 text-foreground/40 ${isExpanded ? 'rotate-180' : ''}`}>
365
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>
366
+ </div>
367
+ </div>
368
+ </div>
369
+
370
+ <AnimatePresence>
371
+ {isExpanded && (
372
+ <motion.div
373
+ initial={{ height: 0, opacity: 0 }}
374
+ animate={{ height: "auto", opacity: 1 }}
375
+ exit={{ height: 0, opacity: 0 }}
376
+ className="overflow-hidden mt-4 pt-4 border-t border-[var(--panel-border)]/50"
377
+ onClick={e => e.stopPropagation()} // Prevent folding when interacting with JSON
378
+ >
379
+ <div className="flex flex-col font-mono text-[13px] leading-relaxed relative bg-background/50 p-4 rounded-xl border border-[var(--panel-border)] shadow-inner">
380
+
381
+ <div className="absolute top-4 right-4 flex items-center gap-1 z-20">
382
+ <button onClick={(e) => { e.stopPropagation(); handleUpdateForm(id, (defaultForms[id] || defaultForms['contact']) as FormConfig); }} className="w-8 h-8 p-0 border-transparent bg-transparent hover:bg-red-500/10 rounded-lg flex items-center justify-center shrink-0 transition-colors group" title="Reset to Defaults">
383
+ {isSaving ? <Loader2 className="w-3.5 h-3.5 text-red-500 animate-spin" /> : <RotateCcw className="w-3.5 h-3.5 text-foreground/40 group-hover:text-red-500 transition-colors" />}
384
+ </button>
385
+ <button onClick={(e) => { e.stopPropagation(); handleCopyBtn(id, config); }} className="w-8 h-8 p-0 border-transparent bg-transparent hover:bg-foreground/10 rounded-lg flex items-center justify-center shrink-0 transition-colors" title="Copy JSON">
386
+ {copyStateId === id ? <CheckCircle2 className="w-3.5 h-3.5 text-emerald-500" /> : <Copy className="w-3.5 h-3.5 text-foreground/40 hover:text-foreground transition-colors" />}
387
+ </button>
388
+ </div>
389
+
390
+ <JsonNode
391
+ value={{
392
+ systemName: config.systemName || id,
393
+ formType: config.formType || 'public',
394
+ submitPage: config.submitPage || (config.formType === 'private' ? 'requests' : 'submissions'),
395
+ redirectTo: config.redirectTo || '',
396
+ title: config.title || '',
397
+ buttonName: config.buttonName || config.submitText || '',
398
+ fields: config.fields || []
399
+ }}
400
+ keysToRender={['systemName', 'formType', 'submitPage', 'redirectTo', 'title', 'buttonName', 'fields']}
401
+ meta={{ formType: config.formType || 'public' }}
402
+ onChange={(val: any) => {
403
+ // Interconnect formType and submitPage
404
+ if (val.formType !== config.formType) {
405
+ val.submitPage = val.formType === 'public' ? 'submissions' : 'requests';
406
+ } else if (val.submitPage !== config.submitPage) {
407
+ val.formType = val.submitPage === 'requests' ? 'private' : 'public';
408
+ }
409
+ handleUpdateForm(id, {...config, ...val, submitText: val.buttonName});
410
+ }}
411
+ isLast={true}
412
+ isGlobalSaving={isSaving}
413
+ disabledKeys={(id === 'contact' || id === 'support') ? ['systemName', 'formType', 'submitPage'] : []}
414
+ customEnums={{ type: ['text', 'email', 'tel', 'textarea', 'number', 'select', 'checkbox', 'currency', 'age'] }}
415
+ customExplanations={{
416
+ formType: id === 'contact'
417
+ ? "Public means its for not signed in users. Private is disabled for contact so don't allow the user to change it."
418
+ : (id === 'support' ? "This means that the form is available to signed in users only." : "Public means it's for non-signed in users."),
419
+ submitPage: id === 'contact'
420
+ ? "Means the form submissions go to this folder. User can't change that since it's directly linked. Public is tied to submissions."
421
+ : (id === 'support' ? "Can't change since all private forms go there." : "Where to route data submissions."),
422
+ redirectTo: "If not added form will just show a success message. If added, in 30 seconds after success user is redirected."
423
+ }}
424
+ />
425
+ </div>
426
+ </motion.div>
427
+ )}
428
+ </AnimatePresence>
429
+ </motion.div>
430
+ );
431
+ })}
432
+ </AnimatePresence>
433
+ )}
434
+ </div>
435
+ </div>
436
+ </div>
437
+
438
+ <ConfirmModal
439
+ isOpen={!!deleteConfirm}
440
+ onCancel={() => setDeleteConfirm(null)}
441
+ onConfirm={() => {
442
+ if (deleteConfirm) handleDeleteForm(deleteConfirm);
443
+ }}
444
+ title="Delete Form"
445
+ message={`Are you sure you want to permanently delete "${deleteConfirm}"? This action cannot be reversed.`}
446
+ confirmText="Delete form"
447
+ isProcessing={isDeleting}
448
+ />
449
+
450
+ {previewForm && (
451
+ <ContactPopup
452
+ formConfig={previewForm}
453
+ onClose={() => setPreviewForm(null)}
454
+ />
455
+ )}
456
+
457
+ </main>
458
+ );
459
+ }
@@ -0,0 +1,98 @@
1
+ // @generated-test
2
+ import { describe, it, expect, vi } from 'vitest';
3
+ import { render, screen, act } from '@testing-library/react';
4
+ import { BrowserRouter } from 'react-router-dom';
5
+ import { AuthProvider } from '../lib/AuthContext';
6
+ import { ThemeProvider } from '../lib/ThemeContext';
7
+ import { Home } from './Home';
8
+
9
+ // Global mocks
10
+ Object.defineProperty(window, 'matchMedia', {
11
+ writable: true,
12
+ value: vi.fn().mockImplementation(query => ({
13
+ matches: false,
14
+ media: query,
15
+ onchange: null,
16
+ addListener: vi.fn(),
17
+ removeListener: vi.fn(),
18
+ addEventListener: vi.fn(),
19
+ removeEventListener: vi.fn(),
20
+ dispatchEvent: vi.fn(),
21
+ })),
22
+ });
23
+
24
+ vi.mock('../lib/AuthContext', () => ({
25
+ AuthProvider: ({ children }: any) => <>{children}</>,
26
+ useAuth: () => ({
27
+ user: { uid: 'mock-user-123', email: 'test@example.com' },
28
+ userWorkspaces: [],
29
+ activeWorkspace: null,
30
+ activeOrg: null,
31
+ loading: false
32
+ })
33
+ }));
34
+
35
+ vi.mock('../lib/ThemeContext', () => ({
36
+ ThemeProvider: ({ children }: any) => <>{children}</>,
37
+ useTheme: () => ({ themeMode: 'light', setThemeMode: vi.fn(), activeConfig: {} })
38
+ }));
39
+
40
+ vi.mock('firebase/auth', () => ({
41
+ getAuth: vi.fn(() => ({})),
42
+ onAuthStateChanged: vi.fn((auth, cb) => { cb({ uid: 'mock-user-123', email: 'test@example.com', getIdToken: vi.fn(() => Promise.resolve('mock-token')) }); return () => {}; })
43
+ }));
44
+ vi.mock('firebase/firestore', () => ({
45
+ getFirestore: vi.fn(() => ({})),
46
+ collection: vi.fn(),
47
+ doc: vi.fn(),
48
+ setDoc: vi.fn(() => Promise.resolve()),
49
+ addDoc: vi.fn(() => Promise.resolve()),
50
+ updateDoc: vi.fn(() => Promise.resolve()),
51
+ deleteDoc: vi.fn(() => Promise.resolve()),
52
+ query: vi.fn(),
53
+ where: vi.fn(),
54
+ orderBy: vi.fn(),
55
+ limit: vi.fn(),
56
+ getDoc: vi.fn(() => Promise.resolve({ exists: () => true, data: () => ({ role: 'super_admin' }) })),
57
+ getDocs: vi.fn(() => Promise.resolve({ docs: [], forEach: vi.fn() })),
58
+ onSnapshot: vi.fn((...args: any[]) => {
59
+ let cb = args[1];
60
+ if (typeof args[2] === 'function') {
61
+ cb = args[2];
62
+ }
63
+ if (typeof cb === 'function') {
64
+ cb({docs: [], forEach: vi.fn(), data: () => ({}), exists: () => true});
65
+ }
66
+ return () => {};
67
+ })
68
+ }));
69
+ vi.mock('firebase/storage', () => ({
70
+ getStorage: vi.fn(() => ({})),
71
+ ref: vi.fn(),
72
+ listAll: vi.fn(() => Promise.resolve({ items: [], prefixes: [] })),
73
+ getDownloadURL: vi.fn(() => Promise.resolve('mock-url')),
74
+ getMetadata: vi.fn(() => Promise.resolve({ size: 1024, timeCreated: new Date().toISOString() }))
75
+ }));
76
+
77
+ describe('Home Component', () => {
78
+ it('renders without crashing', async () => {
79
+ // Wrap in standard application providers inside act to process async side effects and prevent warnings
80
+ await act(async () => {
81
+ render(
82
+ <BrowserRouter>
83
+ <AuthProvider>
84
+ <ThemeProvider>
85
+ {/* @ts-ignore */}
86
+ <Home />
87
+ </ThemeProvider>
88
+ </AuthProvider>
89
+ </BrowserRouter>
90
+ );
91
+ // Wait a tick to flush background state updates
92
+ await new Promise(resolve => setTimeout(resolve, 0));
93
+ });
94
+
95
+ // Check if the document has anything rendered without throwing
96
+ expect(document.body).toBeDefined();
97
+ });
98
+ });