Skip to content

Commit

Permalink
refactor: html format copy and paste, and removed session storage (#215)
Browse files Browse the repository at this point in the history
* refactor: html format copy and paste, and removed session storage

* fix: text field validateCellValue add nullable

* feat: added escape characters when setting the header in the clipboard
  • Loading branch information
boris-w authored Oct 25, 2023
1 parent 8b53476 commit 2bb03a4
Show file tree
Hide file tree
Showing 11 changed files with 249 additions and 17 deletions.
4 changes: 3 additions & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@
"--inspect-brk",
"./node_modules/.bin/jest",
"${relativeFile}",
"--runInBand"
"--runInBand",
"--config",
"./jest.config.js"
],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { clear, copy, paste, RangeType } from '@teable-group/openapi';
import { useTableId, useViewId } from '@teable-group/sdk';
import { useToast } from '@teable-group/ui-lib';
import { useCallback } from 'react';
import { extractTableHeader, serializerHtml } from '@/features/app/utils/clipboard';
import { SelectionRegionType } from '../../../grid';
import type { CombinedSelection } from '../../../grid/managers';

Expand All @@ -15,8 +16,6 @@ const rangeTypes = {
[SelectionRegionType.None]: undefined,
};

const copyMark = 'TeableCopyContent:';

export const useSelectionOperation = () => {
const tableId = useTableId();
const viewId = useViewId();
Expand All @@ -35,8 +34,6 @@ export const useSelectionOperation = () => {

const { toast } = useToast();

const copyHeaderKey = 'teable_copy_header';

const doCopy = useCallback(
async (selection: CombinedSelection) => {
if (!viewId || !tableId) {
Expand All @@ -56,8 +53,12 @@ export const useSelectionOperation = () => {
});
const { content, header } = data;

await navigator.clipboard.writeText(`${copyMark}${content}`);
sessionStorage.setItem(copyHeaderKey, JSON.stringify(header));
await navigator.clipboard.write([
new ClipboardItem({
['text/plain']: new Blob([content], { type: 'text/plain' }),
['text/html']: new Blob([serializerHtml(content, header)], { type: 'text/html' }),
}),
]);
toaster.update({ id: toaster.id, title: 'Copied success!' });
},
[tableId, toast, viewId, copyReq]
Expand All @@ -72,14 +73,18 @@ export const useSelectionOperation = () => {
title: 'Pasting...',
});
const ranges = selection.ranges;
const content = await navigator.clipboard.readText();
const usingHeader = content.startsWith(copyMark);
const headerStr = sessionStorage.getItem(copyHeaderKey);
const header = headerStr && usingHeader ? JSON.parse(headerStr) : undefined;
const clipboardContent = await navigator.clipboard.read();
const text = await (await clipboardContent[0].getType('text/plain')).text();
const html = await (await clipboardContent[0].getType('text/html')).text();
const header = extractTableHeader(html);
if (header.error) {
toaster.update({ id: toaster.id, title: header.error });
return;
}
await pasteReq({
content: usingHeader ? content.split(copyMark)[1] : content,
content: text,
cell: ranges[0],
header,
header: header.result,
});
toaster.update({ id: toaster.id, title: 'Pasted success!' });
},
Expand Down
137 changes: 137 additions & 0 deletions apps/nextjs-app/src/features/app/utils/clipboard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import {
CellValueType,
DbFieldType,
FieldType,
} from '../../../../../../packages/core/src/models/field/constant';
import { extractTableHeader, isTeableHTML, serializerHtml } from './clipboard';

jest.mock('@teable-group/core', () => {
return {
__esModule: true,
IFieldVo: {},
fieldVoSchema: {
safeParse: () => ({ success: true }),
},
};
});

describe('clipboard', () => {
const html =
'<table data-teable-html-marker="1"><thead><tr><td colspan="0" id="%22fldziUf9QuQjkbfMuG5%22" name="%22Name%22" isPrimary="true" columnMeta="%7B%22viwE0sl0GqGdWaBqwFi%22%3A%7B%22order%22%3A0.5%7D%7D" dbFieldName="%22Name_fldziUf9QuQjkbfMuG5%22" dbFieldType="%22TEXT%22" type="%22singleLineText%22" options="%7B%7D" cellValueType="%22string%22">Name</td><td colspan="1" id="%22fldpsQvHI4ugP2luizP%22" name="%22Count%22" columnMeta="%7B%22viwE0sl0GqGdWaBqwFi%22%3A%7B%22order%22%3A1%7D%7D" dbFieldName="%22Count_fldpsQvHI4ugP2luizP%22" dbFieldType="%22REAL%22" type="%22number%22" options="%7B%22formatting%22%3A%7B%22type%22%3A%22decimal%22%2C%22precision%22%3A0%7D%7D" cellValueType="%22number%22">Count</td><td colspan="2" id="%22fldGTKfZvXNXeMJ6nqu%22" name="%22Status%22" columnMeta="%7B%22viwE0sl0GqGdWaBqwFi%22%3A%7B%22order%22%3A2%7D%7D" dbFieldName="%22Status_fldGTKfZvXNXeMJ6nqu%22" dbFieldType="%22TEXT%22" options="%7B%22choices%22%3A%5B%7B%22name%22%3A%22light%22%2C%22id%22%3A%22cho2caYhPrI%22%2C%22color%22%3A%22grayBright%22%7D%2C%7B%22name%22%3A%22medium%22%2C%22id%22%3A%22chor2ob8aU7%22%2C%22color%22%3A%22yellowBright%22%7D%2C%7B%22name%22%3A%22heavy%22%2C%22id%22%3A%22choArPr57sO%22%2C%22color%22%3A%22tealBright%22%7D%5D%7D" type="%22singleSelect%22" cellValueType="%22string%22">Status</td></tr></thead><tbody><tr><td>John</td><td>20</td><td>light</td></tr><tr><td>Tom</td><td>30</td><td>medium</td></tr><tr><td>Bob</td><td>40</td><td>heavy</td></tr></tbody></table>';

const expectedHeader = [
{
id: 'fldziUf9QuQjkbfMuG5',
name: 'Name',
isPrimary: true,
columnMeta: {
viwE0sl0GqGdWaBqwFi: {
order: 0.5,
},
},
dbFieldName: 'Name_fldziUf9QuQjkbfMuG5',
dbFieldType: DbFieldType.Text,
type: FieldType.SingleLineText,
options: {},
cellValueType: CellValueType.String,
},
{
id: 'fldpsQvHI4ugP2luizP',
name: 'Count',
columnMeta: {
viwE0sl0GqGdWaBqwFi: {
order: 1,
},
},
dbFieldName: 'Count_fldpsQvHI4ugP2luizP',
dbFieldType: DbFieldType.Real,
type: FieldType.Number,
options: {
formatting: {
type: 'decimal',
precision: 0,
},
},
cellValueType: CellValueType.Number,
},
{
id: 'fldGTKfZvXNXeMJ6nqu',
name: 'Status',
columnMeta: {
viwE0sl0GqGdWaBqwFi: {
order: 2,
},
},
dbFieldName: 'Status_fldGTKfZvXNXeMJ6nqu',
dbFieldType: DbFieldType.Text,
options: {
choices: [
{
name: 'light',
id: 'cho2caYhPrI',
color: 'grayBright',
},
{
name: 'medium',
id: 'chor2ob8aU7',
color: 'yellowBright',
},
{
name: 'heavy',
id: 'choArPr57sO',
color: 'tealBright',
},
],
},
type: FieldType.SingleSelect,
cellValueType: CellValueType.String,
},
];
it('extractTableHeader should extract table header from HTML', () => {
const { result } = extractTableHeader(html);
expect(result).toEqual(expectedHeader);
});

it('extractTableHeader should return undefined from non-teable HTML', () => {
const { result } = extractTableHeader('<table></table>');
expect(result).toEqual(undefined);
});

it('serializerHtml should serializer table from data and header of table', () => {
const data = 'John\t20\tlight\nTom\t30\tmedium\nBob\t40\theavy';
const result = serializerHtml(data, expectedHeader);
expect(result).toEqual(html);
});

describe('isTeableHtml', () => {
it('returns true for HTML with table tagged as teable', () => {
const html = `
<table data-teable-html-marker="true">
<tr><td>Hello</td></tr>
</table>
`;
expect(isTeableHTML(html)).toBe(true);
});

it('returns false for HTML without table', () => {
const html = `
<div>No Table</div>
`;
expect(isTeableHTML(html)).toBe(false);
});

it('returns false if table lacks marker attribute', () => {
const html = `
<table>
<tr><td>Hello</td></tr>
</table>
`;
expect(isTeableHTML(html)).toBe(false);
});

it('handles invalid HTML gracefully', () => {
const html = `<div>`;
expect(isTeableHTML(html)).toBe(false);
});
});
});
81 changes: 81 additions & 0 deletions apps/nextjs-app/src/features/app/utils/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { fieldVoSchema, type IFieldVo } from '@teable-group/core';
import { mapValues } from 'lodash';
import { fromZodError } from 'zod-validation-error';

const teableHtmlMarker = 'data-teable-html-marker';

export const serializerHtml = (data: string, headers: IFieldVo[]) => {
const records = data.split('\n');
const bodyContent = records
.map((record) => {
const cells = record.split('\t');
return `<tr>${cells.map((cell) => `<td>${cell}</td>`).join('')}</tr>`;
})
.join('');
const headerContent = headers
.map((header, index) => {
const attrs = Object.entries(header)
.map(([key, value]) => `${key}="${encodeURIComponent(JSON.stringify(value))}"`)
.join(' ');
return `<td colspan="${index}" ${attrs}>${header.name}</td>`;
})
.join('');

return `<table ${teableHtmlMarker}="1"><thead><tr>${headerContent}</tr></thead><tbody>${bodyContent}</tbody></table>`;
};

export const extractTableHeader = (html: string) => {
if (!isTeableHTML(html)) {
return { result: undefined };
}
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const table = doc.querySelector('table');
const headerRow = table?.querySelector('thead tr');
const headerCells = headerRow?.querySelectorAll('td') || [];

const headers = Array.from(headerCells);
let error = '';
const result = headers.map((cell) => {
const id = cell.getAttribute('id');
const name = cell.getAttribute('name');
const isPrimary = cell.getAttribute('isPrimary');
const columnMeta = cell.getAttribute('columnMeta');
const dbFieldName = cell.getAttribute('dbFieldName');
const dbFieldType = cell.getAttribute('dbFieldType');
const type = cell.getAttribute('type');
const options = cell.getAttribute('options');
const cellValueType = cell.getAttribute('cellValueType');
const fieldVo = mapValues(
{
id,
name,
isPrimary,
columnMeta,
dbFieldName,
dbFieldType,
type,
options,
cellValueType,
},
(value) => {
const encodeValue = value ? decodeURIComponent(value) : undefined;
return encodeValue ? JSON.parse(encodeValue) : undefined;
}
);
const validate = fieldVoSchema.safeParse(fieldVo);
if (validate.success) {
return fieldVo;
}
error = fromZodError(validate.error).message;
return undefined;
}) as IFieldVo[];
return error ? { result: undefined, error } : { result };
};

export const isTeableHTML = (html: string) => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const table = doc.querySelector('table');
return Boolean(table?.getAttribute(teableHtmlMarker));
};
1 change: 1 addition & 0 deletions packages/core/.eslintrc.cjs → packages/core/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* Specific eslint rules for this workspace, learn how to compose
* @link https://github.com/teable-group/teable/tree/main/packages/eslint-config-bases
*/
require('@teable-group/eslint-config-bases/patch/modern-module-resolution');

const { getDefaultIgnorePatterns } = require('@teable-group/eslint-config-bases/helpers');

Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"eslint": "8.51.0",
"get-tsconfig": "4.2.0",
"jest": "29.7.0",
"prettier": "3.0.3",
"rimraf": "5.0.5",
"size-limit": "9.0.0",
"ts-jest": "29.1.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ describe('LongTextFieldCore', () => {
it('should validate value', () => {
expect(field.validateCellValue('1.234').success).toBe(true);
expect(field.validateCellValue(1.234).success).toBe(false);
expect(field.validateCellValue(null).success).toBe(true);

expect(multipleLookupField.validateCellValue(['1.234']).success).toBe(true);
expect(multipleLookupField.validateCellValue([1.234]).success).toBe(false);
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/models/field/derivate/long-text.field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ export class LongTextFieldCore extends FieldCore {

validateCellValue(value: unknown) {
if (this.isMultipleCellValue) {
return z.array(longTextCelValueSchema).nonempty().optional().safeParse(value);
return z.array(longTextCelValueSchema).nonempty().nullable().safeParse(value);
}
return longTextCelValueSchema.optional().safeParse(value);
return longTextCelValueSchema.nullable().safeParse(value);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ describe('SingleLineTextFieldCore', () => {
it('should validate value', () => {
expect(field.validateCellValue('1.234').success).toBe(true);
expect(field.validateCellValue(1.234).success).toBe(false);
expect(field.validateCellValue(null).success).toBe(true);

expect(multipleLookupField.validateCellValue(['1.234']).success).toBe(true);
expect(multipleLookupField.validateCellValue([1.234]).success).toBe(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ export class SingleLineTextFieldCore extends FieldCore {

validateCellValue(value: unknown) {
if (this.isMultipleCellValue) {
return z.array(singleLineTextCelValueSchema).nonempty().optional().safeParse(value);
return z.array(singleLineTextCelValueSchema).nonempty().nullable().safeParse(value);
}
return singleLineTextCelValueSchema.optional().safeParse(value);
return singleLineTextCelValueSchema.nullable().safeParse(value);
}
}
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 2bb03a4

Please sign in to comment.