From 5467ea11603cef8aa2c17b1771dd13da034a92b3 Mon Sep 17 00:00:00 2001 From: Bobby Wang Date: Fri, 2 Feb 2024 07:00:36 +0800 Subject: [PATCH 1/3] support copy/paste for more fields, copy/paste two-way compatible --- src/components/Table/useMenuAction.tsx | 263 +++++++++++++++++-------- 1 file changed, 177 insertions(+), 86 deletions(-) diff --git a/src/components/Table/useMenuAction.tsx b/src/components/Table/useMenuAction.tsx index 4cc20277..91692582 100644 --- a/src/components/Table/useMenuAction.tsx +++ b/src/components/Table/useMenuAction.tsx @@ -1,29 +1,28 @@ -import { useCallback, useState, useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useAtom, useSetAtom } from "jotai"; import { useSnackbar } from "notistack"; -import { get, find } from "lodash-es"; +import { find, get, isDate, isFunction } from "lodash-es"; import { - tableScope, - tableSchemaAtom, - tableRowsAtom, - updateFieldAtom, SelectedCell, + tableRowsAtom, + tableSchemaAtom, + tableScope, + updateFieldAtom, } from "@src/atoms/tableScope"; import { getFieldProp, getFieldType } from "@src/components/fields"; import { ColumnConfig } from "@src/types/table"; import { FieldType } from "@src/constants/fields"; -import { format } from "date-fns"; +import { format, parse, isValid } from "date-fns"; import { DATE_FORMAT, DATE_TIME_FORMAT } from "@src/constants/dates"; -import { isDate, isFunction } from "lodash-es"; import { getDurationString } from "@src/components/fields/Duration/utils"; import { doc } from "firebase/firestore"; import { firebaseDbAtom } from "@src/sources/ProjectSourceFirebase"; import { projectScope } from "@src/atoms/projectScope"; -export const SUPPORTED_TYPES_COPY = new Set([ +export const SUPPORTED_TYPES_COPY = new Set([ // TEXT FieldType.shortText, FieldType.longText, @@ -54,17 +53,24 @@ export const SUPPORTED_TYPES_COPY = new Set([ FieldType.code, FieldType.markdown, FieldType.array, + // CLOUD FUNCTION + FieldType.action, + FieldType.derivative, + FieldType.status, // AUDIT FieldType.createdBy, FieldType.updatedBy, FieldType.createdAt, FieldType.updatedAt, // CONNECTION + FieldType.arraySubTable, FieldType.reference, + // METADATA + FieldType.user, FieldType.id, ]); -export const SUPPORTED_TYPES_PASTE = new Set([ +export const SUPPORTED_TYPES_PASTE = new Set([ // TEXT FieldType.shortText, FieldType.longText, @@ -72,17 +78,34 @@ export const SUPPORTED_TYPES_PASTE = new Set([ FieldType.email, FieldType.phone, FieldType.url, + // SELECT + FieldType.singleSelect, + FieldType.multiSelect, // NUMERIC + FieldType.checkbox, FieldType.number, FieldType.percentage, FieldType.rating, FieldType.slider, + FieldType.color, + FieldType.geoPoint, + // DATE & TIME + FieldType.date, + FieldType.dateTime, + FieldType.duration, + // FILE + FieldType.image, + FieldType.file, // CODE FieldType.json, FieldType.code, FieldType.markdown, + FieldType.array, // CONNECTION + FieldType.arraySubTable, FieldType.reference, + // METADATA + FieldType.user, ]); export function useMenuAction( @@ -163,93 +186,153 @@ export function useMenuAction( const handlePaste = useCallback( async (e?: ClipboardEvent) => { - try { - if (!selectedCell || !selectedCol) return; + if (!selectedCell || !selectedCol) return; - // checks which element has focus, if it is not the gridcell it won't paste the copied content inside the gridcell - if (document.activeElement?.role !== "gridcell") return; + // if the focus element is not gridcell, it won't paste the copied content inside the gridcell + if (document.activeElement?.role !== "gridcell") return; - let text: string; + let clipboardText: string; + if (navigator.userAgent.includes("Firefox")) { // Firefox doesn't allow for reading clipboard data, hence the workaround - if (navigator.userAgent.includes("Firefox")) { - if (!e || !e.clipboardData) { - enqueueSnackbar( - `If you're on Firefox, please use the hotkey instead (Ctrl + V / Cmd + V).`, - { - variant: "info", - autoHideDuration: 7000, - } - ); - enqueueSnackbar(`Cannot read clipboard data.`, { - variant: "error", - }); - return; - } - text = e.clipboardData.getData("text/plain") || ""; - } else { - try { - text = await navigator.clipboard.readText(); - } catch (e) { - enqueueSnackbar(`Read clipboard permission denied.`, { - variant: "error", - }); - return; - } + if (!e || !e.clipboardData) { + enqueueSnackbar( + `If you're on Firefox, please use the hotkey instead (Ctrl + V / Cmd + V).`, + { + variant: "info", + autoHideDuration: 7000, + } + ); + enqueueSnackbar(`Cannot read clipboard data.`, { + variant: "error", + }); + return; } + clipboardText = e.clipboardData.getData("text/plain") || ""; + } else { + try { + clipboardText = await navigator.clipboard.readText(); + } catch (e) { + enqueueSnackbar(`Read clipboard permission denied.`, { + variant: "error", + }); + return; + } + } + + try { + let parsedValue; const cellDataType = getFieldProp( "dataType", getFieldType(selectedCol) ); - let parsed; - switch (cellDataType) { - case "number": - parsed = Number(text); - if (isNaN(parsed)) throw new Error(`${text} is not a number`); + + // parse value first by type if matches, then by column type + switch (selectedCol.type) { + case FieldType.percentage: + clipboardText = clipboardText.trim(); + if (clipboardText.endsWith("%")) { + clipboardText = clipboardText.slice(0, -1); + parsedValue = Number(clipboardText) / 100; + } else { + parsedValue = Number(clipboardText); + } + if (isNaN(parsedValue)) + throw new Error(`${clipboardText} is not a percentage`); break; - case "string": - parsed = text; + case FieldType.date: + parsedValue = parse( + clipboardText, + selectedCol.config?.format || DATE_FORMAT, + new Date() + ); + if (!isValid(parsedValue)) { + parsedValue = parse(clipboardText, DATE_FORMAT, new Date()); + } + if (!isValid(parsedValue)) { + parsedValue = new Date(clipboardText); + } + if (!isValid(parsedValue)) { + throw new Error(`${clipboardText} is not a date`); + } break; - case "reference": + case FieldType.dateTime: + parsedValue = parse( + clipboardText, + selectedCol.config?.format || DATE_TIME_FORMAT, + new Date() + ); + if (!isValid(parsedValue)) { + parsedValue = parse(clipboardText, DATE_TIME_FORMAT, new Date()); + } + if (!isValid(parsedValue)) { + parsedValue = new Date(clipboardText); + } + if (!isValid(parsedValue)) { + throw new Error(`${clipboardText} is not a date`); + } + break; + case FieldType.duration: try { - parsed = doc(firebaseDb, text); + const json = JSON.parse(clipboardText); + parsedValue = { + start: new Date(json.start), + end: new Date(json.end), + }; } catch (e: any) { - enqueueSnackbar(`Invalid reference.`, { variant: "error" }); + throw new Error( + `${clipboardText} does not have valida start and end dates` + ); } break; default: - parsed = JSON.parse(text); - break; + switch (cellDataType) { + case "number": + parsedValue = Number(clipboardText); + if (isNaN(parsedValue)) + throw new Error(`${clipboardText} is not a number`); + break; + case "string": + parsedValue = clipboardText; + break; + case "reference": + try { + parsedValue = doc(firebaseDb, clipboardText); + } catch (e: any) { + enqueueSnackbar(`Invalid reference.`, { variant: "error" }); + } + break; + default: + parsedValue = JSON.parse(clipboardText); + break; + } } + // post process parsed values if (selectedCol.type === FieldType.slider) { - if (parsed < selectedCol.config?.min) - parsed = selectedCol.config?.min; - else if (parsed > selectedCol.config?.max) - parsed = selectedCol.config?.max; + if (parsedValue < selectedCol.config?.min) + parsedValue = selectedCol.config?.min; + else if (parsedValue > (selectedCol.config?.max || 10)) + parsedValue = selectedCol.config?.max || 10; } - if (selectedCol.type === FieldType.rating) { - if (parsed < 0) parsed = 0; - if (parsed > (selectedCol.config?.max || 5)) - parsed = selectedCol.config?.max || 5; + if (parsedValue < 0) parsedValue = 0; + if (parsedValue > (selectedCol.config?.max || 5)) + parsedValue = selectedCol.config?.max || 5; } - if (selectedCol.type === FieldType.percentage) { - parsed = parsed / 100; - } + console.log(`parsedValue`, parsedValue); updateField({ path: selectedCell.path, fieldName: selectedCol.fieldName, - value: parsed, + value: parsedValue, arrayTableData: { index: selectedCell.arrayIndex, }, }); } catch (error) { - enqueueSnackbar( - `${selectedCol?.type} field does not support the data type being pasted`, - { variant: "error" } - ); + enqueueSnackbar(`Paste error on ${selectedCol?.type}: ${error}`, { + variant: "error", + }); } if (handleClose) handleClose(); }, @@ -286,7 +369,7 @@ export function useMenuAction( if (SUPPORTED_TYPES_COPY.has(fieldType)) { return func(); } else { - enqueueSnackbar(`${fieldType} field cannot be copied`, { + enqueueSnackbar(`${fieldType} cannot be copied`, { variant: "error", }); } @@ -309,12 +392,9 @@ export function useMenuAction( if (SUPPORTED_TYPES_PASTE.has(fieldType)) { return func(e); } else { - enqueueSnackbar( - `${fieldType} field does not support paste functionality`, - { - variant: "error", - } - ); + enqueueSnackbar(`${fieldType} does not support paste`, { + variant: "error", + }); } }; }, @@ -323,12 +403,19 @@ export function useMenuAction( const getValue = useCallback( (cellValue: any) => { + console.log(`getValue cellValue`, cellValue); switch (selectedCol?.type) { - case FieldType.percentage: - return cellValue * 100; + case FieldType.multiSelect: case FieldType.json: case FieldType.color: case FieldType.geoPoint: + case FieldType.image: + case FieldType.file: + case FieldType.array: + case FieldType.arraySubTable: + case FieldType.createdBy: + case FieldType.updatedBy: + case FieldType.user: return JSON.stringify(cellValue); case FieldType.date: if ( @@ -362,19 +449,23 @@ export function useMenuAction( } } return; + case FieldType.percentage: + return `${cellValue * 100}%`; case FieldType.duration: - return getDurationString( - cellValue.start.toDate(), - cellValue.end.toDate() - ); - case FieldType.image: - case FieldType.file: - return cellValue[0].downloadURL; - case FieldType.createdBy: - case FieldType.updatedBy: - return cellValue.displayName; + return JSON.stringify({ + duration: getDurationString( + cellValue.start.toDate(), + cellValue.end.toDate() + ), + start: cellValue.start.toDate(), + end: cellValue.end.toDate(), + }); + case FieldType.action: + return cellValue.status || ""; case FieldType.reference: return cellValue.path; + case FieldType.formula: + return cellValue.formula || ""; default: return cellValue; } From b5e0960df04f33478ba162efa53a1bc869af3ec8 Mon Sep 17 00:00:00 2001 From: Bobby Wang Date: Fri, 2 Feb 2024 09:02:21 +0800 Subject: [PATCH 2/3] fix paste inside array subtable overwrites whole object --- src/components/Table/Table.tsx | 9 +++++++- src/components/Table/useMenuAction.tsx | 31 ++++++++++++++++++++++---- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index ba17786b..de9aeeae 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -285,7 +285,14 @@ export default function Table({ const { handler: hotKeysHandler } = useHotKeys([ ["mod+C", handleCopy], ["mod+X", handleCut], - ["mod+V", (e) => handlePaste], // So the event isn't passed to the handler + [ + "mod+V", + (e) => { + console.log("mod+V", e); + e.stopPropagation(); + return handlePaste; + }, + ], // So the event isn't passed to the handler ]); // Handle prompt to save local column sizes if user `canEditColumns` diff --git a/src/components/Table/useMenuAction.tsx b/src/components/Table/useMenuAction.tsx index 91692582..e7fffc7c 100644 --- a/src/components/Table/useMenuAction.tsx +++ b/src/components/Table/useMenuAction.tsx @@ -188,8 +188,23 @@ export function useMenuAction( async (e?: ClipboardEvent) => { if (!selectedCell || !selectedCol) return; - // if the focus element is not gridcell, it won't paste the copied content inside the gridcell - if (document.activeElement?.role !== "gridcell") return; + // if the focus element is not gridcell or menuitem (click on paste menu action) + // it won't paste the copied content inside the gridcell + if ( + !["gridcell", "menuitem"].includes(document.activeElement?.role ?? "") + ) + return; + + // prevent from pasting inside array subtable overwrites the whole object + if ( + document.activeElement + ?.getAttribute?.("data-row-id") + ?.startsWith("subtable-array") && + selectedCell.columnKey !== + document.activeElement?.getAttribute?.("data-col-id") + ) { + return; + } let clipboardText: string; if (navigator.userAgent.includes("Firefox")) { @@ -284,6 +299,16 @@ export function useMenuAction( ); } break; + case FieldType.arraySubTable: + try { + parsedValue = JSON.parse(clipboardText); + } catch (e: any) { + throw new Error(`${clipboardText} is not valid array subtable`); + } + if (!Array.isArray(parsedValue)) { + throw new Error(`${clipboardText} is not an array`); + } + break; default: switch (cellDataType) { case "number": @@ -320,7 +345,6 @@ export function useMenuAction( parsedValue = selectedCol.config?.max || 5; } - console.log(`parsedValue`, parsedValue); updateField({ path: selectedCell.path, fieldName: selectedCol.fieldName, @@ -403,7 +427,6 @@ export function useMenuAction( const getValue = useCallback( (cellValue: any) => { - console.log(`getValue cellValue`, cellValue); switch (selectedCol?.type) { case FieldType.multiSelect: case FieldType.json: From 398808ee10125bf2b6ade873605cea6997e07f15 Mon Sep 17 00:00:00 2001 From: Bobby Wang Date: Fri, 2 Feb 2024 09:05:53 +0800 Subject: [PATCH 3/3] remove unnecessary code --- src/components/Table/Table.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index de9aeeae..ba17786b 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -285,14 +285,7 @@ export default function Table({ const { handler: hotKeysHandler } = useHotKeys([ ["mod+C", handleCopy], ["mod+X", handleCut], - [ - "mod+V", - (e) => { - console.log("mod+V", e); - e.stopPropagation(); - return handlePaste; - }, - ], // So the event isn't passed to the handler + ["mod+V", (e) => handlePaste], // So the event isn't passed to the handler ]); // Handle prompt to save local column sizes if user `canEditColumns`