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 59571fe48cc..2767ba10df6 100644 Binary files a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/datasets.e2e.js.snap and b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/datasets.e2e.js.snap differ 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""")) + } + } + +}