Improve label interactions (#8417)

- Fixes https://github.com/enso-org/cloud-v2/issues/781
- Implement parser for search query
- Change all UI (labels column, labels side panel, searcher in top bar) to use search query parser
- Change "remove label from asset" to right click
- Also add a tooltip to notify user that right click removes the label
- Change click action on label on asset, to toggle the label from the search
- Stop sending the list of labels for filtering on the server side

Unrelated changes:
- Switch dashboard dev server to use Vite. All other servers should be unaffected.
- `ide watch` works
- `ide build` works
- `gui watch` works
- `dashboard build` works

# Important Notes
There are quite a lot of new interactions with the search bar which should probably all be tested.
This commit is contained in:
somebody1234 2023-11-30 03:29:25 +10:00 committed by GitHub
parent dff1c0c88b
commit 1e93e69523
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 494 additions and 289 deletions

View File

@ -56,12 +56,15 @@ export function esbuildPluginGenerateTailwind(): esbuild.Plugin {
return {
name: 'enso-generate-tailwind',
setup: build => {
const cssProcessor = postcss([
const cssProcessor = postcss(
tailwindcss({
config: tailwindConfig,
...tailwindConfig.default,
content: tailwindConfig.default.content.map(glob =>
glob.replace(/^[.][/]/, THIS_PATH + '/')
),
}),
tailwindcssNesting(),
])
tailwindcssNesting()
)
build.onLoad({ filter: /tailwind\.css$/ }, async loadArgs => {
// console.log(`Processing CSS file '${loadArgs.path}'.`)
const content = await fs.readFile(loadArgs.path, 'utf8')

View File

@ -0,0 +1,58 @@
<!--
FIXME [NP]: https://github.com/enso-org/cloud-v2/issues/345
This file is used by both the `content` and `dashboard` packages. The `dashboard` package uses it
via a symlink. This is temporary, while the `content` and `dashboard` have separate entrypoints
for cloud and desktop. Once they are merged, the symlink must be removed.
-->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<!-- FIXME https://github.com/validator/validator/issues/917 -->
<!-- FIXME Security Vulnerabilities: https://github.com/enso-org/ide/issues/226 -->
<!-- NOTE `frame-src` section of `http-equiv` required only for authorization -->
<meta
http-equiv="Content-Security-Policy"
content="
default-src 'self';
frame-src 'self' data: https://accounts.google.com https://enso-org.firebaseapp.com;
script-src 'self' 'unsafe-eval' data: https://*;
style-src 'self' 'unsafe-inline' data: https://*;
connect-src 'self' data: ws://localhost:* ws://127.0.0.1:* http://localhost:* https://* wss://*;
worker-src 'self' blob:;
img-src 'self' blob: data: https://*;
font-src 'self' data: https://*"
/>
<meta
name="viewport"
content="
width=device-width,
initial-scale = 1.0,
maximum-scale = 1.0,
user-scalable = no"
/>
<title>Enso</title>
<!-- Generated by the build script based on the Enso Font package. -->
<link rel="stylesheet" href="./src/ensoFont.css" />
<script type="module" src="./src/index.ts" defer></script>
</head>
<body>
<div id="root"></div>
<div id="enso-dashboard" class="enso-dashboard"></div>
<div id="enso-chat" class="enso-chat"></div>
<noscript>
This page requires JavaScript to run. Please enable it in your browser.
</noscript>
<script
src="https://cdn.jsdelivr.net/npm/@twemoji/api@14.1.2/dist/twemoji.min.js"
integrity="sha384-D6GSzpW7fMH86ilu73eB95ipkfeXcMPoOGVst/L04yqSSe+RTUY0jXcuEIZk0wrT"
crossorigin="anonymous"
></script>
<!-- Google tag (gtag.js) -->
<script
async
src="https://www.googletagmanager.com/gtag/js?id=G-CLTBJ37MDM"
></script>
</body>
</html>

View File

@ -6,7 +6,7 @@
"scripts": {
"typecheck": "tsc",
"build": "tsx bundle.ts",
"dev": "tsx watch.ts",
"dev": "vite",
"start": "tsx start.ts",
"test": "npm run test:unit",
"test:unit": "playwright test",
@ -43,10 +43,12 @@
"eslint-plugin-jsdoc": "^46.8.1",
"eslint-plugin-react": "^7.32.1",
"playwright": "^1.38.0",
"postcss": "^8.4.29",
"react-toastify": "^9.1.3",
"tailwindcss": "^3.2.7",
"tsx": "^3.12.6",
"typescript": "~5.2.2"
"typescript": "~5.2.2",
"vite": "^4.4.9"
},
"optionalDependencies": {
"@esbuild/darwin-x64": "^0.17.15",

View File

@ -0,0 +1,9 @@
/** @file Configuration for PostCSS. */
/* eslint-disable no-restricted-syntax */
export default {
plugins: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'tailwindcss/nesting': {},
tailwindcss: {},
},
}

View File

@ -0,0 +1,150 @@
/** @file Parsing and representation of the search query. */
// ==================
// === AssetQuery ===
// ==================
/** An {@link AssetQuery}, without the query and methods. */
interface AssetQueryData extends Omit<AssetQuery, 'add' | 'query' | 'remove'> {}
/** An individual segment of a query string input to {@link AssetQuery}. */
interface AssetQueryTerm {
tag: string | null
value: string
}
/** Parsing and representation of the search query. */
export class AssetQuery {
static termsRegex =
// Control characters must be handled, in order to follow the JSON spec.
// eslint-disable-next-line no-control-regex
/(?:([^\s:]*):)?(?:("(?:[^\0-\x1f\\"]|\\[\\/bfnrt"]|\\u[0-9a-fA-F]{4})*")|([^\s"]\S*|))/g
static plainValueRegex = /^(?:|[^"]\S*)$/u
/** Create an {@link AssetQuery}. */
constructor(
readonly query: string,
readonly keywords: string[],
readonly labels: string[]
) {}
/** Return a list of {@link AssetQueryTerm}s found in the raw user input string. */
static terms(query: string): AssetQueryTerm[] {
const terms: AssetQueryTerm[] = []
for (const [, tag, jsonValue, plainValue] of query.trim().matchAll(this.termsRegex)) {
if (tag != null || plainValue == null || plainValue !== '') {
terms.push({
tag: tag ?? null,
value: jsonValue != null ? String(JSON.parse(jsonValue)) : plainValue ?? '',
})
}
}
return terms
}
/** Convert an {@link AssetQueryTerm} to a string usable in a raw user input string. */
static termToString(term: AssetQueryTerm) {
const tagSegment = term.tag == null ? '' : term.tag + ':'
const valueSegment = this.plainValueRegex.test(term.value)
? term.value
: JSON.stringify(term.value)
return tagSegment + valueSegment
}
/** Create an {@link AssetQuery} from a raw user input string. */
static fromString(query: string): AssetQuery {
const terms = AssetQuery.terms(query)
const keywords = terms
.filter(term => term.tag == null || term.tag === '')
.map(term => term.value)
const labels = terms
.filter(term => term.tag?.toLowerCase() === 'label')
.map(term => term.value)
return new AssetQuery(query, keywords, labels)
}
/** Return a new {@link AssetQuery} with the specified terms added,
* or itself if there are no terms to remove. */
add(values: Partial<AssetQueryData>): AssetQuery {
const { keywords, labels } = values
const noKeywords = !keywords || keywords.length === 0
const noLabels = !labels || labels.length === 0
if (noKeywords && noLabels) {
return this
} else {
const newKeywords = this.keywords
let addedKeywords: string[] = []
if (!noKeywords) {
const keywordsSet = new Set(this.keywords)
addedKeywords = keywords.filter(keyword => !keywordsSet.has(keyword))
newKeywords.push(...addedKeywords)
}
const newLabels = this.labels
let addedLabels: string[] = []
if (!noLabels) {
const labelsSet = new Set(this.labels)
addedLabels = labels.filter(keyword => !labelsSet.has(keyword))
newLabels.push(...addedLabels)
}
const newQuery =
this.query +
(this.query === '' ? '' : ' ') +
[
...addedKeywords.map(keyword =>
AssetQuery.termToString({ tag: null, value: keyword })
),
...addedLabels.map(label =>
AssetQuery.termToString({ tag: 'label', value: label })
),
].join(' ')
return new AssetQuery(newQuery, newKeywords, newLabels)
}
}
/** Return a new {@link AssetQuery} with the specified terms removed,
* or itself if there are no terms to remove. */
delete(values: Partial<AssetQueryData>): AssetQuery {
const { keywords, labels } = values
const noKeywords = !keywords || keywords.length === 0
const noLabels = !labels || labels.length === 0
if (noKeywords && noLabels) {
return this
} else {
let newKeywords = this.keywords
const keywordsSet = new Set(keywords ?? [])
if (!noKeywords) {
newKeywords = newKeywords.filter(keyword => !keywordsSet.has(keyword))
}
let newLabels = this.labels
const labelsSet = new Set(labels ?? [])
if (!noLabels) {
newLabels = newLabels.filter(label => !labelsSet.has(label))
}
if (
newKeywords.length === this.keywords.length &&
newLabels.length === this.labels.length
) {
return this
} else {
const newQuery = AssetQuery.terms(this.query)
.filter(term => {
switch (term.tag?.toLowerCase() ?? null) {
case null:
case '': {
return !keywordsSet.has(term.value)
}
case 'label': {
return !labelsSet.has(term.value)
}
default: {
return true
}
}
})
.map(term => AssetQuery.termToString(term))
.join(' ')
return new AssetQuery(newQuery, newKeywords, newLabels)
}
}
}
}

View File

@ -243,7 +243,7 @@ function LabelsColumn(props: AssetColumnProps) {
const {
item: { item: asset },
setItem,
state: { category, labels, deletedLabelNames, doCreateLabel },
state: { category, labels, setQuery, deletedLabelNames, doCreateLabel },
rowState: { temporarilyAddedLabels, temporarilyRemovedLabels },
} = props
const session = authProvider.useNonPartialUserSession()
@ -294,7 +294,9 @@ function LabelsColumn(props: AssetColumnProps) {
? 'relative before:absolute before:rounded-full before:border-2 before:border-delete before:inset-0 before:w-full before:h-full'
: ''
}
onClick={() => {
onContextMenu={event => {
event.preventDefault()
event.stopPropagation()
setAsset(oldAsset => {
const newLabels =
oldAsset.labels?.filter(oldLabel => oldLabel !== label) ?? []
@ -319,6 +321,15 @@ function LabelsColumn(props: AssetColumnProps) {
}
})
}}
onClick={event => {
event.preventDefault()
event.stopPropagation()
setQuery(oldQuery =>
oldQuery.labels.includes(label)
? oldQuery.delete({ labels: [label] })
: oldQuery.add({ labels: [label] })
)
}}
>
{label}
</Label>

View File

@ -5,6 +5,7 @@ import * as toast from 'react-toastify'
import * as array from '../array'
import * as assetEventModule from '../events/assetEvent'
import * as assetListEventModule from '../events/assetListEvent'
import type * as assetQuery from '../../assetQuery'
import * as assetTreeNode from '../assetTreeNode'
import * as backendModule from '../backend'
import * as columnModule from '../column'
@ -149,6 +150,8 @@ export interface AssetsTableState {
setSortColumn: (column: columnModule.SortableColumn | null) => void
sortDirection: sorting.SortDirection | null
setSortDirection: (sortDirection: sorting.SortDirection | null) => void
query: assetQuery.AssetQuery
setQuery: React.Dispatch<React.SetStateAction<assetQuery.AssetQuery>>
dispatchAssetListEvent: (event: assetListEventModule.AssetListEvent) => void
assetEvents: assetEventModule.AssetEvent[]
dispatchAssetEvent: (event: assetEventModule.AssetEvent) => void
@ -197,10 +200,10 @@ export const INITIAL_ROW_STATE = Object.freeze<AssetRowState>({
/** Props for a {@link AssetsTable}. */
export interface AssetsTableProps {
query: string
query: assetQuery.AssetQuery
setQuery: React.Dispatch<React.SetStateAction<assetQuery.AssetQuery>>
category: categorySwitcher.Category
allLabels: Map<backendModule.LabelName, backendModule.Label>
currentLabels: backendModule.LabelName[] | null
initialProjectName: string | null
projectStartupInfo: backendModule.ProjectStartupInfo | null
deletedLabelNames: Set<backendModule.LabelName>
@ -228,9 +231,9 @@ export interface AssetsTableProps {
export default function AssetsTable(props: AssetsTableProps) {
const {
query,
setQuery,
category,
allLabels,
currentLabels,
deletedLabelNames,
initialProjectName,
projectStartupInfo,
@ -277,11 +280,17 @@ export default function AssetsTable(props: AssetsTableProps) {
[backend, organization]
)
const filter = React.useMemo(() => {
if (query === '') {
if (query.query === '') {
return null
} else {
const regex = new RegExp(string.regexEscape(query), 'i')
return (node: assetTreeNode.AssetTreeNode) => regex.test(node.item.title)
return (node: assetTreeNode.AssetTreeNode) => {
const labels: string[] = node.item.labels ?? []
const lowercaseName = node.item.title.toLowerCase()
return (
query.labels.every(label => labels.includes(label)) &&
query.keywords.every(keyword => lowercaseName.includes(keyword.toLowerCase()))
)
}
}
}, [query])
const displayItems = React.useMemo(() => {
@ -465,7 +474,7 @@ export default function AssetsTable(props: AssetsTableProps) {
parentId: null,
filterBy: CATEGORY_TO_FILTER_BY[category],
recentProjects: category === categorySwitcher.Category.recent,
labels: currentLabels,
labels: null,
},
null
)
@ -529,7 +538,7 @@ export default function AssetsTable(props: AssetsTableProps) {
filterBy: CATEGORY_TO_FILTER_BY[category],
recentProjects:
category === categorySwitcher.Category.recent,
labels: currentLabels,
labels: null,
},
entry.item.title
)
@ -572,7 +581,7 @@ export default function AssetsTable(props: AssetsTableProps) {
parentId: null,
filterBy: CATEGORY_TO_FILTER_BY[category],
recentProjects: category === categorySwitcher.Category.recent,
labels: currentLabels,
labels: null,
},
null
)
@ -587,7 +596,7 @@ export default function AssetsTable(props: AssetsTableProps) {
}
}
},
[category, currentLabels, accessToken, organization, backend]
[category, accessToken, organization, backend]
)
React.useEffect(() => {
@ -685,7 +694,7 @@ export default function AssetsTable(props: AssetsTableProps) {
parentId: directoryId,
filterBy: CATEGORY_TO_FILTER_BY[category],
recentProjects: category === categorySwitcher.Category.recent,
labels: currentLabels,
labels: null,
},
title ?? null
)
@ -752,7 +761,7 @@ export default function AssetsTable(props: AssetsTableProps) {
})()
}
},
[category, currentLabels, backend]
[category, backend]
)
const getNewProjectName = React.useCallback(
@ -1274,6 +1283,8 @@ export default function AssetsTable(props: AssetsTableProps) {
setSortColumn,
sortDirection,
setSortDirection,
query,
setQuery,
assetEvents,
dispatchAssetEvent,
dispatchAssetListEvent,
@ -1296,6 +1307,7 @@ export default function AssetsTable(props: AssetsTableProps) {
sortColumn,
sortDirection,
assetEvents,
query,
doToggleDirectoryExpansion,
doOpenManually,
doOpenIde,
@ -1303,6 +1315,7 @@ export default function AssetsTable(props: AssetsTableProps) {
doCreateLabel,
doCut,
doPaste,
/* should never change */ setQuery,
/* should never change */ setSortColumn,
/* should never change */ setSortDirection,
/* should never change */ dispatchAssetEvent,
@ -1389,7 +1402,7 @@ export default function AssetsTable(props: AssetsTableProps) {
placeholder={
category === categorySwitcher.Category.trash
? TRASH_PLACEHOLDER
: query !== '' || currentLabels != null
: query.query !== ''
? QUERY_PLACEHOLDER
: PLACEHOLDER
}
@ -1428,7 +1441,7 @@ export default function AssetsTable(props: AssetsTableProps) {
setModal(
<DragModal
event={event}
className="flex flex-col bg-frame rounded-2xl bg-frame-selected backdrop-blur-3xl"
className="flex flex-col rounded-2xl bg-frame-selected backdrop-blur-3xl"
doCleanup={() => {
drag.ASSET_ROWS.unbind(payload)
}}

View File

@ -4,6 +4,7 @@ import * as React from 'react'
import * as assetEventModule from '../events/assetEvent'
import * as assetListEventModule from '../events/assetListEvent'
import * as assetQuery from '../../assetQuery'
import * as backendModule from '../backend'
import * as hooks from '../../hooks'
import * as http from '../../http'
@ -59,7 +60,7 @@ export default function Dashboard(props: DashboardProps) {
const { localStorage } = localStorageProvider.useLocalStorage()
const { shortcuts } = shortcutsProvider.useShortcuts()
const [initialized, setInitialized] = React.useState(false)
const [query, setQuery] = React.useState('')
const [query, setQuery] = React.useState(() => assetQuery.AssetQuery.fromString(''))
const [isHelpChatOpen, setIsHelpChatOpen] = React.useState(false)
const [isHelpChatVisible, setIsHelpChatVisible] = React.useState(false)
const [loadingProjectManagerDidFail, setLoadingProjectManagerDidFail] = React.useState(false)
@ -99,7 +100,7 @@ export default function Dashboard(props: DashboardProps) {
}, [page, /* should never change */ unsetModal])
React.useEffect(() => {
if (query !== '') {
if (query.query !== '') {
setPage(pageSwitcher.Page.drive)
}
}, [query])
@ -412,6 +413,7 @@ export default function Dashboard(props: DashboardProps) {
page={page}
initialProjectName={initialProjectName}
query={query}
setQuery={setQuery}
projectStartupInfo={projectStartupInfo}
queuedAssetEvents={queuedAssetEvents}
assetListEvents={assetListEvents}

View File

@ -6,11 +6,11 @@ import * as common from 'enso-common'
import * as appInfo from '../../appInfo'
import * as assetEventModule from '../events/assetEvent'
import * as assetListEventModule from '../events/assetListEvent'
import type * as assetQuery from '../../assetQuery'
import * as authProvider from '../../authentication/providers/auth'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as hooks from '../../hooks'
import * as identity from '../identity'
import * as localStorageModule from '../localStorage'
import * as localStorageProvider from '../../providers/localStorage'
import * as modalProvider from '../../providers/modal'
@ -41,7 +41,8 @@ export interface DriveProps {
dispatchAssetListEvent: (directoryEvent: assetListEventModule.AssetListEvent) => void
assetEvents: assetEventModule.AssetEvent[]
dispatchAssetEvent: (directoryEvent: assetEventModule.AssetEvent) => void
query: string
query: assetQuery.AssetQuery
setQuery: React.Dispatch<React.SetStateAction<assetQuery.AssetQuery>>
projectStartupInfo: backendModule.ProjectStartupInfo | null
doCreateProject: (templateId: string | null) => void
doOpenEditor: (
@ -65,6 +66,7 @@ export default function Drive(props: DriveProps) {
initialProjectName,
queuedAssetEvents,
query,
setQuery,
projectStartupInfo,
assetListEvents,
dispatchAssetListEvent,
@ -91,7 +93,7 @@ export default function Drive(props: DriveProps) {
categorySwitcher.Category.home
)
const [labels, setLabels] = React.useState<backendModule.Label[]>([])
const [currentLabels, setCurrentLabels] = React.useState<backendModule.LabelName[] | null>(null)
// const [currentLabels, setCurrentLabels] = React.useState<backendModule.LabelName[] | null>(null)
const [newLabelNames, setNewLabelNames] = React.useState(new Set<backendModule.LabelName>())
const [deletedLabelNames, setDeletedLabelNames] = React.useState(
new Set<backendModule.LabelName>()
@ -186,36 +188,11 @@ export default function Drive(props: DriveProps) {
oldLabel.id === placeholderLabel.id ? newLabel : oldLabel
)
)
setCurrentLabels(oldLabels => {
let found = identity.identity<boolean>(false)
const newLabels =
oldLabels?.map(oldLabel => {
if (oldLabel === placeholderLabel.value) {
found = true
return newLabel.value
} else {
return oldLabel
}
}) ?? null
return found ? newLabels : oldLabels
})
} catch (error) {
toastAndLog(null, error)
setLabels(oldLabels =>
oldLabels.filter(oldLabel => oldLabel.id !== placeholderLabel.id)
)
setCurrentLabels(oldLabels => {
let found = identity.identity<boolean>(false)
const newLabels = (oldLabels ?? []).filter(oldLabel => {
if (oldLabel === placeholderLabel.value) {
found = true
return false
} else {
return true
}
})
return found ? (newLabels.length === 0 ? null : newLabels) : oldLabels
})
}
setNewLabelNames(
labelNames =>
@ -228,22 +205,7 @@ export default function Drive(props: DriveProps) {
const doDeleteLabel = React.useCallback(
async (id: backendModule.TagId, value: backendModule.LabelName) => {
setDeletedLabelNames(oldNames => new Set([...oldNames, value]))
setCurrentLabels(oldLabels => {
let found = identity.identity<boolean>(false)
const newLabels = oldLabels?.filter(oldLabel => {
if (oldLabel === value) {
found = true
return false
} else {
return true
}
})
return newLabels != null && newLabels.length > 0
? found
? newLabels
: oldLabels
: null
})
setQuery(oldQuery => oldQuery.delete({ labels: [value] }))
try {
await backend.deleteTag(id, value)
dispatchAssetEvent({
@ -260,6 +222,7 @@ export default function Drive(props: DriveProps) {
},
[
backend,
/* should never change */ setQuery,
/* should never change */ dispatchAssetEvent,
/* should never change */ toastAndLog,
]
@ -321,14 +284,14 @@ export default function Drive(props: DriveProps) {
<div className="flex flex-col gap-4 text-base text-center">
Upgrade your plan to use {common.PRODUCT_NAME} Cloud.
<a
className="block self-center whitespace-nowrap text-base text-white bg-help rounded-full self-center leading-170 h-8 py-px px-2 w-min"
className="block self-center whitespace-nowrap text-base text-white bg-help rounded-full leading-170 h-8 py-px px-2 w-min"
href="https://enso.org/pricing"
>
Upgrade
</a>
{!supportsLocalBackend && (
<button
className="block self-center whitespace-nowrap text-base text-white bg-help rounded-full self-center leading-170 h-8 py-px px-2 w-min"
className="block self-center whitespace-nowrap text-base text-white bg-help rounded-full leading-170 h-8 py-px px-2 w-min"
onClick={async () => {
const downloadUrl = await appInfo.getDownloadUrl()
if (downloadUrl == null) {
@ -375,8 +338,8 @@ export default function Drive(props: DriveProps) {
/>
<Labels
labels={labels}
currentLabels={currentLabels}
setCurrentLabels={setCurrentLabels}
query={query}
setQuery={setQuery}
doCreateLabel={doCreateLabel}
doDeleteLabel={doDeleteLabel}
newLabelNames={newLabelNames}
@ -386,9 +349,9 @@ export default function Drive(props: DriveProps) {
)}
<AssetsTable
query={query}
setQuery={setQuery}
category={category}
allLabels={allLabels}
currentLabels={currentLabels}
initialProjectName={initialProjectName}
projectStartupInfo={projectStartupInfo}
deletedLabelNames={deletedLabelNames}

View File

@ -47,6 +47,7 @@ export default function Label(props: InternalLabelProps) {
return (
<button
disabled={disabled}
title="Right click to remove label."
className={`flex items-center rounded-full gap-1.5 h-6 px-2.25 ${className} ${
active ? '' : 'text-not-selected opacity-50'
} ${disabled ? '' : 'group-hover:opacity-100'} ${

View File

@ -4,6 +4,7 @@ import * as React from 'react'
import PlusIcon from 'enso-assets/plus.svg'
import Trash2Icon from 'enso-assets/trash2.svg'
import type * as assetQuery from '../../assetQuery'
import type * as backend from '../backend'
import * as drag from '../drag'
import * as modalProvider from '../../providers/modal'
@ -21,8 +22,8 @@ import SvgMask from '../../authentication/components/svgMask'
/** Props for a {@link Labels}. */
export interface LabelsProps {
labels: backend.Label[]
currentLabels: backend.LabelName[] | null
setCurrentLabels: React.Dispatch<React.SetStateAction<backend.LabelName[] | null>>
query: assetQuery.AssetQuery
setQuery: React.Dispatch<React.SetStateAction<assetQuery.AssetQuery>>
doCreateLabel: (name: string, color: backend.LChColor) => void
doDeleteLabel: (id: backend.TagId, name: backend.LabelName) => void
newLabelNames: Set<backend.LabelName>
@ -33,13 +34,14 @@ export interface LabelsProps {
export default function Labels(props: LabelsProps) {
const {
labels,
currentLabels,
setCurrentLabels,
query,
setQuery,
doCreateLabel,
doDeleteLabel,
newLabelNames,
deletedLabelNames,
} = props
const currentLabels = query.labels
const { setModal } = modalProvider.useSetModal()
return (
@ -57,21 +59,14 @@ export default function Labels(props: LabelsProps) {
<Label
draggable
color={label.color}
active={currentLabels?.includes(label.value) ?? false}
active={currentLabels.includes(label.value)}
disabled={newLabelNames.has(label.value)}
onClick={() => {
setCurrentLabels(oldLabels => {
if (oldLabels == null) {
return [label.value]
} else {
const newLabels = oldLabels.includes(label.value)
? oldLabels.filter(
oldLabel => oldLabel !== label.value
)
: [...oldLabels, label.value]
return newLabels.length === 0 ? null : newLabels
}
})
setQuery(oldQuery =>
oldQuery.labels.includes(label.value)
? oldQuery.delete({ labels: [label.value] })
: oldQuery.add({ labels: [label.value] })
)
}}
onDragStart={event => {
drag.setDragImageToBlank(event)

View File

@ -75,6 +75,7 @@ export default function NewLabelModal(props: NewLabelModalProps) {
<div className="w-12 h-6 py-1">Name</div>
<input
autoFocus
size={1}
placeholder="Enter the name of the label"
className={`grow bg-transparent border border-black-a10 rounded-full leading-170 h-6 px-4 py-px ${
// eslint-disable-next-line @typescript-eslint/no-magic-numbers

View File

@ -3,6 +3,7 @@ import * as React from 'react'
import FindIcon from 'enso-assets/find.svg'
import * as assetQuery from '../../assetQuery'
import type * as backendModule from '../backend'
import * as shortcuts from '../shortcuts'
@ -28,8 +29,8 @@ export interface TopBarProps {
setBackendType: (backendType: backendModule.BackendType) => void
isHelpChatOpen: boolean
setIsHelpChatOpen: (isHelpChatOpen: boolean) => void
query: string
setQuery: (value: string) => void
query: assetQuery.AssetQuery
setQuery: (query: assetQuery.AssetQuery) => void
doRemoveSelf: () => void
onSignOut: () => void
}
@ -95,9 +96,9 @@ export default function TopBar(props: TopBarProps) {
size={1}
id="search"
placeholder="Type to search for projects, data connectors, users, and more."
value={query}
value={query.query}
onChange={event => {
setQuery(event.target.value)
setQuery(assetQuery.AssetQuery.fromString(event.target.value))
}}
className="grow bg-transparent leading-5 h-6 py-px"
/>

View File

@ -3,6 +3,8 @@ import * as authentication from 'enso-authentication'
import * as detect from 'enso-common/src/detect'
import './tailwind.css'
// =================
// === Constants ===
// =================
@ -19,7 +21,7 @@ const SERVICE_WORKER_PATH = './serviceWorker.js'
// === Live reload ===
// ===================
if (detect.IS_DEV_MODE) {
if (detect.IS_DEV_MODE && (!(typeof IS_VITE !== 'undefined') || !IS_VITE)) {
new EventSource(ESBUILD_PATH).addEventListener(ESBUILD_EVENT_NAME, () => {
// This acts like `location.reload`, but it preserves the query-string.
// The `toString()` is to bypass a lint without using a comment.

View File

@ -0,0 +1,148 @@
/** @file Configuration for Tailwind. */
// The names come from a third-party API and cannot be changed.
/* eslint-disable no-restricted-syntax, @typescript-eslint/naming-convention, @typescript-eslint/no-magic-numbers */
export default /** @satisfies {import('tailwindcss').Config} */ ({
content: ['./src/**/*.tsx', './src/**/*.ts'],
important: `:is(.enso-dashboard, .enso-chat)`,
theme: {
extend: {
colors: {
/** The default color of all text. */
// This should be named "regular".
primary: 'rgba(0, 0, 0, 0.60)',
'not-selected': 'rgba(0, 0, 0, 0.40)',
'icon-selected': 'rgba(0, 0, 0, 0.50)',
'icon-not-selected': 'rgba(0, 0, 0, 0.30)',
chat: '#484848',
'ide-bg': '#ebeef1',
'ide-bg-dark': '#d0d3d6',
selected: 'rgba(255, 255, 255, 0.40)',
// Should be `#3e515f14`, but `bg-opacity` does not work with RGBA.
label: '#f0f1f3',
help: '#3f68ce',
invite: '#0e81d4',
cloud: '#0666be',
share: '#64b526',
inversed: '#ffffff',
green: '#3e8b29',
delete: 'rgba(243, 24, 10, 0.87)',
v3: '#252423',
youtube: '#c62421',
discord: '#404796',
dim: 'rgba(0, 0, 0, 0.25)',
frame: 'rgba(255, 255, 255, 0.40)',
'frame-selected': 'rgba(255, 255, 255, 0.70)',
'tag-text': 'rgba(255, 255, 255, 0.90)',
'tag-text-2': 'rgba(0, 0, 0, 0.60)',
'permission-owner': 'rgba(236, 2, 2, 0.70)',
'permission-admin': 'rgba(252, 60, 0, 0.70)',
'permission-edit': 'rgba(255, 138, 0, 0.90)',
'permission-read': 'rgba(152, 174, 18, 0.80)',
'permission-docs': 'rgba(91, 8, 226, 0.64)',
'permission-exec': 'rgba(236, 2, 2, 0.70)',
'permission-view': 'rgba(0, 0, 0, 0.10)',
'label-running-project': '#257fd2',
'label-low-resources': '#ff6b18',
'call-to-action': '#fa6c08',
'black-a5': 'rgba(0, 0, 0, 0.05)',
'black-a10': 'rgba(0, 0, 0, 0.10)',
'black-a16': 'rgba(0, 0, 0, 0.16)',
'black-a30': 'rgba(0, 0, 0, 0.30)',
'black-a50': 'rgba(0, 0, 0, 0.50)',
'gray-350': '#b7bcc5',
},
fontSize: {
xs: '0.71875rem',
sm: '0.8125rem',
xl: '1.1875rem',
'4xl': '2.375rem',
},
borderRadius: {
'4xl': '2rem',
},
lineHeight: {
144.5: '144.5%',
170: '170%',
},
spacing: {
0.75: '0.1875rem',
1.25: '0.3125rem',
1.75: '0.4375rem',
2.25: '0.5625rem',
3.25: '0.8125rem',
3.5: '0.875rem',
4.5: '1.125rem',
4.75: '1.1875rem',
5.5: '1.375rem',
6.5: '1.625rem',
9.5: '2.375rem',
9.75: '2.4375rem',
13: '3.25rem',
18: '4.5rem',
25: '6.25rem',
29: '7.25rem',
30: '7.5rem',
30.25: '7.5625rem',
42: '10.5rem',
45: '11.25rem',
51: '12.75rem',
51.5: '12.875rem',
54: '13.5rem',
57.5: '14.375rem',
62: '15.5rem',
70: '17.5rem',
83.5: '20.875rem',
98.25: '24.5625rem',
112.5: '28.125rem',
115.25: '28.8125rem',
140: '35rem',
'10lh': '10lh',
},
minWidth: {
31.5: '7.875rem',
33.25: '8.3125rem',
40: '10rem',
61.25: '15.3125rem',
80: '20rem',
96: '24rem',
},
opacity: {
'1/3': '.33333333',
},
zIndex: {
1: '1',
3: '3',
},
backdropBlur: {
xs: '2px',
},
borderWidth: { 0.5: '0.5px' },
boxShadow: {
soft: `0 0.5px 2.2px 0px #00000008, 0 1.2px 5.3px 0px #0000000b, \
0 2.3px 10px 0 #0000000e, 0 4px 18px 0 #00000011, 0 7.5px 33.4px 0 #00000014, \
0 18px 80px 0 #0000001c`,
},
animation: {
'spin-ease': 'spin cubic-bezier(0.67, 0.33, 0.33, 0.67) 1.5s infinite',
},
transitionProperty: {
width: 'width',
'stroke-dasharray': 'stroke-dasharray',
'grid-template-rows': 'grid-template-rows',
},
transitionDuration: {
5000: '5000ms',
90000: '90000ms',
},
gridTemplateRows: {
'0fr': '0fr',
'1fr': '1fr',
},
gridTemplateColumns: {
'fill-60': 'repeat(auto-fill, minmax(15rem, 1fr))',
'fill-75': 'repeat(auto-fill, minmax(18.75rem, 1fr))',
},
},
},
})

View File

@ -1,158 +0,0 @@
/** @file Configuration for Tailwind. */
import * as path from 'node:path'
import * as url from 'node:url'
// =================
// === Constants ===
// =================
const THIS_PATH = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)))
// =====================
// === Configuration ===
// =====================
// The names come from a third-party API and cannot be changed.
/* eslint-disable no-restricted-syntax, @typescript-eslint/naming-convention */
export const content = [THIS_PATH + '/src/**/*.tsx', THIS_PATH + '/src/**/*.ts']
export const important = `:is(.enso-dashboard, .enso-chat)`
export const theme = {
extend: {
colors: {
/** The default color of all text. */
// This should be named "regular".
primary: 'rgba(0, 0, 0, 0.60)',
'not-selected': 'rgba(0, 0, 0, 0.40)',
'icon-selected': 'rgba(0, 0, 0, 0.50)',
'icon-not-selected': 'rgba(0, 0, 0, 0.30)',
chat: '#484848',
'ide-bg': '#ebeef1',
'ide-bg-dark': '#d0d3d6',
selected: 'rgba(255, 255, 255, 0.40)',
// Should be `#3e515f14`, but `bg-opacity` does not work with RGBA.
label: '#f0f1f3',
help: '#3f68ce',
invite: '#0e81d4',
cloud: '#0666be',
share: '#64b526',
inversed: '#ffffff',
green: '#3e8b29',
delete: 'rgba(243, 24, 10, 0.87)',
v3: '#252423',
youtube: '#c62421',
discord: '#404796',
dim: 'rgba(0, 0, 0, 0.25)',
frame: 'rgba(255, 255, 255, 0.40)',
'frame-selected': 'rgba(255, 255, 255, 0.70)',
'tag-text': 'rgba(255, 255, 255, 0.90)',
'tag-text-2': 'rgba(0, 0, 0, 0.60)',
'permission-owner': 'rgba(236, 2, 2, 0.70)',
'permission-admin': 'rgba(252, 60, 0, 0.70)',
'permission-edit': 'rgba(255, 138, 0, 0.90)',
'permission-read': 'rgba(152, 174, 18, 0.80)',
'permission-docs': 'rgba(91, 8, 226, 0.64)',
'permission-exec': 'rgba(236, 2, 2, 0.70)',
'permission-view': 'rgba(0, 0, 0, 0.10)',
'label-running-project': '#257fd2',
'label-low-resources': '#ff6b18',
'call-to-action': '#fa6c08',
'black-a5': 'rgba(0, 0, 0, 0.05)',
'black-a10': 'rgba(0, 0, 0, 0.10)',
'black-a16': 'rgba(0, 0, 0, 0.16)',
'black-a30': 'rgba(0, 0, 0, 0.30)',
'black-a50': 'rgba(0, 0, 0, 0.50)',
'gray-350': '#b7bcc5',
},
fontSize: {
xs: '0.71875rem',
sm: '0.8125rem',
xl: '1.1875rem',
'4xl': '2.375rem',
},
borderRadius: {
'4xl': '2rem',
},
lineHeight: {
'144.5': '144.5%',
'170': '170%',
},
spacing: {
'0.75': '0.1875rem',
'1.25': '0.3125rem',
'1.75': '0.4375rem',
'2.25': '0.5625rem',
'3.25': '0.8125rem',
'3.5': '0.875rem',
'4.5': '1.125rem',
'4.75': '1.1875rem',
'5.5': '1.375rem',
'6.5': '1.625rem',
'9.5': '2.375rem',
'9.75': '2.4375rem',
'13': '3.25rem',
'18': '4.5rem',
'25': '6.25rem',
'29': '7.25rem',
'30': '7.5rem',
'30.25': '7.5625rem',
'42': '10.5rem',
'45': '11.25rem',
'51': '12.75rem',
'51.5': '12.875rem',
'54': '13.5rem',
'57.5': '14.375rem',
'62': '15.5rem',
'70': '17.5rem',
'83.5': '20.875rem',
'98.25': '24.5625rem',
'112.5': '28.125rem',
'115.25': '28.8125rem',
'140': '35rem',
'10lh': '10lh',
},
minWidth: {
'31.5': '7.875rem',
'33.25': '8.3125rem',
'40': '10rem',
'61.25': '15.3125rem',
'80': '20rem',
'96': '24rem',
},
opacity: {
'1/3': '.33333333',
},
zIndex: {
'1': '1',
'3': '3',
},
backdropBlur: {
xs: '2px',
},
borderWidth: { '0.5': '0.5px' },
boxShadow: {
soft: `0 0.5px 2.2px 0px #00000008, 0 1.2px 5.3px 0px #0000000b, \
0 2.3px 10px 0 #0000000e, 0 4px 18px 0 #00000011, 0 7.5px 33.4px 0 #00000014, \
0 18px 80px 0 #0000001c`,
},
animation: {
'spin-ease': 'spin cubic-bezier(0.67, 0.33, 0.33, 0.67) 1.5s infinite',
},
transitionProperty: {
width: 'width',
'stroke-dasharray': 'stroke-dasharray',
'grid-template-rows': 'grid-template-rows',
},
transitionDuration: {
'5000': '5000ms',
'90000': '90000ms',
},
gridTemplateRows: {
'0fr': '0fr',
'1fr': '1fr',
},
gridTemplateColumns: {
'fill-60': 'repeat(auto-fill, minmax(15rem, 1fr))',
'fill-75': 'repeat(auto-fill, minmax(18.75rem, 1fr))',
},
},
}

View File

@ -0,0 +1,30 @@
/** @file Configuration for vite. */
import * as vite from 'vite'
import vitePluginYaml from '@modyfi/vite-plugin-yaml'
// =================
// === Constants ===
// =================
const SERVER_PORT = 8080
// =====================
// === Configuration ===
// =====================
/* eslint-disable @typescript-eslint/naming-convention */
export default vite.defineConfig({
server: { port: SERVER_PORT },
plugins: [vitePluginYaml()],
define: {
IS_VITE: JSON.stringify(true),
REDIRECT_OVERRIDE: JSON.stringify(`http://localhost:${SERVER_PORT}`),
CLOUD_ENV:
process.env.ENSO_CLOUD_ENV != null
? JSON.stringify(process.env.ENSO_CLOUD_ENV)
: 'undefined',
// Single hardcoded usage of `global` in by aws-amplify.
'global.TYPED_ARRAY_SUPPORT': JSON.stringify(true),
},
})

View File

@ -91,10 +91,10 @@ declare global {
const BUNDLED_ENGINE_VERSION: string
const BUILD_INFO: buildJson.BuildInfo
const PROJECT_MANAGER_IN_BUNDLE_PATH: string
const IS_DEV_MODE: boolean
// This will be `undefined` when it is not defined by esbuild.
// eslint-disable-next-line no-restricted-syntax
const REDIRECT_OVERRIDE: string | undefined
const IS_VITE: boolean
// eslint-disable-next-line no-restricted-syntax
const CLOUD_ENV: 'npekin' | 'pbuchu' | 'production' | undefined
/* eslint-disable @typescript-eslint/naming-convention */

44
package-lock.json generated
View File

@ -249,10 +249,12 @@
"eslint-plugin-jsdoc": "^46.8.1",
"eslint-plugin-react": "^7.32.1",
"playwright": "^1.38.0",
"postcss": "^8.4.29",
"react-toastify": "^9.1.3",
"tailwindcss": "^3.2.7",
"tsx": "^3.12.6",
"typescript": "~5.2.2"
"typescript": "~5.2.2",
"vite": "^4.4.9"
},
"optionalDependencies": {
"@esbuild/darwin-x64": "^0.17.15",
@ -2535,21 +2537,6 @@
"concat-map": "0.0.1"
}
},
"node_modules/@eslint/eslintrc/node_modules/globals": {
"version": "13.23.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-13.23.0.tgz",
"integrity": "sha512-XAmF0RjlrjY23MA51q3HltdlGxUpXPvg0GioKiD9X6HD28iMjo2dKC8Vqwm7lne4GNr78+RHTfliktR6ZH09wA==",
"dev": true,
"dependencies": {
"type-fest": "^0.20.2"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@eslint/eslintrc/node_modules/minimatch": {
"version": "3.1.2",
"dev": true,
@ -6092,7 +6079,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001535",
"version": "1.0.30001565",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001565.tgz",
"integrity": "sha512-xrE//a3O7TP0vaJ8ikzkD2c2NgcVUvsEe2IvFTntV4Yd1Z9FVzh+gW+enX96L0psrbaFMcVcH2l90xNuGDWc8w==",
"dev": true,
"funding": [
{
@ -6107,8 +6096,7 @@
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "CC-BY-4.0"
]
},
"node_modules/capital-case": {
"version": "1.0.4",
@ -8796,21 +8784,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/eslint/node_modules/globals": {
"version": "13.23.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-13.23.0.tgz",
"integrity": "sha512-XAmF0RjlrjY23MA51q3HltdlGxUpXPvg0GioKiD9X6HD28iMjo2dKC8Vqwm7lne4GNr78+RHTfliktR6ZH09wA==",
"dev": true,
"dependencies": {
"type-fest": "^0.20.2"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint/node_modules/has-flag": {
"version": "4.0.0",
"dev": true,
@ -13397,6 +13370,8 @@
},
"node_modules/postcss": {
"version": "8.4.29",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.29.tgz",
"integrity": "sha512-cbI+jaqIeu/VGqXEarWkRCCffhjgXc0qjBtXpqJhTBohMUjUQnbBr0xqX3vEKudc4iviTewcJo5ajcec5+wdJw==",
"funding": [
{
"type": "opencollective",
@ -13411,7 +13386,6 @@
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",