From 114037447ef67e48cc94b43600b6f4a2d8d5b238 Mon Sep 17 00:00:00 2001 From: Florian M Date: Wed, 13 Dec 2023 10:25:39 +0100 Subject: [PATCH] Show Thumbnails in Dataset Table (#7479) * WIP: Show Thumbnails in Dataset Table * WIP: query * use layer names * typecheck,snapshots * order layer names * fix duplicate layer names, fix string array parsing * ordering, make linter happy * snapshots * integrate type column into thumbnail * lint * slightly changed tag colors, more escaping, new folder+dataset images * dark mode, changelog * test naming * Update frontend/javascripts/types/api_flow_types.ts Co-authored-by: Philipp Otto * Update frontend/javascripts/types/api_flow_types.ts Co-authored-by: Philipp Otto * Update frontend/javascripts/dashboard/advanced_dataset/dataset_table.tsx Co-authored-by: Philipp Otto * pr feedback, format --------- Co-authored-by: Philipp Otto --- CHANGELOG.unreleased.md | 1 + app/models/dataset/Dataset.scala | 35 +++- app/utils/sql/SqlEscaping.scala | 42 +++-- .../advanced_dataset/dataset_table.tsx | 84 ++++++---- .../dataset/dataset_collection_context.tsx | 6 +- frontend/javascripts/libs/format_utils.ts | 4 +- frontend/javascripts/main.tsx | 2 +- .../view/components/editable_text_icon.tsx | 12 +- .../backend-snapshot-tests/datasets.e2e.js.md | 20 +++ .../datasets.e2e.js.snap | Bin 3507 -> 3710 bytes frontend/javascripts/types/api_flow_types.ts | 14 +- frontend/stylesheets/_dashboard.less | 21 ++- frontend/stylesheets/dark.less | 4 + public/images/folder-thumbnail.svg | 16 ++ public/images/inactive-dataset-thumbnail.svg | 155 ++++++++++++++++++ test/backend/SqlEscapingTestSuite.scala | 66 ++++++++ 16 files changed, 417 insertions(+), 65 deletions(-) create mode 100644 public/images/folder-thumbnail.svg create mode 100644 public/images/inactive-dataset-thumbnail.svg create mode 100644 test/backend/SqlEscapingTestSuite.scala diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index ef5b5d50376..232533b0787 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -14,6 +14,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released - Added support for S3-compliant object storage services (e.g. MinIO) as a storage backend for remote datasets. [#7453](https://github.com/scalableminds/webknossos/pull/7453) - Added support for blosc compressed N5 datasets. [#7465](https://github.com/scalableminds/webknossos/pull/7465) - Added route for triggering the compute segment index worker job. [#7471](https://github.com/scalableminds/webknossos/pull/7471) +- Added thumbnails to the dashboard dataset list. [#7479](https://github.com/scalableminds/webknossos/pull/7479) ### Changed - Improved loading speed of the annotation list. [#7410](https://github.com/scalableminds/webknossos/pull/7410) diff --git a/app/models/dataset/Dataset.scala b/app/models/dataset/Dataset.scala index 8f1a5d28df9..600668bdd8b 100755 --- a/app/models/dataset/Dataset.scala +++ b/app/models/dataset/Dataset.scala @@ -54,7 +54,7 @@ case class Dataset(_id: ObjectId, logoUrl: Option[String], sortingKey: Instant = Instant.now, details: Option[JsObject] = None, - tags: Set[String] = Set.empty, + tags: List[String] = List.empty, created: Instant = Instant.now, isDeleted: Boolean = false) extends FoxImplicits { @@ -75,7 +75,9 @@ case class DatasetCompactInfo( lastUsedByUser: Instant, status: String, tags: List[String], - isUnreported: Boolean + isUnreported: Boolean, + colorLayerNames: List[String], + segmentationLayerNames: List[String], ) object DatasetCompactInfo { @@ -133,7 +135,7 @@ class DatasetDAO @Inject()(sqlClient: SqlClient, datasetLayerDAO: DatasetLayerDA r.logourl, Instant.fromSql(r.sortingkey), details, - parseArrayLiteral(r.tags).toSet, + parseArrayLiteral(r.tags).sorted, Instant.fromSql(r.created), r.isdeleted ) @@ -262,7 +264,9 @@ class DatasetDAO @Inject()(sqlClient: SqlClient, datasetLayerDAO: DatasetLayerDA ) AS isEditable, COALESCE(lastUsedTimes.lastUsedTime, ${Instant.zero}), d.status, - d.tags + d.tags, + cl.names AS colorLayerNames, + sl.names AS segmentationLayerNames FROM (SELECT $columns FROM $existingCollectionName WHERE $selectionPredicates $limitQuery) d JOIN webknossos.organizations o @@ -271,9 +275,26 @@ class DatasetDAO @Inject()(sqlClient: SqlClient, datasetLayerDAO: DatasetLayerDA ON u._id = $requestingUserIdOpt LEFT JOIN webknossos.dataSet_lastUsedTimes lastUsedTimes ON lastUsedTimes._dataSet = d._id AND lastUsedTimes._user = u._id + LEFT JOIN (SELECT _dataset, ARRAY_AGG(name ORDER BY name) AS names FROM webknossos.dataSet_layers WHERE category = 'color' GROUP BY _dataset) cl + ON d._id = cl._dataset + LEFT JOIN (SELECT _dataset, ARRAY_AGG(name ORDER BY name) AS names FROM webknossos.dataSet_layers WHERE category = 'segmentation' GROUP BY _dataset) sl + ON d._id = sl._dataset """ rows <- run( - query.as[(ObjectId, String, String, ObjectId, Boolean, String, Instant, Boolean, Instant, String, String)]) + query.as[ + (ObjectId, + String, + String, + ObjectId, + Boolean, + String, + Instant, + Boolean, + Instant, + String, + String, + String, + String)]) } yield rows.toList.map( row => @@ -290,6 +311,8 @@ class DatasetDAO @Inject()(sqlClient: SqlClient, datasetLayerDAO: DatasetLayerDA status = row._10, tags = parseArrayLiteral(row._11), isUnreported = unreportedStatusList.contains(row._10), + colorLayerNames = parseArrayLiteral(row._12), + segmentationLayerNames = parseArrayLiteral(row._13) )) private def buildSelectionPredicates(isActiveOpt: Option[Boolean], @@ -513,7 +536,7 @@ class DatasetDAO @Inject()(sqlClient: SqlClient, datasetLayerDAO: DatasetLayerDA ${d.inboxSourceHash}, $defaultViewConfiguration, $adminViewConfiguration, ${d.description}, ${d.displayName}, ${d.isPublic}, ${d.isUsable}, ${d.name}, ${d.scale}, ${d.status.take(1024)}, - ${d.sharingToken}, ${d.sortingKey}, ${d.details}, ${d.tags.toList}, + ${d.sharingToken}, ${d.sortingKey}, ${d.details}, ${d.tags}, ${d.created}, ${d.isDeleted}) """.asUpdate) } yield () diff --git a/app/utils/sql/SqlEscaping.scala b/app/utils/sql/SqlEscaping.scala index 7b08d212e46..ff0f86aea11 100644 --- a/app/utils/sql/SqlEscaping.scala +++ b/app/utils/sql/SqlEscaping.scala @@ -25,26 +25,36 @@ trait SqlEscaping { } } - protected def enumArrayLiteral(elements: List[Enumeration#Value]): String = { - val commaSeparated = elements.map(e => s""""$e"""").mkString(",") - s"'{$commaSeparated}'" - } - - protected def parseArrayLiteral(literal: String): List[String] = { - val trimmed = literal.drop(1).dropRight(1) - if (trimmed.isEmpty) - List.empty + protected def parseArrayLiteral(literal: String): List[String] = + if (literal == null) List.empty else { - val split = trimmed.split(",", -1).toList.map(unescapeInArrayLiteral) - split.map { item => - if (item.startsWith("\"") && item.endsWith("\"")) { - item.drop(1).dropRight(1) - } else item + val trimmed = literal.drop(1).dropRight(1) + if (trimmed.isEmpty) + List.empty + else { + // Removing the escaped quotes to split at commas not surrounded by quotes + // Splitting *the original string* at split positions obtained from matching there + val withoutEscapedQuotes = trimmed.replace("\\\"", "__") + val splitPositions: List[Int] = + ",(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)".r.findAllMatchIn(withoutEscapedQuotes).map(_.start).toList.sorted + val split = splitAtPositions(splitPositions, trimmed) + split.map(unescapeInArrayLiteral) } } + + // Split a string at specified positions. Drop 1 character at every split + private def splitAtPositions(positions: List[Int], aString: String): List[String] = positions match { + case pos :: remainingPositions => + aString.substring(0, pos) :: splitAtPositions(remainingPositions.map(_ - pos - 1), aString.substring(pos + 1)) + case Nil => List(aString) } - protected def unescapeInArrayLiteral(aString: String): String = - aString.replaceAll("""\\"""", """"""").replaceAll("""\\,""", ",") + private def unescapeInArrayLiteral(aString: String): String = { + val withUnescapedQuotes = + aString.replace("\\\"", """"""").replace("\\,", ",").replace("\\\\", "\\") + if (withUnescapedQuotes.startsWith("\"") && withUnescapedQuotes.endsWith("\"")) { + withUnescapedQuotes.drop(1).dropRight(1) + } else withUnescapedQuotes + } } diff --git a/frontend/javascripts/dashboard/advanced_dataset/dataset_table.tsx b/frontend/javascripts/dashboard/advanced_dataset/dataset_table.tsx index 21bd174c27e..3992f3d0a23 100644 --- a/frontend/javascripts/dashboard/advanced_dataset/dataset_table.tsx +++ b/frontend/javascripts/dashboard/advanced_dataset/dataset_table.tsx @@ -44,6 +44,8 @@ const { Column } = Table; const typeHint: RowRenderer[] = []; const useLruRank = true; +const THUMBNAIL_SIZE = 100; + type Props = { datasets: Array; subfolders: FolderItem[]; @@ -278,24 +280,48 @@ class DatasetRenderer { return ; } renderNameColumn() { + const selectedLayerName: string | null = + this.data.colorLayerNames[0] || this.data.segmentationLayerNames[0]; + const imgSrc = selectedLayerName + ? `/api/datasets/${this.data.owningOrganization}/${ + this.data.name + }/layers/${selectedLayerName}/thumbnail?w=${2 * THUMBNAIL_SIZE}&h=${2 * THUMBNAIL_SIZE}` + : "/assets/images/inactive-dataset-thumbnail.svg"; + const iconClassName = selectedLayerName ? "" : " icon-thumbnail"; return ( <> - {this.data.name} + -
+
+ + {this.data.name} + - {this.datasetTable.props.context.globalSearchQuery != null ? ( - - ) : null} + {this.renderTags()} + {this.datasetTable.props.context.globalSearchQuery != null ? ( + <> +
+ + + ) : null} +
); } - renderTagsColumn() { + renderTags() { return this.data.isActive ? ( ; - } renderNameColumn() { - return this.data.name; - } - renderTagsColumn() { - return null; + return ( + <> + +
+ {this.data.name} +
+ + ); } renderCreationDateColumn() { return null; @@ -698,17 +730,10 @@ class DatasetTable extends React.PureComponent { }, }} > - renderer.renderTypeColumn()} - /> ( typeHint, (rowRenderer) => rowRenderer.data.name, @@ -716,15 +741,6 @@ class DatasetTable extends React.PureComponent { sortOrder={sortedInfo.columnKey === "name" ? sortedInfo.order : undefined} render={(_name: string, renderer: RowRenderer) => renderer.renderNameColumn()} /> - , rowRenderer: RowRenderer) => - rowRenderer.renderTagsColumn() - } - /> ))} {dataset.isEditable ? ( - } onChange={_.partial(editTagFromDataset, true)} /> + } + onChange={_.partial(editTagFromDataset, true)} + label="Add Tag" + /> ) : null} ); @@ -896,7 +916,7 @@ function BreadcrumbsTag({ parts: allParts }: { parts: string[] | null }) { return ( - + {formatPath(parts)} diff --git a/frontend/javascripts/dashboard/dataset/dataset_collection_context.tsx b/frontend/javascripts/dashboard/dataset/dataset_collection_context.tsx index b4b374010ed..dd540df2a59 100644 --- a/frontend/javascripts/dashboard/dataset/dataset_collection_context.tsx +++ b/frontend/javascripts/dashboard/dataset/dataset_collection_context.tsx @@ -2,7 +2,7 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useS import type { APIDatasetId, APIDatasetCompact, - APIDatasetCompactWithoutStatus, + APIDatasetCompactWithoutStatusAndLayerNames, FolderItem, } from "types/api_flow_types"; import { DatasetUpdater, getDatastores, triggerDatasetCheck } from "admin/admin_rest_api"; @@ -45,7 +45,7 @@ export type DatasetCollectionContextValue = { setGlobalSearchQuery: (val: string | null) => void; searchRecursively: boolean; setSearchRecursively: (val: boolean) => void; - getBreadcrumbs: (dataset: APIDatasetCompactWithoutStatus) => string[] | null; + getBreadcrumbs: (dataset: APIDatasetCompactWithoutStatusAndLayerNames) => string[] | null; getActiveSubfolders: () => FolderItem[]; showCreateFolderPrompt: (parentFolderId: string) => void; queries: { @@ -160,7 +160,7 @@ export default function DatasetCollectionContextProvider({ await updateDatasetMutation.mutateAsync([id, updater]); } - const getBreadcrumbs = (dataset: APIDatasetCompactWithoutStatus) => { + const getBreadcrumbs = (dataset: APIDatasetCompactWithoutStatusAndLayerNames) => { if (folderHierarchyQuery.data?.itemById == null) { return null; } diff --git a/frontend/javascripts/libs/format_utils.ts b/frontend/javascripts/libs/format_utils.ts index e5e8759ba0d..2a08c9c2988 100644 --- a/frontend/javascripts/libs/format_utils.ts +++ b/frontend/javascripts/libs/format_utils.ts @@ -41,8 +41,8 @@ const COLOR_MAP: Array = [ "#575AFF", "#8086FF", "#2A0FC6", - "#37C6DC", - "#F61A76", + "#40bfd2", + "#b92779", "#FF7BA6", "#FF9364", "#750790", diff --git a/frontend/javascripts/main.tsx b/frontend/javascripts/main.tsx index 97726b75b60..1ad2da1d13b 100644 --- a/frontend/javascripts/main.tsx +++ b/frontend/javascripts/main.tsx @@ -48,7 +48,7 @@ const localStoragePersister = createSyncStoragePersister({ storage: UserLocalStorage, serialize: (data) => compress(JSON.stringify(data)), deserialize: (data) => JSON.parse(decompress(data) || "{}"), - key: "query-cache-v2", + key: "query-cache-v3", }); async function loadActiveUser() { diff --git a/frontend/javascripts/oxalis/view/components/editable_text_icon.tsx b/frontend/javascripts/oxalis/view/components/editable_text_icon.tsx index ac0e19db0fa..6a19bb67dbb 100644 --- a/frontend/javascripts/oxalis/view/components/editable_text_icon.tsx +++ b/frontend/javascripts/oxalis/view/components/editable_text_icon.tsx @@ -3,6 +3,7 @@ import React from "react"; type Props = { icon: React.ReactElement; + label?: string; onChange: (value: string, event: React.SyntheticEvent) => void; }; @@ -57,13 +58,20 @@ class EditableTextIcon extends React.PureComponent { ); } } diff --git a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/datasets.e2e.js.md b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/datasets.e2e.js.md index 79d865b740a..27593cdb0d6 100644 --- a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/datasets.e2e.js.md +++ b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/datasets.e2e.js.md @@ -8,6 +8,7 @@ Generated by [AVA](https://avajs.dev). [ { + colorLayerNames: [], created: 1460379470082, displayName: null, folderId: '570b9f4e4bb848d0885ea917', @@ -18,10 +19,12 @@ Generated by [AVA](https://avajs.dev). lastUsedByUser: 0, name: '2012-06-28_Cortex', owningOrganization: 'Organization_X', + segmentationLayerNames: [], status: 'No longer available on datastore.', tags: [], }, { + colorLayerNames: [], created: 1460379470080, displayName: null, folderId: '570b9f4e4bb848d0885ea917', @@ -32,10 +35,12 @@ Generated by [AVA](https://avajs.dev). lastUsedByUser: 0, name: '2012-09-28_ex145_07x2', owningOrganization: 'Organization_X', + segmentationLayerNames: [], status: 'No longer available on datastore.', tags: [], }, { + colorLayerNames: [], created: 1460379470079, displayName: null, folderId: '570b9f4e4bb848d0885ea917', @@ -46,10 +51,16 @@ Generated by [AVA](https://avajs.dev). lastUsedByUser: 0, name: 'Experiment_001', owningOrganization: 'Organization_X', + segmentationLayerNames: [], status: 'No longer available on datastore.', tags: [], }, { + colorLayerNames: [ + 'color_1', + 'color_2', + 'color_3', + ], created: 1508495293763, displayName: null, folderId: '570b9f4e4bb848d0885ea917', @@ -60,10 +71,14 @@ Generated by [AVA](https://avajs.dev). lastUsedByUser: 0, name: 'confocal-multi_knossos', owningOrganization: 'Organization_X', + segmentationLayerNames: [], status: '', tags: [], }, { + colorLayerNames: [ + 'color', + ], created: 1508495293789, displayName: null, folderId: '570b9f4e4bb848d0885ea917', @@ -74,10 +89,14 @@ Generated by [AVA](https://avajs.dev). lastUsedByUser: 0, name: 'l4_sample', owningOrganization: 'Organization_X', + segmentationLayerNames: [ + 'segmentation', + ], status: '', tags: [], }, { + colorLayerNames: [], created: 1460379603792, displayName: null, folderId: '570b9f4e4bb848d0885ea917', @@ -88,6 +107,7 @@ Generated by [AVA](https://avajs.dev). lastUsedByUser: 0, name: 'rgb', owningOrganization: 'Organization_X', + segmentationLayerNames: [], status: 'No longer available on datastore.', tags: [], }, diff --git a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/datasets.e2e.js.snap b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/datasets.e2e.js.snap index 59571fe48cc3729e6a1b0b617832ef2d7bdb4a11..2767ba10df6406d996d447a9e68b8ccadb804f6d 100644 GIT binary patch literal 3710 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 literal 3507 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 diff --git a/frontend/javascripts/types/api_flow_types.ts b/frontend/javascripts/types/api_flow_types.ts index 06a15afdf72..6b89770a834 100644 --- a/frontend/javascripts/types/api_flow_types.ts +++ b/frontend/javascripts/types/api_flow_types.ts @@ -1,3 +1,4 @@ +import _ from "lodash"; import type { BoundingBoxObject, Edge, @@ -186,7 +187,7 @@ export type MaintenanceInfo = { // Should be a strict subset of APIMaybeUnimportedDataset which makes // typing easier in some places. -export type APIDatasetCompactWithoutStatus = Pick< +export type APIDatasetCompactWithoutStatusAndLayerNames = Pick< APIMaybeUnimportedDataset, | "owningOrganization" | "name" @@ -199,12 +200,19 @@ export type APIDatasetCompactWithoutStatus = Pick< | "tags" | "isUnreported" >; -export type APIDatasetCompact = APIDatasetCompactWithoutStatus & { +export type APIDatasetCompact = APIDatasetCompactWithoutStatusAndLayerNames & { id?: string; status: MutableAPIDataSourceBase["status"]; + colorLayerNames: Array; + segmentationLayerNames: Array; }; export function convertDatasetToCompact(dataset: APIDataset): APIDatasetCompact { + const [colorLayerNames, segmentationLayerNames] = _.partition( + dataset.dataSource.dataLayers, + (layer) => layer.category === "segmentation", + ).map((layers) => layers.map((layer) => layer.name).sort()); + return { owningOrganization: dataset.owningOrganization, name: dataset.name, @@ -217,6 +225,8 @@ export function convertDatasetToCompact(dataset: APIDataset): APIDatasetCompact status: dataset.dataSource.status, tags: dataset.tags, isUnreported: dataset.isUnreported, + colorLayerNames: colorLayerNames, + segmentationLayerNames: segmentationLayerNames, }; } diff --git a/frontend/stylesheets/_dashboard.less b/frontend/stylesheets/_dashboard.less index ace85f2eeda..c7593d64746 100644 --- a/frontend/stylesheets/_dashboard.less +++ b/frontend/stylesheets/_dashboard.less @@ -194,10 +194,29 @@ pre.dataset-import-folder-structure-hint { } .tags-container { - max-width: 280px; display: inline-block; line-height: 26px; .ant-tag { margin-right: 4px; } } + +.dataset-table-thumbnail { + width: 80px; + height: 80px; + border-radius: 5px; + margin-right: 20px; +} + +.dataset-table-name { + font-size: 16px; + font-weight: 500; + display: block; + min-height: 30px +} + +.dataset-table-name-container { + display: inline-block; + vertical-align: middle; + max-width: 520px; +} diff --git a/frontend/stylesheets/dark.less b/frontend/stylesheets/dark.less index 2172e3bc52d..4f92e688cef 100644 --- a/frontend/stylesheets/dark.less +++ b/frontend/stylesheets/dark.less @@ -124,3 +124,7 @@ a:hover { .keyboard-key-icon { background-image: url(/assets/images/icon-keyboard-key-dark.svg); } + +.dataset-table-thumbnail.icon-thumbnail { + filter: invert() contrast(0.8); +} diff --git a/public/images/folder-thumbnail.svg b/public/images/folder-thumbnail.svg new file mode 100644 index 00000000000..83d3d630ff2 --- /dev/null +++ b/public/images/folder-thumbnail.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/public/images/inactive-dataset-thumbnail.svg b/public/images/inactive-dataset-thumbnail.svg new file mode 100644 index 00000000000..e28c5221a58 --- /dev/null +++ b/public/images/inactive-dataset-thumbnail.svg @@ -0,0 +1,155 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ? + diff --git a/test/backend/SqlEscapingTestSuite.scala b/test/backend/SqlEscapingTestSuite.scala new file mode 100644 index 00000000000..c8c6554acce --- /dev/null +++ b/test/backend/SqlEscapingTestSuite.scala @@ -0,0 +1,66 @@ +package backend + +import org.scalatestplus.play.PlaySpec +import utils.sql.{SqlEscaping, SqlTypeImplicits} + +class SqlEscapingTestSuite extends PlaySpec with SqlTypeImplicits with SqlEscaping { + "SQL Escaping" should { + "not change plain string" in { + assert(escapeLiteral("hello") == "'hello'") + } + "escape string with single quotes (')" in { + assert(escapeLiteral("he'l'lo") == "'he''l''lo'") + } + "escape string with consecutive single quotes (')" in { + assert(escapeLiteral("he''l''lo") == "'he''''l''''lo'") + } + "escape string with backslash" in { + assert(escapeLiteral("he\\llo") == "E'he\\\\llo'") + } + } + + "Array Literal Parsing" should { + "handle null" in { + assert(parseArrayLiteral(null) == List()) + } + "handle emptystring" in { + assert(parseArrayLiteral("") == List()) + } + "handle empty array literal" in { + assert(parseArrayLiteral("{}") == List()) + } + "parse single element" in { + assert(parseArrayLiteral("{hello}") == List("hello")) + } + "parse two elements" in { + assert(parseArrayLiteral("{hello,there}") == List("hello", "there")) + } + "parse two numerical elements" in { + assert(parseArrayLiteral("{1,2.5,5}") == List("1", "2.5", "5")) + } + "parse two elements if one has a comma" in { + assert(parseArrayLiteral("""{"he,llo",there}""") == List("he,llo", "there")) + } + "parse two elements if one has a comma and escaped double quotes" in { + assert(parseArrayLiteral("""{"h\"e,llo",there}""") == List("""h"e,llo""", "there")) + } + "parse single element if the comma is between escaped duoble quotes" in { + assert(parseArrayLiteral("""{"this one has \"spe,cial\" chars"}""") == List("""this one has "spe,cial" chars""")) + } + "parse single elements if it has a comma and single escaped double quote" in { + assert(parseArrayLiteral("""{"h\"e,llo"}""") == List("""h"e,llo""")) + } + "parse single elements if it has escaped double quotes" in { + assert(parseArrayLiteral("""{"\"hello\""}""") == List(""""hello"""")) + } + "parse single element if it has single quotes (')" in { + assert(parseArrayLiteral("""{'hello'}""") == List("""'hello'""")) + } + "parse single element with multiple double quotes, backslashes, and commas" in { + assert( + parseArrayLiteral("""{"can I \\\\\\\\\"\\\",\\\\\"break,\",\",\"\",,\"it"}""") == List( + """can I \\\\"\",\\"break,",","",,"it""")) + } + } + +}