diff --git a/packages/server/src/main/resources/diagram-resource.ts b/packages/server/src/main/resources/diagram-resource.ts index 766e1a4c..b827cf91 100644 --- a/packages/server/src/main/resources/diagram-resource.ts +++ b/packages/server/src/main/resources/diagram-resource.ts @@ -30,11 +30,49 @@ export class DiagramResource { } }; - publishDiagram = (req: Request, res: Response) => { - const diagram: DiagramDTO = req.body; - this.diagramService.saveDiagramAndGenerateTokens(diagram).then((token: string) => { - res.status(200).send(token); - }); + publishDiagramVersion = (req: Request, res: Response) => { + const diagram: DiagramDTO = req.body.diagram; + const existingToken: string | undefined = req.body.token; + this.diagramService + .saveDiagramVersion(diagram, existingToken) + .then((savedDiagram) => { + res.status(200).send(savedDiagram); + }) + .catch((error) => { + console.error(error); + res.status(503).send('Error occurred while publishing'); + }); + }; + + deleteDiagramVersion = (req: Request, res: Response) => { + const token: string = req.params.token; + const versionIndex: number = req.body.versionIndex; + + this.diagramService + .deleteDiagramVersion(token, versionIndex) + .then((deletedDiagramVersion) => { + res.status(200).send(deletedDiagramVersion); + }) + .catch((error) => { + console.error(error); + res.status(503).send('Error occurred while deleting version'); + }); + }; + + editDiagramVersion = (req: Request, res: Response) => { + const token: string = req.params.token; + const versionIndex: number = req.body.versionIndex; + const title: string = req.body.title; + const description: string = req.body.description; + this.diagramService + .editDiagramVersion(token, versionIndex, title, description) + .then((editedDiagram) => { + res.status(200).send(editedDiagram); + }) + .catch((error) => { + console.error(error); + res.status(503).send('Error occurred while editing version'); + }); }; convertSvgToPdf = (req: Request, res: Response) => { diff --git a/packages/server/src/main/routes.ts b/packages/server/src/main/routes.ts index 1109b85b..39a22cf9 100644 --- a/packages/server/src/main/routes.ts +++ b/packages/server/src/main/routes.ts @@ -18,7 +18,9 @@ export const register = (app: express.Application) => { // routes router.get('/diagrams/:token', (req, res) => diagramResource.getDiagram(req, res)); - router.post('/diagrams/publish', (req, res) => diagramResource.publishDiagram(req, res)); + router.post('/diagrams/publish', (req, res) => diagramResource.publishDiagramVersion(req, res)); + router.delete('/diagrams/:token', (req, res) => diagramResource.deleteDiagramVersion(req, res)); + router.post('/diagrams/:token', (req, res) => diagramResource.editDiagramVersion(req, res)); router.post('/diagrams/pdf', (req, res) => diagramResource.convertSvgToPdf(req, res)); app.use('/api', router); }; diff --git a/packages/server/src/main/services/diagram-service/diagram-service.ts b/packages/server/src/main/services/diagram-service/diagram-service.ts index d19df827..bfb635a9 100644 --- a/packages/server/src/main/services/diagram-service/diagram-service.ts +++ b/packages/server/src/main/services/diagram-service/diagram-service.ts @@ -10,11 +10,84 @@ export class DiagramService { this.storageService = storageService; } - saveDiagramAndGenerateTokens(diagramDTO: DiagramDTO): Promise { - // alpha numeric token with length = tokenLength - const token = randomString(tokenLength); - return this.storageService.saveDiagram(diagramDTO, token); + async saveDiagramVersion( + diagramDTO: DiagramDTO, + existingToken?: string, + ): Promise<{ diagramToken: string; diagram: DiagramDTO }> { + const diagramExists = existingToken !== undefined && (await this.storageService.diagramExists(existingToken)); + const diagramToken = diagramExists ? existingToken : randomString(tokenLength); + const diagram = !diagramExists ? diagramDTO : await this.getDiagramByLink(diagramToken); + const model = diagramDTO.model; + const title = diagramDTO.title; + const description = diagramDTO.description; + + if (!diagram) { + throw Error(`Could not retrieve a saved diagram with the token ${diagramToken}`); + } + + if (!diagram.versions) { + diagram.versions = []; + } + + diagram.model = model; + diagram.token = diagramToken; + diagram.title = title; + diagram.description = description; + + const newDiagramVersion = { + ...diagramDTO, + lastUpdate: new Date().toISOString(), + }; + // Remove token and versions fields from newDiagramVersion + delete newDiagramVersion.token; + delete newDiagramVersion.versions; + + diagram.versions.push(newDiagramVersion); + await this.storageService.saveDiagram(diagram, diagramToken); + + return { diagramToken, diagram }; } + + async deleteDiagramVersion(token: string, versionIndex: number): Promise { + const diagram = await this.getDiagramByLink(token); + + if (!diagram) { + throw Error(`Could not retrieve a saved diagram with the token ${token}`); + } + + if (!diagram.versions) { + throw Error(`Diagram with the token ${token} doesn't have any versions`); + } + + diagram.versions.splice(versionIndex, 1); + await this.storageService.saveDiagram(diagram, token); + + return diagram; + } + + async editDiagramVersion( + token: string, + versionIndex: number, + title: string, + description: string, + ): Promise { + const diagram = await this.getDiagramByLink(token); + + if (!diagram) { + throw Error(`Could not retrieve a saved diagram with the token ${token}`); + } + + if (!diagram.versions) { + throw Error(`Diagram with the token ${token} doesn't have any versions`); + } + + diagram.versions[versionIndex].title = title; + diagram.versions[versionIndex].description = description; + await this.storageService.saveDiagram(diagram, token); + + return diagram; + } + getDiagramByLink(token: string): Promise { return this.storageService.getDiagramByLink(token); } diff --git a/packages/server/src/main/services/diagram-storage/diagram-file-storage-service.ts b/packages/server/src/main/services/diagram-storage/diagram-file-storage-service.ts index aa9f87a0..25d78214 100644 --- a/packages/server/src/main/services/diagram-storage/diagram-file-storage-service.ts +++ b/packages/server/src/main/services/diagram-storage/diagram-file-storage-service.ts @@ -69,7 +69,7 @@ export class DiagramFileStorageService implements DiagramStorageService { ); } - async saveDiagram(diagramDTO: DiagramDTO, token: string, shared: boolean = false): Promise { + async saveDiagram(diagramDTO: DiagramDTO, token: string, shared: boolean = true): Promise { const path = this.getFilePathForToken(token); const exists = await this.diagramExists(path); diff --git a/packages/server/src/main/services/diagram-storage/diagram-redis-storage-service.ts b/packages/server/src/main/services/diagram-storage/diagram-redis-storage-service.ts index 1c77d591..43785507 100644 --- a/packages/server/src/main/services/diagram-storage/diagram-redis-storage-service.ts +++ b/packages/server/src/main/services/diagram-storage/diagram-redis-storage-service.ts @@ -85,7 +85,7 @@ export class DiagramRedisStorageService implements DiagramStorageService { ); } - async saveDiagram(diagramDTO: DiagramDTO, token: string, shared: boolean = false): Promise { + async saveDiagram(diagramDTO: DiagramDTO, token: string, shared: boolean = true): Promise { const key = this.getKeyForToken(token); const exists = await this.diagramExists(key); diff --git a/packages/shared/src/diagram-dto.ts b/packages/shared/src/diagram-dto.ts index da483206..c94aa2b9 100644 --- a/packages/shared/src/diagram-dto.ts +++ b/packages/shared/src/diagram-dto.ts @@ -5,11 +5,16 @@ export class DiagramDTO { title: string; model: UMLModel; lastUpdate: string; + versions?: DiagramDTO[]; + description?: string; + token?: string; - constructor(id: string, title: string, model: UMLModel, lastUpdate: string) { + constructor(id: string, title: string, model: UMLModel, lastUpdate: string, versions: DiagramDTO[], token: string) { this.id = id; this.title = title; this.model = model; this.lastUpdate = lastUpdate; + this.versions = versions; + this.token = token; } } diff --git a/packages/webapp/src/main/application.tsx b/packages/webapp/src/main/application.tsx index 1d9967c1..b21a08ac 100644 --- a/packages/webapp/src/main/application.tsx +++ b/packages/webapp/src/main/application.tsx @@ -12,6 +12,7 @@ import { ToastContainer } from 'react-toastify'; import { PostHogProvider } from 'posthog-js/react'; import { ApplicationStore } from './components/store/application-store'; import { ApollonEditorComponentWithConnection } from './components/apollon-editor-component/ApollonEditorComponentWithConnection'; +import { VersionManagementSidebar } from './components/version-management-sidebar/VersionManagementSidebar'; const postHogOptions = { api_host: POSTHOG_HOST, @@ -31,6 +32,7 @@ export function RoutedApplication() { + {isFirefox && } } /> diff --git a/packages/webapp/src/main/components/.DS_Store b/packages/webapp/src/main/components/.DS_Store new file mode 100644 index 00000000..5008ddfc Binary files /dev/null and b/packages/webapp/src/main/components/.DS_Store differ diff --git a/packages/webapp/src/main/components/apollon-editor-component/ApollonEditorComponent.tsx b/packages/webapp/src/main/components/apollon-editor-component/ApollonEditorComponent.tsx index 2ffd063b..cbbe8326 100644 --- a/packages/webapp/src/main/components/apollon-editor-component/ApollonEditorComponent.tsx +++ b/packages/webapp/src/main/components/apollon-editor-component/ApollonEditorComponent.tsx @@ -6,6 +6,7 @@ import { uuid } from '../../utils/uuid'; import { setCreateNewEditor, updateDiagramThunk, selectCreatenewEditor } from '../../services/diagram/diagramSlice'; import { ApollonEditorContext } from './apollon-editor-context'; import { useAppDispatch, useAppSelector } from '../store/hooks'; +import { selectPreviewedDiagramIndex } from '../../services/version-management/versionManagementSlice'; const ApollonContainer = styled.div` display: flex; @@ -21,18 +22,27 @@ export const ApollonEditorComponent: React.FC = () => { const { diagram: reduxDiagram } = useAppSelector((state) => state.diagram); const options = useAppSelector((state) => state.diagram.editorOptions); const createNewEditor = useAppSelector(selectCreatenewEditor); - const editorContext = useContext(ApollonEditorContext); - const setEditor = editorContext?.setEditor; + const previewedDiagramIndex = useAppSelector(selectPreviewedDiagramIndex); + const { setEditor } = useContext(ApollonEditorContext); useEffect(() => { - const initializeEditor = async () => { - if (containerRef.current != null && createNewEditor) { + const setupEditor = async () => { + if (!containerRef.current) return; + + if (createNewEditor || previewedDiagramIndex === -1) { + // Initialize or reset editor + if (editorRef.current) { + await editorRef.current.nextRender; + editorRef.current.destroy(); + } editorRef.current = new ApollonEditor(containerRef.current, options); - await editorRef.current?.nextRender; + await editorRef.current.nextRender; + // Load diagram model if available if (reduxDiagram.model) { editorRef.current.model = reduxDiagram.model; } + editorRef.current.subscribeToModelChange((model: UMLModel) => { const diagram = { ...reduxDiagram, model }; dispatch(updateDiagramThunk(diagram)); @@ -40,11 +50,23 @@ export const ApollonEditorComponent: React.FC = () => { setEditor!(editorRef.current); dispatch(setCreateNewEditor(false)); + } else if (previewedDiagramIndex !== -1 && editorRef.current) { + // Handle preview mode + const editorOptions = { ...options, readonly: true }; + await editorRef.current.nextRender; + editorRef.current.destroy(); + editorRef.current = new ApollonEditor(containerRef.current, editorOptions); + await editorRef.current.nextRender; + + const modelToPreview = reduxDiagram?.versions && reduxDiagram.versions[previewedDiagramIndex]?.model; + if (modelToPreview) { + editorRef.current.model = modelToPreview; + } } }; - initializeEditor(); - }, [containerRef.current, createNewEditor]); + setupEditor(); + }, [createNewEditor, previewedDiagramIndex]); const key = reduxDiagram?.id || uuid(); diff --git a/packages/webapp/src/main/components/apollon-editor-component/ApollonEditorComponentWithConnection.tsx b/packages/webapp/src/main/components/apollon-editor-component/ApollonEditorComponentWithConnection.tsx index 4a8b6ff1..eed41b49 100644 --- a/packages/webapp/src/main/components/apollon-editor-component/ApollonEditorComponentWithConnection.tsx +++ b/packages/webapp/src/main/components/apollon-editor-component/ApollonEditorComponentWithConnection.tsx @@ -39,8 +39,7 @@ export const ApollonEditorComponentWithConnection: React.FC = () => { const { diagram: reduxDiagram } = useAppSelector((state) => state.diagram); const options = useAppSelector((state) => state.diagram.editorOptions); const createNewEditor = useAppSelector(selectCreatenewEditor); - const editorContext = useContext(ApollonEditorContext); - const setEditor = editorContext!.setEditor; + const { setEditor } = useContext(ApollonEditorContext); const [searchParams] = useSearchParams(); const view = searchParams.get('view'); const navigate = useNavigate(); @@ -120,6 +119,7 @@ export const ApollonEditorComponentWithConnection: React.FC = () => { }); } }; + useEffect(() => { const initializeEditor = async () => { const shouldConnectToServer = diff --git a/packages/webapp/src/main/components/apollon-editor-component/apollon-editor-context.ts b/packages/webapp/src/main/components/apollon-editor-component/apollon-editor-context.ts index 50bee4d4..22aa3381 100644 --- a/packages/webapp/src/main/components/apollon-editor-component/apollon-editor-context.ts +++ b/packages/webapp/src/main/components/apollon-editor-component/apollon-editor-context.ts @@ -6,6 +6,11 @@ export type ApollonEditorContextType = { setEditor: (editor: ApollonEditor) => void; }; -export const ApollonEditorContext = createContext(null); +// Provide a default no-op function for `setEditor` +export const ApollonEditorContext = createContext({ + setEditor: () => { + throw new Error("setEditor is not defined. Make sure to wrap your component within ApollonEditorProvider."); + }, +}); export const { Consumer: ApollonEditorConsumer, Provider: ApollonEditorProvider } = ApollonEditorContext; diff --git a/packages/webapp/src/main/components/application-bar/application-bar.tsx b/packages/webapp/src/main/components/application-bar/application-bar.tsx index 5e11ef9e..e964ec90 100644 --- a/packages/webapp/src/main/components/application-bar/application-bar.tsx +++ b/packages/webapp/src/main/components/application-bar/application-bar.tsx @@ -11,6 +11,8 @@ import { ConnectClientsComponent } from './connected-clients-component'; import { useAppDispatch, useAppSelector } from '../store/hooks'; import { updateDiagramThunk } from '../../services/diagram/diagramSlice'; import { showModal } from '../../services/modal/modalSlice'; +import { LayoutTextSidebarReverse } from 'react-bootstrap-icons'; +import { selectDisplaySidebar, toggleSidebar } from '../../services/version-management/versionManagementSlice'; const DiagramTitle = styled.input` font-size: x-large; @@ -26,12 +28,18 @@ const ApplicationVersion = styled.span` margin-right: 10px; `; +const MainContent = styled.div<{ $isSidebarOpen: boolean }>` + transition: margin-right 0.3s ease; + margin-right: ${(props) => (props.$isSidebarOpen ? '250px' : '0')}; /* Adjust based on sidebar width */ +`; + export const ApplicationBar: React.FC = () => { const dispatch = useAppDispatch(); - const { diagram } = useAppSelector((state) => state.diagram); - const [diagramTitle, setDiagramTitle] = useState(diagram?.title || ''); + const isSidebarOpen = useAppSelector(selectDisplaySidebar); + const urlPath = window.location.pathname; + const tokenInUrl = urlPath.substring(1); // This removes the leading "/" useEffect(() => { if (diagram?.title) { @@ -54,33 +62,46 @@ export const ApplicationBar: React.FC = () => { }; return ( - - - {' '} - Apollon - - {appVersion} - - - - - - - + + + + {' '} + Apollon + + {appVersion} + + + + + {!tokenInUrl && ( + { + dispatch(toggleSidebar()); + }} + > +
+ +
+
+ )} + {tokenInUrl && } + +
+
); }; diff --git a/packages/webapp/src/main/components/error-handling/error-panel.tsx b/packages/webapp/src/main/components/error-handling/error-panel.tsx index c5ee7761..18e7804d 100644 --- a/packages/webapp/src/main/components/error-handling/error-panel.tsx +++ b/packages/webapp/src/main/components/error-handling/error-panel.tsx @@ -1,17 +1,25 @@ import React from 'react'; +import styled from 'styled-components'; import { ErrorMessage } from './error-message'; import { useAppDispatch, useAppSelector } from '../store/hooks'; import { dismissError } from '../../services/error-management/errorManagementSlice'; +import { selectDisplaySidebar } from '../../services/version-management/versionManagementSlice'; + +const MainContent = styled.div<{ $isSidebarOpen: boolean }>` + transition: margin-right 0.3s ease; + margin-right: ${(props) => (props.$isSidebarOpen ? '250px' : '0')}; /* Adjust based on sidebar width */ +`; export const ErrorPanel: React.FC = () => { const errors = useAppSelector((state) => state.errors); + const isSidebarOpen = useAppSelector(selectDisplaySidebar); const dispatch = useAppDispatch(); return ( - <> + {errors.map((error, index) => ( dispatch(dismissError(apollonError.id))} key={index} /> ))} - + ); }; diff --git a/packages/webapp/src/main/components/incompatability-hints/firefox-incompatibility-hint.tsx b/packages/webapp/src/main/components/incompatability-hints/firefox-incompatibility-hint.tsx index 4995c07a..83859e1b 100644 --- a/packages/webapp/src/main/components/incompatability-hints/firefox-incompatibility-hint.tsx +++ b/packages/webapp/src/main/components/incompatability-hints/firefox-incompatibility-hint.tsx @@ -1,13 +1,24 @@ import React, { ReactElement, useState } from 'react'; import { Alert } from 'react-bootstrap'; +import { useAppSelector } from '../store/hooks'; +import { selectDisplaySidebar } from '../../services/version-management/versionManagementSlice'; +import { styled } from 'styled-components'; + +const MainContent = styled.div<{ $isSidebarOpen: boolean }>` + transition: margin-right 0.3s ease; + margin-right: ${(props) => (props.$isSidebarOpen ? '250px' : '0')}; /* Adjust based on sidebar width */ +`; export const FirefoxIncompatibilityHint: React.FC = (): ReactElement | null => { const [show, setShow] = useState(true); + const isSidebarOpen = useAppSelector(selectDisplaySidebar); return ( - setShow(false)} dismissible show={show}> - {' '} - Firefox is not fully supported - some features might not work. Please use another browser (latest Chrome or - Safari) to make sure all features are working as expected. - + + setShow(false)} dismissible show={show}> + {' '} + Firefox is not fully supported - some features might not work. Please use another browser (latest Chrome or + Safari) to make sure all features are working as expected. + + ); }; diff --git a/packages/webapp/src/main/components/modals/application-modal-content.ts b/packages/webapp/src/main/components/modals/application-modal-content.ts index a7cb1b01..bc3217a3 100644 --- a/packages/webapp/src/main/components/modals/application-modal-content.ts +++ b/packages/webapp/src/main/components/modals/application-modal-content.ts @@ -7,6 +7,10 @@ import { CreateDiagramModal } from './create-diagram-modal/create-diagram-modal' import { CreateFromTemplateModal } from './create-diagram-from-template-modal/create-from-template-modal'; import { ShareModal } from './share-modal/share-modal'; import { CollaborationModal } from './collaboration-modal/collaboration-modal'; +import { DeleteVersionModal } from './delete-version-modal/delete-version-modal'; +import { RestoreVersionModal } from './restore-version-modal/restore-version-modal'; +import { EditVersionModal } from './edit-version-info-modal/edit-version-info-modal'; +import { CreateVersionModal } from './create-version-modal/create-version-modal'; export const ApplicationModalContent: { [key in ModalContentType]: React.FC } = { [ModalContentType.HelpModelingModal]: HelpModelingModal, @@ -17,4 +21,8 @@ export const ApplicationModalContent: { [key in ModalContentType]: React.FC = ({ close }) => { const createNewDiagram = (diagramType: UMLDiagramType) => { dispatch(createDiagram({ title, diagramType })); + dispatch(setPreviewedDiagramIndex(-1)); posthog.capture('diagram_created', { title, diff --git a/packages/webapp/src/main/components/modals/create-version-modal/create-version-modal.tsx b/packages/webapp/src/main/components/modals/create-version-modal/create-version-modal.tsx new file mode 100644 index 00000000..bb6d4f67 --- /dev/null +++ b/packages/webapp/src/main/components/modals/create-version-modal/create-version-modal.tsx @@ -0,0 +1,98 @@ +import React, { useState } from 'react'; +import { Button, FormControl, InputGroup, Modal } from 'react-bootstrap'; +import { ModalContentProps } from '../application-modal-types'; +import { toast } from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; +import { selectDiagram, updateDiagramThunk } from '../../../services/diagram/diagramSlice'; +import { LocalStorageRepository } from '../../../services/local-storage/local-storage-repository'; +import { displayError } from '../../../services/error-management/errorManagementSlice'; +import { DiagramRepository } from '../../../services/diagram/diagram-repository'; +import { setDisplayUnpublishedVersion } from '../../../services/diagram/diagramSlice'; + +export const CreateVersionModal: React.FC = ({ close }) => { + const dispatch = useAppDispatch(); + const diagram = useAppSelector(selectDiagram); + const [title, setTitle] = useState(diagram.title); + const [description, setDescription] = useState(''); + + const displayToast = () => { + toast.success(`You have successfuly published a new version`, { + autoClose: 10000, + }); + }; + + const createNewVersion = () => { + if (!diagram || !diagram.model || Object.keys(diagram.model.elements).length === 0) { + dispatch( + displayError( + 'Publishing version fialed', + 'You are trying to publish an empty diagram. Please insert at least one element to the canvas before publishing.', + ), + ); + + return; + } + + const token = diagram.token; + const diagramCopy = Object.assign({}, diagram); + diagramCopy.title = title; + diagramCopy.description = description; + + DiagramRepository.publishDiagramVersionOnServer(diagramCopy, token) + .then((res) => { + dispatch(updateDiagramThunk(res.diagram)); + dispatch(setDisplayUnpublishedVersion(false)); + LocalStorageRepository.setLastPublishedToken(res.diagramToken); + displayToast(); + }) + .catch((error) => { + dispatch( + displayError('Connection failed', 'Connection to the server failed. Please try again or report a problem.'), + ); + console.error(error); + }) + .finally(() => { + close(); + }); + }; + + return ( + <> + + Create Version + + + <> + + + setTitle(e.target.value)} + value={title} + /> + + + + setDescription(e.target.value)} + as={'textarea'} + value={description} + /> + + + + + + + + + ); +}; diff --git a/packages/webapp/src/main/components/modals/delete-version-modal/delete-version-modal.tsx b/packages/webapp/src/main/components/modals/delete-version-modal/delete-version-modal.tsx new file mode 100644 index 00000000..29b6962d --- /dev/null +++ b/packages/webapp/src/main/components/modals/delete-version-modal/delete-version-modal.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { Button, Modal } from 'react-bootstrap'; +import { ModalContentProps } from '../application-modal-types'; +import { toast } from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; +import { selectDiagram, updateDiagramThunk } from '../../../services/diagram/diagramSlice'; +import { displayError } from '../../../services/error-management/errorManagementSlice'; +import { DiagramRepository } from '../../../services/diagram/diagram-repository'; +import { + selectVersionActionIndex, + setPreviewedDiagramIndex, +} from '../../../services/version-management/versionManagementSlice'; + +export const DeleteVersionModal: React.FC = ({ close }) => { + const dispatch = useAppDispatch(); + const diagram = useAppSelector(selectDiagram); + const versionActionIndex = useAppSelector(selectVersionActionIndex); + + const displayToast = () => { + toast.success(`You have successfuly deleted the chosen diagram version`, { + autoClose: 10000, + }); + }; + + const deleteVersion = () => { + const token = diagram.token; + + if (!token || !diagram.versions) { + dispatch(displayError('Deleting failed', 'Can not delete version that is not published on the server.')); + close(); + + return; + } + + DiagramRepository.deleteDiagramVersionOnServer(token, versionActionIndex) + .then((diagram) => { + dispatch(updateDiagramThunk({ versions: diagram.versions })); + dispatch(setPreviewedDiagramIndex(-1)); + displayToast(); + }) + .catch((error) => { + dispatch( + displayError('Connection failed', 'Connection to the server failed. Please try again or report a problem.'), + ); + console.error(error); + }) + .finally(() => { + close(); + }); + }; + + return ( + <> + + Delete Version + + +

Are you sure you want to delete the version {diagram.title}? This change is irreversible.

+
+ + + + + + ); +}; diff --git a/packages/webapp/src/main/components/modals/edit-version-info-modal/edit-version-info-modal.tsx b/packages/webapp/src/main/components/modals/edit-version-info-modal/edit-version-info-modal.tsx new file mode 100644 index 00000000..9741645b --- /dev/null +++ b/packages/webapp/src/main/components/modals/edit-version-info-modal/edit-version-info-modal.tsx @@ -0,0 +1,89 @@ +import React, { useState } from 'react'; +import { Button, FormControl, InputGroup, Modal } from 'react-bootstrap'; +import { ModalContentProps } from '../application-modal-types'; +import { toast } from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; +import { selectDiagram, setCreateNewEditor, updateDiagramThunk } from '../../../services/diagram/diagramSlice'; +import { selectVersionActionIndex } from '../../../services/version-management/versionManagementSlice'; +import { DiagramRepository } from '../../../services/diagram/diagram-repository'; +import { displayError } from '../../../services/error-management/errorManagementSlice'; + +export const EditVersionModal: React.FC = ({ close }) => { + const dispatch = useAppDispatch(); + const diagram = useAppSelector(selectDiagram); + const versionActionIndex = useAppSelector(selectVersionActionIndex); + const [title, setTitle] = useState(diagram.versions![versionActionIndex].title); + const [description, setDescription] = useState(diagram.versions![versionActionIndex].description || ''); + const displayToast = () => { + toast.success(`Successfully edited diagram version`, { + autoClose: 10000, + }); + }; + + const editVersionInfo = () => { + const token = diagram.token; + + if (!token || !diagram.versions) { + dispatch(displayError('Editing failed', 'Can not edit version that is not published on the server.')); + close(); + + return; + } + + DiagramRepository.editDiagramVersionOnServer(token, versionActionIndex, title, description) + .then((diagram) => { + dispatch(updateDiagramThunk({ versions: diagram.versions })); + dispatch(setCreateNewEditor(true)); + displayToast(); + }) + .catch((error) => { + dispatch( + displayError('Connection failed', 'Connection to the server failed. Please try again or report a problem.'), + ); + console.error(error); + }) + .finally(() => { + close(); + }); + }; + + return ( + <> + + Edit Version Info + + + <> + + + setTitle(e.target.value)} + /> + + + + setDescription(e.target.value)} + /> + + + + + + + + + ); +}; diff --git a/packages/webapp/src/main/components/modals/load-diagram-modal/load-diagram-modal.tsx b/packages/webapp/src/main/components/modals/load-diagram-modal/load-diagram-modal.tsx index f9bd186c..02134334 100644 --- a/packages/webapp/src/main/components/modals/load-diagram-modal/load-diagram-modal.tsx +++ b/packages/webapp/src/main/components/modals/load-diagram-modal/load-diagram-modal.tsx @@ -9,6 +9,7 @@ import { ModalContentProps } from '../application-modal-types'; import { loadDiagram } from '../../../services/diagram/diagramSlice'; import { ApollonEditorContext } from '../../apollon-editor-component/apollon-editor-context'; import { useNavigate } from 'react-router-dom'; +import { setPreviewedDiagramIndex } from '../../../services/version-management/versionManagementSlice'; export const LoadDiagramModal: React.FC = ({ close }) => { const { diagram } = useAppSelector((state) => state.diagram); @@ -25,6 +26,7 @@ export const LoadDiagramModal: React.FC = ({ close }) => { const loadedDiagram = fromlocalStorage(id); if (loadedDiagram && loadedDiagram.model && editorContext?.editor) { dispatch(loadDiagram(loadedDiagram)); + dispatch(setPreviewedDiagramIndex(-1)); navigate('/', { relative: 'path' }); } close(); diff --git a/packages/webapp/src/main/components/modals/restore-version-modal/restore-version-modal.tsx b/packages/webapp/src/main/components/modals/restore-version-modal/restore-version-modal.tsx new file mode 100644 index 00000000..a48340bd --- /dev/null +++ b/packages/webapp/src/main/components/modals/restore-version-modal/restore-version-modal.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { Button, Modal } from 'react-bootstrap'; +import { ModalContentProps } from '../application-modal-types'; +import { toast } from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; +import { + selectDiagram, + setDisplayUnpublishedVersion, + updateDiagramThunk, +} from '../../../services/diagram/diagramSlice'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; +import { + selectVersionActionIndex, + setPreviewedDiagramIndex, +} from '../../../services/version-management/versionManagementSlice'; +import { LocalStorageRepository } from '../../../services/local-storage/local-storage-repository'; +import { displayError } from '../../../services/error-management/errorManagementSlice'; +import { DiagramRepository } from '../../../services/diagram/diagram-repository'; +import { useNavigate } from 'react-router-dom'; + +export const RestoreVersionModal: React.FC = ({ close }) => { + const dispatch = useAppDispatch(); + const diagram = useAppSelector(selectDiagram); + const versionActionIndex = useAppSelector(selectVersionActionIndex); + const navigate = useNavigate(); + + const displayToast = () => { + toast.success(`You have successfuly restored the chosen diagram version`, { + autoClose: 10000, + }); + }; + + const restoreVersion = () => { + const token = diagram.token; + + if (!token || !diagram.versions) { + dispatch(displayError('Restore failed', 'Can not restore version that is not published on the server.')); + close(); + + return; + } + + // Restore version + const diagramCopy = Object.assign({}, diagram); + diagramCopy.model = diagram.versions[versionActionIndex].model; + diagramCopy.title = `Restored: ${diagram.versions[versionActionIndex].title}`; + diagramCopy.description = diagram.versions[versionActionIndex].description; + + DiagramRepository.publishDiagramVersionOnServer(diagramCopy, token) + .then((res) => { + dispatch(updateDiagramThunk(res.diagram)); + dispatch(setPreviewedDiagramIndex(-1)); + dispatch(setDisplayUnpublishedVersion(false)); + LocalStorageRepository.setLastPublishedToken(res.diagramToken); + displayToast(); + }) + .catch((error) => { + dispatch( + displayError('Connection failed', 'Connection to the server failed. Please try again or report a problem.'), + ); + console.error(error); + }) + .finally(() => { + close(); + }); + }; + + return ( + <> + + Restore Version + + +

Are you sure you want to restore the version {diagram.title}?

+
+ + + + + + ); +}; diff --git a/packages/webapp/src/main/components/modals/share-modal/share-modal.tsx b/packages/webapp/src/main/components/modals/share-modal/share-modal.tsx index b7e8b7d8..87fbb57b 100644 --- a/packages/webapp/src/main/components/modals/share-modal/share-modal.tsx +++ b/packages/webapp/src/main/components/modals/share-modal/share-modal.tsx @@ -11,20 +11,22 @@ import { InfoCircle } from 'react-bootstrap-icons'; import { useAppDispatch, useAppSelector } from '../../store/hooks'; import { displayError } from '../../../services/error-management/errorManagementSlice'; import { useNavigate } from 'react-router-dom'; -import { setCreateNewEditor } from '../../../services/diagram/diagramSlice'; +import { setDisplayUnpublishedVersion, updateDiagramThunk } from '../../../services/diagram/diagramSlice'; +import { selectDisplaySidebar, toggleSidebar } from '../../../services/version-management/versionManagementSlice'; export const ShareModal: React.FC = ({ close }) => { const dispatch = useAppDispatch(); const diagram = useAppSelector((state) => state.diagram.diagram); const navigate = useNavigate(); + const isSidebarDisplayed = useAppSelector(selectDisplaySidebar); const urlPath = window.location.pathname; const tokenInUrl = urlPath.substring(1); // This removes the leading "/" - const getLinkForView = () => { - return `${DEPLOYMENT_URL}/${LocalStorageRepository.getLastPublishedToken()}?view=${LocalStorageRepository.getLastPublishedType()}`; + const getLinkForView = (token?: string) => { + return `${DEPLOYMENT_URL}/${token || LocalStorageRepository.getLastPublishedToken()}?view=${LocalStorageRepository.getLastPublishedType()}`; }; - const getMessageForView = (view: DiagramView) => { + const getMessageForView = (view: string) => { switch (view) { case DiagramView.GIVE_FEEDBACK: return 'give feedback'; @@ -38,10 +40,13 @@ export const ShareModal: React.FC = ({ close }) => { return 'edit'; }; - const copyLink = (view?: DiagramView) => { - const link = getLinkForView(); + const copyLink = (view?: DiagramView, token?: string) => { + const link = getLinkForView(token); navigator.clipboard.writeText(link); - const viewUsedInMessage = view ? getMessageForView(view) : LocalStorageRepository.getLastPublishedType(); + const lastPublishedTypeLocalStorage = LocalStorageRepository.getLastPublishedType(); + const viewUsedInMessage = view + ? getMessageForView(view) + : getMessageForView(lastPublishedTypeLocalStorage || DiagramView.EDIT); toast.success( 'The link has been copied to your clipboard and can be shared to ' + @@ -54,35 +59,27 @@ export const ShareModal: React.FC = ({ close }) => { }; const handleShareButtonPress = (view: DiagramView) => { + const token = tokenInUrl ? tokenInUrl : publishDiagram(); LocalStorageRepository.setLastPublishedType(view); - if (tokenInUrl) { - copyLink(view); - navigate(`/${tokenInUrl}?view=${view}`); - close(); - } else { - publishDiagram(view); + if (token) { + LocalStorageRepository.setLastPublishedToken(token); + } + + copyLink(view, token); + close(); + + if (view === DiagramView.COLLABORATE) { + if (isSidebarDisplayed) { + dispatch(toggleSidebar()); + } + + navigate(`/${token || LocalStorageRepository.getLastPublishedToken()}?view=${view}`); } }; - const publishDiagram = (view: DiagramView) => { - if (diagram && diagram.model && Object.keys(diagram.model.elements).length > 0) { - DiagramRepository.publishDiagramOnServer(diagram) - .then((token: string) => { - LocalStorageRepository.setLastPublishedToken(token); - copyLink(view); - dispatch(setCreateNewEditor(true)); - navigate(`/${token}?view=${view}`); - close(); - }) - .catch((error) => { - dispatch( - displayError('Connection failed', 'Connection to the server failed. Please try again or report a problem.'), - ); - close(); - console.error(error); - }); - } else { + const publishDiagram = () => { + if (!diagram || !diagram.model || Object.keys(diagram.model.elements).length === 0) { dispatch( displayError( 'Sharing diagram failed', @@ -90,7 +87,30 @@ export const ShareModal: React.FC = ({ close }) => { ), ); close(); + + return; } + + let token = diagram.token; + const diagramCopy = Object.assign({}, diagram); + diagramCopy.title = 'New shared version '; + diagramCopy.description = 'Your auto-generated version for sharing'; + + DiagramRepository.publishDiagramVersionOnServer(diagramCopy, diagram.token) + .then((res) => { + dispatch(updateDiagramThunk(res.diagram)); + dispatch(setDisplayUnpublishedVersion(false)); + token = res.diagramToken; + }) + .catch((error) => { + dispatch( + displayError('Connection failed', 'Connection to the server failed. Please try again or report a problem.'), + ); + // tslint:disable-next-line:no-console + console.error(error); + }); + + return token; }; const hasRecentlyPublished = () => { diff --git a/packages/webapp/src/main/components/store/application-store.tsx b/packages/webapp/src/main/components/store/application-store.tsx index 3fa71b18..c84ade51 100644 --- a/packages/webapp/src/main/components/store/application-store.tsx +++ b/packages/webapp/src/main/components/store/application-store.tsx @@ -6,6 +6,7 @@ import { diagramReducer } from '../../services/diagram/diagramSlice'; import { errorReducer } from '../../services/error-management/errorManagementSlice'; import { modalReducer } from '../../services/modal/modalSlice'; import { shareReducer } from '../../services/share/shareSlice'; +import { versionManagementReducer } from '../../services/version-management/versionManagementSlice'; const store = configureStore({ reducer: { @@ -13,6 +14,7 @@ const store = configureStore({ errors: errorReducer, modal: modalReducer, share: shareReducer, + versionManagement: versionManagementReducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware(), devTools: process.env.NODE_ENV !== 'production', // Enable Redux DevTools in non-production environments diff --git a/packages/webapp/src/main/components/version-management-sidebar/TimelineHeader.tsx b/packages/webapp/src/main/components/version-management-sidebar/TimelineHeader.tsx new file mode 100644 index 00000000..122cf029 --- /dev/null +++ b/packages/webapp/src/main/components/version-management-sidebar/TimelineHeader.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import styled from 'styled-components'; +import { useAppDispatch } from '../store/hooks'; +import { showModal } from '../../services/modal/modalSlice'; +import { ModalContentType } from '../modals/application-modal-types'; +import { PlusLg } from 'react-bootstrap-icons'; + +const Header = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.8rem 1.5rem; + font-size: 0.9rem; + font-weight: 600; + border-bottom: 1px solid #e6e6e6; + color: var(--apollon-background-inverse); +`; + +const NewVersionButton = styled.div` + & { + display: flex; + align-items: center; + justify-content: center; + transition: 0.2s ease-in; + } + + &:hover { + color: #6f7174; + } +`; + +export const TimelineHeader: React.FC = () => { + const dispatch = useAppDispatch(); + + return ( +
+
Version History
+ { + dispatch(showModal({ type: ModalContentType.CreateVersionModal, size: 'lg' })); + }} + > + + +
+ ); +}; diff --git a/packages/webapp/src/main/components/version-management-sidebar/VersionManagementSidebar.tsx b/packages/webapp/src/main/components/version-management-sidebar/VersionManagementSidebar.tsx new file mode 100644 index 00000000..447b1376 --- /dev/null +++ b/packages/webapp/src/main/components/version-management-sidebar/VersionManagementSidebar.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import styled from 'styled-components'; +import { useAppSelector } from '../store/hooks'; +import { selectDisplaySidebar } from '../../services/version-management/versionManagementSlice'; +import { TimelineHeader } from './TimelineHeader'; +import { Timeline } from './timeline/Timeline'; + +const TimelineContainer = styled.div<{ $isOpen: boolean }>` + z-index: 1; + position: fixed; + top: 0; + right: 0; + height: 100%; + width: ${(props) => (props.$isOpen ? '250px' : '0')}; /* 0 width when closed */ + background-color: var(--apollon-background); + border-left: ${(props) => (props.$isOpen ? '1px solid #e6e6e6' : 'none')}; + overflow-y: auto; + transition: width 0.3s ease; +`; + +export const VersionManagementSidebar: React.FC = () => { + const isVersionManagementSidebarOpen = useAppSelector(selectDisplaySidebar); + + if (!isVersionManagementSidebarOpen) { + return null; + } + + return ( + + + + + ); +}; diff --git a/packages/webapp/src/main/components/version-management-sidebar/timeline/Timeline.tsx b/packages/webapp/src/main/components/version-management-sidebar/timeline/Timeline.tsx new file mode 100644 index 00000000..7d6421af --- /dev/null +++ b/packages/webapp/src/main/components/version-management-sidebar/timeline/Timeline.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import styled from 'styled-components'; +import { useAppSelector } from '../../store/hooks'; +import { selectPreviewedDiagramIndex } from '../../../services/version-management/versionManagementSlice'; +import { selectDisplayUnpublishedVersion } from '../../../services/diagram/diagramSlice'; +import { selectDiagram } from '../../../services/diagram/diagramSlice'; +import { TimelineVersion } from './timeline-version/TimelineVersion'; + +const TimelineVersions = styled.div` + max-height: 100%; + margin: 0.75rem 0; +`; + +export const Timeline: React.FC = () => { + const diagram = useAppSelector(selectDiagram); + const previewedDiagramIndex = useAppSelector(selectPreviewedDiagramIndex); + const displayUnpublishedVersion = useAppSelector(selectDisplayUnpublishedVersion); + const versions = diagram.versions ? diagram.versions : []; + + return ( +
+ + {displayUnpublishedVersion && ( + + )} + {versions + .slice() + .reverse() + .map((version, index) => ( + + ))} + +
+ ); +}; diff --git a/packages/webapp/src/main/components/version-management-sidebar/timeline/timeline-version/PreviewActions.tsx b/packages/webapp/src/main/components/version-management-sidebar/timeline/timeline-version/PreviewActions.tsx new file mode 100644 index 00000000..817257f8 --- /dev/null +++ b/packages/webapp/src/main/components/version-management-sidebar/timeline/timeline-version/PreviewActions.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { styled } from 'styled-components'; +import { useAppDispatch } from '../../../store/hooks'; +import { + setPreviewedDiagramIndex, + setVersionActionIndex, +} from '../../../../services/version-management/versionManagementSlice'; +import { showModal } from '../../../../services/modal/modalSlice'; +import { ModalContentType } from '../../../modals/application-modal-types'; + +const ActionsContainer = styled.div` + width: 100%; + display: flex; + justify-content: space-between; + margin-top: 0.25rem; + + div { + font-size: 0.75rem; + border-radius: 0.25rem; + transition: 0.2s ease-in; + padding: 0.25rem; + } + + div:hover { + background-color: var(--apollon-modal-bottom-border); + cursor: pointer; + } +`; + +type Props = { + index: number; +}; + +export const PreviewActions: React.FC = (props) => { + const dispatch = useAppDispatch(); + + return ( + +
{ + dispatch(setPreviewedDiagramIndex(-1)); + }} + > + Exit preview +
+
{ + dispatch(setVersionActionIndex(props.index)); + dispatch(showModal({ type: ModalContentType.RestoreVersionModal, size: 'lg' })); + }} + > + Restore version +
+
+ ); +}; diff --git a/packages/webapp/src/main/components/version-management-sidebar/timeline/timeline-version/TimelineVersion.tsx b/packages/webapp/src/main/components/version-management-sidebar/timeline/timeline-version/TimelineVersion.tsx new file mode 100644 index 00000000..3eebeabb --- /dev/null +++ b/packages/webapp/src/main/components/version-management-sidebar/timeline/timeline-version/TimelineVersion.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import styled from 'styled-components'; +import { Circle, RecordCircle } from 'react-bootstrap-icons'; +import { Diagram } from '../../../../services/diagram/diagramSlice'; +import { VersionActions } from './VersionActions'; +import { PreviewActions } from './PreviewActions'; +import { useAppSelector } from '../../../store/hooks'; +import { selectPreviewedDiagramIndex } from '../../../../services/version-management/versionManagementSlice'; +import { selectDisplayUnpublishedVersion } from '../../../../services/diagram/diagramSlice'; + +const FirstVerticalLine = styled.div` + width: 1px; + height: 100%; + background-color: #e6e6e6; + position: absolute; + margin-top: 20px; + z-index: -1; + top: 0; +`; + +const VerticalLine = styled.div` + width: 1px; + height: calc(100% + 0.5em); + background-color: #e6e6e6; + position: absolute; + z-index: -1; +`; + +const Version = styled.div` + position: relative; + padding: 0.25rem 0.75rem; + display: inline-flex; + width: 100%; +`; + +const VersionPosition = styled.div` + flex-shrink: 0; + border-radius: 5px; + margin-right: 0.25rem; + align-self: flex-start; + display: flex; + flex-direction: column; + align-items: center; + width: 24px; + background-color: var(--apollon-background); + color: var(--apollon-background-inverse); + + svg { + z-index: 1; + } +`; + +const VersionInfo = styled.div` + & { + min-height: 23px; + padding: 8px 12px; + display: flex; + flex-direction: column; + align-items: start; + justify-content: start; + background-color: var(--apollon-background-variant); + color: var(--apollon-background-inverse); + border: 1px solid var(--apollon-modal-bottom-border); + border-radius: 0.25rem; + transition: 0.2s ease-in; + width: 100%; + box-sizing: border-box; + } +`; + +type Props = { + isPreviewedVersion: boolean; + isFirstVersion: boolean; + isLastVersion: boolean; + index: number; + version?: Diagram; + isOnlyUnpublishedVersion?: boolean; +}; + +export const TimelineVersion: React.FC = (props) => { + const displayUnpublishedVersion = useAppSelector(selectDisplayUnpublishedVersion); + const previewedDiagramIndex = useAppSelector(selectPreviewedDiagramIndex); + const formatLastUpdated = (lastUpdated: Date) => { + return `${lastUpdated.getMonth()}/${lastUpdated.getDate()}/${lastUpdated.getFullYear()} ${String( + lastUpdated.getHours(), + ).padStart(2, '0')}:${String(lastUpdated.getMinutes()).padStart(2, '0')}`; + }; + + return ( + + + {props.index === -1 && !props.isOnlyUnpublishedVersion && } + {!props.isLastVersion && props.index !== -1 && } + {props.isPreviewedVersion || + (!displayUnpublishedVersion && previewedDiagramIndex === -1 && props.isFirstVersion) ? ( + + ) : ( + + )} + + {props.index === -1 && ( + +
Current Unpublished Version
+
+ )} + {props.index !== -1 && ( + +
{props.version!.title}
+
{props.version!.description}
+
+ {formatLastUpdated(new Date(props.version!.lastUpdate))} +
+ + {props.isPreviewedVersion && } +
+ )} +
+ ); +}; diff --git a/packages/webapp/src/main/components/version-management-sidebar/timeline/timeline-version/VersionActions.tsx b/packages/webapp/src/main/components/version-management-sidebar/timeline/timeline-version/VersionActions.tsx new file mode 100644 index 00000000..dec06576 --- /dev/null +++ b/packages/webapp/src/main/components/version-management-sidebar/timeline/timeline-version/VersionActions.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import styled from 'styled-components'; +import { Eye, Pencil, Trash } from 'react-bootstrap-icons'; +import { useAppDispatch } from '../../../store/hooks'; +import { showModal } from '../../../../services/modal/modalSlice'; +import { ModalContentType } from '../../../modals/application-modal-types'; +import { + setPreviewedDiagramIndex, + setVersionActionIndex, +} from '../../../../services/version-management/versionManagementSlice'; + +type Props = { + index: number; +}; + +const ActionButton = styled.div` + & { + padding: 0.35rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 0.25rem; + transition: 0.2s ease-in; + } + + &:hover { + background-color: var(--apollon-modal-bottom-border); + cursor: pointer; + } +`; + +export const VersionActions: React.FC = (props) => { + const dispatch = useAppDispatch(); + + return ( +
+ { + dispatch(setVersionActionIndex(props.index)); + dispatch(showModal({ type: ModalContentType.EditVersionInfoModal, size: 'lg' })); + }} + > + + + { + dispatch(setVersionActionIndex(props.index)); + dispatch( + showModal({ + type: ModalContentType.DeleteVersionModal, + size: 'lg', + }), + ); + }} + > + + + { + dispatch(setPreviewedDiagramIndex(props.index)); + }} + > + + +
+ ); +}; diff --git a/packages/webapp/src/main/services/diagram/diagram-repository.ts b/packages/webapp/src/main/services/diagram/diagram-repository.ts index b4117025..01171c09 100644 --- a/packages/webapp/src/main/services/diagram/diagram-repository.ts +++ b/packages/webapp/src/main/services/diagram/diagram-repository.ts @@ -23,9 +23,12 @@ export const DiagramRepository = { return null; }); }, - publishDiagramOnServer(diagram: Diagram): Promise { + publishDiagramVersionOnServer( + diagram: Diagram, + token?: string, + ): Promise<{ diagramToken: string; diagram: DiagramDTO }> { const resourceUrl = `${BASE_URL}/diagrams/publish`; - const body = JSON.stringify(diagram); + const body = JSON.stringify({ diagram, token }); return fetch(resourceUrl, { method: 'POST', headers: { @@ -34,13 +37,58 @@ export const DiagramRepository = { body, }).then((response: Response) => { if (response.ok) { - return response.text(); + return response.json(); } else { // error occured or no diagram found throw Error('Publish of diagram failed'); } }); }, + editDiagramVersionOnServer( + token: string, + versionIndex: number, + title: string, + description: string, + ): Promise { + const resourceUrl = `${BASE_URL}/diagrams/${token}`; + const body = JSON.stringify({ + versionIndex, + title, + description, + }); + return fetch(resourceUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body, + }).then((response: Response) => { + if (response.ok) { + return response.json(); + } else { + throw Error('Editing the diagram failed'); + } + }); + }, + deleteDiagramVersionOnServer(token: string, versionIndex: number): Promise { + const resourceUrl = `${BASE_URL}/diagrams/${token}`; + const body = JSON.stringify({ + versionIndex, + }); + return fetch(resourceUrl, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body, + }).then((response: Response) => { + if (response.ok) { + return response.json(); + } else { + throw Error('Deleting the diagram version failed'); + } + }); + }, convertSvgToPdf(svg: string, width: number, height: number): Promise { const resourceUrl = `${BASE_URL}/diagrams/pdf`; const body = JSON.stringify({ svg, width, height }); diff --git a/packages/webapp/src/main/services/diagram/diagramSlice.ts b/packages/webapp/src/main/services/diagram/diagramSlice.ts index e8cb2cd7..4cf9ff8b 100644 --- a/packages/webapp/src/main/services/diagram/diagramSlice.ts +++ b/packages/webapp/src/main/services/diagram/diagramSlice.ts @@ -11,6 +11,9 @@ export type Diagram = { title: string; model?: UMLModel; lastUpdate: string; + versions?: Diagram[]; + description?: string; + token?: string; }; export type EditorOptions = { @@ -65,12 +68,13 @@ const initialState = { loading: false, error: null, createNewEditor: true, + displayUnpublishedVersion: true, }; export const updateDiagramThunk = createAsyncThunk( 'diagram/updateWithLocalStorage', async (diagram: Partial, { dispatch }) => { - await dispatch(updateDiagram(diagram)); + dispatch(updateDiagram(diagram)); }, ); @@ -82,6 +86,10 @@ const diagramSlice = createSlice({ if (state.diagram) { state.diagram = { ...state.diagram, ...action.payload }; } + + if (!state.displayUnpublishedVersion) { + state.displayUnpublishedVersion = true; + } }, createDiagram: ( state, @@ -101,7 +109,6 @@ const diagramSlice = createSlice({ state.createNewEditor = true; state.editorOptions.type = action.payload.model?.type ?? 'ClassDiagram'; }, - setCreateNewEditor: (state, action: PayloadAction) => { state.createNewEditor = action.payload; }, @@ -114,6 +121,9 @@ const diagramSlice = createSlice({ changeReadonlyMode: (state, action: PayloadAction) => { state.editorOptions.readonly = action.payload; }, + setDisplayUnpublishedVersion(state, action: PayloadAction) { + state.displayUnpublishedVersion = action.payload; + }, }, extraReducers: (builder) => { builder.addCase(updateDiagramThunk.fulfilled, (state) => { @@ -127,6 +137,7 @@ const diagramSlice = createSlice({ selectors: { selectDiagram: (state) => state.diagram, selectCreatenewEditor: (state) => state.createNewEditor, + selectDisplayUnpublishedVersion: (state) => state.displayUnpublishedVersion, }, }); @@ -138,7 +149,9 @@ export const { changeDiagramType, createDiagram, loadDiagram, + setDisplayUnpublishedVersion, } = diagramSlice.actions; -export const { selectDiagram, selectCreatenewEditor } = diagramSlice.selectors; + +export const { selectDiagram, selectCreatenewEditor, selectDisplayUnpublishedVersion } = diagramSlice.selectors; export const diagramReducer = diagramSlice.reducer; diff --git a/packages/webapp/src/main/services/version-management/versionManagementSlice.ts b/packages/webapp/src/main/services/version-management/versionManagementSlice.ts new file mode 100644 index 00000000..dc694bc6 --- /dev/null +++ b/packages/webapp/src/main/services/version-management/versionManagementSlice.ts @@ -0,0 +1,41 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +export type VersionManagementState = { + displaySidebar: boolean; + previewedDiagramIndex: number; + versionActionIndex: number; +}; + +const initialState: VersionManagementState = { + displaySidebar: false, + previewedDiagramIndex: -1, + versionActionIndex: -1, +}; + +const versionManagementSlice = createSlice({ + name: 'versionManagement', + initialState, + reducers: { + toggleSidebar(state, action: PayloadAction) { + state.displaySidebar = !state.displaySidebar; + }, + setPreviewedDiagramIndex(state, action: PayloadAction) { + state.previewedDiagramIndex = action.payload; + }, + setVersionActionIndex(state, action: PayloadAction) { + state.versionActionIndex = action.payload; + }, + }, + selectors: { + selectDisplaySidebar: (state) => state.displaySidebar, + selectPreviewedDiagramIndex: (state) => state.previewedDiagramIndex, + selectVersionActionIndex: (state) => state.versionActionIndex, + }, +}); + +export const { toggleSidebar, setPreviewedDiagramIndex, setVersionActionIndex } = versionManagementSlice.actions; + +export const { selectDisplaySidebar, selectPreviewedDiagramIndex, selectVersionActionIndex } = + versionManagementSlice.selectors; + +export const versionManagementReducer = versionManagementSlice.reducer; diff --git a/packages/webapp/src/main/styles.css b/packages/webapp/src/main/styles.css index 32f8d6fb..689d82cf 100644 --- a/packages/webapp/src/main/styles.css +++ b/packages/webapp/src/main/styles.css @@ -98,6 +98,10 @@ legend.scheduler-border { border-color: var(--apollon-alert-warning-border); } +.sidebar-toggle { + cursor: pointer; +} + .theme-toggle { cursor: pointer; } @@ -173,7 +177,10 @@ legend.scheduler-border { color: #ffffff; } -#diagram-title { +.diagram-title, +.diagram-title:focus, +.diagram-description, +.diagram-description:focus { background-color: var(--apollon-list-group-color); color: var(--apollon-background-inverse); }