From 4bad308669be85334fa042f242a53506461f869b Mon Sep 17 00:00:00 2001 From: Samed <sbalcioglu@graphpolaris.com> Date: Mon, 9 Dec 2024 13:35:39 +0000 Subject: [PATCH] feat: persist report on the backend --- .../lib/components/textEditor/TextEditor.tsx | 59 +++-- .../components/textEditor/VariableNode.tsx | 26 ++- .../plugins/InsertVariablesPlugin.tsx | 1 + .../textEditor/plugins/SaveButtonPlugin.tsx | 18 ++ .../textEditor/plugins/ToolbarPlugin.tsx | 5 +- libs/shared/lib/data-access/api/eventBus.tsx | 4 +- libs/shared/lib/data-access/broker/types.ts | 2 +- .../data-access/broker/wsInsightSharing.ts | 93 ++++++++ libs/shared/lib/data-access/store/hooks.ts | 4 + .../data-access/store/insightSharingSlice.ts | 74 +++++++ libs/shared/lib/data-access/store/store.ts | 2 + libs/shared/lib/insight-sharing/FormAlert.tsx | 178 +++++++++++++++ .../shared/lib/insight-sharing/FormReport.tsx | 203 ++++++++++++++++++ .../lib/insight-sharing/InsightDialog.tsx | 59 ++--- .../lib/insight-sharing/SettingsPanel.tsx | 53 ----- .../insight-sharing/alerting/AlertingForm.tsx | 43 ---- .../insight-sharing/components/AddItem.tsx | 52 ++++- .../insight-sharing/components/Sidebar.tsx | 89 ++++---- .../components/StartScreen.tsx | 11 +- .../reporting/ReportingForm.tsx | 57 ----- 20 files changed, 783 insertions(+), 250 deletions(-) create mode 100644 libs/shared/lib/components/textEditor/plugins/SaveButtonPlugin.tsx create mode 100644 libs/shared/lib/data-access/broker/wsInsightSharing.ts create mode 100644 libs/shared/lib/data-access/store/insightSharingSlice.ts create mode 100644 libs/shared/lib/insight-sharing/FormAlert.tsx create mode 100644 libs/shared/lib/insight-sharing/FormReport.tsx delete mode 100644 libs/shared/lib/insight-sharing/SettingsPanel.tsx delete mode 100644 libs/shared/lib/insight-sharing/alerting/AlertingForm.tsx delete mode 100644 libs/shared/lib/insight-sharing/reporting/ReportingForm.tsx diff --git a/libs/shared/lib/components/textEditor/TextEditor.tsx b/libs/shared/lib/components/textEditor/TextEditor.tsx index 2b931b872..32664ad31 100644 --- a/libs/shared/lib/components/textEditor/TextEditor.tsx +++ b/libs/shared/lib/components/textEditor/TextEditor.tsx @@ -1,43 +1,61 @@ -import { useRef } from 'react'; +import { useEffect, useRef, useCallback } from 'react'; import { LexicalComposer } from '@lexical/react/LexicalComposer'; import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; import { ContentEditable } from '@lexical/react/LexicalContentEditable'; import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'; -import { LexicalEditor, EditorState } from 'lexical'; -import { $generateHtmlFromNodes } from '@lexical/html'; -import { MyOnChangePlugin } from './plugins/StateChangePlugin'; +import { SerializedEditorState} from 'lexical'; import { ToolbarPlugin } from './plugins/ToolbarPlugin'; import { PreviewPlugin } from './plugins/PreviewPlugin'; import { InsertVariablesPlugin } from './plugins/InsertVariablesPlugin'; import { ErrorHandler } from './ErrorHandler'; import { Placeholder } from './Placeholder'; import { VariableNode } from './VariableNode'; -import { fontFamily } from 'html2canvas/dist/types/css/property-descriptors/font-family'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { SaveButtonPlugin } from './plugins/SaveButtonPlugin'; type TextEditorProps = { - editorState: EditorState | undefined; - setEditorState: (value: EditorState) => void; + children: React.ReactNode; + editorState: SerializedEditorState | null; + setEditorState: (value: SerializedEditorState) => void; showToolbar: boolean; placeholder?: string; + handleSave: (elements: SerializedEditorState) => void; }; -export function TextEditor({ editorState, setEditorState, showToolbar, placeholder }: TextEditorProps) { - function onChange(editorState: EditorState, editor: LexicalEditor) { - setEditorState(editorState); - editor.read(() => { - // TODO: +function InitialStateLoader({ editorState }: { editorState: SerializedEditorState | null}) { + const [editor] = useLexicalComposerContext(); + const previousEditorStateRef = useRef<string | null>(null); + + useEffect(() => { + if (!editor || !editorState) return; + queueMicrotask(() => { + editor.setEditorState(editor.parseEditorState(editorState)); }); - } + + }, [editor, editorState]); + + return null; +} + +export function TextEditor({ editorState, showToolbar, placeholder, handleSave, children }: TextEditorProps) { + const contentEditableRef = useRef<HTMLDivElement>(null); + const updateTimeoutRef = useRef<NodeJS.Timeout | null>(null); + const isUpdatingRef = useRef(false); + + useEffect(() => { + return () => { + if (updateTimeoutRef.current) { + clearTimeout(updateTimeoutRef.current); + } + }; + }, []); const initialConfig = { namespace: 'MyEditor', - editorState: editorState, onError: ErrorHandler, nodes: [VariableNode], }; - const contentEditableRef = useRef<HTMLDivElement>(null); - return ( <LexicalComposer initialConfig={initialConfig}> <div className="editor-toolbar flex items-center bg-secondary-50 rounded mt-4 space-x-2"> @@ -54,8 +72,13 @@ export function TextEditor({ editorState, setEditorState, showToolbar, placehold </div> <div className="preview min-h-24 p-3 hidden"></div> </div> - <MyOnChangePlugin onChange={onChange} /> <InsertVariablesPlugin /> + <InitialStateLoader editorState={editorState} /> + <div className='flex justify-end mt-3'> + {children} + <SaveButtonPlugin + onChange={handleSave}></SaveButtonPlugin> + </div> </LexicalComposer> ); -} +} \ No newline at end of file diff --git a/libs/shared/lib/components/textEditor/VariableNode.tsx b/libs/shared/lib/components/textEditor/VariableNode.tsx index 300242055..a2341e498 100644 --- a/libs/shared/lib/components/textEditor/VariableNode.tsx +++ b/libs/shared/lib/components/textEditor/VariableNode.tsx @@ -1,4 +1,4 @@ -import type { NodeKey, LexicalEditor, DOMExportOutput } from 'lexical'; +import type { NodeKey, LexicalEditor, DOMExportOutput, SerializedLexicalNode, Spread } from 'lexical'; import { DecoratorNode, EditorConfig } from 'lexical'; export enum VariableType { @@ -6,6 +6,14 @@ export enum VariableType { visualization = 'visualization', } +export type SerializedVariableNode = Spread< + { + name: string, + variableType: VariableType + }, + SerializedLexicalNode +>; + export class VariableNode extends DecoratorNode<JSX.Element> { __name: string; __variableType: VariableType; @@ -39,6 +47,22 @@ export class VariableNode extends DecoratorNode<JSX.Element> { return `{{ ${self.__variableType}:${self.__name} }}`; } + // Import and export + + exportJSON(): SerializedVariableNode { + return { + type: this.getType(), + variableType: this.__variableType, + name: this.__name, + version: 1, + }; + } + + static importJSON(jsonNode: SerializedVariableNode): VariableNode { + const node = new VariableNode(jsonNode.name, jsonNode.variableType); + return node; + } + // View createDOM(config: EditorConfig): HTMLElement { diff --git a/libs/shared/lib/components/textEditor/plugins/InsertVariablesPlugin.tsx b/libs/shared/lib/components/textEditor/plugins/InsertVariablesPlugin.tsx index c854fe4a2..c996a62e5 100644 --- a/libs/shared/lib/components/textEditor/plugins/InsertVariablesPlugin.tsx +++ b/libs/shared/lib/components/textEditor/plugins/InsertVariablesPlugin.tsx @@ -44,6 +44,7 @@ export const InsertVariablesPlugin = () => { <> {nodeTypes.map((nodeType) => ( <Input + key={nodeType} type="dropdown" label={`${nodeType} variable`} value="" diff --git a/libs/shared/lib/components/textEditor/plugins/SaveButtonPlugin.tsx b/libs/shared/lib/components/textEditor/plugins/SaveButtonPlugin.tsx new file mode 100644 index 000000000..b4c8944ed --- /dev/null +++ b/libs/shared/lib/components/textEditor/plugins/SaveButtonPlugin.tsx @@ -0,0 +1,18 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { Button } from '../../buttons'; +import { SerializedEditorState } from 'lexical'; + +export function SaveButtonPlugin({ onChange }: { onChange: (elements: SerializedEditorState) => void }) { + const [editor] = useLexicalComposerContext(); + + function handleSave() { + editor.read(() => { + const data = editor.getEditorState(); + const jsonData = data.toJSON(); + + onChange(jsonData); + }) + } + + return <Button label="Save" variantType="primary" className="ml-2" onClick={handleSave} />; +} diff --git a/libs/shared/lib/components/textEditor/plugins/ToolbarPlugin.tsx b/libs/shared/lib/components/textEditor/plugins/ToolbarPlugin.tsx index e4b1ec07e..041367db0 100644 --- a/libs/shared/lib/components/textEditor/plugins/ToolbarPlugin.tsx +++ b/libs/shared/lib/components/textEditor/plugins/ToolbarPlugin.tsx @@ -19,8 +19,9 @@ export function ToolbarPlugin() { }; return [ - <div style={{ flex: '1 1 auto' }}></div>, + <div key= "spacer" style={{ flex: '1 1 auto' }}></div>, <Button + key = "bold" className="my-2" variantType="secondary" variant="ghost" @@ -29,6 +30,7 @@ export function ToolbarPlugin() { onClick={formatBold} />, <Button + key="italic" className="my-2" variantType="secondary" variant="ghost" @@ -37,6 +39,7 @@ export function ToolbarPlugin() { onClick={formatItalic} />, <Button + key="underline" className="my-2 me-2" variantType="secondary" variant="ghost" diff --git a/libs/shared/lib/data-access/api/eventBus.tsx b/libs/shared/lib/data-access/api/eventBus.tsx index 03d9e7f29..d58c915cf 100644 --- a/libs/shared/lib/data-access/api/eventBus.tsx +++ b/libs/shared/lib/data-access/api/eventBus.tsx @@ -43,14 +43,14 @@ import { setFetchingSaveStates, setStateAuthorization, } from '../store/sessionSlice'; -import { URLParams, getParam, deleteParam } from './url'; +import { URLParams, getParam } from './url'; import { VisState, setVisualizationState } from '../store/visualizationSlice'; import { isEqual } from 'lodash-es'; import { setSchemaAttributeDimensions, setSchemaAttributeInformation, setSchemaLoading } from '../store/schemaSlice'; import { addError } from '@graphpolaris/shared/lib/data-access/store/configSlice'; import { unSelect } from '../store/interactionSlice'; import { SchemaGraphStats } from '../../schema'; -import { wsUserGetPolicy, wsUserPolicyCheck } from '../broker/wsUser'; +import { wsUserGetPolicy } from '../broker/wsUser'; import { authorized } from '../store/authSlice'; export const EventBus = (props: { onRunQuery: Function; onAuthorized: Function }) => { diff --git a/libs/shared/lib/data-access/broker/types.ts b/libs/shared/lib/data-access/broker/types.ts index dc3dd7c22..8d6decc5a 100644 --- a/libs/shared/lib/data-access/broker/types.ts +++ b/libs/shared/lib/data-access/broker/types.ts @@ -19,7 +19,7 @@ type QueryOrchestratorMessage = { queryID: string; }; -export type keyTypeI = 'broadcastState' | 'dbConnection' | 'schema' | 'query' | 'state' | 'user'; +export type keyTypeI = 'broadcastState' | 'dbConnection' | 'schema' | 'query' | 'state' | 'user' | 'insight'; export type subKeyTypeI = // Crud | 'create' diff --git a/libs/shared/lib/data-access/broker/wsInsightSharing.ts b/libs/shared/lib/data-access/broker/wsInsightSharing.ts new file mode 100644 index 000000000..04c366a3e --- /dev/null +++ b/libs/shared/lib/data-access/broker/wsInsightSharing.ts @@ -0,0 +1,93 @@ +import { Broker } from './broker'; +import { InsightRequest, InsightResponse, InsightType } from '../store/insightSharingSlice'; + +type GetInsightsResponse = (data: { reports: InsightResponse[]; alerts: InsightResponse[] }, status: string) => void; +export function wsGetInsights(saveStateId: string, callback?: GetInsightsResponse) { + const internalCallback: GetInsightsResponse = (data, status) => { + if (callback) callback(data, status); + }; + + Broker.instance().sendMessage( + { + key: 'insight', + subKey: 'getAll', + body: JSON.stringify({ saveStateId: saveStateId }) + }, + internalCallback + ); +} + +export function wsCreateInsight(insight: InsightRequest, callback?: Function) { + Broker.instance().sendMessage( + { + key: 'insight', + subKey: 'create', + body: JSON.stringify(insight), + }, + (data: any, status: string) => { + if (status === 'Bad Request') { + console.error('Failed to create insight:', data); + if (callback) callback(data, status); + return; + } + + if (!data || typeof data !== 'object') { + console.error('Invalid repsonse data', data) + if (callback) callback(null, 'error'); + return; + } + + if (!data.type || !data.id) { + console.error('Missing fields in response', data) + if (callback) callback(null, 'error'); + return; + } + + if (callback) callback(data, status); + } + ); +} + +export function wsUpdateInsight( + id: string, + insight: InsightRequest, + callback?: (data: any, status: string) => void +) { + Broker.instance().sendMessage( + { + key: 'insight', + subKey: 'update', + body: JSON.stringify({ id: id, insight: insight }), + }, + (data: any, status: string) => { + if (status === 'Bad Request') { + console.error('Failed to update insight:', data); + } + if (callback) callback(data, status); + } + ); +} + +export function wsDeleteInsight(id: string, callback?: Function) { + + Broker.instance().sendMessage( + { + key: 'insight', + subKey: 'delete', + body: JSON.stringify({ id }), + }, + (data: any, status: string) => { + if (status === 'Bad Request') { + console.error('Failed to delete insight:', data); + } + if (callback) callback(data, status); + } + ); +} + +export function wsInsightSubscription(callback: (data: any, status: string) => void) { + const id = Broker.instance().subscribe(callback, 'insight_result'); + return () => { + Broker.instance().unSubscribe('insight_result', id); + }; +} diff --git a/libs/shared/lib/data-access/store/hooks.ts b/libs/shared/lib/data-access/store/hooks.ts index 7df070ec3..444a28b38 100644 --- a/libs/shared/lib/data-access/store/hooks.ts +++ b/libs/shared/lib/data-access/store/hooks.ts @@ -44,6 +44,7 @@ import { PolicyUsersState, selectPolicyState } from './authorizationUsersSlice'; import { PolicyResourcesState, selectResourcesPolicyState } from './authorizationResourcesSlice'; import { SaveStateAuthorizationHeaders, SaveStateI } from '..'; import { GraphStatistics } from '../../statistics'; +import { InsightResponse, selectInsights } from './insightSharingSlice'; // Use throughout your app instead of plain `useDispatch` and `useSelector` export const useAppDispatch: () => AppDispatch = useDispatch; @@ -100,3 +101,6 @@ export const useUsersPolicy: () => PolicyUsersState = () => useAppSelector(selec // Authorization Resources Slice export const useResourcesPolicy: () => PolicyResourcesState = () => useAppSelector(selectResourcesPolicyState); + +// Insights - Reports and Alerts +export const useInsights: () => InsightResponse[] = () => useAppSelector(selectInsights); \ No newline at end of file diff --git a/libs/shared/lib/data-access/store/insightSharingSlice.ts b/libs/shared/lib/data-access/store/insightSharingSlice.ts new file mode 100644 index 000000000..a3a4105e0 --- /dev/null +++ b/libs/shared/lib/data-access/store/insightSharingSlice.ts @@ -0,0 +1,74 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { RootState } from '../store'; + +export type InsightType = 'report' | 'alert'; + +export type InsightRequest = { + name: string; + description: string; + recipients: string[]; + frequency: string; + template: string; + saveStateId: string; + type: 'report' | 'alert'; +} + +export type InsightResponse = { + id: string; + createdAt: string; + updatedAt: string; +} & InsightRequest; + +type InsightState = { + insights: InsightResponse[]; +} + +const initialState: InsightState = { + insights: [] +}; + +const insightSharingSlice = createSlice({ + name: 'insightSharing', + initialState, + reducers: { + setInsights(state, action: PayloadAction<InsightResponse[]>) { + state.insights = action.payload; + }, + addInsight: (state, action: PayloadAction<InsightResponse>) => { + if (!action.payload) { + console.error('Received null payload in addInsight'); + return; + } + + const type = action.payload.type; + if (!type) { + console.error('Insight type is missing in payload', action.payload); + return; + } + + state.insights.push(action.payload); + }, + updateInsight(state, action: PayloadAction<InsightResponse>) { + const index = state.insights.findIndex((i) => i.id === action.payload.id); + if (index !== -1) { + state.insights[index] = { ...action.payload }; + } else { + state.insights.push(action.payload); + } + }, + deleteInsight(state, action: PayloadAction<{ id: string; }>) { + const index = state.insights.findIndex((i) => i.id === action.payload.id); + if (index !== -1) { + state.insights.splice(index, 1); + } + }, + }, +}); + +export const { setInsights, addInsight, updateInsight, deleteInsight } = insightSharingSlice.actions; + +export const selectReports = (state: RootState): InsightResponse[] => state.insightSharing.insights.filter((i) => i.type === 'report'); +export const selectAlerts = (state: RootState): InsightResponse[] => state.insightSharing.insights.filter((i) => i.type === 'alert'); +export const selectInsights = (state: RootState): InsightResponse[] => state.insightSharing.insights; + +export default insightSharingSlice.reducer; diff --git a/libs/shared/lib/data-access/store/store.ts b/libs/shared/lib/data-access/store/store.ts index c1aea4606..3b1ea1c4d 100644 --- a/libs/shared/lib/data-access/store/store.ts +++ b/libs/shared/lib/data-access/store/store.ts @@ -11,6 +11,7 @@ import visualizationSlice from './visualizationSlice'; import interactionSlice from './interactionSlice'; import policyUsersSlice from './authorizationUsersSlice'; import policyPermissionSlice from './authorizationResourcesSlice'; +import insightSharingSlice from './insightSharingSlice'; export const store = configureStore({ reducer: { @@ -26,6 +27,7 @@ export const store = configureStore({ visualize: visualizationSlice, policyUsers: policyUsersSlice, policyResources: policyPermissionSlice, + insightSharing: insightSharingSlice, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ diff --git a/libs/shared/lib/insight-sharing/FormAlert.tsx b/libs/shared/lib/insight-sharing/FormAlert.tsx new file mode 100644 index 000000000..4f4d2c6af --- /dev/null +++ b/libs/shared/lib/insight-sharing/FormAlert.tsx @@ -0,0 +1,178 @@ +import { useEffect, useState } from 'react'; +import { Accordion, AccordionItem, AccordionHead, AccordionBody } from '../components/accordion'; +import { TextEditor } from '../components/textEditor'; +import { + InsightResponse, + updateInsight, + deleteInsight, + addInsight, + InsightRequest, +} from '@graphpolaris/shared/lib/data-access/store/insightSharingSlice'; +import { useAppDispatch, useSessionCache } from '@graphpolaris/shared/lib/data-access'; +import { Button, Input, LoadingSpinner } from '../components'; +import { wsUpdateInsight, wsCreateInsight, wsDeleteInsight } from '@graphpolaris/shared/lib/data-access/broker/wsInsightSharing'; +import { addError, addSuccess } from '@graphpolaris/shared/lib/data-access/store/configSlice'; +import { SerializedEditorState } from 'lexical'; + +type Props = { + insight: InsightResponse; + setActive?: (val: string) => void; + handleDelete: () => void; +}; + +export function FormAlert(props: Props) { + const dispatch = useAppDispatch(); + const session = useSessionCache(); + const [loading, setLoading] = useState(false); + const [name, setName] = useState(props.insight.name); + const [description, setDescription] = useState(props.insight.description); + const [recipients, setRecipients] = useState<string[]>(props.insight.recipients || []); + const [recipientInput, setRecipientInput] = useState<string>(''); + const [editorState, setEditorState] = useState<SerializedEditorState | null>(null); + + useEffect(() => { + let isMounted = true; + + if (isMounted) { + setName(props.insight.name); + setDescription(props.insight.description); + setRecipientInput(props.insight.recipients ? props.insight.recipients.join(', ') : ''); + setRecipients(props.insight.recipients || []); + if (props.insight.template) { + try { + const parsedTemplate = JSON.parse(props.insight.template); + setEditorState(parsedTemplate); + } catch (e) { + setEditorState(null); + console.error('Failed to parse template:', e); + } + } else { + setEditorState(null); + } + } + + return () => { + isMounted = false; + setEditorState(null); + }; + }, [props.insight]); + + const handleSave = async (element: SerializedEditorState) => { + if (!name || name.trim() === '') { + dispatch(addError('Name is required')); + return; + } + + const alertData: InsightRequest = { + name, + description, + recipients, + template: JSON.stringify(element), + saveStateId: session.currentSaveState || '', + type: 'alert' as const, + frequency: '', + }; + + setLoading(true); + + if (props.insight.id) { + wsUpdateInsight(props.insight.id, alertData, (data: any, status: string) => { + setLoading(false); + if (status === 'success') { + dispatch(updateInsight(data)); + dispatch(addSuccess('Alert updated successfully')); + console.log('Alert updated successfully'); + } else { + console.error('Failed to update alert:', data); + dispatch(addError('Failed to update alert.')); + } + }); + } else { + wsCreateInsight(alertData, (data: any, status: string) => { + setLoading(false); + if (status === 'success') { + dispatch(addInsight(data)); + dispatch(addSuccess('Alert created successfully')); + console.log('Alert created successfully'); + if (props.setActive) props.setActive(data.id); + } else { + console.error('Failed to create alert:', data); + dispatch(addError('Failed to create alert.')); + } + }); + } + }; + + const handleDelete = async () => { + if (!props.insight.id) { + dispatch(addError('Cannot delete an alert without an ID.')); + return; + } + + setLoading(true); + wsDeleteInsight(props.insight.id, (data: any, status: string) => { + setLoading(false); + if (status === 'success') { + dispatch(deleteInsight({ id: props.insight.id })); + if (props.setActive) props.setActive(''); + dispatch(addSuccess('Alert deleted successfully')); + console.log('Alert deleted successfully'); + } else { + console.error('Failed to delete alert:', data); + dispatch(addError('Failed to delete alert')); + } + }); + }; + + if (loading) { + return <LoadingSpinner />; + } + + return ( + <div> + <span className="text-lg text-secondary-600 font-bold mb-4">Alert ID: {props.insight.id}</span> + <Input label="Name" type="text" value={name} onChange={setName} className="mb-4" /> + <Input label="Description" type="text" value={description} onChange={setDescription} className="mb-4" /> + <Accordion defaultOpenAll={true} className="border-t divide-y"> + <AccordionItem className="pt-2 pb-4"> + <AccordionHead showArrow={false}> + <span className="font-semibold">Recipient(s)</span> + </AccordionHead> + <AccordionBody> + <div> + <Input + type="text" + value={recipientInput} + onChange={(value) => { + setRecipientInput(String(value)); + const recipientList = String(value) + .split(/[, ]/) + .map((r) => r.trim()) + .filter((r) => r.length > 0); + setRecipients(recipientList); + }} + placeholder="Enter recipient(s)" + className="w-full" + /> + </div> + </AccordionBody> + </AccordionItem> + <AccordionItem className="pt-2 pb-4"> + <AccordionHead showArrow={false}> + <span className="font-semibold">Alerting text</span> + </AccordionHead> + <AccordionBody> + <TextEditor + key={`editor-${props.insight.id}`} + editorState={editorState} + setEditorState={setEditorState} + showToolbar={true} + placeholder="Start typing your alert template..." + handleSave={handleSave} + ><Button label="Delete" variantType="secondary" variant="outline" onClick={handleDelete} /></TextEditor> + </AccordionBody> + </AccordionItem> + </Accordion> + </div> + ); +} diff --git a/libs/shared/lib/insight-sharing/FormReport.tsx b/libs/shared/lib/insight-sharing/FormReport.tsx new file mode 100644 index 000000000..e41546b2f --- /dev/null +++ b/libs/shared/lib/insight-sharing/FormReport.tsx @@ -0,0 +1,203 @@ +import { useState, useEffect } from 'react'; +import { Button, Input, LoadingSpinner } from '../components'; +import { Accordion, AccordionBody, AccordionHead, AccordionItem } from '../components/accordion'; +import { TextEditor } from '../components/textEditor'; +import { + InsightResponse, + updateInsight, + deleteInsight, + InsightRequest, +} from '@graphpolaris/shared/lib/data-access/store/insightSharingSlice'; +import { useAppDispatch, useSessionCache } from '@graphpolaris/shared/lib/data-access'; +import { SerializedEditorState } from 'lexical'; +import { wsCreateInsight, wsDeleteInsight, wsUpdateInsight } from '@graphpolaris/shared/lib/data-access/broker/wsInsightSharing'; +import { addError, addSuccess } from '@graphpolaris/shared/lib/data-access/store/configSlice'; + +type Props = { + insight: InsightResponse; + setActive: (val: string) => void; + handleDelete: () => void; +}; + +export function FormReport(props: Props) { + const dispatch = useAppDispatch(); + const session = useSessionCache(); + const [loading, setLoading] = useState(false); + const [name, setName] = useState(props.insight.name); + const [description, setDescription] = useState(props.insight.description); + const [recipients, setRecipients] = useState(props.insight.recipients || []); + const [recipientInput, setRecipientInput] = useState<string>(''); + const [frequency, setFrequency] = useState(props.insight.frequency || 'Daily'); + const [editorState, setEditorState] = useState<SerializedEditorState| null>(null); + + useEffect(() => { + let isMounted = true; + + if (isMounted) { + setName(props.insight.name); + setDescription(props.insight.description); + setRecipientInput(props.insight.recipients ? props.insight.recipients.join(', ') : ''); + setRecipients(props.insight.recipients || []); + if (props.insight.template) { + try { + const parsedTemplate = JSON.parse(props.insight.template); + setEditorState(parsedTemplate); + } catch (e) { + setEditorState(null); + console.error('Failed to parse template:', e); + } + } else { + setEditorState(null); + } + } + + return () => { + isMounted = false; + setEditorState(null); + }; + }, [props.insight]); + + const handleSetFrequency = (value: string | number) => { + setFrequency(value as string); + }; + + const handleSetName = (value: string | number) => { + setName(value as string); + }; + + const handleSetDescription = (value: string | number) => { + setDescription(value as string); + }; + + const handleSave = async (elements: SerializedEditorState) => { + if (!name || name.trim() === '') { + dispatch(addError('Please enter a name for the report.')); + return; + } + + const reportData: InsightRequest = { + name, + description, + recipients, + frequency, + template: JSON.stringify(elements), + saveStateId: session.currentSaveState || '', + type: 'report' as const, + }; + + setLoading(true); + if (props.insight.id) { + wsUpdateInsight(props.insight.id, reportData, (data: any, status: string) => { + setLoading(false); + if (status === 'success') { + dispatch(updateInsight(data)); + dispatch(addSuccess('Report updated successfully')); + console.log('Report updated successfully'); + } else { + console.error('Failed to update report:', data); + dispatch(addError('Failed to update alert')); + } + }); + } else { + wsCreateInsight(reportData, (data: any, status: string) => { + debugger; + setLoading(false); + if (status === 'success') { + dispatch(updateInsight(data)); + dispatch(addSuccess('Report created successfully')); + console.log('Report created successfully'); + if (props.setActive) props.setActive(data.id); + } else { + console.error('Failed to create report:', data); + dispatch(addError('Failed to create alert')); + } + }); + } + }; + + const handleDelete = async () => { + if (!props.insight.id) { + dispatch(addError('Cannot delete a report without an ID.')); + return; + } + + setLoading(true); + wsDeleteInsight(props.insight.id, (data: any, status: string) => { + setLoading(false); + if (status === 'success') { + dispatch(deleteInsight({ id: props.insight.id })); + props.setActive(''); + dispatch(addSuccess('Report deleted successfully')); + console.log('Report deleted successfully'); + } else { + console.error('Failed to delete report:', data); + dispatch(addError('Failed to delete report')); + } + }); + }; + + return loading ? ( + <LoadingSpinner /> + ) : ( + <div> + <span className="text-lg text-secondary-600 font-bold mb-4">Report ID: {props.insight.id}</span> + <Input label="Name" type="text" value={name} onChange={handleSetName} className="mb-4" /> + <Input label="Description" type="text" value={description} onChange={handleSetDescription} className="mb-4" /> + <Accordion defaultOpenAll={true} className="border-t divide-y"> + <AccordionItem className="pt-2 pb-4"> + <AccordionHead showArrow={false}> + <span className="font-semibold">Recipient(s)</span> + </AccordionHead> + <AccordionBody> + <div> + <Input + type="text" + value={recipientInput} + onChange={(value) => { + setRecipientInput(String(value)); + const recipientList = String(value) + .split(/[, ]/) + .map((r) => r.trim()) + .filter((r) => r.length > 0); + setRecipients(recipientList); + }} + placeholder="Enter recipient(s)" + className="w-full" + /> + </div> + </AccordionBody> + </AccordionItem> + <AccordionItem className="pt-2 pb-4"> + <AccordionHead showArrow={false}> + <span className="font-semibold">Repeat</span> + </AccordionHead> + <AccordionBody> + <Input + label="Frequency" + type="dropdown" + value={frequency} + onChange={handleSetFrequency} + options={['Daily', 'Weekly']} + className="mb-1" + /> + </AccordionBody> + </AccordionItem> + <AccordionItem className="pt-2 pb-4"> + <AccordionHead showArrow={false}> + <span className="font-semibold">Email Template</span> + </AccordionHead> + <AccordionBody> + <TextEditor + key={`editor-${props.insight.id}`} + editorState={editorState} + setEditorState={setEditorState} + showToolbar={true} + placeholder="Start typing your alert template..." + handleSave={handleSave} + ><Button label="Delete" variantType="secondary" variant="outline" onClick={handleDelete} /></TextEditor> + </AccordionBody> + </AccordionItem> + </Accordion> + </div> + ); +} diff --git a/libs/shared/lib/insight-sharing/InsightDialog.tsx b/libs/shared/lib/insight-sharing/InsightDialog.tsx index 0af6191f2..44f1d2fe1 100644 --- a/libs/shared/lib/insight-sharing/InsightDialog.tsx +++ b/libs/shared/lib/insight-sharing/InsightDialog.tsx @@ -1,24 +1,34 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { Dialog, DialogContent } from '../components'; -import { SettingsPanel } from './SettingsPanel'; import { MonitorType, Sidebar } from './components/Sidebar'; +import { AddItem } from './components/AddItem'; +import { useAppDispatch, useInsights } from '..'; +import { StartScreen } from './components/StartScreen'; +import { FormReport } from './FormReport'; +import { FormAlert } from './FormAlert'; +import { wsDeleteInsight } from '../data-access/broker/wsInsightSharing'; +import { deleteInsight } from '../data-access/store/insightSharingSlice'; type Props = { open: boolean; onClose: () => void; }; -const reports = ['Sequence 1', 'Sequence 2']; -const alerts = ['Potential New Incident', 'Potential New Info']; - export function InsightDialog(props: Props) { - const [adding, setAdding] = useState<boolean>(false); + const dispatch = useAppDispatch(); + + const [adding, setAdding] = useState<false | MonitorType>(false); const [active, setActive] = useState<string>(''); - const [activeCategory, setActiveCategory] = useState<MonitorType | undefined>(undefined); + const insights = useInsights(); + + const selectedInsight = useMemo(() => insights.find((i) => i.id === active), [active, insights]); - const handleChangeActive = (category: MonitorType, name: string) => { - setActive(name); - setActiveCategory(category); + const handleDelete = () => { + if (!selectedInsight) return; + + dispatch(deleteInsight({ id: selectedInsight.id })); + wsDeleteInsight(selectedInsight.id); + setActive(''); }; return ( @@ -30,20 +40,21 @@ export function InsightDialog(props: Props) { > <DialogContent className="w-5/6 h-5/6 rounded-none py-0 px-0"> <div className="flex w-full h-full"> - <Sidebar - reports={reports} - alerts={alerts} - changeActive={handleChangeActive} - setAdding={setAdding} - setActiveCategory={setActiveCategory} - /> - <SettingsPanel - activeCategory={activeCategory} - active={active} - adding={adding} - setAdding={setAdding} - setActiveCategory={setActiveCategory} - /> + <div className="w-1/4 border-r overflow-auto flex flex-col h-full"> + <Sidebar setAdding={setAdding} setActive={setActive} /> + </div> + + <div className="w-3/4 p-8"> + {adding ? ( + <AddItem setAdding={setAdding} setActive={setActive} type={adding} /> + ) : !selectedInsight ? ( + <StartScreen setAdding={setAdding} /> + ) : selectedInsight.type === 'report' ? ( + <FormReport insight={selectedInsight} setActive={setActive} handleDelete={handleDelete} /> + ) : ( + <FormAlert insight={selectedInsight} setActive={setActive} handleDelete={handleDelete} /> + )} + </div> </div> </DialogContent> </Dialog> diff --git a/libs/shared/lib/insight-sharing/SettingsPanel.tsx b/libs/shared/lib/insight-sharing/SettingsPanel.tsx deleted file mode 100644 index 9d0a7a676..000000000 --- a/libs/shared/lib/insight-sharing/SettingsPanel.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { ReportingForm } from './reporting/ReportingForm'; -import { AlertingForm } from './alerting/AlertingForm'; -import { Button, Icon } from '../components'; -import { AddItem } from './components/AddItem'; -import { StartScreen } from './components/StartScreen'; -import { MonitorType } from './components/Sidebar'; - -type Props = { - active: string; - activeCategory: MonitorType | undefined; - adding: boolean; - setAdding: (val: boolean) => void; - setActiveCategory: (val: MonitorType | undefined) => void; -}; - -export function SettingsPanel(props: Props) { - const [contacts, setContacts] = useState<string[]>([]); - - const getContacts = (): string[] => ['Jan', 'Piet']; - - useEffect(() => { - if (!contacts) { - const userContacts = getContacts(); - setContacts(userContacts); - } - }, []); - - return props.activeCategory ? ( - <div className="w-3/4 p-4"> - {props.adding ? ( - <AddItem category={props.activeCategory} /> - ) : ( - props.active && - props.activeCategory && ( - <div> - {props.activeCategory === 'report' ? ( - <ReportingForm activeTemplate={props.active} /> - ) : ( - <AlertingForm activeTemplate={props.active} /> - )} - </div> - ) - )} - <div className="flex justify-end mt-2"> - <Button label="Delete" variantType="secondary" variant="outline" /> - <Button label="Save" variantType="primary" className="ml-2" /> - </div> - </div> - ) : ( - <StartScreen setAdding={props.setAdding} setActiveCategory={props.setActiveCategory} /> - ); -} diff --git a/libs/shared/lib/insight-sharing/alerting/AlertingForm.tsx b/libs/shared/lib/insight-sharing/alerting/AlertingForm.tsx deleted file mode 100644 index 11ea8d907..000000000 --- a/libs/shared/lib/insight-sharing/alerting/AlertingForm.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React, { useState } from 'react'; -import { Accordion, AccordionItem, AccordionHead, AccordionBody } from '../../components/accordion'; -import { TextEditor } from '../../components/textEditor'; -import { EditorState } from 'lexical'; - -type Props = { - activeTemplate: string; -}; - -export function AlertingForm(props: Props) { - const [editorState, setEditorState] = useState<EditorState | undefined>(undefined); - - return ( - <div> - <span className="text-lg text-secondary-600 font-bold mb-4">Alert ID: {props.activeTemplate}</span> - <Accordion defaultOpenAll={true} className="border-t divide-y"> - <AccordionItem className="pt-2 pb-4"> - <AccordionHead showArrow={false}> - <span className="font-semibold">Recipient(s)</span> - </AccordionHead> - <AccordionBody> - <div> - <input className="border" /> - </div> - </AccordionBody> - </AccordionItem> - <AccordionItem className="pt-2 pb-4"> - <AccordionHead showArrow={false}> - <span className="font-semibold">Alerting text</span> - </AccordionHead> - <AccordionBody> - <TextEditor - editorState={editorState} - setEditorState={setEditorState} - showToolbar={true} - placeholder="Start typing your alert template..." - /> - </AccordionBody> - </AccordionItem> - </Accordion> - </div> - ); -} diff --git a/libs/shared/lib/insight-sharing/components/AddItem.tsx b/libs/shared/lib/insight-sharing/components/AddItem.tsx index 771ab6bcc..b207b9ec6 100644 --- a/libs/shared/lib/insight-sharing/components/AddItem.tsx +++ b/libs/shared/lib/insight-sharing/components/AddItem.tsx @@ -1,18 +1,58 @@ import React, { useState } from 'react'; -import { Input } from '../../components'; -import { MonitorType } from './Sidebar'; +import { Input, Button } from '../../components'; +import { MonitorType as InsightType, MonitorType } from './Sidebar'; +import { useAppDispatch, useSessionCache } from '../../data-access'; +import { addInsight } from '../../data-access/store/insightSharingSlice'; +import { wsCreateInsight } from '../../data-access/broker/wsInsightSharing'; +import { addError, addSuccess, } from '@graphpolaris/shared/lib/data-access/store/configSlice'; type Props = { - category: MonitorType; + setAdding: (val: false | MonitorType) => void; + setActive: (val: string) => void; + type: InsightType; }; export function AddItem(props: Props) { - const [value, setValue] = useState<string>(''); + const [name, setName] = useState<string>(''); + const [description, setDescription] = useState<string>(''); + const dispatch = useAppDispatch(); + const session = useSessionCache(); + + const handleSave = async () => { + if (!name.trim()) { + dispatch(addError('Name is required')); + return; + } + + const newInsight = { + name, + description, + recipients: [], + template: '', + frequency: props.type === 'report' ? 'Daily' : '', + saveStateId: session.currentSaveState || '', + type: props.type, + }; + + wsCreateInsight(newInsight, (data: any, status: string) => { + if (status === 'success') { + dispatch(addInsight(data)); + props.setActive(data.id); + props.setAdding(false); + dispatch(addSuccess('Succesfully created ' + props.type)); + } else { + console.error('Failed to create insight:', data); + dispatch(addError('Failed to create new ' + props.type)) + } + }); + }; return ( <div> - <span className="text-lg text-secondary-600 font-bold mb-4">Add a new {props.category}ing service</span> - <Input type="text" label="Name" value={value} onChange={setValue} /> + <span className="text-lg text-secondary-600 font-bold mb-4">Add a new {props.type}ing service</span> + <Input type="text" label="Name" value={name} onChange={setName} className="mb-2" /> + <Input type="text" label="Description" value={description} onChange={setDescription} className="mb-2" /> + <Button label="Save" onClick={handleSave} disabled={!name || !description} className="mt-2" /> </div> ); } diff --git a/libs/shared/lib/insight-sharing/components/Sidebar.tsx b/libs/shared/lib/insight-sharing/components/Sidebar.tsx index d8ebcfe1c..96aefb1f1 100644 --- a/libs/shared/lib/insight-sharing/components/Sidebar.tsx +++ b/libs/shared/lib/insight-sharing/components/Sidebar.tsx @@ -1,30 +1,42 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { Button } from '../../components'; import { Accordion, AccordionBody, AccordionHead, AccordionItem } from '../../components/accordion'; +import { useAppDispatch, useInsights } from '../../data-access'; +import { setInsights } from '../../data-access/store/insightSharingSlice'; +import { useSessionCache } from '../../data-access'; +import { wsGetInsights } from '../../data-access/broker/wsInsightSharing'; export type MonitorType = 'report' | 'alert'; type SidebarProps = { - reports: string[]; - alerts: string[]; - changeActive: (category: MonitorType, val: string) => void; - setAdding: (val: boolean) => void; - setActiveCategory: (val: MonitorType | undefined) => void; + setAdding: (val: false | MonitorType) => void; + setActive: (val: string) => void; }; export function Sidebar(props: SidebarProps) { + const dispatch = useAppDispatch(); + const session = useSessionCache(); + const insights = useInsights(); + + useEffect(() => { + if (session.currentSaveState && session.currentSaveState !== '') { + wsGetInsights(session.currentSaveState, (data: any, status: string) => { + dispatch(setInsights(data)); + }); + } + }, [session.currentSaveState]); + return ( - <div className="w-1/4 border-r overflow-auto flex flex-col h-full"> + <> <span className="text-lg text-secondary-700 font-semibold px-2 py-4">Insight Sharing</span> - <Accordion defaultOpenIndex={0}> + <Accordion defaultOpenAll={true}> <AccordionItem className=""> <AccordionHead className="border-b bg-secondary-50 hover:bg-secondary-100 p-1"> <div className="w-full flex justify-between"> <span className="font-semibold">Reports</span> <div onClick={(e) => { - props.setAdding(true); - props.setActiveCategory('report'); + props.setAdding('report'); e.stopPropagation(); }} > @@ -41,18 +53,20 @@ export function Sidebar(props: SidebarProps) { </AccordionHead> <AccordionBody className="ml-0"> <ul className="space-y-2"> - {props.reports.map((name, index) => ( - <li - key={index} - className="cursor-pointer p-2 hover:bg-secondary-50" - onClick={() => { - props.changeActive('report', name); - props.setAdding(false); - }} - > - {name} - </li> - ))} + {insights + .filter((insight) => insight.type === 'report') + .map((report) => ( + <li + key={report.id} + className="cursor-pointer p-2 hover:bg-secondary-50" + onClick={() => { + props.setAdding(false); + props.setActive(report.id); + }} + > + {report.name} + </li> + ))} </ul> </AccordionBody> </AccordionItem> @@ -62,8 +76,7 @@ export function Sidebar(props: SidebarProps) { <span className="font-semibold">Alerts</span> <div onClick={(e) => { - props.setAdding(true); - props.setActiveCategory('alert'); + props.setAdding('alert'); e.stopPropagation(); }} > @@ -80,22 +93,24 @@ export function Sidebar(props: SidebarProps) { </AccordionHead> <AccordionBody className="ml-0"> <ul className="space-y-2"> - {props.alerts.map((name, index) => ( - <li - key={index} - className="cursor-pointer p-2 hover:bg-secondary-50" - onClick={() => { - props.changeActive('alert', name); - props.setAdding(false); - }} - > - {name} - </li> - ))} + {insights + .filter((insight) => insight.type === 'alert') + .map((alert) => ( + <li + key={alert.id} + className="cursor-pointer p-2 hover:bg-secondary-50" + onClick={() => { + props.setAdding(false); + props.setActive(alert.id); + }} + > + {alert.name} + </li> + ))} </ul> </AccordionBody> </AccordionItem> </Accordion> - </div> + </> ); } diff --git a/libs/shared/lib/insight-sharing/components/StartScreen.tsx b/libs/shared/lib/insight-sharing/components/StartScreen.tsx index 801331c40..377ad49d9 100644 --- a/libs/shared/lib/insight-sharing/components/StartScreen.tsx +++ b/libs/shared/lib/insight-sharing/components/StartScreen.tsx @@ -3,13 +3,12 @@ import { Button, Icon } from '../../components'; import { MonitorType } from './Sidebar'; type Props = { - setAdding: (val: boolean) => void; - setActiveCategory: (val: MonitorType | undefined) => void; + setAdding: (val: false | MonitorType) => void; }; export function StartScreen(props: Props) { return ( - <div className="w-full flex justify-center items-center"> + <div className="w-full h-full flex justify-center items-center"> <div className=""> <span className="text-lg text-secondary-700 font-bold mb-4">Start</span> <div> @@ -19,8 +18,7 @@ export function StartScreen(props: Props) { variant="outline" className="mb-2" onClick={() => { - props.setAdding(true); - props.setActiveCategory('report'); + props.setAdding('report'); }} /> <Button @@ -28,8 +26,7 @@ export function StartScreen(props: Props) { label="New alert" variant="outline" onClick={() => { - props.setAdding(true); - props.setActiveCategory('alert'); + props.setAdding('alert'); }} /> </div> diff --git a/libs/shared/lib/insight-sharing/reporting/ReportingForm.tsx b/libs/shared/lib/insight-sharing/reporting/ReportingForm.tsx deleted file mode 100644 index 9b2645b91..000000000 --- a/libs/shared/lib/insight-sharing/reporting/ReportingForm.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import React, { useState } from 'react'; -import { Input, LoadingSpinner } from '../../components'; -import { Accordion, AccordionBody, AccordionHead, AccordionItem } from '../../components/accordion'; -import { TextEditor } from '../../components/textEditor'; -import { EditorState } from 'lexical'; - -type Props = { - activeTemplate: string; -}; - -export function ReportingForm(props: Props) { - const [loading, setLoading] = useState(false); - const [editorState, setEditorState] = useState<EditorState | undefined>(undefined); - - return loading ? ( - <LoadingSpinner /> - ) : ( - props.activeTemplate && ( - <div> - <span className="text-lg text-secondary-600 font-bold mb-4">Report ID: {props.activeTemplate}</span> - <Accordion defaultOpenAll={true} className="border-t divide-y"> - <AccordionItem className="pt-2 pb-4"> - <AccordionHead showArrow={false}> - <span className="font-semibold">Recipient(s)</span> - </AccordionHead> - <AccordionBody> - <div> - <input className="border" /> - </div> - </AccordionBody> - </AccordionItem> - <AccordionItem className="pt-2 pb-4"> - <AccordionHead showArrow={false}> - <span className="font-semibold">Repeat</span> - </AccordionHead> - <AccordionBody> - <Input label="Frequency" type="dropdown" value={'Daily'} onChange={() => {}} options={['Daily', 'Weekly']} className="mb-1" /> - </AccordionBody> - </AccordionItem> - <AccordionItem className="pt-2 pb-4"> - <AccordionHead showArrow={false}> - <span className="font-semibold">Email template</span> - </AccordionHead> - <AccordionBody> - <TextEditor - editorState={editorState} - setEditorState={setEditorState} - showToolbar={true} - placeholder="Start typing your report template..." - /> - </AccordionBody> - </AccordionItem> - </Accordion> - </div> - ) - ); -} -- GitLab