From d3763ba41960660d0ba229bed0ae9a2c0c2b8a90 Mon Sep 17 00:00:00 2001 From: Richard Tan <30404522+richardhjtan@users.noreply.github.com> Date: Thu, 8 Jan 2026 23:27:28 +0800 Subject: [PATCH] Add listing: Spreadsheet --- .../454c33bd-bc8a-4bda-bd32-321bd541548a.json | 71 + .../0c1aed91-ae63-4b87-bcf0-3505520c7169.json | 35 + .../f629aeaf-f9e0-42cb-b8ba-facd94d7eff9.json | 29 + spreadsheet/spreadsheet.gts | 1218 +++++++++++++++++ 4 files changed, 1353 insertions(+) create mode 100644 CardListing/454c33bd-bc8a-4bda-bd32-321bd541548a.json create mode 100644 Spec/0c1aed91-ae63-4b87-bcf0-3505520c7169.json create mode 100644 spreadsheet/Spreadsheet/f629aeaf-f9e0-42cb-b8ba-facd94d7eff9.json create mode 100644 spreadsheet/spreadsheet.gts diff --git a/CardListing/454c33bd-bc8a-4bda-bd32-321bd541548a.json b/CardListing/454c33bd-bc8a-4bda-bd32-321bd541548a.json new file mode 100644 index 0000000..eb6b540 --- /dev/null +++ b/CardListing/454c33bd-bc8a-4bda-bd32-321bd541548a.json @@ -0,0 +1,71 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "CardListing", + "module": "../catalog-app/listing/listing" + } + }, + "type": "card", + "attributes": { + "name": "Spreadsheet", + "images": [ + "https://boxel-images.boxel.ai/app-assets/catalog/spreadsheet-listing/screenshot_01.png" + ], + "summary": "A comprehensive spreadsheet management component that enables importing, editing and exporting CSV data with support for custom delimiters and real-time saving, designed for seamless data handling within applications.", + "cardInfo": { + "notes": null, + "title": null, + "description": null, + "thumbnailURL": "https://boxel-images.boxel.ai/app-assets/catalog/spreadsheet-listing/thumbnail.png" + } + }, + "relationships": { + "tags": { + "links": { + "self": null + } + }, + "skills": { + "links": { + "self": null + } + }, + "license": { + "links": { + "self": "../License/4c5a023b-a72c-4f90-930b-da60a1de5b2d" + } + }, + "specs.0": { + "links": { + "self": "../Spec/0c1aed91-ae63-4b87-bcf0-3505520c7169" + } + }, + "publisher": { + "links": { + "self": null + } + }, + "examples.0": { + "links": { + "self": "../spreadsheet/Spreadsheet/f629aeaf-f9e0-42cb-b8ba-facd94d7eff9" + } + }, + "categories.0": { + "links": { + "self": "../Category/analytics-reporting" + } + }, + "categories.1": { + "links": { + "self": "../Category/data-analytics" + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + } + } +} \ No newline at end of file diff --git a/Spec/0c1aed91-ae63-4b87-bcf0-3505520c7169.json b/Spec/0c1aed91-ae63-4b87-bcf0-3505520c7169.json new file mode 100644 index 0000000..a84a698 --- /dev/null +++ b/Spec/0c1aed91-ae63-4b87-bcf0-3505520c7169.json @@ -0,0 +1,35 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../spreadsheet/spreadsheet", + "name": "Spreadsheet" + }, + "specType": "card", + "containedExamples": [], + "title": "Spreadsheet", + "description": null, + "cardInfo": { + "title": null, + "description": null, + "thumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/spreadsheet/Spreadsheet/f629aeaf-f9e0-42cb-b8ba-facd94d7eff9.json b/spreadsheet/Spreadsheet/f629aeaf-f9e0-42cb-b8ba-facd94d7eff9.json new file mode 100644 index 0000000..582ec8b --- /dev/null +++ b/spreadsheet/Spreadsheet/f629aeaf-f9e0-42cb-b8ba-facd94d7eff9.json @@ -0,0 +1,29 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "Spreadsheet", + "module": "../spreadsheet" + } + }, + "type": "card", + "attributes": { + "name": "Users", + "csvData": "Username; Identifier;First name;Last name\nbooker12;9012;Rachel;Booker\ngrey07;2070;Laura;Grey\njohnson81;4081;Craig;Johnson\njenkins46;9346;Mary;Jenkins\nsmith79;5079;Jamie;Smith", + "cardInfo": { + "notes": null, + "title": null, + "description": null, + "thumbnailURL": null + }, + "delimiter": ";" + }, + "relationships": { + "cardInfo.theme": { + "links": { + "self": null + } + } + } + } +} diff --git a/spreadsheet/spreadsheet.gts b/spreadsheet/spreadsheet.gts new file mode 100644 index 0000000..0b519c0 --- /dev/null +++ b/spreadsheet/spreadsheet.gts @@ -0,0 +1,1218 @@ +import { eq, add, gt } from '@cardstack/boxel-ui/helpers'; +import { + CardDef, + field, + contains, + Component, +} from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import TextAreaField from 'https://cardstack.com/base/text-area'; +import { Button } from '@cardstack/boxel-ui/components'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { restartableTask, timeout } from 'ember-concurrency'; +import TableIcon from '@cardstack/boxel-icons/table'; +import type Owner from '@ember/owner'; + +class SpreadsheetIsolated extends Component { + @tracked parsedData: string[][] = []; + @tracked headers: string[] = []; + @tracked hasUnsavedChanges = false; + @tracked saveStatus = ''; + @tracked delimiter = ','; + @tracked tempDelimiter = ''; + @tracked showDelimiterHelp = false; + @tracked isEditingDelimiter = false; + + constructor(owner: Owner, args: any) { + super(owner, args); + this.delimiter = this.args.model?.delimiter || ','; + this.initialParse.perform(); + } + + private initialParse = restartableTask(async () => { + this.parseCSV(); + await Promise.resolve(); + }); + + parseCSV() { + try { + const csvContent = this.args.model?.csvData || ''; + if (!csvContent.trim()) { + this.headers = []; + this.parsedData = []; + return; + } + + const lines = csvContent + .trim() + .split('\n') + .filter((line) => line.length > 0); + if (lines.length === 0) { + this.headers = ['Column A']; + this.parsedData = [['']]; + return; + } + + const newHeaders = this.parseCSVLine(lines[0]); + if (newHeaders.length === 0) { + this.headers = ['Column A']; + this.parsedData = [['']]; + return; + } + + const newData = lines.slice(1).map((line) => this.parseCSVLine(line)); + + const headerCount = newHeaders.length; + const normalizedData = newData.map((row) => { + if (row.length === headerCount) return row; + if (row.length > headerCount) return row.slice(0, headerCount); + + const padded = [...row]; + padded.length = headerCount; + padded.fill('', row.length); + return padded; + }); + + this.headers = newHeaders; + this.parsedData = normalizedData; + + if (this.saveStatus && !this.hasUnsavedChanges) { + this.saveStatus = ''; + } + } catch (error) { + console.error('Error parsing CSV:', error); + this.headers = ['Column A']; + this.parsedData = [['Error parsing CSV']]; + } + } + + parseCSVLine(line: string): string[] { + if (!line || typeof line !== 'string') return ['']; + + const result: string[] = []; + let current = ''; + let inQuotes = false; + + try { + for (let i = 0; i < line.length; i++) { + const char = line[i]; + const nextChar = line[i + 1]; + + if (char === '"' && !inQuotes) { + inQuotes = true; + } else if (char === '"' && inQuotes && nextChar === '"') { + current += '"'; + i++; + } else if (char === '"' && inQuotes) { + inQuotes = false; + } else if (char === this.delimiterChar && !inQuotes) { + result.push(current); + current = ''; + } else { + current += char; + } + } + + result.push(current); + return result; + } catch (error) { + console.warn('CSV line parsing error:', error, 'Line:', line); + return [line]; + } + } + + generateCSV(): string { + const escapeCSVValue = (value: string | null | undefined): string => { + const safeValue = value?.toString() ?? ''; + if ( + safeValue.includes(this.delimiterChar) || + safeValue.includes('"') || + safeValue.includes('\n') + ) { + return '"' + safeValue.replace(/"/g, '""') + '"'; + } + return safeValue; + }; + + const headers = this.headers || []; + const data = this.parsedData || []; + + if (headers.length === 0) { + return ''; + } + + const headerRow = headers.map(escapeCSVValue).join(this.delimiterChar); + const dataRows = data.map((row) => + row.map(escapeCSVValue).join(this.delimiterChar), + ); + + return [headerRow, ...dataRows].join('\n'); + } + + private autoSave = restartableTask(async () => { + if (!this.hasUnsavedChanges) return; + + this.saveStatus = 'Saving...'; + const csvContent = this.generateCSV(); + + try { + if (this.args.model) { + this.args.model.csvData = csvContent; + } + + await timeout(500); + + this.hasUnsavedChanges = false; + this.saveStatus = 'Saved ✓'; + + await timeout(2000); + this.saveStatus = ''; + } catch (error) { + console.error('Save error:', error); + this.saveStatus = 'Save failed ✗'; + await timeout(3000); + this.saveStatus = ''; + } + }); + + get delimiterChar(): string { + const rawDelimiter = this.delimiter || this.args.model?.delimiter || ','; + if (!rawDelimiter) return ','; + const trimmed = rawDelimiter.trim(); + return trimmed === '\\t' ? '\t' : trimmed; + } + + updateTempDelimiter = (event: Event) => { + this.tempDelimiter = (event?.target as HTMLInputElement)?.value ?? ''; + }; + + saveDelimiterEdit = () => { + const normalized = this.tempDelimiter || ','; + this.delimiter = normalized; + if (this.args.model) { + this.args.model.delimiter = normalized === '\t' ? '\\t' : normalized; + } + this.parseCSV(); + this.hasUnsavedChanges = true; + this.autoSave.perform(); + this.isEditingDelimiter = false; + }; + + handleDelimiterKeydown = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + this.saveDelimiterEdit(); + (event.target as HTMLInputElement).blur(); + } else if (event.key === 'Escape') { + event.preventDefault(); + this.tempDelimiter = this.delimiter; + this.isEditingDelimiter = false; + (event.target as HTMLInputElement).blur(); + } + }; + + startDelimiterEdit = () => { + this.tempDelimiter = this.delimiter; + this.isEditingDelimiter = true; + }; + + toggleDelimiterHelp = () => { + this.showDelimiterHelp = !this.showDelimiterHelp; + }; + + detectDelimiter = (csvText: string): string => { + if (!csvText.trim()) return ','; + + const firstLine = csvText.split('\n')[0] || ''; + const delimiters = [';', ',', '|', '\t']; + + const counts = delimiters.map((delim) => ({ + delimiter: delim, + count: firstLine.split(delim).length - 1, + })); + + const best = counts.reduce((prev, curr) => + curr.count > prev.count ? curr : prev, + ); + + return best.count > 0 ? best.delimiter : ','; + }; + + importFromFile = async (event: Event) => { + const input = event?.target as HTMLInputElement | null; + const file = input?.files?.[0]; + if (!file) return; + + if (file.size > 10 * 1024 * 1024) { + console.error('File too large. Maximum size is 10MB.'); + return; + } + + const validTypes = ['text/csv', 'application/csv', 'text/plain']; + const validExtensions = ['.csv', '.txt']; + const isValidType = + validTypes.includes(file.type) || + validExtensions.some((ext) => file.name.toLowerCase().endsWith(ext)); + + if (!isValidType) { + console.warn( + 'Unexpected file type. Expected CSV file, but will attempt to process.', + ); + } + + if (file.size === 0) { + console.error('Cannot import empty file.'); + return; + } + + try { + const text = await file.text(); + const normalizedText = text + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + .trim(); + + const detectedDelimiter = this.detectDelimiter(normalizedText); + + if (this.args.model) { + this.args.model.csvData = normalizedText; + this.args.model.delimiter = + detectedDelimiter === '\t' ? '\\t' : detectedDelimiter; + } + + // Update the component's delimiter to match + this.delimiter = detectedDelimiter; + + this.parseCSV(); + this.hasUnsavedChanges = true; + this.autoSave.perform(); + if (input) input.value = ''; + } catch (e) { + console.error('Import CSV failed', e); + } + }; + + downloadCSV = () => { + try { + const csv = this.generateCSV(); + const base = + this.args.model?.csvFilename?.trim() || + this.args.model?.name?.trim() || + 'spreadsheet'; + const filename = base.endsWith('.csv') ? base : `${base}.csv`; + + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (e) { + console.error('Download CSV failed', e); + } + }; + + +} + +export class Spreadsheet extends CardDef { + static displayName = 'Spreadsheet'; + static icon = TableIcon; + + @field name = contains(StringField); + @field csvData = contains(TextAreaField); + @field csvFilename = contains(StringField); + @field delimiter = contains(StringField); + + @field title = contains(StringField, { + computeVia: function (this: Spreadsheet) { + return this.name ?? 'Untitled Spreadsheet'; + }, + }); + + static isolated = SpreadsheetIsolated; + + static embedded = class Embedded extends Component { + get rowCount(): number { + if (!this.args.model?.csvData) return 0; + return this.args.model.csvData.split('\n').length - 1; + } + + get columnCount(): number { + if (!this.args.model?.csvData) return 0; + const firstLine = this.args.model.csvData.split('\n')[0]; + const delim = + this.args.model?.delimiter === '\\t' + ? '\t' + : this.args.model?.delimiter || ','; + return firstLine ? firstLine.split(delim).length : 0; + } + + + }; + + static fitted = class Fitted extends Component { + + + get dataInfo(): string { + if (!this.args.model?.csvData) return 'Empty spreadsheet'; + + const delim = + this.args.model?.delimiter === '\\t' + ? '\t' + : this.args.model?.delimiter || ','; + const lines = this.args.model.csvData.split('\n'); + const rows = Math.max(0, lines.length - 1); + const cols = lines[0] ? lines[0].split(delim).length : 0; + + return `${rows} rows × ${cols} cols`; + } + }; +}