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.
- package/dist/FirebaseOS.d.ts +15 -0
- package/dist/firebase-os.cjs.js +2 -17
- package/dist/firebase-os.es.js +63 -72
- package/dist/index.d.ts +3 -0
- package/dist/lib/ConfigContext.d.ts +12 -0
- package/package.json +3 -2
- package/scripts/postinstall.js +89 -10
- package/src/App.css +184 -0
- package/src/App.tsx +214 -0
- package/src/FirebaseOS.tsx +80 -0
- package/src/assets/hero.png +0 -0
- package/src/assets/react.svg +1 -0
- package/src/assets/vite.svg +1 -0
- package/src/components/AdminNotifications.test.tsx +98 -0
- package/src/components/AdminNotifications.tsx +194 -0
- package/src/components/Button.test.tsx +22 -0
- package/src/components/Button.tsx +53 -0
- package/src/components/ConfirmModal.test.tsx +98 -0
- package/src/components/ConfirmModal.tsx +73 -0
- package/src/components/ContactPopup.test.tsx +98 -0
- package/src/components/ContactPopup.tsx +437 -0
- package/src/components/CustomSelect.test.tsx +47 -0
- package/src/components/CustomSelect.tsx +89 -0
- package/src/components/DashboardNav.test.tsx +98 -0
- package/src/components/DashboardNav.tsx +281 -0
- package/src/components/Input.test.tsx +33 -0
- package/src/components/Input.tsx +61 -0
- package/src/components/JsonEditor.tsx +579 -0
- package/src/components/Navbar.test.tsx +98 -0
- package/src/components/Navbar.tsx +563 -0
- package/src/configs/forms/contactForm.config.ts +15 -0
- package/src/configs/forms/index.ts +29 -0
- package/src/configs/forms/pubForm.config.ts +11 -0
- package/src/configs/forms/supportForm.config.ts +14 -0
- package/src/configs/forms/userForm.config.ts +11 -0
- package/src/configs/pages/admin.config.ts +29 -0
- package/src/configs/pages/contact.config.ts +6 -0
- package/src/configs/pages/home.config.ts +18 -0
- package/src/configs/pages/mem.config.ts +2 -0
- package/src/configs/pages/menuOrders.config.ts +11 -0
- package/src/configs/pages/pub.config.ts +11 -0
- package/src/configs/pages/shared.config.ts +29 -0
- package/src/configs/pages/support.config.ts +7 -0
- package/src/configs/pages/tabOrders.config.ts +33 -0
- package/src/configs/pages/user.config.ts +29 -0
- package/src/configs/theme.config.ts +93 -0
- package/src/index.css +403 -0
- package/src/index.ts +22 -0
- package/src/lib/AuthContext.test.tsx +88 -0
- package/src/lib/AuthContext.tsx +191 -0
- package/src/lib/ConfigContext.tsx +45 -0
- package/src/lib/ThemeContext.tsx +227 -0
- package/src/lib/firebase.ts +91 -0
- package/src/main.tsx +22 -0
- package/src/microcomponents/AdminExampleContent.tsx +44 -0
- package/src/microcomponents/PrivateExampleContent.tsx +39 -0
- package/src/microcomponents/Public.tsx +126 -0
- package/src/microcomponents/SharedExampleContent.tsx +53 -0
- package/src/pages/Dashboard.test.tsx +98 -0
- package/src/pages/Dashboard.tsx +60 -0
- package/src/pages/DynamicPage.tsx +237 -0
- package/src/pages/FormsAdmin.test.tsx +98 -0
- package/src/pages/FormsAdmin.tsx +459 -0
- package/src/pages/Home.test.tsx +98 -0
- package/src/pages/Home.tsx +144 -0
- package/src/pages/Login.test.tsx +98 -0
- package/src/pages/Login.tsx +108 -0
- package/src/pages/PagesAdmin.test.tsx +98 -0
- package/src/pages/PagesAdmin.tsx +1022 -0
- package/src/pages/Profile.test.tsx +98 -0
- package/src/pages/Profile.tsx +319 -0
- package/src/pages/Register.test.tsx +98 -0
- package/src/pages/Register.tsx +116 -0
- package/src/pages/Requests.test.tsx +95 -0
- package/src/pages/Requests.tsx +422 -0
- package/src/pages/ResetPassword.test.tsx +98 -0
- package/src/pages/ResetPassword.tsx +92 -0
- package/src/pages/Settings.test.tsx +98 -0
- package/src/pages/Settings.tsx +393 -0
- package/src/pages/Setup.tsx +401 -0
- package/src/pages/StorageAdmin.test.tsx +150 -0
- package/src/pages/StorageAdmin.tsx +769 -0
- package/src/pages/Submissions.test.tsx +95 -0
- package/src/pages/Submissions.tsx +372 -0
- package/src/pages/Templates.test.tsx +98 -0
- package/src/pages/Templates.tsx +103 -0
- package/src/pages/ThemeAdmin.test.tsx +144 -0
- package/src/pages/ThemeAdmin.tsx +1000 -0
- package/src/pages/Users.test.tsx +95 -0
- package/src/pages/Users.tsx +334 -0
- package/src/pages/Verify.test.tsx +98 -0
- package/src/pages/Verify.tsx +95 -0
- package/src/prompts/index.ts +13 -0
- package/src/prompts/pages/publicPage.ts +44 -0
- package/src/prompts/sharedConstants.ts +12 -0
- package/src/prompts/tabs/board/adminboard.ts +32 -0
- package/src/prompts/tabs/board/privateboard.ts +36 -0
- package/src/prompts/tabs/board/publicboard.ts +36 -0
- package/src/prompts/tabs/calendar/admincalendar.ts +32 -0
- package/src/prompts/tabs/calendar/privatecalendar.ts +36 -0
- package/src/prompts/tabs/calendar/publiccalendar.ts +36 -0
- package/src/prompts/tabs/crud/admin.ts +54 -0
- package/src/prompts/tabs/crud/private.ts +55 -0
- package/src/prompts/tabs/crud/shared.ts +53 -0
- package/src/prompts/tabs/table/admintable.ts +32 -0
- package/src/prompts/tabs/table/privatetable.ts +36 -0
- package/src/prompts/tabs/table/publictable.ts +36 -0
- package/src/setupTests.ts +1 -0
- package/src/templates/AdminPageTemplate.tsx +678 -0
- package/src/templates/PrivatePageTemplate.tsx +594 -0
- package/src/templates/PublicPageTemplate.tsx +92 -0
- package/src/templates/SharedPageTemplate.tsx +551 -0
- package/src/templates/TemplateBoard.test.tsx +106 -0
- package/src/templates/TemplateBoard.tsx +642 -0
- package/src/templates/TemplateCalendar.test.tsx +106 -0
- package/src/templates/TemplateCalendar.tsx +848 -0
- package/src/templates/TemplateConfirmation.test.tsx +106 -0
- package/src/templates/TemplateConfirmation.tsx +145 -0
- package/src/templates/TemplateInlineForm.test.tsx +106 -0
- package/src/templates/TemplateInlineForm.tsx +129 -0
- package/src/templates/TemplatePopupForm.test.tsx +106 -0
- package/src/templates/TemplatePopupForm.tsx +174 -0
- package/src/templates/TemplateTable.test.tsx +106 -0
- 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
|
+
});
|