backend/frontend: add edit / update support
This change makes good progress towards having a usable app. "Write", and "Preview" tabs have been added - in the "Preview" tab markdown is transformed into HTML in real-time.
This commit is contained in:
parent
de6b33f1aa
commit
2a0e987e0f
19 changed files with 284 additions and 175 deletions
33
twenty-frontend/package-lock.json
generated
33
twenty-frontend/package-lock.json
generated
|
@ -14,11 +14,13 @@
|
|||
"@types/luxon": "^3.3.7",
|
||||
"@types/react": "^18.0.18",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@types/showdown": "^2.0.6",
|
||||
"eslint": "^8.26.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"luxon": "^3.4.4",
|
||||
"prettier": "^2.7.1",
|
||||
"react-hook-form": "^7.49.2",
|
||||
"showdown": "^2.1.0",
|
||||
"ts-loader": "^9.3.1",
|
||||
"ts-standard": "^12.0.1",
|
||||
"typescript": "^4.8.2",
|
||||
|
@ -317,6 +319,12 @@
|
|||
"integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/showdown": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/showdown/-/showdown-2.0.6.tgz",
|
||||
"integrity": "sha512-pTvD/0CIeqe4x23+YJWlX2gArHa8G0J0Oh6GKaVXV7TAeickpkkZiNOgFcFcmLQ5lB/K0qBJL1FtRYltBfbGCQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "5.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz",
|
||||
|
@ -4018,6 +4026,31 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/showdown": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/showdown/-/showdown-2.1.0.tgz",
|
||||
"integrity": "sha512-/6NVYu4U819R2pUIk79n67SYgJHWCce0a5xTP979WbNp0FL9MN1I1QK662IDU1b6JzKTvmhgI7T7JYIxBi3kMQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"showdown": "bin/showdown.js"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://www.paypal.me/tiviesantos"
|
||||
}
|
||||
},
|
||||
"node_modules/showdown/node_modules/commander": {
|
||||
"version": "9.5.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
|
||||
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
|
||||
|
|
|
@ -9,11 +9,13 @@
|
|||
"@types/luxon": "^3.3.7",
|
||||
"@types/react": "^18.0.18",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@types/showdown": "^2.0.6",
|
||||
"eslint": "^8.26.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"luxon": "^3.4.4",
|
||||
"prettier": "^2.7.1",
|
||||
"react-hook-form": "^7.49.2",
|
||||
"showdown": "^2.1.0",
|
||||
"ts-loader": "^9.3.1",
|
||||
"ts-standard": "^12.0.1",
|
||||
"typescript": "^4.8.2",
|
||||
|
|
|
@ -1 +1,3 @@
|
|||
$black: lighten(#000, 20%);
|
||||
$gray1: #f4f0ec;
|
||||
$gray2: lighten($gray1, 5%);
|
||||
|
|
41
twenty-frontend/src/css/_tables.scss
Normal file
41
twenty-frontend/src/css/_tables.scss
Normal file
|
@ -0,0 +1,41 @@
|
|||
@import "colors";
|
||||
|
||||
.table {
|
||||
.table.div {
|
||||
display: flex;
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
color: #FFF;
|
||||
background: $gray1;
|
||||
color: $black;
|
||||
border-radius: 2px;
|
||||
border: #cfcfc4 1px solid;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table.div.tabbed {
|
||||
padding: 5px 5px 0 5px;
|
||||
ul.tabs {
|
||||
list-style-type: none;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
li {
|
||||
height: 100%;
|
||||
border: 1px solid #cfcfc4;
|
||||
border-bottom: none;
|
||||
padding: 10px;
|
||||
background: #FFF;
|
||||
margin-right: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table.content {
|
||||
background: $gray2;
|
||||
padding: 10px;
|
||||
border: #cfcfc4 1px solid;
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
@import "fonts";
|
||||
@import "colors";
|
||||
@import "forms";
|
||||
@import "tables";
|
||||
|
||||
*, *:before, *:after {
|
||||
box-sizing: border-box;
|
||||
|
@ -11,60 +12,58 @@ html, html body, .root {
|
|||
}
|
||||
|
||||
body {
|
||||
font-family: "SF Mono","Segoe UI Mono","Roboto Mono",Menlo,Courier,monospace;
|
||||
font-weight: normal;
|
||||
font-family: "Noto Sans Serif";
|
||||
}
|
||||
|
||||
.root {
|
||||
margin: 0 auto;
|
||||
color: $black;
|
||||
width: 75%;
|
||||
padding: 20px 0 20px 0;
|
||||
max-width: 1024px;
|
||||
.navbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 10px 0px 10px 0px;
|
||||
}
|
||||
form {
|
||||
margin: 0 auto;
|
||||
height: 100%;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
margin: 0 0 10px 0;
|
||||
height: 50%;
|
||||
textarea { height: 100%; }
|
||||
.root {
|
||||
.issue {
|
||||
.content {
|
||||
padding: 10px;
|
||||
}
|
||||
.content, textarea {
|
||||
min-height: 350px;
|
||||
}
|
||||
}
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style-type: none;
|
||||
li {
|
||||
margin: 0 0 15px 0;
|
||||
.item.row {
|
||||
display: flex;
|
||||
background: #f4f0ec;
|
||||
border: #cfcfc4 1px solid;
|
||||
border-radius: 10px;
|
||||
padding: 15px;
|
||||
flex-wrap: wrap;
|
||||
.header {
|
||||
.item.id {
|
||||
border: 1px #cfcfc4 solid;
|
||||
border-radius: 10px;
|
||||
padding: 5px;
|
||||
margin-right: 5px;
|
||||
background: #FFF;
|
||||
}
|
||||
}
|
||||
.footer {
|
||||
|
||||
.react-mount.issues {
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style-type: none;
|
||||
li {
|
||||
margin: 0 0 15px 0;
|
||||
.item.row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
margin: 20px 0 0 0;
|
||||
font-size: small;
|
||||
background: #f4f0ec;
|
||||
border: #cfcfc4 1px solid;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
flex-wrap: wrap;
|
||||
.header {
|
||||
.item.id {
|
||||
border: 1px #cfcfc4 solid;
|
||||
border-radius: 10px;
|
||||
padding: 5px;
|
||||
margin-right: 5px;
|
||||
background: #FFF;
|
||||
}
|
||||
}
|
||||
.footer {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
margin: 10px 0 0 0;
|
||||
font-size: small;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
<div class="root">
|
||||
<div class="react-mount navbar"></div>
|
||||
<div class="react-mount issues"></div>
|
||||
<script src="/js/entry-point/issues.js"></script>
|
||||
</div>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<div class="root">
|
||||
<div className="react-mount-point" id="reactapp"></div>
|
||||
<div class="react-mount new-issue"></div>
|
||||
<script src="/js/entry-point/new-issue.js"></script>
|
||||
</div>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<div class="root">
|
||||
<div className="react-mount-point" id="reactapp"></div>
|
||||
<div class="react-mount edit-issue"></div>
|
||||
<script src="/js/entry-point/read-issue.js"></script>
|
||||
</div>
|
||||
|
|
95
twenty-frontend/src/js/components/Issue.tsx
Normal file
95
twenty-frontend/src/js/components/Issue.tsx
Normal file
|
@ -0,0 +1,95 @@
|
|||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Select } from "/components/forms/Select";
|
||||
import { useUpsertIssue } from "/hooks/useUpsertIssue";
|
||||
import { useConnections } from "/hooks/useConnections";
|
||||
import { Issue } from "/types/schema";
|
||||
import showdown from "showdown";
|
||||
|
||||
type Inputs = {
|
||||
id?: number;
|
||||
title: string;
|
||||
content: string;
|
||||
connectionId: number;
|
||||
};
|
||||
|
||||
export function Issue({ issue }: { issue?: Issue }) {
|
||||
const { register, handleSubmit, watch, setValue: set } = useForm<Inputs>();
|
||||
const [isEditable, setIsEditable] = useState<boolean>(!issue);
|
||||
const selectRef = useRef<HTMLSelectElement>(null);
|
||||
const upsert = useUpsertIssue();
|
||||
const [connections] = useConnections();
|
||||
const c = new showdown.Converter();
|
||||
const content = watch("content");
|
||||
const onSave = (input: Inputs) => {
|
||||
upsert({ input }).then(() => {
|
||||
location.href = "/issues/";
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
set("connectionId", 1);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form className="issue" onSubmit={handleSubmit(onSave)}>
|
||||
<input type="hidden" value={issue?.id} {...register("id")} />
|
||||
<div className="table">
|
||||
<div className="table tabbed div">
|
||||
<ul className="tabs">
|
||||
<li onClick={() => setIsEditable(true)}>Write</li>
|
||||
<li onClick={() => setIsEditable(false)}>Preview</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="table content">
|
||||
<div>
|
||||
<Select
|
||||
{...register("connectionId")}
|
||||
ref={selectRef}
|
||||
className="form"
|
||||
>
|
||||
{connections.map((conn, key) => {
|
||||
return (
|
||||
<option key={key} value={conn.id}>
|
||||
{conn.name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
className="form"
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
defaultValue={issue?.title}
|
||||
{...register("title", { required: true })}
|
||||
/>
|
||||
</div>
|
||||
{isEditable ? (
|
||||
<>
|
||||
<div className="row textarea">
|
||||
<textarea
|
||||
className="form"
|
||||
placeholder="Add your description heren"
|
||||
defaultValue={issue?.content}
|
||||
{...register("content", { required: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="row">
|
||||
<input className="form" type="submit" value="Save" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
className="issue content"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: c.makeHtml(content || issue?.content),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -10,34 +10,39 @@ export function Issues() {
|
|||
setIssues(issues.filter(i => i.id !== issue.id));
|
||||
};
|
||||
return (
|
||||
<ul>
|
||||
{issues.map((issue: Issue, key: number) => {
|
||||
const { updated_at: updatedAt } = issue;
|
||||
const datetime = DateTime.fromISO(updatedAt);
|
||||
return (
|
||||
<li key={key}>
|
||||
<div className="item row">
|
||||
<div className="header">
|
||||
<a href={`/issues/read#id=${issue.id}`}>
|
||||
<span className="item title">{issue.title}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div className="footer">
|
||||
<div>
|
||||
<DestroyIssueButton
|
||||
issue={issue}
|
||||
onSuccess={onDestroySuccess}
|
||||
/>
|
||||
<div className="table">
|
||||
<div className="table div">Issues</div>
|
||||
<div className="table content">
|
||||
<ul>
|
||||
{issues.map((issue: Issue, key: number) => {
|
||||
const { updated_at: updatedAt } = issue;
|
||||
const datetime = DateTime.fromISO(updatedAt);
|
||||
return (
|
||||
<li key={key}>
|
||||
<div className="item row">
|
||||
<div className="header">
|
||||
<a href={`/issues/read#id=${issue.id}`}>
|
||||
<span className="item title">{issue.title}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div className="footer">
|
||||
<div>
|
||||
<DestroyIssueButton
|
||||
issue={issue}
|
||||
onSuccess={onDestroySuccess}
|
||||
/>
|
||||
</div>
|
||||
<span>
|
||||
{datetime.toFormat("dd LLL, yyyy")} at{" "}
|
||||
{datetime.toFormat("HH:mm")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span>
|
||||
{datetime.toFormat("dd LLL, yyyy")} at{" "}
|
||||
{datetime.toFormat("HH:mm")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -2,7 +2,6 @@ import React from "react";
|
|||
export function NavBar() {
|
||||
return (
|
||||
<div className="navbar">
|
||||
<div className="h1">Issues</div>
|
||||
<div>
|
||||
<a href="/issues/new">New issue</a>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Issue } from "/types/schema";
|
||||
import { useParams } from "/hooks/useParams";
|
||||
import { Issue } from "/types/schema";
|
||||
import { Issue as Component } from "/components/Issue";
|
||||
|
||||
export function ReadIssue() {
|
||||
const { id } = useParams();
|
||||
|
@ -14,12 +15,7 @@ export function ReadIssue() {
|
|||
}, [id]);
|
||||
|
||||
if (id?.length && issue) {
|
||||
return (
|
||||
<div className="pure-u-5-5">
|
||||
<div className="pure-u-1-1">{issue.title}</div>
|
||||
<p className="pure-u-3-5">{issue.content}</p>
|
||||
</div>
|
||||
);
|
||||
return <Component issue={issue} />;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -1,61 +0,0 @@
|
|||
import React, { useEffect, useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Select } from "/components/forms/Select";
|
||||
import { useCreateIssue } from "/hooks/useCreateIssue";
|
||||
import { useConnections } from "/hooks/useConnections";
|
||||
|
||||
type Inputs = {
|
||||
title: string;
|
||||
content: string;
|
||||
connectionId: number;
|
||||
};
|
||||
|
||||
export function NewIssue() {
|
||||
const { register, handleSubmit, setValue: set } = useForm<Inputs>();
|
||||
const selectRef = useRef<HTMLSelectElement>(null);
|
||||
const [create] = useCreateIssue();
|
||||
const [connections] = useConnections();
|
||||
const onSave = (input: Inputs) => {
|
||||
create({ input }).then(() => {
|
||||
location.href = "/issues/";
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
set("connectionId", 1);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSave)}>
|
||||
<div className="row">
|
||||
<Select {...register("connectionId")} ref={selectRef} className="form">
|
||||
{connections.map((conn, key) => {
|
||||
return (
|
||||
<option key={key} value={conn.id}>
|
||||
{conn.name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="row">
|
||||
<input
|
||||
className="form"
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
{...register("title", { required: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="row textarea">
|
||||
<textarea
|
||||
className="form"
|
||||
placeholder="Add your description here"
|
||||
{...register("content", { required: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="row">
|
||||
<input className="form" type="submit" value="Save" />
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -1,11 +1,8 @@
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { Issues } from "/components/Issues";
|
||||
import { NavBar } from "/components/NavBar";
|
||||
|
||||
(function () {
|
||||
const n1 = document.querySelector(".react-mount.navbar");
|
||||
const n2 = document.querySelector(".react-mount.issues")!;
|
||||
ReactDOM.createRoot(n1).render(<NavBar />);
|
||||
ReactDOM.createRoot(n2).render(<Issues />);
|
||||
const n1 = document.querySelector(".react-mount.issues")!;
|
||||
ReactDOM.createRoot(n1).render(<Issues />);
|
||||
})();
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { NewIssue } from "/components/forms/NewIssue";
|
||||
import { Issue } from "/components/Issue";
|
||||
|
||||
(function () {
|
||||
const root = document.getElementById("reactapp")!;
|
||||
ReactDOM.createRoot(root).render(<NewIssue />);
|
||||
const root = document.querySelector(".react-mount.new-issue")!;
|
||||
ReactDOM.createRoot(root).render(<Issue />);
|
||||
})();
|
||||
|
|
|
@ -3,6 +3,6 @@ import ReactDOM from "react-dom/client";
|
|||
import { ReadIssue } from "/components/ReadIssue";
|
||||
|
||||
(function () {
|
||||
const root = document.getElementById("reactapp")!;
|
||||
const root = document.querySelector(".react-mount.edit-issue")!;
|
||||
ReactDOM.createRoot(root).render(<ReadIssue />);
|
||||
})();
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
type Params = {
|
||||
title: string;
|
||||
content: string;
|
||||
connectionId: number;
|
||||
};
|
||||
|
||||
export function useCreateIssue() {
|
||||
return [
|
||||
function ({ input }: { input: Params }) {
|
||||
return new Promise((accept, reject) => {
|
||||
const { connectionId, title, content } = input;
|
||||
const req = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ connection_id: connectionId, title, content }),
|
||||
};
|
||||
return fetch("/servlet/issues", req)
|
||||
.then(res => res.json())
|
||||
.then(accept)
|
||||
.catch(reject);
|
||||
});
|
||||
},
|
||||
];
|
||||
}
|
|
@ -23,5 +23,5 @@ export function useIssues(): Result {
|
|||
req();
|
||||
}, []);
|
||||
|
||||
return { issues, setIssues, req };
|
||||
return { issues, setIssues: set, req };
|
||||
}
|
||||
|
|
25
twenty-frontend/src/js/hooks/useUpsertIssue.ts
Normal file
25
twenty-frontend/src/js/hooks/useUpsertIssue.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
type Params = {
|
||||
id?: number;
|
||||
title: string;
|
||||
content: string;
|
||||
connectionId: number;
|
||||
};
|
||||
|
||||
export function useUpsertIssue() {
|
||||
const normalize = (input: Params) => {
|
||||
const { id, title, content, connectionId } = input;
|
||||
return { id, title, content, connection_id: connectionId };
|
||||
};
|
||||
return function ({ input }: { input: Params }) {
|
||||
return new Promise((accept, reject) => {
|
||||
const req = {
|
||||
method: input.id ? "PUT" : "POST",
|
||||
body: JSON.stringify(normalize(input)),
|
||||
};
|
||||
return fetch("/servlet/issues", req)
|
||||
.then(res => res.json())
|
||||
.then(accept)
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
}
|
Loading…
Reference in a new issue