Compare commits
4 Commits
fix/react-
...
fix/checkl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80aa6aa2aa | ||
|
|
6852e83349 | ||
|
|
8b6e46fe63 | ||
|
|
4088f9abd5 |
130
apps/web/src/components/PlainTextEditor.tsx
Normal file
130
apps/web/src/components/PlainTextEditor.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
|
import { EditorContent, useEditor } from "@tiptap/react";
|
||||||
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
interface PlainTextEditorProps {
|
||||||
|
content: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
onBlur?: (value: string) => void;
|
||||||
|
onEnter?: (value: string) => void;
|
||||||
|
onEscape?: () => void;
|
||||||
|
readOnly?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlainTextEditor({
|
||||||
|
content,
|
||||||
|
onChange,
|
||||||
|
onBlur,
|
||||||
|
onEnter,
|
||||||
|
onEscape,
|
||||||
|
readOnly = false,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
}: PlainTextEditorProps) {
|
||||||
|
const onEnterRef = useRef(onEnter);
|
||||||
|
const onEscapeRef = useRef(onEscape);
|
||||||
|
const onBlurRef = useRef(onBlur);
|
||||||
|
const onChangeRef = useRef(onChange);
|
||||||
|
const contentRef = useRef(content);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onEnterRef.current = onEnter;
|
||||||
|
}, [onEnter]);
|
||||||
|
useEffect(() => {
|
||||||
|
onEscapeRef.current = onEscape;
|
||||||
|
}, [onEscape]);
|
||||||
|
useEffect(() => {
|
||||||
|
onBlurRef.current = onBlur;
|
||||||
|
}, [onBlur]);
|
||||||
|
useEffect(() => {
|
||||||
|
onChangeRef.current = onChange;
|
||||||
|
}, [onChange]);
|
||||||
|
useEffect(() => {
|
||||||
|
contentRef.current = content;
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
const editor = useEditor(
|
||||||
|
{
|
||||||
|
extensions: [
|
||||||
|
StarterKit.configure({
|
||||||
|
bold: false,
|
||||||
|
italic: false,
|
||||||
|
strike: false,
|
||||||
|
code: false,
|
||||||
|
codeBlock: false,
|
||||||
|
blockquote: false,
|
||||||
|
heading: false,
|
||||||
|
bulletList: false,
|
||||||
|
orderedList: false,
|
||||||
|
listItem: false,
|
||||||
|
horizontalRule: false,
|
||||||
|
hardBreak: false,
|
||||||
|
}),
|
||||||
|
Placeholder.configure({ placeholder }),
|
||||||
|
],
|
||||||
|
content,
|
||||||
|
editable: !readOnly,
|
||||||
|
onUpdate: ({ editor }) => onChangeRef.current?.(editor.getText()),
|
||||||
|
onBlur: ({ editor }) => onBlurRef.current?.(editor.getText()),
|
||||||
|
editorProps: {
|
||||||
|
handleKeyDown: (view, event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
onEnterRef.current?.(view.state.doc.textContent.trim());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
// Reset to original content before calling the callback
|
||||||
|
editor?.commands.setContent(contentRef.current, false);
|
||||||
|
editor?.commands.blur();
|
||||||
|
onEscapeRef.current?.();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
attributes: {
|
||||||
|
class: "outline-none focus:outline-none focus-visible:ring-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
if (content !== editor.getText()) {
|
||||||
|
editor.commands.setContent(content, false);
|
||||||
|
}
|
||||||
|
}, [content, editor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
editor.setEditable(!readOnly);
|
||||||
|
}, [readOnly, editor]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style jsx global>{`
|
||||||
|
.plain-text-editor p.is-empty::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
color: var(--placeholder-color, #9ca3af);
|
||||||
|
}
|
||||||
|
.plain-text-editor .tiptap p {
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<EditorContent
|
||||||
|
editor={editor}
|
||||||
|
className={twMerge("plain-text-editor", className)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { DraggableProvided } from "react-beautiful-dnd";
|
import type { DraggableProvided } from "react-beautiful-dnd";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import ContentEditable from "react-contenteditable";
|
|
||||||
import { HiXMark } from "react-icons/hi2";
|
import { HiXMark } from "react-icons/hi2";
|
||||||
import { RiDraggable } from "react-icons/ri";
|
import { RiDraggable } from "react-icons/ri";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
import PlainTextEditor from "~/components/PlainTextEditor";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||||
@@ -33,9 +33,7 @@ export default function ChecklistItemRow({
|
|||||||
}: ChecklistItemRowProps) {
|
}: ChecklistItemRowProps) {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
|
const [completed, setCompleted] = useState(item.completed);
|
||||||
const [title, setTitle] = useState("");
|
|
||||||
const [completed, setCompleted] = useState(false);
|
|
||||||
|
|
||||||
const updateItem = api.checklist.updateItem.useMutation({
|
const updateItem = api.checklist.updateItem.useMutation({
|
||||||
onMutate: async (vars) => {
|
onMutate: async (vars) => {
|
||||||
@@ -103,21 +101,6 @@ export default function ChecklistItemRow({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only resync from props when switching items to avoid clobbering edits
|
|
||||||
useEffect(() => {
|
|
||||||
setTitle(item.title);
|
|
||||||
setCompleted(item.completed);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [item.publicId]);
|
|
||||||
|
|
||||||
const sanitizeHtmlToPlainText = (html: string): string =>
|
|
||||||
html
|
|
||||||
.replace(/<br\s*\/?>(\n)?/gi, "\n")
|
|
||||||
.replace(/<div><br\s*\/?><\/div>/gi, "")
|
|
||||||
.replace(/<[^>]*>/g, "")
|
|
||||||
.replace(/ /g, " ")
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
const handleToggleCompleted = () => {
|
const handleToggleCompleted = () => {
|
||||||
if (viewOnly) return;
|
if (viewOnly) return;
|
||||||
setCompleted((prev) => !prev);
|
setCompleted((prev) => !prev);
|
||||||
@@ -127,14 +110,8 @@ export default function ChecklistItemRow({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const commitTitle = (rawHtml: string) => {
|
const commitTitle = (plain: string) => {
|
||||||
if (viewOnly) return;
|
if (!plain || plain === item.title) return;
|
||||||
const plain = sanitizeHtmlToPlainText(rawHtml);
|
|
||||||
if (!plain || plain === item.title) {
|
|
||||||
setTitle(item.title);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTitle(plain);
|
|
||||||
updateItem.mutate({
|
updateItem.mutate({
|
||||||
checklistItemPublicId: item.publicId,
|
checklistItemPublicId: item.publicId,
|
||||||
title: plain,
|
title: plain,
|
||||||
@@ -183,36 +160,26 @@ export default function ChecklistItemRow({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="flex-1 pr-7">
|
<div className="flex-1 pr-7">
|
||||||
<ContentEditable
|
<PlainTextEditor
|
||||||
html={title}
|
key={item.publicId}
|
||||||
disabled={viewOnly}
|
content={item.title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
readOnly={viewOnly}
|
||||||
// @ts-expect-error - valid event
|
placeholder={t`Add details...`}
|
||||||
onBlur={(e: Event) => {
|
onBlur={commitTitle}
|
||||||
const innerHTML = (e.target as HTMLElement).innerHTML;
|
onEnter={(plain) => {
|
||||||
commitTitle(innerHTML);
|
commitTitle(plain);
|
||||||
|
onCreateNewItem?.();
|
||||||
}}
|
}}
|
||||||
|
onEscape={() => undefined}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"m-0 min-h-[20px] w-full p-0 text-sm leading-[20px] text-light-950 outline-none focus-visible:outline-none dark:text-dark-950",
|
"m-0 min-h-[20px] w-full p-0 text-sm leading-[20px] text-light-950 dark:text-dark-950",
|
||||||
viewOnly && "cursor-default",
|
viewOnly && "cursor-default",
|
||||||
)}
|
)}
|
||||||
placeholder={t`Add details...`}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (viewOnly) return;
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
const innerHTML = (e.currentTarget as HTMLElement).innerHTML;
|
|
||||||
commitTitle(innerHTML);
|
|
||||||
onCreateNewItem?.();
|
|
||||||
}
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
setTitle(item.title);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!viewOnly && (
|
{!viewOnly && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { z } from "zod";
|
|||||||
import * as cardRepo from "@kan/db/repository/card.repo";
|
import * as cardRepo from "@kan/db/repository/card.repo";
|
||||||
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
|
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
|
||||||
import * as checklistRepo from "@kan/db/repository/checklist.repo";
|
import * as checklistRepo from "@kan/db/repository/checklist.repo";
|
||||||
|
import { stripHtml } from "@kan/shared/utils";
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
import { assertPermission } from "../utils/permissions";
|
import { assertPermission } from "../utils/permissions";
|
||||||
@@ -223,7 +224,7 @@ export const checklistRouter = createTRPCRouter({
|
|||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
checklistPublicId: z.string().length(12),
|
checklistPublicId: z.string().length(12),
|
||||||
title: z.string().min(1).max(500),
|
title: z.string().min(1).max(500).transform(stripHtml),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.output(checklistItemSchema)
|
.output(checklistItemSchema)
|
||||||
@@ -288,7 +289,7 @@ export const checklistRouter = createTRPCRouter({
|
|||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
checklistItemPublicId: z.string().length(12),
|
checklistItemPublicId: z.string().length(12),
|
||||||
title: z.string().min(1).max(500).optional(),
|
title: z.string().min(1).max(500).transform(stripHtml).optional(),
|
||||||
completed: z.boolean().optional(),
|
completed: z.boolean().optional(),
|
||||||
index: z.number().int().min(0).optional(),
|
index: z.number().int().min(0).optional(),
|
||||||
}),
|
}),
|
||||||
@@ -322,30 +323,29 @@ export const checklistRouter = createTRPCRouter({
|
|||||||
|
|
||||||
const previousTitle = item.title;
|
const previousTitle = item.title;
|
||||||
|
|
||||||
let updatedItem;
|
let updatedItem;
|
||||||
|
|
||||||
if (input.title !== undefined || input.completed !== undefined) {
|
if (input.title !== undefined || input.completed !== undefined) {
|
||||||
updatedItem = await checklistRepo.updateItemById(ctx.db, {
|
updatedItem = await checklistRepo.updateItemById(ctx.db, {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
completed: input.completed,
|
completed: input.completed,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.index !== undefined) {
|
if (input.index !== undefined) {
|
||||||
updatedItem = await checklistRepo.reorderItem(ctx.db, {
|
updatedItem = await checklistRepo.reorderItem(ctx.db, {
|
||||||
itemId: item.id,
|
itemId: item.id,
|
||||||
newIndex: input.index,
|
newIndex: input.index,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!updatedItem) {
|
|
||||||
throw new TRPCError({
|
|
||||||
message: `Failed to update checklist item`,
|
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (!updatedItem) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: `Failed to update checklist item`,
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Log completion toggle
|
// Log completion toggle
|
||||||
if (input.completed !== undefined) {
|
if (input.completed !== undefined) {
|
||||||
@@ -371,7 +371,6 @@ export const checklistRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return updatedItem;
|
return updatedItem;
|
||||||
|
|
||||||
}),
|
}),
|
||||||
deleteItem: protectedProcedure
|
deleteItem: protectedProcedure
|
||||||
.meta({
|
.meta({
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ export const userRouter = createTRPCRouter({
|
|||||||
.object({
|
.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
prefix: z.string().nullable(),
|
prefix: z.string().nullable(),
|
||||||
key: z.string(),
|
|
||||||
})
|
})
|
||||||
.nullable(),
|
.nullable(),
|
||||||
}),
|
}),
|
||||||
@@ -62,7 +61,7 @@ export const userRouter = createTRPCRouter({
|
|||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
image: imageUrl,
|
image: imageUrl,
|
||||||
apiKey: apiKey ?? null,
|
apiKey: apiKey ? { id: apiKey.id, prefix: apiKey.prefix } : null,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
update: protectedProcedure
|
update: protectedProcedure
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ export * from "./email";
|
|||||||
export * from "./dueDateFilters";
|
export * from "./dueDateFilters";
|
||||||
export * from "./s3";
|
export * from "./s3";
|
||||||
export * from "./mentions";
|
export * from "./mentions";
|
||||||
|
export * from "./sanitize";
|
||||||
|
|||||||
6
packages/shared/src/utils/sanitize.ts
Normal file
6
packages/shared/src/utils/sanitize.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Strips all HTML tags from a string, returning plain text.
|
||||||
|
* Use this on any user-supplied text field before storing or displaying.
|
||||||
|
*/
|
||||||
|
export const stripHtml = (value: string): string =>
|
||||||
|
value.replace(/<[^>]*>/g, "").trim();
|
||||||
Reference in New Issue
Block a user