Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Show Thumbnails in Dataset Table #7479

Merged
merged 22 commits into from
Dec 13, 2023
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
35 changes: 29 additions & 6 deletions app/models/dataset/Dataset.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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
Expand All @@ -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 =>
Expand All @@ -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],
Expand Down Expand Up @@ -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 ()
Expand Down
42 changes: 26 additions & 16 deletions app/utils/sql/SqlEscaping.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

}
78 changes: 46 additions & 32 deletions frontend/javascripts/dashboard/advanced_dataset/dataset_table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -278,24 +278,45 @@ class DatasetRenderer {
return <FileOutlined style={{ fontSize: "18px" }} />;
}
renderNameColumn() {
let selectedLayerName = null;
if (this.data.colorLayerNames.length > 0) {
selectedLayerName = this.data.colorLayerNames[0];
} else if (this.data.segmentationLayerNames.length > 0) {
selectedLayerName = this.data.segmentationLayerNames[0];
}
fm3 marked this conversation as resolved.
Show resolved Hide resolved
const imgSrc = selectedLayerName
? `/api/datasets/${this.data.owningOrganization}/${this.data.name}/layers/${selectedLayerName}/thumbnail?w=200&h=200`
fm3 marked this conversation as resolved.
Show resolved Hide resolved
: "/assets/images/inactive-dataset-thumbnail.svg";
fm3 marked this conversation as resolved.
Show resolved Hide resolved
const iconClassName = selectedLayerName ? "" : " icon-thumbnail";
return (
<>
<Link
to={`/datasets/${this.data.owningOrganization}/${this.data.name}/view`}
title="View Dataset"
className="incognito-link"
>
{this.data.name}
<img src={imgSrc} className={`dataset-table-thumbnail ${iconClassName}`} alt="" />
</Link>
<br />
<div className="dataset-table-name-container">
<Link
to={`/datasets/${this.data.owningOrganization}/${this.data.name}/view`}
title="View Dataset"
className="incognito-link dataset-table-name"
>
{this.data.name}
</Link>

{this.datasetTable.props.context.globalSearchQuery != null ? (
<BreadcrumbsTag parts={this.datasetTable.props.context.getBreadcrumbs(this.data)} />
) : null}
{this.renderTags()}
{this.datasetTable.props.context.globalSearchQuery != null ? (
<>
<br />
<BreadcrumbsTag parts={this.datasetTable.props.context.getBreadcrumbs(this.data)} />
</>
) : null}
</div>
</>
);
}
renderTagsColumn() {
renderTags() {
return this.data.isActive ? (
<DatasetTags
dataset={this.data}
Expand Down Expand Up @@ -335,14 +356,19 @@ class FolderRenderer {
getRowKey() {
return this.data.key;
}
renderTypeColumn() {
return <FolderOpenOutlined style={{ fontSize: "18px" }} />;
}
renderNameColumn() {
return this.data.name;
}
renderTagsColumn() {
return null;
return (
<>
<img
src={"/assets/images/folder-thumbnail.svg"}
className="dataset-table-thumbnail icon-thumbnail"
alt=""
/>
<div className="dataset-table-name-container">
<span className="incognito-link dataset-table-name">{this.data.name}</span>
</div>
</>
);
}
renderCreationDateColumn() {
return null;
Expand Down Expand Up @@ -698,33 +724,17 @@ class DatasetTable extends React.PureComponent<Props, State> {
},
}}
>
<Column
width={70}
title="Type"
key="type"
render={(__, renderer: RowRenderer) => renderer.renderTypeColumn()}
/>
<Column
title="Name"
dataIndex="name"
key="name"
width={280}
sorter={Utils.localeCompareBy<RowRenderer>(
typeHint,
(rowRenderer) => rowRenderer.data.name,
)}
sortOrder={sortedInfo.columnKey === "name" ? sortedInfo.order : undefined}
render={(_name: string, renderer: RowRenderer) => renderer.renderNameColumn()}
/>
<Column
title="Tags"
dataIndex="tags"
key="tags"
sortOrder={sortedInfo.columnKey === "name" ? sortedInfo.order : undefined}
render={(_tags: Array<string>, rowRenderer: RowRenderer) =>
rowRenderer.renderTagsColumn()
}
/>
<Column
width={180}
title="Creation Date"
Expand Down Expand Up @@ -803,7 +813,11 @@ export function DatasetTags({
/>
))}
{dataset.isEditable ? (
<EditableTextIcon icon={<PlusOutlined />} onChange={_.partial(editTagFromDataset, true)} />
<EditableTextIcon
icon={<PlusOutlined />}
onChange={_.partial(editTagFromDataset, true)}
label="Add Tag"
/>
) : null}
</div>
);
Expand Down Expand Up @@ -896,7 +910,7 @@ function BreadcrumbsTag({ parts: allParts }: { parts: string[] | null }) {

return (
<Tooltip title={`This dataset is located in ${formatPath(allParts)}.`}>
<Tag>
<Tag style={{ marginTop: "5px" }}>
<FolderOpenOutlined />
{formatPath(parts)}
</Tag>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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;
}
Expand Down
4 changes: 2 additions & 2 deletions frontend/javascripts/libs/format_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ const COLOR_MAP: Array<string> = [
"#575AFF",
"#8086FF",
"#2A0FC6",
"#37C6DC",
"#F61A76",
"#40bfd2",
"#b92779",
"#FF7BA6",
"#FF9364",
"#750790",
Expand Down
2 changes: 1 addition & 1 deletion frontend/javascripts/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
12 changes: 10 additions & 2 deletions frontend/javascripts/oxalis/view/components/editable_text_icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React from "react";

type Props = {
icon: React.ReactElement;
label?: string;
onChange: (value: string, event: React.SyntheticEvent<HTMLInputElement>) => void;
};

Expand Down Expand Up @@ -57,13 +58,20 @@ class EditableTextIcon extends React.PureComponent<Props, State> {
<Button
size="small"
icon={this.props.icon}
style={{ height: 22, width: 22 }}
style={{
height: 22,
width: this.props.label ? "initial" : 22,
fontSize: "12px",
color: "#7c7c7c",
}}
onClick={() =>
this.setState({
isEditing: true,
})
}
/>
>
{this.props.label ? <span style={{ marginLeft: 0 }}>{this.props.label}</span> : null}
</Button>
);
}
}
Expand Down
Loading