added prompter, theme for canvas api, added endpoint to delete existing routes
This commit is contained in:
parent
5e201cc033
commit
f75be32123
37
README.md
37
README.md
@ -21,7 +21,15 @@ fusero-app-boilerplate/
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Option 1: Running with Docker (Recommended for Development)
|
||||
### Important Note: Database Must Run in Docker
|
||||
The PostgreSQL database must always run in Docker, regardless of your development setup choice. This ensures consistent database behavior across all environments.
|
||||
|
||||
To start the database:
|
||||
```bash
|
||||
docker-compose -f docker-compose.dev.yml up db
|
||||
```
|
||||
|
||||
### Option 1: Running Everything in Docker (Recommended for Development)
|
||||
|
||||
1. **Start the Development Environment**
|
||||
```bash
|
||||
@ -34,7 +42,14 @@ fusero-app-boilerplate/
|
||||
|
||||
### Option 2: Running Services Separately (Recommended for Debugging)
|
||||
|
||||
For better debugging experience, you can run the frontend and backend in separate terminal windows:
|
||||
For better debugging experience, you can run the frontend and backend in separate terminal windows, while keeping the database in Docker:
|
||||
|
||||
1. **First, ensure the database is running in Docker**
|
||||
```bash
|
||||
docker-compose -f docker-compose.dev.yml up db
|
||||
```
|
||||
|
||||
2. **Then, in separate terminal windows:**
|
||||
|
||||
#### Terminal 1: Backend Service
|
||||
```bash
|
||||
@ -87,17 +102,22 @@ The frontend will be available at http://localhost:3000
|
||||
|
||||
## Development Best Practices
|
||||
|
||||
1. **Running Services Separately**
|
||||
1. **Database Management**
|
||||
- Always run the database in Docker
|
||||
- Use `docker-compose.dev.yml` for development
|
||||
- Never run PostgreSQL directly on your host machine
|
||||
|
||||
2. **Running Services Separately**
|
||||
- For development, it's recommended to run frontend and backend in separate terminal windows
|
||||
- This allows for better debugging and hot-reloading
|
||||
- You can see logs from each service clearly
|
||||
|
||||
2. **Code Organization**
|
||||
3. **Code Organization**
|
||||
- Frontend code should be in the `frontend/` directory
|
||||
- Backend code should be in the `backend/` directory
|
||||
- Shared types and utilities should be in their respective directories
|
||||
|
||||
3. **Version Control**
|
||||
4. **Version Control**
|
||||
- Commit `package-lock.json` files
|
||||
- Don't commit `.env` files
|
||||
- Use meaningful commit messages
|
||||
@ -122,9 +142,14 @@ The backend API is documented using Swagger/OpenAPI. After starting the backend
|
||||
```
|
||||
|
||||
2. **Database Issues**
|
||||
- Ensure PostgreSQL is running and accessible
|
||||
- Ensure PostgreSQL is running in Docker
|
||||
- Check database connection settings in `.env`
|
||||
- Verify database migrations are up to date
|
||||
- If database issues persist, try:
|
||||
```bash
|
||||
docker-compose -f docker-compose.dev.yml down
|
||||
docker-compose -f docker-compose.dev.yml up db
|
||||
```
|
||||
|
||||
3. **CORS Issues**
|
||||
- If you see CORS errors, verify the frontend's API base URL
|
||||
|
BIN
frontend/src/assets/roc_logo.png
Normal file
BIN
frontend/src/assets/roc_logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 41 KiB |
427
frontend/src/components/CanvasEndpoints.tsx
Normal file
427
frontend/src/components/CanvasEndpoints.tsx
Normal file
@ -0,0 +1,427 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { Column } from 'primereact/column';
|
||||
import { FilterMatchMode } from 'primereact/api';
|
||||
import TableGroupHeader from '../shared/components/_V1/TableGroupHeader';
|
||||
import TableGroup from '../shared/components/_V1/TableGroup';
|
||||
import { TSizeOptionValue } from '../types/DataTable.types';
|
||||
import { api } from '../services/api';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||
import ChatGptModal from './ChatGPTModal';
|
||||
import { Toast } from 'primereact/toast';
|
||||
|
||||
export default function CanvasEndpoints() {
|
||||
const [endpoints, setEndpoints] = useState<any[]>([]);
|
||||
const [filters, setFilters] = useState<any>(null);
|
||||
const [globalFilterValue, setGlobalFilterValue] = useState('');
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [newEndpoint, setNewEndpoint] = useState({ name: '', method: '', path: '', description: '' });
|
||||
const sizeOptions: { label: string; value: TSizeOptionValue }[] = [
|
||||
{ label: 'Small', value: 'small' },
|
||||
{ label: 'Normal', value: 'normal' },
|
||||
{ label: 'Large', value: 'large' },
|
||||
];
|
||||
const [size, setSize] = useState<TSizeOptionValue>(sizeOptions[0].value);
|
||||
const [showCallModal, setShowCallModal] = useState(false);
|
||||
const [callModalData, setCallModalData] = useState<any[]>([]);
|
||||
const [callModalColumns, setCallModalColumns] = useState<string[]>([]);
|
||||
const [callModalLoading, setCallModalLoading] = useState(false);
|
||||
const [callEndpoint, setCallEndpoint] = useState<any>(null);
|
||||
const [callModalFilters, setCallModalFilters] = useState<any>(null);
|
||||
const [callModalGlobalFilterValue, setCallModalGlobalFilterValue] = useState('');
|
||||
const [callModalSize, setCallModalSize] = useState<TSizeOptionValue>(sizeOptions[0].value);
|
||||
const [callModalFirst, setCallModalFirst] = useState(0);
|
||||
const [callModalRows, setCallModalRows] = useState(10);
|
||||
const [showChatGptModal, setShowChatGptModal] = useState(false);
|
||||
const [chatPrompt, setChatPrompt] = useState('');
|
||||
const [chatMessages, setChatMessages] = useState<{ role: 'user' | 'assistant'; content: string }[]>([]);
|
||||
const [chatLoading, setChatLoading] = useState(false);
|
||||
const [chatError, setChatError] = useState<string | null>(null);
|
||||
const toast = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
initFilters();
|
||||
fetchEndpoints();
|
||||
}, []);
|
||||
|
||||
const fetchEndpoints = async () => {
|
||||
try {
|
||||
const response = await api('get', '/api/v1/canvas-api/endpoints');
|
||||
setEndpoints(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch endpoints:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const initFilters = () => {
|
||||
setFilters({
|
||||
global: { value: null, matchMode: FilterMatchMode.CONTAINS },
|
||||
});
|
||||
setGlobalFilterValue('');
|
||||
};
|
||||
|
||||
const clearFilter = () => {
|
||||
initFilters();
|
||||
};
|
||||
|
||||
const onGlobalFilterChange = (e: { target: { value: any } }) => {
|
||||
const value = e.target.value;
|
||||
let _filters = { ...filters };
|
||||
_filters['global'].value = value;
|
||||
setFilters(_filters);
|
||||
setGlobalFilterValue(value);
|
||||
};
|
||||
|
||||
// Utility to ensure path never starts with /api/v1
|
||||
function sanitizeCanvasPath(path: string) {
|
||||
return path.replace(/^\/api\/v1/, '');
|
||||
}
|
||||
|
||||
const handleCreateEndpoint = async () => {
|
||||
try {
|
||||
const sanitizedEndpoint = { ...newEndpoint, path: sanitizeCanvasPath(newEndpoint.path) };
|
||||
await api('post', '/api/v1/canvas-api/endpoints', sanitizedEndpoint);
|
||||
setShowCreateModal(false);
|
||||
setNewEndpoint({ name: '', method: '', path: '', description: '' });
|
||||
fetchEndpoints();
|
||||
} catch (error) {
|
||||
console.error('Failed to create endpoint:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCallEndpoint = async (endpoint: any) => {
|
||||
setShowCallModal(true);
|
||||
setCallModalLoading(true);
|
||||
setCallEndpoint(endpoint);
|
||||
try {
|
||||
const res = await api('post', '/api/v1/canvas-api/proxy-external', {
|
||||
path: endpoint.path,
|
||||
method: endpoint.method || 'GET',
|
||||
});
|
||||
let data = res.data;
|
||||
if (!Array.isArray(data)) data = [data];
|
||||
setCallModalData(data);
|
||||
setCallModalColumns(data.length > 0 ? Object.keys(data[0]) : []);
|
||||
} catch (error) {
|
||||
setCallModalData([]);
|
||||
setCallModalColumns([]);
|
||||
} finally {
|
||||
setCallModalLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const initCallModalFilters = () => {
|
||||
setCallModalFilters({
|
||||
global: { value: null, matchMode: FilterMatchMode.CONTAINS },
|
||||
});
|
||||
setCallModalGlobalFilterValue('');
|
||||
};
|
||||
|
||||
const clearCallModalFilter = () => {
|
||||
initCallModalFilters();
|
||||
};
|
||||
|
||||
const onCallModalGlobalFilterChange = (e: { target: { value: any } }) => {
|
||||
const value = e.target.value;
|
||||
let _filters = { ...callModalFilters };
|
||||
_filters['global'].value = value;
|
||||
setCallModalFilters(_filters);
|
||||
setCallModalGlobalFilterValue(value);
|
||||
};
|
||||
|
||||
const handleChatPromptSend = async () => {
|
||||
if (!chatPrompt.trim()) return;
|
||||
setChatLoading(true);
|
||||
setChatError(null);
|
||||
setChatMessages(prev => [...prev, { role: 'user', content: chatPrompt }]);
|
||||
try {
|
||||
const formattedRequest = {
|
||||
data: chatPrompt,
|
||||
};
|
||||
const response = await api(
|
||||
'post',
|
||||
'/api/v1/canvas-api/chatgpt/completions',
|
||||
formattedRequest,
|
||||
undefined,
|
||||
'json',
|
||||
60,
|
||||
true
|
||||
);
|
||||
const data = response.data;
|
||||
let isEndpointJson = false;
|
||||
if (data && data.responseText) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = typeof data.responseText === 'string' ? JSON.parse(data.responseText) : data.responseText;
|
||||
} catch (e) {
|
||||
parsed = null;
|
||||
}
|
||||
if (parsed && parsed.name && parsed.method && parsed.path) {
|
||||
// Auto-create endpoint
|
||||
const sanitizedParsed = { ...parsed, path: sanitizeCanvasPath(parsed.path) };
|
||||
await api('post', '/api/v1/canvas-api/endpoints', sanitizedParsed);
|
||||
fetchEndpoints();
|
||||
isEndpointJson = true;
|
||||
if (toast.current) {
|
||||
toast.current.show({ severity: 'success', summary: 'Endpoint Created', detail: `Endpoint "${parsed.name}" created successfully!` });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isEndpointJson) {
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: typeof data.responseText === 'string' ? data.responseText : JSON.stringify(data.responseText, null, 2) }]);
|
||||
}
|
||||
} catch (err) {
|
||||
setChatError('Failed to get response from server.');
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: 'Failed to get response from server.' }]);
|
||||
} finally {
|
||||
setChatLoading(false);
|
||||
setChatPrompt('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleChatPromptKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleChatPromptSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteEndpoint = async (endpoint: any) => {
|
||||
try {
|
||||
await api('delete', `/api/v1/canvas-api/endpoints/${endpoint.id}`);
|
||||
fetchEndpoints();
|
||||
if (toast.current) {
|
||||
toast.current.show({ severity: 'success', summary: 'Endpoint Deleted', detail: `Endpoint "${endpoint.name}" deleted successfully!` });
|
||||
}
|
||||
} catch (error) {
|
||||
if (toast.current) {
|
||||
toast.current.show({ severity: 'error', summary: 'Delete Failed', detail: 'Failed to delete endpoint.' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderHeader = () => (
|
||||
<TableGroupHeader
|
||||
size={size}
|
||||
setSize={setSize}
|
||||
sizeOptions={sizeOptions}
|
||||
globalFilterValue={globalFilterValue}
|
||||
onGlobalFilterChange={onGlobalFilterChange}
|
||||
onRefresh={fetchEndpoints}
|
||||
clearFilter={clearFilter}
|
||||
extraButtonsTemplate={() => (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
label="Create Endpoint"
|
||||
icon="pi pi-plus"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
/>
|
||||
<Button
|
||||
label="Ask ChatGPT"
|
||||
icon="pi pi-comments"
|
||||
onClick={() => setShowChatGptModal(true)}
|
||||
className="p-button-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderCallButton = (rowData: any) => (
|
||||
<div className="flex gap-2">
|
||||
<Button label="Call" icon="pi pi-play" onClick={() => handleCallEndpoint(rowData)} />
|
||||
<Button label="Delete" icon="pi pi-trash" className="p-button-danger" onClick={() => handleDeleteEndpoint(rowData)} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderCallModalHeader = () => (
|
||||
<TableGroupHeader
|
||||
size={callModalSize}
|
||||
setSize={setCallModalSize}
|
||||
sizeOptions={sizeOptions}
|
||||
globalFilterValue={callModalGlobalFilterValue}
|
||||
onGlobalFilterChange={onCallModalGlobalFilterChange}
|
||||
onRefresh={() => handleCallEndpoint(callEndpoint)}
|
||||
clearFilter={clearCallModalFilter}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='w-full h-full p-6'>
|
||||
<h2 className='text-xl font-semibold mb-4'>Canvas Endpoints</h2>
|
||||
<Toast ref={toast} position="bottom-right" />
|
||||
<TableGroup
|
||||
body={endpoints}
|
||||
size={size}
|
||||
header={renderHeader}
|
||||
filters={filters}
|
||||
rows={10}
|
||||
paginator
|
||||
showGridlines={true}
|
||||
removableSort={true}
|
||||
dragSelection={false}
|
||||
emptyMessage='No endpoints found.'
|
||||
>
|
||||
<Column field='id' header='ID' sortable filter style={{ width: '10%' }} />
|
||||
<Column field='name' header='Name' sortable filter style={{ width: '20%' }} />
|
||||
<Column field='method' header='Method' sortable filter style={{ width: '10%' }} />
|
||||
<Column field='path' header='Path' sortable filter style={{ width: '40%' }} />
|
||||
<Column header='Action' body={renderCallButton} style={{ width: '20%' }} />
|
||||
</TableGroup>
|
||||
|
||||
<Dialog
|
||||
visible={showCreateModal}
|
||||
onHide={() => setShowCreateModal(false)}
|
||||
header="Create New Endpoint"
|
||||
footer={
|
||||
<div>
|
||||
<Button label="Cancel" icon="pi pi-times" onClick={() => setShowCreateModal(false)} className="p-button-text" />
|
||||
<Button label="Create" icon="pi pi-check" onClick={handleCreateEndpoint} autoFocus disabled={!(newEndpoint.name && newEndpoint.method && newEndpoint.path)} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="p-fluid">
|
||||
<div className="p-field">
|
||||
<label htmlFor="name">Name</label>
|
||||
<InputText id="name" value={newEndpoint.name} onChange={(e) => setNewEndpoint({ ...newEndpoint, name: e.target.value })} />
|
||||
</div>
|
||||
<div className="p-field">
|
||||
<label htmlFor="method">Method</label>
|
||||
<Dropdown
|
||||
id="method"
|
||||
value={newEndpoint.method}
|
||||
options={[
|
||||
{ label: 'GET', value: 'GET' },
|
||||
{ label: 'POST', value: 'POST' },
|
||||
{ label: 'PUT', value: 'PUT' },
|
||||
{ label: 'DELETE', value: 'DELETE' }
|
||||
]}
|
||||
onChange={(e) => setNewEndpoint({ ...newEndpoint, method: e.value })}
|
||||
placeholder="Select a method"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-field">
|
||||
<label htmlFor="path">Path</label>
|
||||
<InputText id="path" value={newEndpoint.path} onChange={(e) => setNewEndpoint({ ...newEndpoint, path: e.target.value })} />
|
||||
</div>
|
||||
<div className="p-field">
|
||||
<label htmlFor="description">Description</label>
|
||||
<InputText id="description" value={newEndpoint.description} onChange={(e) => setNewEndpoint({ ...newEndpoint, description: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
visible={showCallModal}
|
||||
onHide={() => setShowCallModal(false)}
|
||||
header={callEndpoint ? `Response for ${callEndpoint.name}` : 'Endpoint Response'}
|
||||
style={{ width: '96vw', maxWidth: 1800, minHeight: 600 }}
|
||||
modal
|
||||
>
|
||||
{callModalLoading ? (
|
||||
<div className="flex justify-center items-center" style={{ minHeight: 200 }}>
|
||||
<ProgressSpinner />
|
||||
</div>
|
||||
) : callModalData.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '70vh' }}>
|
||||
<div style={{ flex: 1, overflow: 'auto', maxWidth: '100%' }}>
|
||||
<TableGroup
|
||||
body={callModalData.slice(callModalFirst, callModalFirst + callModalRows)}
|
||||
size={callModalSize}
|
||||
header={renderCallModalHeader}
|
||||
filters={callModalFilters}
|
||||
rows={callModalRows}
|
||||
paginator={false}
|
||||
showGridlines={true}
|
||||
removableSort={true}
|
||||
dragSelection={false}
|
||||
emptyMessage='No data found.'
|
||||
>
|
||||
{callModalColumns.map((col) => (
|
||||
<Column
|
||||
key={col}
|
||||
field={col}
|
||||
header={col}
|
||||
sortable
|
||||
filter
|
||||
body={rowData => {
|
||||
const value = rowData[col];
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return <span style={{ fontFamily: 'monospace', fontSize: 12 }}>{JSON.stringify(value)}</span>;
|
||||
}
|
||||
return value;
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</TableGroup>
|
||||
</div>
|
||||
<div style={{ position: 'sticky', bottom: 0, background: '#fff', padding: '8px 0 0 0', zIndex: 2, borderTop: '1px solid #eee' }}>
|
||||
<div style={{ width: '100%' }}>
|
||||
<span style={{ float: 'right' }}>
|
||||
<span style={{ marginRight: 16 }}>
|
||||
Showing {callModalFirst + 1} to {Math.min(callModalFirst + callModalRows, callModalData.length)} of {callModalData.length} entries
|
||||
</span>
|
||||
<select
|
||||
value={callModalRows}
|
||||
onChange={e => setCallModalRows(Number(e.target.value))}
|
||||
style={{ marginRight: 8 }}
|
||||
>
|
||||
{[10, 25, 50].map(opt => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
<button onClick={() => setCallModalFirst(0)} disabled={callModalFirst === 0}>{'<<'}</button>
|
||||
<button onClick={() => setCallModalFirst(Math.max(0, callModalFirst - callModalRows))} disabled={callModalFirst === 0}>{'<'}</button>
|
||||
<span style={{ margin: '0 8px' }}>{Math.floor(callModalFirst / callModalRows) + 1}</span>
|
||||
<button onClick={() => setCallModalFirst(Math.min(callModalFirst + callModalRows, callModalData.length - callModalRows))} disabled={callModalFirst + callModalRows >= callModalData.length}>{'>'}</button>
|
||||
<button onClick={() => setCallModalFirst(Math.max(0, callModalData.length - callModalRows))} disabled={callModalFirst + callModalRows >= callModalData.length}>{'>>'}</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>No data found or error.</div>
|
||||
)}
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
visible={showChatGptModal}
|
||||
onHide={() => setShowChatGptModal(false)}
|
||||
header="ChatGPT Assistant"
|
||||
style={{ width: '50vw' }}
|
||||
>
|
||||
<div className="flex flex-col h-[60vh]">
|
||||
<div className="flex-1 overflow-auto mb-4 p-2 bg-gray-50 rounded">
|
||||
{chatMessages.length === 0 && <div className="text-gray-400 text-center mt-8">Start a conversation with ChatGPT...</div>}
|
||||
{chatMessages.map((msg, idx) => (
|
||||
<div key={idx} className={`my-2 p-2 rounded-lg max-w-[80%] ${msg.role === 'user' ? 'bg-blue-100 ml-auto' : 'bg-gray-200'}`}>
|
||||
<span>{msg.content}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2 mt-auto">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 p-2 border rounded"
|
||||
placeholder="Type your message..."
|
||||
value={chatPrompt}
|
||||
onChange={e => setChatPrompt(e.target.value)}
|
||||
onKeyDown={handleChatPromptKeyDown}
|
||||
disabled={chatLoading}
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-send"
|
||||
onClick={handleChatPromptSend}
|
||||
loading={chatLoading}
|
||||
disabled={!chatPrompt.trim() || chatLoading}
|
||||
/>
|
||||
</div>
|
||||
{chatError && <div className="text-red-500 mt-2">{chatError}</div>}
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
372
frontend/src/components/CanvasEndpoints/CanvasDataTable.tsx
Normal file
372
frontend/src/components/CanvasEndpoints/CanvasDataTable.tsx
Normal file
@ -0,0 +1,372 @@
|
||||
import React from 'react';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { Card } from 'primereact/card';
|
||||
|
||||
interface CanvasData {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface CanvasDataTableProps {
|
||||
data: CanvasData[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const CanvasDataTable: React.FC<CanvasDataTableProps> = ({
|
||||
data,
|
||||
loading,
|
||||
errorimport { Column } from 'primereact/column';
|
||||
import { FilterMatchMode, FilterOperator } from 'primereact/api';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { api } from '../../services/api.ts';
|
||||
|
||||
import TableGroupHeader from '../../shared/components/_V1/TableGroupHeader.tsx';
|
||||
import TableGroup from '../../shared/components/_V1/TableGroup.tsx';
|
||||
|
||||
import { Toast } from 'primereact/toast';
|
||||
import ConfirmModal from '../../shared/components/modals/ConfirmModal.tsx';
|
||||
import CreateVoucherModal from './CreateVoucherModal.tsx';
|
||||
// import EditVoucherModal from './AddVoucherCodeModal.tsx';
|
||||
import LoadingPage from '../../shared/components/_V1/LoadingPage.tsx';
|
||||
import DetailModal from './DetailModal.tsx';
|
||||
import AddVoucherCodeModal, { AddVoucherCodeModalProps } from './AddVoucherCodeModal.tsx';
|
||||
import { Voucher, useVoucherStore } from '../../state/stores/Mews/useMewsVoucherStore.ts';
|
||||
import { TSizeOptionValue } from '../../types/DataTable.types.ts';
|
||||
import Button from '../../shared/components/_V2/Button.tsx';
|
||||
|
||||
export default function VoucherTableGroup() {
|
||||
const { createVoucher, fetchVouchers, updateVoucher, vouchers, addVoucherCode } =
|
||||
useVoucherStore();
|
||||
|
||||
const [filters, setFilters] = useState(null);
|
||||
const [globalFilterValue, setGlobalFilterValue] = useState('');
|
||||
|
||||
const [showCreateVoucherModal, setShowCreateVoucherModal] = useState(false);
|
||||
const [showConfirmDeleteModal, setShowConfirmDeleteModal] = useState(false);
|
||||
|
||||
const [showAddCodeModal, setShowAddCodeModal] = useState(false);
|
||||
const [currentVoucherForCode, setCurrentVoucherForCode] = useState(null);
|
||||
|
||||
const [detailModalVisible, setDetailModalVisible] = useState(false);
|
||||
const [detailContent, setDetailContent] = useState([]);
|
||||
const [detailTitle, setDetailTitle] = useState('');
|
||||
|
||||
const [selectedVouchers, setSelectedVouchers] = useState<Voucher[]>(null);
|
||||
|
||||
const toast = useRef<Toast>(null);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Change table row size
|
||||
const sizeOptions: { label: string; value: TSizeOptionValue }[] = [
|
||||
{ label: 'Small', value: 'small' },
|
||||
{ label: 'Normal', value: 'normal' },
|
||||
{ label: 'Large', value: 'large' },
|
||||
];
|
||||
|
||||
const [size, setSize] = useState<TSizeOptionValue>(sizeOptions[0].value);
|
||||
|
||||
useEffect(() => {
|
||||
initFilters();
|
||||
// fetchVouchers;
|
||||
handleRefresh();
|
||||
}, []);
|
||||
|
||||
const showToastMessage = (options) => {
|
||||
toast.current?.show(options);
|
||||
};
|
||||
|
||||
const initFilters = () => {
|
||||
setFilters({
|
||||
global: { value: null, matchMode: FilterMatchMode.CONTAINS },
|
||||
representative: { value: null, matchMode: FilterMatchMode.IN },
|
||||
createdAt: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{ value: null, matchMode: FilterMatchMode.DATE_IS }],
|
||||
},
|
||||
});
|
||||
setGlobalFilterValue('');
|
||||
};
|
||||
|
||||
const clearFilter = () => {
|
||||
initFilters();
|
||||
};
|
||||
|
||||
const onGlobalFilterChange = (e: { target: { value: any } }) => {
|
||||
const value = e.target.value;
|
||||
// @ts-ignore
|
||||
let _filters = { ...filters };
|
||||
_filters['global'].value = value;
|
||||
setFilters(_filters);
|
||||
setGlobalFilterValue(value);
|
||||
};
|
||||
|
||||
// TODO: refresh data
|
||||
const handleRefresh = async () => {
|
||||
setIsLoading(true);
|
||||
await fetchVouchers();
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const formatDate = (value: string | number | Date) => {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const dateBodyTemplate = (rowData: { createdAt: string | number | Date }) => {
|
||||
return formatDate(rowData.createdAt);
|
||||
};
|
||||
|
||||
const actionButtons = () => {
|
||||
return (
|
||||
<div className={'flex gap-3'}>
|
||||
<Button text={true} onClick={() => setShowCreateVoucherModal(true)}>
|
||||
Add Voucher
|
||||
</Button>
|
||||
{/* <Button
|
||||
onClick={() => setShowConfirmDeleteModal(true)}
|
||||
severity={'danger'}
|
||||
disabled={selectedVouchers == null || selectedVouchers.length === 0}
|
||||
>
|
||||
Delete selected
|
||||
</Button> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const editButton = (rowData: Voucher) => {
|
||||
return <Button>action</Button>;
|
||||
};
|
||||
|
||||
const openDetailModal = (content, title) => {
|
||||
setDetailContent(content);
|
||||
setDetailTitle(title);
|
||||
setDetailModalVisible(true);
|
||||
};
|
||||
|
||||
const closeDetailModal = () => {
|
||||
setDetailModalVisible(false);
|
||||
};
|
||||
|
||||
const handleCreateVoucher = async (newVoucher: Voucher) => {
|
||||
await createVoucher(newVoucher);
|
||||
handleRefresh();
|
||||
setShowCreateVoucherModal(false);
|
||||
};
|
||||
|
||||
const handleDeleteSelectedVouchers = () => {
|
||||
const URL = '/neppeURL';
|
||||
const body = selectedVouchers.map((voucher) => {
|
||||
return {
|
||||
id: voucher.id,
|
||||
};
|
||||
});
|
||||
api('delete', URL, body);
|
||||
};
|
||||
|
||||
const voucherRatesBodyTemplate = (rowData) => {
|
||||
if (rowData?.voucherCodes.length < 1) {
|
||||
return '';
|
||||
}
|
||||
const details = rowData.rates.map((rate) => `Name: ${rate.name}, Type: ${rate.type}`);
|
||||
return (
|
||||
<Button
|
||||
label='View Rates'
|
||||
onClick={() => openDetailModal(details, 'Voucher Rates')}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const voucherCodesBodyTemplate = (rowData) => {
|
||||
const details = rowData.voucherCodes.join(', ');
|
||||
return (
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
label='View Codes'
|
||||
onClick={() => openDetailModal([details], 'Voucher Codes')}
|
||||
disabled={isLoading || rowData.voucherCodes.length === 0}
|
||||
/>
|
||||
<Button
|
||||
label='Add Code'
|
||||
onClick={() => {
|
||||
setCurrentVoucherForCode(rowData);
|
||||
setShowAddCodeModal(true);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddVoucherCode: AddVoucherCodeModalProps['addVoucherCode'] = async (
|
||||
voucherId,
|
||||
code
|
||||
) => {
|
||||
try {
|
||||
await addVoucherCode(voucherId, code);
|
||||
showToastMessage({
|
||||
severity: 'success',
|
||||
summary: 'Code Added',
|
||||
detail: 'Voucher code has been successfully added.',
|
||||
});
|
||||
setShowAddCodeModal(false);
|
||||
handleRefresh();
|
||||
} catch (error) {
|
||||
showToastMessage({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'Failed to add voucher code.',
|
||||
});
|
||||
console.error('Failed to add voucher code:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const renderHeader = () => {
|
||||
return (
|
||||
<TableGroupHeader
|
||||
size={size}
|
||||
setSize={setSize}
|
||||
sizeOptions={sizeOptions}
|
||||
globalFilterValue={globalFilterValue}
|
||||
onGlobalFilterChange={(e: any) => onGlobalFilterChange(e)}
|
||||
onRefresh={handleRefresh}
|
||||
clearFilter={clearFilter}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const voucherCodeSortFunction = (e) => {
|
||||
const result = e.data.sort((data1, data2) => {
|
||||
const codes1 = data1.voucherCodes.length;
|
||||
const codes2 = data2.voucherCodes.length;
|
||||
|
||||
return e.order * (codes1 - codes2);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='w-full h-full flex flex-col'>
|
||||
<CreateVoucherModal
|
||||
visible={showCreateVoucherModal}
|
||||
onHide={() => setShowCreateVoucherModal(false)}
|
||||
// @ts-ignore
|
||||
onConfirm={handleCreateVoucher}
|
||||
/>
|
||||
|
||||
<AddVoucherCodeModal
|
||||
visible={showAddCodeModal}
|
||||
onHide={() => setShowAddCodeModal(false)}
|
||||
addVoucherCode={handleAddVoucherCode} // Pass the function directly
|
||||
voucherId={currentVoucherForCode?.id || ''}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
visible={showConfirmDeleteModal}
|
||||
header={`Delete ${selectedVouchers?.length} voucher(s)`}
|
||||
message={`Are you sure?`}
|
||||
onHide={() => setShowConfirmDeleteModal(false)}
|
||||
onConfirm={handleDeleteSelectedVouchers}
|
||||
confirmLabel={'Confirm'}
|
||||
cancelLabel={'Cancel'}
|
||||
onCancel={() => setShowConfirmDeleteModal(false)}
|
||||
/>
|
||||
|
||||
<DetailModal
|
||||
visible={detailModalVisible}
|
||||
onHide={closeDetailModal}
|
||||
title={detailTitle}
|
||||
details={detailContent}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<LoadingPage />
|
||||
) : (
|
||||
<TableGroup
|
||||
body={vouchers}
|
||||
size={size}
|
||||
header={renderHeader}
|
||||
filters={filters}
|
||||
rows={10}
|
||||
paginator
|
||||
// stripedRows={true}
|
||||
showGridlines={true}
|
||||
removableSort={true}
|
||||
dragSelection={false}
|
||||
selectionMode={'multiple'}
|
||||
selection={selectedVouchers}
|
||||
onSelectionChange={(e: any) => setSelectedVouchers(e.value)}
|
||||
emptyMessage='No vouchers found.'
|
||||
>
|
||||
<Column selectionMode='multiple' headerStyle={{ width: '3rem' }}></Column>
|
||||
|
||||
<Column field='id' header={actionButtons} sortable filter style={{ width: '40%' }} />
|
||||
<Column field='name' header='Name' sortable filter style={{ width: '20%' }} />
|
||||
<Column field='type' header='Type' sortable filter style={{ width: '20%' }} />
|
||||
<Column
|
||||
field='voucherCodes'
|
||||
header='Voucher Codes'
|
||||
body={voucherCodesBodyTemplate}
|
||||
sortable
|
||||
filter
|
||||
sortFunction={voucherCodeSortFunction}
|
||||
className='h-full align-top bg-red'
|
||||
style={{ width: '20%', textAlign: 'center', lineHeight: '20px' }}
|
||||
/>
|
||||
<Column
|
||||
field='rates'
|
||||
header='Voucher Rates'
|
||||
body={voucherRatesBodyTemplate}
|
||||
sortable
|
||||
filter
|
||||
style={{ width: '20%' }}
|
||||
/>
|
||||
{/* <Column
|
||||
field='actions'
|
||||
header='Actions'
|
||||
body={editButton}
|
||||
style={{ width: '15%', textAlign: 'center' }}
|
||||
/> */}
|
||||
</TableGroup>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}) => {
|
||||
// Dynamically generate columns based on the first data item
|
||||
const columns = data.length > 0
|
||||
? Object.keys(data[0]).map(key => (
|
||||
<Column
|
||||
key={key}
|
||||
field={key}
|
||||
header={key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, ' ')}
|
||||
sortable
|
||||
/>
|
||||
))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Card className="flex-grow mb-4">
|
||||
<DataTable
|
||||
value={data}
|
||||
loading={loading}
|
||||
emptyMessage={error || "No data found. Try using the prompt below."}
|
||||
className="p-datatable-sm"
|
||||
paginator
|
||||
rows={10}
|
||||
rowsPerPageOptions={[10, 25, 50]}
|
||||
>
|
||||
{columns}
|
||||
</DataTable>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CanvasDataTable;
|
120
frontend/src/components/CanvasEndpoints/ChatGptModal.tsx
Normal file
120
frontend/src/components/CanvasEndpoints/ChatGptModal.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { Button } from 'primereact/button';
|
||||
import { ScrollPanel } from 'primereact/scrollpanel';
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface ChatGptModalProps {
|
||||
visible: boolean;
|
||||
onHide: () => void;
|
||||
}
|
||||
|
||||
const ChatGptModal: React.FC<ChatGptModalProps> = ({ visible, onHide }) => {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
|
||||
setLoading(true);
|
||||
const userMessage: Message = { role: 'user', content: prompt };
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
|
||||
try {
|
||||
const response = await fetch('/canvas-api/chatgpt/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
data: prompt,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.errorMessage) {
|
||||
throw new Error(data.errorMessage);
|
||||
}
|
||||
|
||||
const assistantMessage: Message = {
|
||||
role: 'assistant',
|
||||
content: typeof data.responseText === 'string'
|
||||
? data.responseText
|
||||
: JSON.stringify(data.responseText, null, 2)
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, assistantMessage]);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
const errorMessage: Message = {
|
||||
role: 'assistant',
|
||||
content: `Error: ${error instanceof Error ? error.message : 'Failed to get response'}`
|
||||
};
|
||||
setMessages(prev => [...prev, errorMessage]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setPrompt('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
visible={visible}
|
||||
onHide={onHide}
|
||||
header="ChatGPT Assistant"
|
||||
style={{ width: '50vw' }}
|
||||
className="chatgpt-modal"
|
||||
>
|
||||
<div className="flex flex-col h-[60vh]">
|
||||
<ScrollPanel style={{ width: '100%', height: 'calc(100% - 60px)' }} className="mb-4">
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
{messages.map((message, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`p-3 rounded-lg max-w-[80%] ${message.role === 'user'
|
||||
? 'bg-blue-100 ml-auto'
|
||||
: 'bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<p className="whitespace-pre-wrap">{message.content}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollPanel>
|
||||
|
||||
<div className="flex gap-2 mt-auto">
|
||||
<InputText
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Type your message..."
|
||||
className="flex-1"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-send"
|
||||
onClick={handleSubmit}
|
||||
loading={loading}
|
||||
disabled={!prompt.trim() || loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatGptModal;
|
37
frontend/src/components/CanvasEndpoints/PromptInput.tsx
Normal file
37
frontend/src/components/CanvasEndpoints/PromptInput.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { Button } from 'primereact/button';
|
||||
|
||||
interface PromptInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const PromptInput: React.FC<PromptInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
placeholder = "Type your prompt here"
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-4 border-t">
|
||||
<InputText
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="flex-grow"
|
||||
onKeyPress={(e) => e.key === 'Enter' && onSubmit()}
|
||||
/>
|
||||
<Button
|
||||
label="Send"
|
||||
icon="pi pi-send"
|
||||
onClick={onSubmit}
|
||||
disabled={!value.trim()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PromptInput;
|
33
frontend/src/components/CanvasEndpoints/SystemSelector.tsx
Normal file
33
frontend/src/components/CanvasEndpoints/SystemSelector.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
|
||||
export type SystemType = 'council' | 'fusemind' | 'canvas';
|
||||
|
||||
interface SystemSelectorProps {
|
||||
value: SystemType | null;
|
||||
onChange: (value: SystemType | null) => void;
|
||||
}
|
||||
|
||||
const systemOptions = [
|
||||
{ label: 'CouncilAI', value: 'council' },
|
||||
{ label: 'FuseMind (AI)', value: 'fusemind' },
|
||||
{ label: 'Canvas API', value: 'canvas' }
|
||||
];
|
||||
|
||||
const SystemSelector: React.FC<SystemSelectorProps> = ({ value, onChange }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-4 border-b w-80">
|
||||
<span className="font-semibold mb-1">Apps & Flows</span>
|
||||
<Dropdown
|
||||
value={value}
|
||||
options={systemOptions}
|
||||
onChange={(e) => onChange(e.value)}
|
||||
placeholder="Select an integration"
|
||||
className="w-full"
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemSelector;
|
83
frontend/src/components/CanvasEndpoints/commandParser.ts
Normal file
83
frontend/src/components/CanvasEndpoints/commandParser.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { SystemType } from './SystemSelector';
|
||||
|
||||
interface CommandResult {
|
||||
system: SystemType;
|
||||
endpoint: string;
|
||||
params?: Record<string, any>;
|
||||
courseId?: number;
|
||||
}
|
||||
|
||||
export const parseCommand = (prompt: string, system: SystemType): CommandResult | null => {
|
||||
const lowerPrompt = prompt.toLowerCase().trim();
|
||||
|
||||
if (system === 'canvas') {
|
||||
// Extract course ID if present (e.g., "get students in course 123")
|
||||
const courseIdMatch = lowerPrompt.match(/course\s+(\d+)/);
|
||||
const courseId = courseIdMatch ? parseInt(courseIdMatch[1], 10) : 1; // Default to 1 if not specified
|
||||
|
||||
if (lowerPrompt.includes('get all students')) {
|
||||
return {
|
||||
system: 'canvas',
|
||||
endpoint: '/users',
|
||||
params: {
|
||||
enrollment_type: ['student'],
|
||||
per_page: 100
|
||||
},
|
||||
courseId
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerPrompt.includes('get courses')) {
|
||||
return {
|
||||
system: 'canvas',
|
||||
endpoint: '/courses',
|
||||
params: {
|
||||
per_page: 100
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerPrompt.includes('get assignments')) {
|
||||
return {
|
||||
system: 'canvas',
|
||||
endpoint: '/assignments',
|
||||
params: {
|
||||
per_page: 100
|
||||
},
|
||||
courseId
|
||||
};
|
||||
}
|
||||
} else if (system === 'council') {
|
||||
if (lowerPrompt.includes('get all students')) {
|
||||
return {
|
||||
system: 'council',
|
||||
endpoint: '/students',
|
||||
params: {
|
||||
limit: 100
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerPrompt.includes('get courses')) {
|
||||
return {
|
||||
system: 'council',
|
||||
endpoint: '/courses',
|
||||
params: {
|
||||
limit: 100
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerPrompt.includes('get assignments')) {
|
||||
return {
|
||||
system: 'council',
|
||||
endpoint: '/assignments',
|
||||
params: {
|
||||
limit: 100
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
90
frontend/src/components/CanvasEndpoints/index.tsx
Normal file
90
frontend/src/components/CanvasEndpoints/index.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Column } from 'primereact/column';
|
||||
import { FilterMatchMode } from 'primereact/api';
|
||||
import { TSizeOptionValue } from '../../types/DataTable.types';
|
||||
import { api } from '../../services/api';
|
||||
import ChatGptModal from './ChatGptModal';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Card } from 'primereact/card';
|
||||
|
||||
const CanvasEndpoints: React.FC = () => {
|
||||
const [showChatGptModal, setShowChatGptModal] = useState(false);
|
||||
const [endpoints, setEndpoints] = useState<any[]>([]);
|
||||
const [filters, setFilters] = useState<any>(null);
|
||||
const [globalFilterValue, setGlobalFilterValue] = useState('');
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [newEndpoint, setNewEndpoint] = useState({ name: '', method: '', path: '', description: '' });
|
||||
const sizeOptions: { label: string; value: TSizeOptionValue }[] = [
|
||||
{ label: 'Small', value: 'small' },
|
||||
{ label: 'Normal', value: 'normal' },
|
||||
{ label: 'Large', value: 'large' },
|
||||
];
|
||||
const [size, setSize] = useState<TSizeOptionValue>(sizeOptions[0].value);
|
||||
|
||||
const fetchEndpoints = async () => {
|
||||
try {
|
||||
const response = await api('get', '/api/v1/canvas-api/endpoints');
|
||||
setEndpoints(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch endpoints:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const initFilters = () => {
|
||||
setFilters({
|
||||
global: { value: null, matchMode: FilterMatchMode.CONTAINS },
|
||||
});
|
||||
setGlobalFilterValue('');
|
||||
};
|
||||
|
||||
const clearFilter = () => {
|
||||
initFilters();
|
||||
};
|
||||
|
||||
const onGlobalFilterChange = (e: { target: { value: any } }) => {
|
||||
const value = e.target.value;
|
||||
let _filters = { ...filters };
|
||||
_filters['global'].value = value;
|
||||
setFilters(_filters);
|
||||
setGlobalFilterValue(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Card className="mb-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-2">Canvas Endpoints</h1>
|
||||
<p className="text-gray-400">Manage and test your Canvas API endpoints</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
label="Create Endpoint"
|
||||
icon="pi pi-plus"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
/>
|
||||
<Button
|
||||
label="Ask ChatGPT"
|
||||
icon="pi pi-comments"
|
||||
onClick={() => setShowChatGptModal(true)}
|
||||
className="p-button-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<DataTable value={endpoints} paginator rows={10} showGridlines responsiveLayout="scroll" emptyMessage="No endpoints found.">
|
||||
<Column field="id" header="ID" sortable filter style={{ width: '10%' }} />
|
||||
<Column field="name" header="Name" sortable filter style={{ width: '20%' }} />
|
||||
<Column field="method" header="Method" sortable filter style={{ width: '10%' }} />
|
||||
<Column field="path" header="Path" sortable filter style={{ width: '40%' }} />
|
||||
</DataTable>
|
||||
<ChatGptModal
|
||||
visible={showChatGptModal}
|
||||
onHide={() => setShowChatGptModal(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CanvasEndpoints;
|
@ -81,3 +81,5 @@ Rules:
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatGPTModal;
|
@ -6,6 +6,7 @@ import Dropdown from '../shared/components/_V2/Dropdown';
|
||||
import Divider from '../shared/components/_V1/Divider';
|
||||
|
||||
import fusero_logo from '../assets/fusero_logo.svg';
|
||||
import roc_logo from '../assets/roc_logo.png';
|
||||
import { useSystemStore } from '../state/stores/useSystemStore';
|
||||
|
||||
export type SystemDropdownOption = {
|
||||
@ -47,6 +48,14 @@ const mockedSystems = [
|
||||
logo: fusero_logo,
|
||||
category: 'Apps',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
label: 'Canvas API',
|
||||
businessLabel: 'Canvas API',
|
||||
urlSlug: 'canvas',
|
||||
logo: roc_logo,
|
||||
category: 'Apps',
|
||||
},
|
||||
];
|
||||
|
||||
// Add system default routes configuration
|
||||
@ -128,7 +137,7 @@ const SystemSelectionModal = ({ systemDetails }: SystemSelectionModalProps) => {
|
||||
</Card>
|
||||
{selectedSystem && (
|
||||
<img
|
||||
src={selectedSystem.logo }
|
||||
src={selectedSystem.logo}
|
||||
alt={`${selectedSystem.label} Logo`}
|
||||
className='w-48'
|
||||
/>
|
||||
|
13
frontend/src/config/canvas.ts
Normal file
13
frontend/src/config/canvas.ts
Normal file
@ -0,0 +1,13 @@
|
||||
interface CanvasConfig {
|
||||
accessToken: string;
|
||||
baseUrl: string;
|
||||
defaultCourseId: number;
|
||||
}
|
||||
|
||||
const canvasConfig: CanvasConfig = {
|
||||
accessToken: process.env.VITE_CANVAS_ACCESS_TOKEN || '',
|
||||
baseUrl: process.env.VITE_CANVAS_API_BASE_URL || 'https://canvas.instructure.com/api/v1',
|
||||
defaultCourseId: parseInt(process.env.VITE_CANVAS_DEFAULT_COURSE_ID || '1', 10),
|
||||
};
|
||||
|
||||
export default canvasConfig;
|
@ -1,6 +1,7 @@
|
||||
@import 'primereact/resources/themes/saga-blue/theme.css';
|
||||
@import 'primereact/resources/primereact.min.css';
|
||||
@import 'primeicons/primeicons.css';
|
||||
@import './styles/canvas-theme.css';
|
||||
|
||||
@layer tailwind-base, primereact, tailwind-utilities;
|
||||
|
||||
@ -62,6 +63,7 @@
|
||||
|
||||
.p-datatable.p-datatable-gridlines .p-datatable-header {
|
||||
/* padding: 10px 8px 10px 8px !important; */
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.p-datatable-header {
|
||||
@ -78,7 +80,13 @@
|
||||
padding: 0 0 18px 0;
|
||||
}
|
||||
.p-datatable-header > div > div > div > button {
|
||||
width: 100px;
|
||||
/* width: 100px; */
|
||||
}
|
||||
|
||||
.p-datatable > .p-datatable-wrapper {
|
||||
overflow: initial !important;
|
||||
margin: 0 0 80px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.p-datatable .p-datatable-header {
|
||||
@ -118,12 +126,6 @@ thead tr th {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.p-datatable > .p-datatable-wrapper {
|
||||
overflow: initial !important;
|
||||
margin: 0 0 80px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* search bar data table */
|
||||
.p-datatable-header .p-inputtext.p-component {
|
||||
padding: 6px 64px;
|
||||
@ -183,3 +185,11 @@ thead tr th {
|
||||
text-decoration: underline;
|
||||
color: blue;
|
||||
}
|
||||
|
||||
/* Ensure custom header in .p-datatable-header fills the width */
|
||||
.p-datatable-header > div {
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
background: #fff !important;
|
||||
display: flex !important;
|
||||
}
|
||||
|
@ -57,13 +57,12 @@ const DualModalComponent = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-center h-screen bg-gradient-to-br from-gray-700 to-teal-900'>
|
||||
<div className='flex items-center justify-center h-screen bg-gradient-to-br from-gray-700 to-teal-900' data-system={systemId}>
|
||||
<div
|
||||
className={`transition-all ease-in-out duration-500 rounded ${
|
||||
isExpanded
|
||||
? 'w-1/5 h-screen px-4'
|
||||
: `${isLoggedIn ? 'w-1/2 h-3/4' : 'w-1/3 h-2/4'} my-12 mx-6 px-4`
|
||||
} m-2 bg-white shadow-xl flex justify-center items-center px-4`}
|
||||
className={`transition-all ease-in-out duration-500 rounded ${isExpanded
|
||||
? 'w-1/5 h-screen px-4'
|
||||
: `${isLoggedIn ? 'w-1/2 h-3/4' : 'w-1/3 h-2/4'} my-12 mx-6 px-4`
|
||||
} m-2 bg-white shadow-xl flex justify-center items-center px-4`}
|
||||
>
|
||||
{isLoggedIn ? (
|
||||
showOutlet && systemId && animationFinished ? (
|
||||
@ -74,9 +73,8 @@ const DualModalComponent = () => {
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
className={`h-full w-full transition-opacity duration-50 ${
|
||||
isExpanded ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
className={`h-full w-full transition-opacity duration-50 ${isExpanded ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
>
|
||||
<TenantProfile />
|
||||
</p>
|
||||
@ -87,9 +85,8 @@ const DualModalComponent = () => {
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`relative flex-1 w-full overflow-auto transition-all ease-in-out duration-500 flex rounded ${
|
||||
isExpanded ? 'w-1/3 max-w-none h-full' : 'w-1/3 h-1/2 mx-6'
|
||||
} bg-white shadow-xl justify-center items-center ${isLoggedIn ? '' : 'hidden'}`}
|
||||
className={`relative flex-1 w-full overflow-auto transition-all ease-in-out duration-500 flex rounded ${isExpanded ? 'w-1/3 max-w-none h-full' : 'w-1/3 h-1/2 mx-6'
|
||||
} bg-white shadow-xl justify-center items-center ${isLoggedIn ? '' : 'hidden'}`}
|
||||
>
|
||||
{isLoggedIn && showOutlet && systemId ? (
|
||||
<Outlet />
|
||||
|
@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Button } from 'primereact/button';
|
||||
import fusero_logo from '../assets/fusero_logo.svg';
|
||||
import roc_logo from '../assets/roc_logo.png';
|
||||
|
||||
import 'primeicons/primeicons.css';
|
||||
|
||||
@ -19,6 +20,12 @@ const systemConfig = {
|
||||
],
|
||||
logo: fusero_logo,
|
||||
},
|
||||
canvas: {
|
||||
routes: [
|
||||
{ path: 'canvas-endpoints', label: 'Canvas Endpoints', icon: 'pi pi-table' }
|
||||
],
|
||||
logo: roc_logo,
|
||||
},
|
||||
};
|
||||
|
||||
const Sidebar = ({ systemId, isCollapsed, setIsCollapsed }) => {
|
||||
|
@ -4,6 +4,7 @@ import DualModalComponent from './layouts/DualModal';
|
||||
import CouncilAI from './components/CouncilAI/CouncilAI';
|
||||
import FuseMindHome from './components/FuseMind/FuseMindHome';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import CanvasEndpoints from './components/CanvasEndpoints';
|
||||
|
||||
const FuseMindHomeWrapper = () => {
|
||||
const { systemId } = useParams();
|
||||
@ -30,6 +31,14 @@ const router = createBrowserRouter([
|
||||
path: 'home',
|
||||
element: <FuseMindHomeWrapper />,
|
||||
},
|
||||
{
|
||||
path: 'endpoints',
|
||||
element: <CanvasEndpoints />,
|
||||
},
|
||||
{
|
||||
path: 'canvas-endpoints',
|
||||
element: <CanvasEndpoints />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
78
frontend/src/services/canvasApi.ts
Normal file
78
frontend/src/services/canvasApi.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { api } from './api';
|
||||
|
||||
interface CanvasApiConfig {
|
||||
baseUrl: string;
|
||||
accessToken: string;
|
||||
courseId?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CANVAS_CONFIG: CanvasApiConfig = {
|
||||
baseUrl: import.meta.env.VITE_CANVAS_API_BASE_URL || 'https://canvas.instructure.com',
|
||||
accessToken: import.meta.env.VITE_CANVAS_ACCESS_TOKEN || '',
|
||||
};
|
||||
|
||||
/**
|
||||
* Constructs a Canvas API endpoint URL with optional parameters
|
||||
*/
|
||||
export const constructCanvasEndpoint = (path: string, params?: Record<string, any>) => {
|
||||
const queryString = params ? `?${new URLSearchParams(params).toString()}` : '';
|
||||
return `${path}${queryString}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Makes a request to the Canvas API
|
||||
*/
|
||||
export const getCanvasData = async <T = any>(
|
||||
endpoint: string,
|
||||
params?: Record<string, any>,
|
||||
config: Partial<CanvasApiConfig> = {}
|
||||
): Promise<T> => {
|
||||
const finalConfig = { ...DEFAULT_CANVAS_CONFIG, ...config };
|
||||
const url = constructCanvasEndpoint(endpoint, params);
|
||||
|
||||
try {
|
||||
const response = await api<T>('get', url, undefined, undefined, 'json', 60, true);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Canvas API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Example usage:
|
||||
// const students = await getCanvasData('/courses/123/users', { enrollment_type: ['student'] });
|
||||
// const courses = await getCanvasData('/courses', { per_page: 100 });
|
||||
|
||||
class CanvasApi {
|
||||
private accessToken: string;
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(config: CanvasApiConfig) {
|
||||
this.accessToken = config.accessToken;
|
||||
this.baseUrl = config.baseUrl || DEFAULT_CANVAS_CONFIG.baseUrl;
|
||||
}
|
||||
|
||||
private getHeaders() {
|
||||
return {
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
async getStudents(courseId: number) {
|
||||
try {
|
||||
const response = await getCanvasData('/courses/' + courseId + '/users', {
|
||||
enrollment_type: ['student'],
|
||||
per_page: 100,
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Error fetching students:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Add more Canvas API methods here as needed
|
||||
}
|
||||
|
||||
export default CanvasApi;
|
35
frontend/src/state/stores/useCanvasStore.ts
Normal file
35
frontend/src/state/stores/useCanvasStore.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { create } from 'zustand';
|
||||
import { api } from '../../services/api';
|
||||
|
||||
interface CanvasRequestData {
|
||||
endpoint: string;
|
||||
params?: Record<string, any>;
|
||||
courseId?: number;
|
||||
}
|
||||
|
||||
type CanvasStore = {
|
||||
sendCanvasRequest: (data: CanvasRequestData) => Promise<any>;
|
||||
};
|
||||
|
||||
export const useCanvasStore = create<CanvasStore>(() => ({
|
||||
sendCanvasRequest: async (requestData) => {
|
||||
try {
|
||||
if (!requestData.endpoint) {
|
||||
throw new Error("Invalid input: 'endpoint' is required.");
|
||||
}
|
||||
|
||||
// Construct the full URL with course ID if provided
|
||||
const url = requestData.courseId
|
||||
? `/courses/${requestData.courseId}${requestData.endpoint}`
|
||||
: requestData.endpoint;
|
||||
|
||||
console.log('Sending Canvas request:', { url, params: requestData.params });
|
||||
const response = await api('get', url, undefined, requestData.params, 'json', 60, true);
|
||||
console.log('Got Canvas response:', response);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to send Canvas request:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
}));
|
100
frontend/src/styles/canvas-theme.css
Normal file
100
frontend/src/styles/canvas-theme.css
Normal file
@ -0,0 +1,100 @@
|
||||
/* Canvas API specific theme overrides */
|
||||
[data-system="canvas"] {
|
||||
/* Override all teal colors with #e70022 (red) */
|
||||
--canvas-primary: #e70022;
|
||||
--canvas-primary-light: #ff1a3d;
|
||||
--canvas-primary-dark: #cc001f;
|
||||
--canvas-button-text: #f3f4f6; /* light gray for button text */
|
||||
}
|
||||
|
||||
/* Override teal colors in Canvas API system */
|
||||
[data-system="canvas"] .p-button {
|
||||
background-color: var(--canvas-primary) !important;
|
||||
border-color: var(--canvas-primary) !important;
|
||||
color: var(--canvas-button-text) !important;
|
||||
}
|
||||
|
||||
[data-system="canvas"] .p-button:hover {
|
||||
background-color: var(--canvas-primary-dark) !important;
|
||||
border-color: var(--canvas-primary-dark) !important;
|
||||
color: var(--canvas-button-text) !important;
|
||||
}
|
||||
|
||||
[data-system="canvas"] .p-button:hover:not(.p-disabled) {
|
||||
background-color: var(--canvas-primary-dark) !important;
|
||||
border-color: var(--canvas-primary-dark) !important;
|
||||
}
|
||||
|
||||
[data-system="canvas"] .p-button.p-button-outlined {
|
||||
color: var(--canvas-primary) !important;
|
||||
border-color: var(--canvas-primary) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
[data-system="canvas"] .p-button.p-button-outlined:hover {
|
||||
background-color: var(--canvas-primary) !important;
|
||||
color: var(--canvas-button-text) !important;
|
||||
}
|
||||
|
||||
/* Override teal in tabview */
|
||||
[data-system="canvas"] .p-tabview .p-tabview-nav li.p-highlight .p-tabview-nav-link {
|
||||
color: var(--canvas-primary) !important;
|
||||
border-color: var(--canvas-primary) !important;
|
||||
}
|
||||
|
||||
[data-system="canvas"] .p-tabview .p-tabview-nav li .p-tabview-nav-link:hover {
|
||||
color: var(--canvas-primary) !important;
|
||||
}
|
||||
|
||||
/* Override teal in dropdowns */
|
||||
[data-system="canvas"] .p-dropdown:not(.p-disabled).p-focus {
|
||||
border-color: var(--canvas-primary) !important;
|
||||
box-shadow: 0 0 0 1px var(--canvas-primary) !important;
|
||||
}
|
||||
|
||||
[data-system="canvas"] .p-dropdown-panel .p-dropdown-items .p-dropdown-item.p-highlight {
|
||||
background-color: var(--canvas-primary) !important;
|
||||
}
|
||||
|
||||
/* Override teal in checkboxes */
|
||||
[data-system="canvas"] .p-checkbox .p-checkbox-box.p-highlight {
|
||||
background-color: var(--canvas-primary) !important;
|
||||
border-color: var(--canvas-primary) !important;
|
||||
}
|
||||
|
||||
/* Override teal in links */
|
||||
[data-system="canvas"] .link-style {
|
||||
color: var(--canvas-primary) !important;
|
||||
}
|
||||
|
||||
[data-system="canvas"] .link-style:hover {
|
||||
color: var(--canvas-primary-dark) !important;
|
||||
}
|
||||
|
||||
/* Override teal in borders */
|
||||
[data-system="canvas"] .border-teal-500 {
|
||||
border-color: var(--canvas-primary) !important;
|
||||
}
|
||||
|
||||
/* Override teal in text */
|
||||
[data-system="canvas"] .text-teal-500,
|
||||
[data-system="canvas"] .text-teal-600,
|
||||
[data-system="canvas"] .text-teal-700 {
|
||||
color: var(--canvas-primary) !important;
|
||||
}
|
||||
|
||||
/* Override teal in backgrounds */
|
||||
[data-system="canvas"] .bg-teal-500,
|
||||
[data-system="canvas"] .bg-teal-600,
|
||||
[data-system="canvas"] .bg-teal-700 {
|
||||
background-color: var(--canvas-primary) !important;
|
||||
}
|
||||
|
||||
/* Override teal in focus states */
|
||||
[data-system="canvas"] .focus\:border-teal-500:focus {
|
||||
border-color: var(--canvas-primary) !important;
|
||||
}
|
||||
|
||||
[data-system="canvas"] .focus\:ring-teal-500:focus {
|
||||
--tw-ring-color: var(--canvas-primary) !important;
|
||||
}
|
Loading…
Reference in New Issue
Block a user