Repository: inovex/scrumlr.io
Pull request #1983 (2022-07-16 19:49:17)

Add export option copy to clipboard in Markdown format
## Changelog

- [x] Added option to export board data to clipboard in a compact Markdown format

## Checklist

- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] The light- and dark-theme are both supported and tested
- [x] The design was implemented and is responsive for all devices and screen sizes
- [x] The application was tested in the most commonly used browsers (e.g. Chrome, Firefox, Safari)

timengel: Example of MD export:  
<hr />

# Lorem ipsum
## Columns
### Positive (12 Notes)

- qwdasdqweqwe (Odd Orc, 9 Votes)
- asdasdasdasdasd (Odd Orc, 5 Votes)
    - sdf (Odd Orc, 3 Votes)
    - sdf (Odd Orc)
- swdfsdf (Odd Orc)
- Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet  (Odd Orc)
- Lorem ipsum dolor sit amet, consetetur sadipscing (Odd Orc)
- Lorem ipsum dolor sit amet (Odd Orc)
- Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet 3 Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet 3 Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet 3 Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet 3 Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet (Odd Orc)
    - Lorem ipsum dolor sit amet (Odd Orc)
    - Lorem ipsum dolor sit ametLorem ipsum dolor sit amet (Odd Orc)
    - Lorem ipsum dolor sit amet (Odd Orc)

### Negative (8 Notes)

- sdf jhughdh (Odd Orc, 5 Votes)
- sfd asdf (Odd Orc, 2 Votes)
- Lorem ipsum dolor sit amet (Odd Orc, 2 Votes)
    - Lorem ipsum dolor sit amet (Odd Orc)
- sdf (Odd Orc, 1 Vote)
- Lorem ipsum dolor sit ametLorem ipsum dolor sit amet (Odd Orc)
- Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet (Odd Orc)
- Lorem ipsum dolor sit amet (Odd Orc)

### Lorem ipsum dolor sit amet (8 Notes)

- Lorem ipsum dolor sit amet (Odd Orc, 3 Votes)
- dfs Lorem ipsum dolor sit amet (Odd Orc)
    - 5 Lorem ipsum dolor sit amet (Odd Orc)
    - s ASffdd (Odd Orc)
- Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet (Odd Orc)
    - Lorem ipsum dolor sit ametLorem sum dolor sit amet (Odd Orc)
    - Lorem ipsum dolor sit amet (Odd Orc)
- Lorem ipsum dolor sit amet (Odd Orc)

### Qwertz (3 Notes)

- asdasdasd (Odd Orc)
- Bacon ipsum dolor amet kielbasa rump ham buffalo jowl frankfurter meatloaf bacon tongue cow corned beef pork venison.  (Furious Frog)
- Bacon ipsum dolor amet kielbasa rump ham buffalo jowl frankfurter meatloaf bacon tongue cow corned beef pork venison.  (Furious Frog)

## Board Properties
- Board-ID: 15d21cf5-94da-4afc-86bf-5d6412887f5f
- Participants: 5

> Generated on [scrumlr.io](https://www.scrumlr.io)  
Provided with ♥️ by [inovex](https://www.inovex.de)  

![Scrumlr Logo](http://localhost:3000/scrumlr-logo-light.svg)
timengel: @bitionaire can this be merged?

Patch:
diff --git a/.env b/.env
index 98b36212..b6dd15fe 100644
--- a/.env
+++ b/.env
@@ -1,2 +1,3 @@
 PUBLIC_URL=/
+REACT_APP_PUBLIC_URL=/
 REACT_APP_VERSION=$npm_package_version
diff --git a/.env.development b/.env.development
index 576d060d..7d277b4f 100644
--- a/.env.development
+++ b/.env.development
@@ -3,3 +3,4 @@ REACT_APP_SERVER_WEBSOCKET_URL=ws://localhost:8080
 REACT_APP_VERSION=$npm_package_version
 SKIP_PREFLIGHT_CHECK=true
 PUBLIC_URL=http://localhost:3000
+REACT_APP_PUBLIC_URL=http://localhost:3000
diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json
index 8749fbe5..4a031604 100644
--- a/public/locales/de/translation.json
+++ b/public/locales/de/translation.json
@@ -72,14 +72,27 @@
     "button": "Sitzung exportieren",
     "exportAsJson": "JSON exportieren",
     "exportAsCSV": "CSV exportieren",
-    "openPrintView": "Druckansicht öffnen"
+    "openPrintView": "Druckansicht öffnen",
+    "exportToClipboard": "Markdown in die Zwischenablage kopieren",
+    "copyToClipboardSuccess": "Markdown kopiert!"
   },
   "PrintView": {
     "Participants": "Teilnehmer",
     "GeneratedOn": "Erstellt auf",
     "ProvidedBy": "Bereitgestellt mit ♥️ von",
     "Notes": "Kärtchen",
-    "Votes": "Votes"
+    "Votes": "Stimmen"
+  },
+  "MarkdownExport": {
+    "columns": "Spalten",
+    "note": "Kärtchen",
+    "notes": "Kärtchen",
+    "participants": "Teilnehmer",
+    "linkToBoard": "Link zum Board",
+    "vote": "Stimme",
+    "votes": "Stimmen",
+    "stackSize": "Stapelgröße",
+    "showAuthorsDisabled": "Kartenautoren werden nicht angezeigt"
   },
   "ShareQrCodeOption": {
     "title": "Sitzung teilen",
diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index b6c5bef1..7ee6a83e 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -72,7 +72,10 @@
     "button": "Export board",
     "exportAsJson": "Export as JSON",
     "exportAsCSV": "Export as CSV",
-    "openPrintView": "Open print view"
+    "openPrintView": "Open print view",
+    "exportToClipboard": "Copy Markdown to Clipboard",
+    "copyToClipboardSuccess": "Markdown copied!"
+
   },
   "PrintView": {
     "Participants": "Participants",
@@ -81,11 +84,22 @@
     "Notes": "Kärtchen",
     "Votes": "Stimmen"
   },
+  "MarkdownExport": {
+    "columns": "Columns",
+    "note": "Note",
+    "notes": "Notes",
+    "participants": "Participants",
+    "linkToBoard": "Link to board",
+    "vote": "Vote",
+    "votes": "Votes",
+    "stackSize": "Stack size",
+    "showAuthorsDisabled": "Note authors are hidden"
+  },
   "ShareQrCodeOption": {
     "title": "Share Session",
     "button": "Share board",
     "copyInviteURL": "Copy Invite URL",
-    "inviteUrlCopied": "Invite URL Copied"
+    "inviteUrlCopied": "Invite URL copied"
   },
   "Column": {
     "settings": "Settings",
diff --git a/public/scrumlr-logo-light.svg b/public/scrumlr-logo-light.svg
new file mode 100644
index 00000000..6792852f
--- /dev/null
+++ b/public/scrumlr-logo-light.svg
@@ -0,0 +1,10 @@
+<svg id="Logo" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="112.715" height="48" viewBox="0 0 112.715 48">
+  <defs>
+    <clipPath id="clip-path">
+      <path id="Pfad_2" d="M0-.471S47.983-15.4,108.987-17.86c1.44-.057,2.655.815,2.655,1.9a2.229,2.229,0,0,1-2.371,1.9c-11.1.472-59.257,3.044-103.045,13.589C6.226-.309,0,.5,0-.471M97.956-39.849c1.349-2.427,2.379-4.051,2.538-4,.238.12-.278,1.866-1.626,4.218A29.132,29.132,0,0,1,94.9-34.262c.28-.686,2.022-3.776,3.053-5.588M24.884-37.984a3.413,3.413,0,0,1,2.418.48,5.938,5.938,0,0,1,2.1,3c.4,1.51-.6,3.165-1.19,4.083-.952,1.345-2.1,3.479-2.655,4.468-.357.657-.795,1.02-1.547.357a2.39,2.39,0,0,1-.952-1.932,8.449,8.449,0,0,1,1.824-4.207c.106-.165.213-.334.319-.5l.21-.339c.793-1.3,1.433-2.572.978-2.825-1.467-.834-10.15,5.917-10.348,10.327-.12,2.532,5.312,3.985,6.857,4.848a2.089,2.089,0,0,1,1.192,1.786,5.809,5.809,0,0,1-1.547,3.254,8.325,8.325,0,0,1-5.352,2.652A12.326,12.326,0,0,1,7.32-16.256c-.554-.577-.277-1.032.278-1.3a5.91,5.91,0,0,1,1.229-.278,4.594,4.594,0,0,1,3.092.544c1.547,1.015,4.165,2.574,6.463,2.269a3.531,3.531,0,0,0,2.1-.888,1,1,0,0,0-.041-1.6,30.633,30.633,0,0,0-2.806-1.422l-.474-.226c-2.532-1.217-5.086-2.678-5.086-5.325,0-5.183,6.185-10.537,9.754-12.459A8.108,8.108,0,0,1,24.884-37.984Zm74.3-9.157c.752-.824,1.466-1.146,2.221-.561a3.166,3.166,0,0,1,1.306,2.611,5.127,5.127,0,0,1-.238,1.632c-1.228,4.7-5.549,9.767-9.038,12.479a15.8,15.8,0,0,0-1.309,5.166c0,.914.237,1.53.754,1.462,1.824-.242,4.123-3.1,5.629-6.043a1.835,1.835,0,0,1,.5-.633c.83-1.31,1.811-2.766,2.833-4.116a1.667,1.667,0,0,1-.752-1.311c0-1.944,2.538-4.985,3.765-5.148.832-.109,1.189.3,1.189,1.175a11.19,11.19,0,0,1-.712,3.144,11.493,11.493,0,0,0,3.212-2.025c.675-.545,1.07-.674,1.586-.248a3.246,3.246,0,0,1,1.149,2.288,2.622,2.622,0,0,1-.634,1.569c-2.14,2.648-4.4,5.958-4.4,8.207,0,.992.317,1.559.872,1.484,1.347-.177,2.934-2.521,4.162-5.315.317-.727.872-.839,1.229-.2a3.066,3.066,0,0,1-.119,2.494,7.666,7.666,0,0,1-5.549,4.547,3.013,3.013,0,0,1-3.728-3.2c0-2.439,2.1-5.655,4.085-8.241a9.471,9.471,0,0,1-2.895.993c-2.974,5.349-5.233,9.155-5.789,11.859-.158.707-.674,1.117-1.387.487a2.5,2.5,0,0,1-.944-1.642,7.669,7.669,0,0,1-3.258,1.361c-1.914.253-3.241-.419-3.719-2.007a9.1,9.1,0,0,1-5.323,3.32c-2.7.357-4.162-.86-4.162-2.918a6.515,6.515,0,0,1,.594-2.556c.634-1.381,1.944-3.611,2.855-5.1.872-1.374,1.824-2.643,1.506-2.6s-1.149.533-3.488,3.093a39.163,39.163,0,0,0-6.66,11.02c-.4.778-.951.965-1.586.438a2.218,2.218,0,0,1-.872-1.98,9.92,9.92,0,0,1,.714-2.916A40.655,40.655,0,0,1,75.712-30.5c1.072-1.855,2.182-3.453,1.824-3.4-.317.043-1.861,1.085-4.241,3.916a38.523,38.523,0,0,0-5.589,10c-.28.648-.714.935-1.389.452a2.161,2.161,0,0,1-.681-.758,7.385,7.385,0,0,1-3.561,1.7c-2.339.31-3.531-.639-3.688-2.332a8.577,8.577,0,0,1-5.154,3.312,2.168,2.168,0,0,1-2.553-1.3,8.612,8.612,0,0,1-4.465,2.343,3.168,3.168,0,0,1-3.926-3.407c0-1.982,2.1-5.88,4.283-8.646a11.02,11.02,0,0,1-3.093,1.1c-2.855,6.475-7.3,12.246-12.33,12.913-3.648.484-5.629-1.538-5.629-4.513a10.682,10.682,0,0,1,1.189-4.54c2.1-4.167,5.233-6.715,7.533-7.02a2.592,2.592,0,0,1,1.387.121A4.749,4.749,0,0,1,37.968-28.7c.594,1.14-.04,2.6-.795,4.184a14.107,14.107,0,0,0-.712,1.656c-.278.838-.435,1.048-1.426.685A2.061,2.061,0,0,1,33.647-24.2a10.9,10.9,0,0,1,.912-2.828c.4-.855.792-1.668.6-1.795-.517-.313-3.054,2.349-4.719,5.807a10.954,10.954,0,0,0-1.19,4.391c0,1.6.675,2.578,1.824,2.5,3.885-.363,8.127-6.49,10.188-11.45A1.441,1.441,0,0,1,40.148-29.1c0-2.02,2.616-5.151,3.965-5.367.832-.11,1.149.344,1.149,1.181a12.166,12.166,0,0,1-.752,3.3,11.945,11.945,0,0,0,3.45-2.1c.594-.5,1.229-.736,1.744-.27a3.4,3.4,0,0,1,1.11,2.445,2.594,2.594,0,0,1-.675,1.615c-1.7,2.131-3.648,4.864-4.282,6.969a5.6,5.6,0,0,0-.317,1.757c0,.953.317,1.483.912,1.442,1.255-.059,3.289-2.439,4.524-5.016a50.145,50.145,0,0,1,5.31-10.123,1.038,1.038,0,0,1,1.706-.227,2.466,2.466,0,0,1,.594,1.674c0,1.027-1.251,3.454-2.5,5.883l-.325.633c-.54,1.052-1.059,2.081-1.455,2.972-.714,1.619-.833,3.043.04,2.929,1.031-.137,3.133-3.006,5.155-6.324,2.338-3.97,3.369-6.7,4.795-8.639.556-.8,1.19-1.034,1.667-.45a2.806,2.806,0,0,1,.594,1.9c0,1.3-.832,3.083-2.221,5.478-1.149,1.941-2.695,4.739-2.695,6.225,0,.687.238,1.037.754.968.958-.126,2.3-1.719,3.509-3.579a81.968,81.968,0,0,1,6.007-11.668c.515-.792,1.109-.834,1.506-.2a3.162,3.162,0,0,1,.555,1.757,5.2,5.2,0,0,1-.12,1.044c1.706-1.827,3.925-3.569,5.2-3.737s2.1,1.094,2.1,2.694c1.783-1.761,3.884-3.335,5.154-3.465,1.149-.114,1.783.832,1.783,2.088,0,1.3-.832,3.162-2.457,5.776-1.11,1.787-2.142,3.6-2.142,4.933,0,.878.4,1.435,1.032,1.353,1.229-.164,3.17-3.09,4.639-5.608a1.516,1.516,0,0,1,.308-.391A49.857,49.857,0,0,1,99.186-47.141Z" transform="translate(0 48)" fill="none" clip-rule="evenodd"/>
+    </clipPath>
+  </defs>
+  <g id="Gruppe_6" clip-path="url(#clip-path)">
+    <path id="Pfad_1" d="M-1-49H113.824V1.006H-1Z" transform="translate(0 48)" fill="#0057ff"/>
+  </g>
+</svg>
diff --git a/src/components/SettingsDialog/BoardSettings/BoardSettings.tsx b/src/components/SettingsDialog/BoardSettings/BoardSettings.tsx
index 7163803e..a5da3aa5 100644
--- a/src/components/SettingsDialog/BoardSettings/BoardSettings.tsx
+++ b/src/components/SettingsDialog/BoardSettings/BoardSettings.tsx
@@ -8,7 +8,7 @@ import {ReactComponent as DeleteIcon} from "assets/icon-delete.svg";
 import {ReactComponent as VisibleIcon} from "assets/icon-visible.svg";
 import {ReactComponent as HiddenIcon} from "assets/icon-hidden.svg";
 import {ReactComponent as RefreshIcon} from "assets/icon-refresh.svg";
-import {DEFAULT_BOARD_NAME, MIN_PASSWORD_LENGTH, PLACEHOLDER_PASSWORD} from "constants/misc";
+import {DEFAULT_BOARD_NAME, MIN_PASSWORD_LENGTH, PLACEHOLDER_PASSWORD, TOAST_TIMER_SHORT} from "constants/misc";
 import {Toast} from "utils/Toast";
 import {generateRandomString} from "utils/random";
 import {Toggle} from "components/Toggle";
@@ -45,7 +45,7 @@ export const BoardSettings = () => {
           <div>
             <div>{t("Toast.passwordCopied")}</div>
           </div>,
-          1500
+          TOAST_TIMER_SHORT
         )
       );
       setIsProtected(true);
diff --git a/src/components/SettingsDialog/ExportBoard/ExportBoard.tsx b/src/components/SettingsDialog/ExportBoard/ExportBoard.tsx
index c2f43cb9..ad6b7289 100644
--- a/src/components/SettingsDialog/ExportBoard/ExportBoard.tsx
+++ b/src/components/SettingsDialog/ExportBoard/ExportBoard.tsx
@@ -4,9 +4,12 @@ import {useTranslation} from "react-i18next";
 import {ReactComponent as ExportCSV} from "assets/icon-export-csv.svg";
 import {ReactComponent as ExportJSON} from "assets/icon-export-json.svg";
 import {ReactComponent as PrintIcon} from "assets/icon-print.svg";
+import {ReactComponent as ClipboardIcon} from "assets/icon-clipboard.svg";
 import {useNavigate} from "react-router";
-import {useAppSelector} from "../../../store";
-import {exportAsJSON, exportAsCSV} from "../../../utils/export";
+import {useAppSelector} from "store";
+import {exportAsJSON, exportAsCSV, getMarkdownExport} from "utils/export";
+import {Toast} from "utils/Toast";
+import {TOAST_TIMER_SHORT} from "constants/misc";
 import {SettingsButton} from "../Components/SettingsButton";
 import "./ExportBoard.scss";
 import "../SettingsDialog.scss";
@@ -53,6 +56,18 @@ export const ExportBoard: VFC = () => {
             navigate(`/board/${boardId}/print`);
           }}
         />
+        <hr className="settings-dialog__separator" />
+        <SettingsButton
+          label={t("ExportBoardOption.exportToClipboard")}
+          icon={ClipboardIcon}
+          className="export-board__button-reverse-order"
+          onClick={() => {
+            getMarkdownExport(boardId).then((result) => {
+              navigator.clipboard.writeText(result).then(() => Toast.success(t("ExportBoardOption.copyToClipboardSuccess"), TOAST_TIMER_SHORT));
+            });
+          }}
+          data-testid="export-markdown"
+        />
       </div>
     </div>
   );
diff --git a/src/components/SettingsDialog/ExportBoard/PrintView/PrintView.tsx b/src/components/SettingsDialog/ExportBoard/PrintView/PrintView.tsx
index 4fc8afeb..aa120245 100644
--- a/src/components/SettingsDialog/ExportBoard/PrintView/PrintView.tsx
+++ b/src/components/SettingsDialog/ExportBoard/PrintView/PrintView.tsx
@@ -8,53 +8,10 @@ import {ReactComponent as PrintIcon} from "assets/icon-print.svg";
 import {ReactComponent as CloseIcon} from "assets/icon-close.svg";
 import {useTranslation} from "react-i18next";
 import classNames from "classnames";
-import {Color, getColorClassName} from "constants/colors";
-
-interface BoardData {
-  board: {
-    id: string;
-    name: string;
-    showAuthors: boolean;
-    showVoting: boolean;
-  };
-  columns: [
-    {
-      id: string;
-      name: string;
-      color: Color;
-    }
-  ];
-  notes: [
-    {
-      id: string;
-      author: string;
-      text: string;
-      votes: number;
-      position: {
-        column: string;
-        stack: string;
-        rank: number;
-      };
-    }
-  ];
-  participants: [
-    {
-      role: string;
-      user: {
-        id: string;
-        name: string;
-      };
-    }
-  ];
-  votings: [
-    {
-      votes: {
-        votes: number;
-        votesPerNote: [];
-      };
-    }
-  ];
-}
+import {getColorClassName} from "constants/colors";
+import {compareNotes, getAuthorName, getChildNotes, getNoteVotes} from "utils/export";
+import {DEFAULT_URL} from "constants/misc";
+import {BoardDataType} from "../types";
 
 interface PrintViewProps {
   boardId: string;
@@ -65,12 +22,11 @@ export const PrintView = ({boardId, boardName}: PrintViewProps) => {
   const {t} = useTranslation();
   const navigate = useNavigate();
 
-  const [boardData, setBoardData] = useState<BoardData>();
+  const [boardData, setBoardData] = useState<BoardDataType>();
 
   const pageStyle = `
       @page {
         size: A4;
-
       }
     `;
 
@@ -100,30 +56,17 @@ export const PrintView = ({boardId, boardName}: PrintViewProps) => {
     currDate.getHours()
   ).padStart(2, "0")}:${String(currDate.getMinutes()).padStart(2, "0")}`;
 
-  const getAuthorName = (authorId: string) => (boardData?.board.showAuthors ? boardData?.participants.filter((p) => p.user?.id === authorId)[0].user?.name : "");
-
-  const getNoteVotes = (noteId: string) => (boardData?.votings ? boardData?.votings[0].votes?.votesPerNote[noteId]?.total ?? 0 : 0);
-
   const voteLabel = (noteId: string) => {
     if (!(boardData?.votings && boardData?.board.showVoting)) return "";
-    const votes = getNoteVotes(noteId);
+    const votes = getNoteVotes(noteId, boardData?.votings);
     return votes > 0 ? <div className="print-view__note-info-votes">{votes} Votes</div> : "";
   };
 
-  const compareNotes = (a: {id: string; position: {rank: number}}, b: {id: string; position: {rank: number}}) => {
-    if (boardData?.votings && getNoteVotes(a.id) !== getNoteVotes(b.id)) {
-      return getNoteVotes(a.id) > getNoteVotes(b.id) ? -1 : 1;
-    }
-    return a.position.rank > b.position.rank ? -1 : 1;
-  };
-
-  const getChildNotes = (noteId: string) => boardData?.notes.filter((n) => n.position.stack === noteId).sort((a, b) => compareNotes(a, b));
-
   const noteElement = (id: string, text: string, authorId: string, isChild: boolean, isTop: boolean) => (
     <div key={id} className={classNames("print-view__note", {"print-view__note--isChild": isChild, "print-view__note--isTop": isTop})}>
       <p className="print-view__note-text">{text}</p>
       <div className="print-view__note-info-wrapper">
-        <span className="print-view__note-info-author">{getAuthorName(authorId)}</span>
+        <span className="print-view__note-info-author">{boardData?.board.showAuthors ? getAuthorName(authorId, boardData?.participants) : ""}</span>
         <span className={classNames({"print-view__note-info--isTop": isTop, "print-view__note-info--isChild": isChild})}>{voteLabel(id)}</span>
       </div>
     </div>
@@ -173,10 +116,10 @@ export const PrintView = ({boardId, boardName}: PrintViewProps) => {
                     </div>
                     {boardData.notes
                       .filter((n) => n.position.column === c.id)
-                      .sort((a, b) => compareNotes(a, b))
+                      .sort((a, b) => compareNotes(a, b, boardData?.votings))
                       .map((n) => {
                         if (!n.position.stack) {
-                          const childNotes = getChildNotes(n.id);
+                          const childNotes = getChildNotes(boardData?.notes, boardData?.votings, n.id);
                           return childNotes && childNotes.length > 0
                             ? noteStackWrapper(noteElement(n.id, n.text, n.author, false, true), childNotes)
                             : noteElement(n.id, n.text, n.author, false, false);
@@ -190,7 +133,7 @@ export const PrintView = ({boardId, boardName}: PrintViewProps) => {
         <div className="print-view__footer-container">
           <p>
             {t("PrintView.GeneratedOn")}{" "}
-            <a href="https://scrumlr.io/" target="_blank" rel="noopener noreferrer">
+            <a href={DEFAULT_URL} target="_blank" rel="noopener noreferrer">
               scrumlr.io
             </a>
           </p>
diff --git a/src/components/SettingsDialog/ExportBoard/types.ts b/src/components/SettingsDialog/ExportBoard/types.ts
new file mode 100644
index 00000000..7d32be63
--- /dev/null
+++ b/src/components/SettingsDialog/ExportBoard/types.ts
@@ -0,0 +1,50 @@
+import {Color} from "constants/colors";
+
+export interface BoardType {
+  id: string;
+  name: string;
+  showAuthors: boolean;
+  showVoting: string;
+  accessPolicy: string;
+}
+
+export interface ColumnType {
+  id: string;
+  name: string;
+  color: Color;
+  visible: boolean;
+}
+
+export interface NoteType {
+  id: string;
+  author: string;
+  text: string;
+  position: {
+    column: string;
+    stack: string | null;
+    rank: number;
+  };
+}
+
+export interface ParticipantType {
+  role: string;
+  user: {
+    id: string;
+    name: string;
+  };
+}
+
+export interface VotingType {
+  votes: {
+    votes: number;
+    votesPerNote: [];
+  };
+}
+
+export interface BoardDataType {
+  board: BoardType;
+  columns: ColumnType[];
+  notes: NoteType[];
+  participants: ParticipantType[];
+  votings: VotingType[];
+}
diff --git a/src/components/SettingsDialog/SettingsDialog.scss b/src/components/SettingsDialog/SettingsDialog.scss
index d286f223..983df5c4 100644
--- a/src/components/SettingsDialog/SettingsDialog.scss
+++ b/src/components/SettingsDialog/SettingsDialog.scss
@@ -266,8 +266,7 @@ $settings-dialog-container--bottom: 62px;
 }
 
 .settings-dialog__separator {
-  margin: 0;
-  margin-left: 5%;
+  margin: 0 0 0 5%;
   width: 90%;
   border: 0;
   border-top: 0.5px solid $color-light-gray;
diff --git a/src/constants/misc.ts b/src/constants/misc.ts
index 74eee761..1e1fefac 100644
--- a/src/constants/misc.ts
+++ b/src/constants/misc.ts
@@ -2,3 +2,7 @@ export const DEFAULT_BOARD_NAME = "scrumlr.io";
 export const MIN_PASSWORD_LENGTH = 1;
 export const PLACEHOLDER_PASSWORD = "••••••••••";
 export const TEMPORARY_COLUMN_ID = "TEMP_ID";
+export const DEFAULT_URL = "https://scrumlr.io";
+
+export const TOAST_TIMER_DEFAULT = 3000;
+export const TOAST_TIMER_SHORT = 1500;
diff --git a/src/utils/Toast.tsx b/src/utils/Toast.tsx
index 41cae8c1..aa9acecc 100644
--- a/src/utils/Toast.tsx
+++ b/src/utils/Toast.tsx
@@ -1,6 +1,7 @@
 import {toast, ToastOptions} from "react-toastify";
 import "react-toastify/dist/ReactToastify.css";
 import {ReactNode} from "react";
+import {TOAST_TIMER_DEFAULT} from "constants/misc";
 
 const toastConfig: ToastOptions = {
   position: "bottom-right",
@@ -13,7 +14,7 @@ const toastConfig: ToastOptions = {
  *
  * @param content Success message.
  */
-function success(content: ReactNode, autoClose: number | false = 3000) {
+function success(content: ReactNode, autoClose: number | false = TOAST_TIMER_DEFAULT) {
   toast.success(content, {...toastConfig, autoClose});
 }
 
@@ -22,7 +23,7 @@ function success(content: ReactNode, autoClose: number | false = 3000) {
  *
  * @param content Error message.
  */
-function error(content: ReactNode, autoClose: number | false = 3000) {
+function error(content: ReactNode, autoClose: number | false = TOAST_TIMER_DEFAULT) {
   toast.error(content, {...toastConfig, autoClose});
 }
 
@@ -31,7 +32,7 @@ function error(content: ReactNode, autoClose: number | false = 3000) {
  *
  * @param content Info message.
  */
-function info(content: ReactNode, autoClose: number | false = 3000) {
+function info(content: ReactNode, autoClose: number | false = TOAST_TIMER_DEFAULT) {
   toast.info(content, {...toastConfig, autoClose});
 }
 
diff --git a/src/utils/export.ts b/src/utils/export.ts
index b0fc6bb3..247ccc1d 100644
--- a/src/utils/export.ts
+++ b/src/utils/export.ts
@@ -1,6 +1,11 @@
 import {saveAs} from "file-saver";
+import {BoardDataType, BoardType, ColumnType, NoteType, ParticipantType, VotingType} from "components/SettingsDialog/ExportBoard/types";
+import i18n from "i18next";
+import {DEFAULT_BOARD_NAME, DEFAULT_URL} from "constants/misc";
 import {API} from "../api";
 
+const {t} = i18n;
+
 export const fileName = (name?: string) => {
   const date = new Date().toJSON().slice(0, 10);
   return `${date}_${name}`;
@@ -9,12 +14,97 @@ export const fileName = (name?: string) => {
 export const exportAsCSV = async (id: string, name?: string) => {
   const response = await API.exportBoard(id, "text/csv");
   const blob = await response.blob();
-  saveAs(blob, `${fileName(name ?? "scrumlr.io")}.csv`);
+  saveAs(blob, `${fileName(name ?? DEFAULT_BOARD_NAME)}.csv`);
 };
 
 export const exportAsJSON = async (id: string, name?: string) => {
   const response = await API.exportBoard(id, "application/json");
   const json = await response.json();
   const blob = new Blob([JSON.stringify(json)], {type: "application/json"});
-  saveAs(blob, `${fileName(name ?? "scrumlr.io")}.json`);
+  saveAs(blob, `${fileName(name ?? DEFAULT_BOARD_NAME)}.json`);
+};
+
+export const getNoteVotes = (noteId: string, votings: VotingType[]) => votings[0].votes?.votesPerNote[noteId]?.total ?? 0;
+
+export const compareNotes = (a: {id: string; position: {rank: number}}, b: {id: string; position: {rank: number}}, votings: VotingType[]) => {
+  if (votings && getNoteVotes(a.id, votings) !== getNoteVotes(b.id, votings)) {
+    return getNoteVotes(a.id, votings) > getNoteVotes(b.id, votings) ? -1 : 1;
+  }
+  return a.position.rank > b.position.rank ? -1 : 1;
+};
+
+export const getChildNotes = (notes: NoteType[], votings: VotingType[], noteId: string) =>
+  notes.filter((n) => n.position.stack === noteId).sort((a, b) => compareNotes(a, b, votings));
+
+export const getAuthorName = (authorId: string, participants: ParticipantType[]) => participants.filter((p: ParticipantType) => p.user?.id === authorId)[0].user?.name;
+
+const mdItalicBrackets = (addBrackets: boolean, input: string) => (addBrackets ? `_(${input})_` : input);
+
+const mdBoardHeader = (boardName: string) => `# ${boardName}\n\n`;
+
+const mdBoardProperties = (board: BoardType, participants: ParticipantType[]) => {
+  const linkToBoard = `- [${t("MarkdownExport.linkToBoard")}](${window.location.href.replace("/settings/export", "")})\n`;
+  const participantsNumber = `- ${t("MarkdownExport.participants")}: ${participants.length}\n`;
+  const authorsHidden = !board.showAuthors ? `- ${t("MarkdownExport.showAuthorsDisabled")}\n` : "";
+  return `${participantsNumber + authorsHidden + linkToBoard}\n`;
+};
+
+const mdVotesPerNote = (noteId: string, votings: VotingType[]) => {
+  const votes = votings ? getNoteVotes(noteId, votings) : 0;
+  return votes ? `${votes} ${votes === 1 ? t("MarkdownExport.vote") : t("MarkdownExport.votes")}` : "";
+};
+
+const mdNote = (note: NoteType, author: string, votes: string, stack: string) => {
+  const nested = stack === "" && note.position.stack;
+  const bracketsRequired = !!author || !!votes;
+  const separator = !!author && !!votes ? ", " : "";
+  return `${nested ? "\n    " : "\n"}- ${note.text} ${mdItalicBrackets(bracketsRequired, `${author + separator + votes}`)}${stack}`;
+};
+
+const mdStack = (notesInStack: NoteType[], boardData: BoardDataType) =>
+  notesInStack.length > 0
+    ? `${notesInStack
+        .map((n) =>
+          mdNote(
+            n,
+            boardData.board.showAuthors ? getAuthorName(n.author, boardData.participants) : "",
+            boardData.board.showAuthors ? mdVotesPerNote(n.id, boardData.votings) : "",
+            ""
+          )
+        )
+        .join("")}`
+    : "";
+
+const mdNotesPerColumn = (columnId: string, boardData: BoardDataType) =>
+  boardData.notes
+    .filter((n) => !n.position.stack && n.position.column === columnId)
+    .sort((a, b) => compareNotes(a, b, boardData.votings))
+    .map((n) => {
+      const votes = boardData.board.showVoting ? mdVotesPerNote(n.id, boardData.votings) : "";
+      const author = boardData.board.showAuthors ? getAuthorName(n.author, boardData.participants) : "";
+      const stack = mdStack(getChildNotes(boardData.notes, boardData.votings, n.id), boardData);
+      return mdNote(n, author, votes, stack);
+    })
+    .join("");
+
+const mdColumns = (boardData: BoardDataType) => {
+  const columnList = boardData.columns
+    .filter((c) => boardData.notes.filter((n) => n.position.column === c.id).length > 0)
+    .map((c: ColumnType) => `## ${c.name} (${boardData.notes.filter((n) => n.position.column === c.id).length} ${t("MarkdownExport.notes")})\n${mdNotesPerColumn(c.id, boardData)}`)
+    .join("\n\n");
+  return `${columnList}\n\n`;
+};
+
+const mdBranding = () =>
+  `> ${t("PrintView.GeneratedOn")} [scrumlr.io](${DEFAULT_URL})  \n${t("PrintView.ProvidedBy")} [inovex](https://www.inovex.de)  \n\n![Scrumlr Logo](${
+    process.env.REACT_APP_PUBLIC_URL || DEFAULT_URL
+  }/scrumlr-logo-light.svg)`;
+
+const mdTemplate = (boardData: BoardDataType) =>
+  mdBoardHeader(boardData.board.name || DEFAULT_BOARD_NAME) + mdBoardProperties(boardData.board, boardData.participants) + mdColumns(boardData) + mdBranding();
+
+export const getMarkdownExport = async (id: string) => {
+  const response = await API.exportBoard(id, "application/json");
+  const json: BoardDataType = await response.json();
+  return `${mdTemplate(json)}`;
 };


Test patch:
diff --git a/src/components/SettingsDialog/ExportBoard/__tests__/ExportBoard.test.tsx b/src/components/SettingsDialog/ExportBoard/__tests__/ExportBoard.test.tsx
index 5deb6986..aea7bc50 100644
--- a/src/components/SettingsDialog/ExportBoard/__tests__/ExportBoard.test.tsx
+++ b/src/components/SettingsDialog/ExportBoard/__tests__/ExportBoard.test.tsx
@@ -11,7 +11,6 @@ jest.mock("utils/export", () => ({
   ...jest.requireActual("utils/export"),
   exportAsJSON: jest.fn(),
   exportAsCSV: jest.fn(),
-  exportAsCSVZip: jest.fn(),
 }));
 
 const renderExportBoard = () => {
diff --git a/src/components/SettingsDialog/ExportBoard/__tests__/__snapshots__/ExportBoard.test.tsx.snap b/src/components/SettingsDialog/ExportBoard/__tests__/__snapshots__/ExportBoard.test.tsx.snap
index c9bb1ffd..7d3b0067 100644
--- a/src/components/SettingsDialog/ExportBoard/__tests__/__snapshots__/ExportBoard.test.tsx.snap
+++ b/src/components/SettingsDialog/ExportBoard/__tests__/__snapshots__/ExportBoard.test.tsx.snap
@@ -75,6 +75,25 @@ Object {
               icon-print.svg
             </svg>
           </button>
+          <hr
+            class="settings-dialog__separator"
+          />
+          <button
+            class="settings-option-button export-board__button-reverse-order"
+            data-testid="export-markdown"
+            type="button"
+          >
+            <span
+              class="settings-option-button__label"
+            >
+              Copy Markdown to Clipboard
+            </span>
+            <svg
+              class="settings-option-button__icon"
+            >
+              icon-clipboard.svg
+            </svg>
+          </button>
         </div>
       </div>
     </div>
@@ -150,6 +169,25 @@ Object {
             icon-print.svg
           </svg>
         </button>
+        <hr
+          class="settings-dialog__separator"
+        />
+        <button
+          class="settings-option-button export-board__button-reverse-order"
+          data-testid="export-markdown"
+          type="button"
+        >
+          <span
+            class="settings-option-button__label"
+          >
+            Copy Markdown to Clipboard
+          </span>
+          <svg
+            class="settings-option-button__icon"
+          >
+            icon-clipboard.svg
+          </svg>
+        </button>
       </div>
     </div>
   </div>,
diff --git a/src/utils/test/dummy-board-data.json b/src/utils/test/dummy-board-data.json
new file mode 100644
index 00000000..734195ce
--- /dev/null
+++ b/src/utils/test/dummy-board-data.json
@@ -0,0 +1,97 @@
+{
+  "board": {
+    "id": "4af0854f-2cf1-4607-b1a6-6f96cd88a000",
+    "name": "Test Board",
+    "accessPolicy": "PUBLIC",
+    "showAuthors": true,
+    "showVoting": "8337b5a4-9011-46d2-a884-115668760be5"
+  },
+  "participants": [
+    {
+      "user": {
+        "id": "1a536773-7159-4c33-b92f-1d510547170d",
+        "name": "Happy Hornet"
+      },
+      "role": "OWNER"
+    }
+  ],
+  "columns": [
+    {
+      "id": "68559232-b70f-4db7-abae-da6f31206a31",
+      "name": "Positive",
+      "color": "backlog-blue",
+      "visible": true
+    },
+    {
+      "id": "9f43d504-351d-4afe-b248-93b03bd2f9dd",
+      "name": "Negative",
+      "color": "lean-lilac",
+      "visible": true
+    }
+  ],
+  "notes": [
+    {
+      "id": "24ceedf8-9c7b-4993-b021-06ef0d67ef80",
+      "author": "1a536773-7159-4c33-b92f-1d510547170d",
+      "text": "qwert",
+      "position": {
+        "column": "68559232-b70f-4db7-abae-da6f31206a31",
+        "stack": null,
+        "rank": 0
+      }
+    },
+    {
+      "id": "cd93b71b-d5df-404d-b34d-da5f03f29498",
+      "author": "1a536773-7159-4c33-b92f-1d510547170d",
+      "text": "asdf",
+      "position": {
+        "column": "68559232-b70f-4db7-abae-da6f31206a31",
+        "stack": "24ceedf8-9c7b-4993-b021-06ef0d67ef80",
+        "rank": 0
+      }
+    },
+    {
+      "id": "835929bf-0bf6-444d-b718-ac23568fb2d0",
+      "author": "1a536773-7159-4c33-b92f-1d510547170d",
+      "text": "poiuz",
+      "position": {
+        "column": "9f43d504-351d-4afe-b248-93b03bd2f9dd",
+        "stack": null,
+        "rank": 1
+      }
+    },
+    {
+      "id": "b2ed2bd1-e77f-4311-b4ae-c6279a8d90aa",
+      "author": "1a536773-7159-4c33-b92f-1d510547170d",
+      "text": "asdf",
+      "position": {
+        "column": "9f43d504-351d-4afe-b248-93b03bd2f9dd",
+        "stack": null,
+        "rank": 0
+      }
+    }
+  ],
+  "votings": [
+    {
+      "id": "8337b5a4-9011-46d2-a884-115668760be5",
+      "voteLimit": 5,
+      "allowMultipleVotes": true,
+      "showVotesOfOthers": false,
+      "status": "CLOSED",
+      "votes": {
+        "total": 5,
+        "votesPerNote": {
+          "24ceedf8-9c7b-4993-b021-06ef0d67ef80": {
+            "total": 2
+          },
+          "b2ed2bd1-e77f-4311-b4ae-c6279a8d90aa": {
+            "total": 2
+          },
+          "cd93b71b-d5df-404d-b34d-da5f03f29498": {
+            "total": 1
+          }
+        }
+      }
+    }
+  ]
+}
diff --git a/src/utils/test/export.test.ts b/src/utils/test/export.test.ts
new file mode 100644
index 00000000..c96662d9
--- /dev/null
+++ b/src/utils/test/export.test.ts
@@ -0,0 +1,31 @@
+import {API} from "api";
+import {BoardDataType} from "components/SettingsDialog/ExportBoard/types";
+import {getMarkdownExport} from "../export";
+import dummyBoardData from "./dummy-board-data.json";
+
+describe("the board export functions", () => {
+  it("returns the board data in markdown format", async () => {
+    // eslint-disable-next-line jest/valid-expect-in-promise
+    import("i18nTest").then(async () => {
+      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+      // @ts-ignore
+      const boardData: BoardDataType = dummyBoardData;
+      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+      // @ts-ignore
+      delete window.location;
+      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+      // @ts-ignore
+      window.location = new URL(`https://scrumlr.io/board/${boardData.board.id}/settings/export`);
+
+      API.exportBoard = jest.fn((): Promise<Response> => Promise.resolve(new Response(JSON.stringify(boardData))));
+
+      const mdExport = await getMarkdownExport(boardData.board.id);
+
+      expect(API.exportBoard).toHaveBeenCalledTimes(1);
+      expect(mdExport).toBe(
+        // eslint-disable-next-line max-len
+        `# Test Board\n\n- Participants: 1\n- [Link to board](https://scrumlr.io/board/4af0854f-2cf1-4607-b1a6-6f96cd88a000)\n\n## Positive (2 Notes)\n\n- qwert _(Happy Hornet, 2 Votes)_\n    - asdf _(Happy Hornet, 1 Vote)_\n\n## Negative (2 Notes)\n\n- asdf _(Happy Hornet, 2 Votes)_\n- poiuz _(Happy Hornet)_\n\n> Generated on [scrumlr.io](https://scrumlr.io)  \nProvided with ♥️ by [inovex](https://www.inovex.de)  \n\n![Scrumlr Logo](//scrumlr-logo-light.svg)`
+      );
+    });
+  });
+});