Smart search (#8554)

- Closes https://github.com/enso-org/cloud-v2/issues/782
- `owner:` to do exact matches against owner username
- `label:` (already existed)
- `name:` to do exact matches against asset name (exact match; case insensitive; the glob character `*` matches 0 or more characters)
- negated searches (`-owner:`, `-label:`, `-:` for negating keywords)
- Related changes that were not explicitly requested:
- add `no:`, `-no:`, `has:` and `-has:` to filter for assets that lack a specific field. Currently this only works for labels, because most (if not all) other fields cannot be empty.
- `label:a,b,c` to OR results together. `label:a label:b` ANDs results as usual.
- shift-click labels to add/remove it to the last search term as an OR
- clicking labels now cycles them from: absent -> present (positive) -> present (negative)
- Unrelated changes
- Switches unit tests to use `vitest`

# Important Notes
Some other suggestions have been added in the original issue, but currently intentionally left out to avoid prematurely over-engineering this feature.
This commit is contained in:
somebody1234 2024-01-08 22:58:09 +10:00 committed by GitHub
parent d86c6c472c
commit 942e6c2305
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 1592 additions and 329 deletions

View File

@ -8,8 +8,8 @@
"build": "tsx bundle.ts",
"dev": "vite",
"start": "tsx start.ts",
"test": "npm run test:unit",
"test:unit": "playwright test",
"test": "vitest run",
"test:unit": "vitest",
"test:browsers": "npx --yes playwright install && npm run test:component && npm run test:e2e-and-log",
"test:component": "playwright test -c playwright-component.config.ts",
"test:e2e": "npx playwright test -c playwright-e2e.config.ts",
@ -50,7 +50,8 @@
"tailwindcss": "^3.2.7",
"tsx": "^3.12.6",
"typescript": "~5.2.2",
"vite": "^4.4.9"
"vite": "^4.4.9",
"vitest": "^0.34.4"
},
"optionalDependencies": {
"@esbuild/darwin-x64": "^0.17.15",

View File

@ -1,8 +0,0 @@
/** @file Playwright non-browser testing configuration. While Playwright is not designed for
* non-browser testing, it avoids the fragmentation of installing a different testing framework
* for other tests. */
import * as test from '@playwright/test'
export default test.defineConfig({
testDir: './test',
})

View File

@ -0,0 +1,142 @@
/** @file Tests for {@link assetQuery.AssetQuery}. */
import * as v from 'vitest'
import * as assetQuery from '../assetQuery'
v.test.each([
{ query: '' },
{ query: 'name:' },
{ query: '-name:' },
{ query: 'label:' },
{ query: '-label:' },
{ query: 'owner:' },
{ query: '-owner:' },
{ query: '"', keywords: [['']] },
{ query: '""', keywords: [['']] },
{ query: 'a', keywords: [['a']] },
{ query: 'a b', keywords: [['a'], ['b']] },
{ query: '"a" "b"', keywords: [['a'], ['b']] },
{ query: 'a,b', keywords: [['a', 'b']] },
{ query: '"a","b"', keywords: [['a', 'b']] },
{ query: '-:a', negativeKeywords: [['a']] },
{ query: '-:a,b', negativeKeywords: [['a', 'b']] },
{ query: 'name:a,b', names: [['a', 'b']] },
{ query: '-name:a', negativeNames: [['a']] },
{ query: '-name:a,b', negativeNames: [['a', 'b']] },
{ query: 'label:a', labels: [['a']] },
{ query: '-label:a', negativeLabels: [['a']] },
{ query: 'owner:a', owners: [['a']] },
{ query: '-owner:a', negativeOwners: [['a']] },
{ query: 'no:a', nos: [['a']] },
{ query: '-no:a', negativeNos: [['a']] },
{ query: 'has:a', negativeNos: [['a']] },
{ query: '-has:a', nos: [['a']] },
// Ensure that invalid queries are parsed reasonably
{ query: '-label', keywords: [['-label']] },
{ query: '"a" "b', keywords: [['a'], ['b']] },
{ query: '"a","b', keywords: [['a', 'b']] },
{ query: '"a""b"', keywords: [['a', 'b']] },
{ query: '"a""b', keywords: [['a', 'b']] },
{ query: '"a"b"', keywords: [['a', 'b"']] },
])(
'AssetQuery.fromString',
({
query,
keywords,
negativeKeywords,
names,
negativeNames,
labels,
negativeLabels,
owners,
negativeOwners,
nos,
negativeNos,
}) => {
const parsed = assetQuery.AssetQuery.fromString(query)
v.expect(parsed.keywords, `Keywords in '${query}'`).toEqual(keywords ?? [])
v.expect(parsed.negativeKeywords, `Negative keywords in '${query}'`).toEqual(
negativeKeywords ?? []
)
v.expect(parsed.names, `Names in '${query}'`).toEqual(names ?? [])
v.expect(parsed.negativeNames, `Negative names in '${query}'`).toEqual(negativeNames ?? [])
v.expect(parsed.labels, `Labels in '${query}'`).toEqual(labels ?? [])
v.expect(parsed.negativeLabels, `Negative labels in '${query}'`).toEqual(
negativeLabels ?? []
)
v.expect(parsed.owners, `Owners in '${query}'`).toEqual(owners ?? [])
v.expect(parsed.negativeOwners, `Negative owners in '${query}'`).toEqual(
negativeOwners ?? []
)
v.expect(parsed.nos, `Nos in '${query}'`).toEqual(nos ?? [])
v.expect(parsed.negativeNos, `Negative nos in '${query}'`).toEqual(negativeNos ?? [])
}
)
v.test.each([{ query: 'a', updates: { keywords: [['b']] }, newQuery: 'a b' }])(
'AssetQuery#add',
({ query, updates, newQuery }) => {
const parsed = assetQuery.AssetQuery.fromString(query)
v.expect(
parsed.add(updates).toString(),
`'${query}' with ${JSON.stringify(updates)} added should be '${newQuery}'`
).toBe(newQuery)
}
)
v.test.each([
{ query: 'a b', updates: { keywords: [['b']] }, newQuery: 'a' },
{ query: 'a', updates: { keywords: [['a']] }, newQuery: '' },
// Edge cases. The exact result should not matter, as long as it is reasonable.
{ query: 'a a', updates: { keywords: [['a']] }, newQuery: '' },
])('AssetQuery#delete', ({ query, updates, newQuery }) => {
const parsed = assetQuery.AssetQuery.fromString(query)
v.expect(
parsed.delete(updates).toString(),
`'${query}' with ${JSON.stringify(updates)} deleted should be '${newQuery}'`
).toBe(newQuery)
})
v.test.each([{ query: 'a', updates: { keywords: ['b'] }, newQuery: 'a,b' }])(
'AssetQuery#addToLastTerm',
({ query, updates, newQuery }) => {
const parsed = assetQuery.AssetQuery.fromString(query)
v.expect(
parsed.addToLastTerm(updates).toString(),
`'${query}' with ${JSON.stringify(updates)} added should be '${newQuery}'`
).toBe(newQuery)
}
)
v.test.each([
{ query: 'a b', updates: { keywords: ['b'] }, newQuery: 'a' },
{ query: 'a b', updates: { keywords: ['a'] }, newQuery: 'a b' },
{ query: 'a b,c', updates: { keywords: ['c'] }, newQuery: 'a b' },
{ query: 'a b,c', updates: { keywords: ['b', 'd', 'e', 'f'] }, newQuery: 'a c' },
{ query: 'a b,c', updates: { keywords: ['b', 'c'] }, newQuery: 'a' },
{ query: 'a', updates: { keywords: ['a'] }, newQuery: '' },
{ query: 'a b c', updates: { keywords: ['b', 'c'] }, newQuery: 'a b' },
])('AssetQuery#deleteFromLastTerm', ({ query, updates, newQuery }) => {
const parsed = assetQuery.AssetQuery.fromString(query)
v.expect(
parsed.deleteFromLastTerm(updates).toString(),
`'${query}' with ${JSON.stringify(updates)} deleted should be '${newQuery}'`
).toBe(newQuery)
})
v.test.each([
{ query: 'a b a', updates: { keywords: ['b'] }, newQuery: 'a a' },
{ query: 'a b a', updates: { keywords: ['a'] }, newQuery: 'b' },
{ query: 'a b,c', updates: { keywords: ['c'] }, newQuery: 'a b' },
{ query: 'a b,c', updates: { keywords: ['b', 'd', 'e', 'f'] }, newQuery: 'a c' },
{ query: 'a b,c', updates: { keywords: ['b', 'c'] }, newQuery: 'a' },
{ query: 'b,c a', updates: { keywords: ['b', 'c'] }, newQuery: 'a' },
{ query: 'a', updates: { keywords: ['a'] }, newQuery: '' },
{ query: 'a b c', updates: { keywords: ['b', 'c'] }, newQuery: 'a' },
])('AssetQuery#deleteFromEveryTerm', ({ query, updates, newQuery }) => {
const parsed = assetQuery.AssetQuery.fromString(query)
v.expect(
parsed.deleteFromEveryTerm(updates).toString(),
`'${query}' with ${JSON.stringify(updates)} deleted should be '${newQuery}'`
).toBe(newQuery)
})

View File

@ -0,0 +1,17 @@
/** @file Tests for `error.ts`. */
import * as v from 'vitest'
import * as error from '../error'
// =============
// === Tests ===
// =============
v.test('tryGetMessage', () => {
const message = 'A custom error message.'
v.expect(error.tryGetMessage<unknown>(new Error(message))).toBe(message)
v.expect(error.tryGetMessage<unknown>({ message: 'a' })).toBe('a')
v.expect(error.tryGetMessage<unknown>(message)).toBeNull()
v.expect(error.tryGetMessage<unknown>({})).toBeNull()
v.expect(error.tryGetMessage<unknown>(null)).toBeNull()
})

View File

@ -0,0 +1,14 @@
/** @file Tests for `fileInfo.ts`. */
import * as v from 'vitest'
import * as fileInfo from '../fileInfo'
// =============
// === Tests ===
// =============
v.test('fileExtension', () => {
v.expect(fileInfo.fileExtension('image.png')).toBe('png')
v.expect(fileInfo.fileExtension('.gif')).toBe('gif')
v.expect(fileInfo.fileExtension('fileInfo.spec.js')).toBe('js')
})

View File

@ -1,5 +1,14 @@
/** @file Utilities for manipulating arrays. */
// ====================
// === shallowEqual ===
// ====================
/** Whether both arrays contain the same items. Does not recurse into the items. */
export function shallowEqual<T>(a: readonly T[], b: readonly T[]) {
return a.length === b.length && a.every((item, i) => item === b[i])
}
// =========================
// === includesPredicate ===
// =========================

View File

@ -1,41 +1,122 @@
/** @file Parsing and representation of the search query. */
import * as array from './array'
// =====================
// === Regex Helpers ===
// =====================
// Control characters must be handled, in order to follow the JSON spec.
// eslint-disable-next-line no-control-regex
const JSON_VALUE_REGEX = /"(?:[^\0-\x1f\\"]|\\[\\/bfnrt"]|\\u[0-9a-fA-F]{4})*"?/.source
/** The regex, with `<json>` replaced with a regex subexpression matching a JSON-escaped search
* term. */
function interpolateRegex(regex: RegExp) {
return new RegExp(regex.source.replace(/<json>/g, JSON_VALUE_REGEX), regex.flags)
}
// ==================
// === AssetQuery ===
// ==================
/** Keys of an {@Link AssetQuery} which correspond to tags. */
export type AssetQueryKey = Exclude<keyof AssetQuery & `${string}s`, 'withUpdates'>
/** An {@link AssetQuery}, without the query and methods. */
interface AssetQueryData extends Omit<AssetQuery, 'add' | 'query' | 'remove'> {}
export interface AssetQueryData extends Record<AssetQueryKey, string[][]> {}
/** An {@link AssetQuery}, without the query and methods, and with all the values being `string[]`s
* instead of `string[][]`s, representing the last term rather than all terms. */
export interface AssetQueryLastTermData extends Record<AssetQueryKey, string[]> {}
/** An individual segment of a query string input to {@link AssetQuery}. */
interface AssetQueryTerm {
tag: string | null
value: string
values: 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
static plainValueRegex = interpolateRegex(/^(?:|[^"]\S*)$/)
static jsonValueRegex = interpolateRegex(/^(<json>)$/)
static termsRegex = interpolateRegex(/(?:([^\s:]*):)?((?:(?:<json>|(?:[^,\s"][^,\s]*)),?)*|)/g)
static valuesRegex = interpolateRegex(/(?:<json>)|(?:[^,\s"][^,\s]*)/g)
// `key` MUST be a string literal type.
// eslint-disable-next-line no-restricted-syntax
static tagNames = [
['keywords', null],
['negativeKeywords', '-'],
['names', 'name'],
['negativeNames', '-name'],
['types', 'type'],
['negativeTypes', '-type'],
['extensions', 'extension'],
['negativeExtensions', '-extension'],
['descriptions', 'description'],
['negativeDescriptions', '-description'],
['modifieds', 'modified'],
['negativeModifieds', '-modified'],
['labels', 'label'],
['negativeLabels', '-label'],
['owners', 'owner'],
['negativeOwners', '-owner'],
['nos', 'no'],
['negativeNos', 'has'],
] as const satisfies readonly (readonly [keyof AssetQueryData, string | null])[]
query
/** Create an {@link AssetQuery}. */
constructor(
readonly query: string,
readonly keywords: string[],
readonly labels: string[]
) {}
query: string | null,
readonly keywords: string[][],
readonly negativeKeywords: string[][],
readonly names: string[][],
readonly negativeNames: string[][],
readonly labels: string[][],
readonly negativeLabels: string[][],
readonly types: string[][],
readonly negativeTypes: string[][],
readonly extensions: string[][],
readonly negativeExtensions: string[][],
readonly descriptions: string[][],
readonly negativeDescriptions: string[][],
readonly modifieds: string[][],
readonly negativeModifieds: string[][],
readonly owners: string[][],
readonly negativeOwners: string[][],
readonly nos: string[][],
readonly negativeNos: string[][]
) {
this.query = query ?? ''
if (query == null) {
this.query = this.toString()
}
}
/** 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 !== '') {
for (const [, tag, valuesRaw = ''] of query.trim().matchAll(this.termsRegex)) {
// Ignore values with a tag but without a value.
if (tag != null || valuesRaw !== '') {
const values = valuesRaw.match(AssetQuery.valuesRegex) ?? []
terms.push({
tag: tag ?? null,
value: jsonValue != null ? String(JSON.parse(jsonValue)) : plainValue ?? '',
values:
valuesRaw === ''
? []
: values.map(value =>
AssetQuery.jsonValueRegex.test(value)
? String(
JSON.parse(
value.endsWith('"') && value.length > 1
? value
: value + '"'
)
)
: value
),
})
}
}
@ -45,106 +126,367 @@ export class AssetQuery {
/** 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)
const valueSegment = term.values
.map(value => (AssetQuery.plainValueRegex.test(value) ? value : JSON.stringify(value)))
.join(',')
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)
const keywords: string[][] = []
const negativeKeywords: string[][] = []
const names: string[][] = []
const negativeNames: string[][] = []
const labels: string[][] = []
const negativeLabels: string[][] = []
const types: string[][] = []
const negativeTypes: string[][] = []
const extensions: string[][] = []
const negativeExtensions: string[][] = []
const descriptions: string[][] = []
const negativeDescriptions: string[][] = []
const modifieds: string[][] = []
const negativeModifieds: string[][] = []
const owners: string[][] = []
const negativeOwners: string[][] = []
const nos: string[][] = []
const negativeNos: string[][] = []
const tagNameToSet: Record<string, string[][]> = {
// This is a dictionary, not an object.
/* eslint-disable @typescript-eslint/naming-convention */
'': keywords,
'-': negativeKeywords,
name: names,
'-name': negativeNames,
label: labels,
'-label': negativeLabels,
type: types,
'-type': negativeTypes,
extension: extensions,
'-extension': negativeExtensions,
ext: extensions,
'-ext': negativeExtensions,
description: descriptions,
'-description': negativeDescriptions,
desc: descriptions,
'-desc': negativeDescriptions,
modified: modifieds,
'-modified': negativeModifieds,
owner: owners,
'-owner': negativeOwners,
no: nos,
'-no': negativeNos,
has: negativeNos,
'-has': nos,
/* eslint-enable @typescript-eslint/naming-convention */
}
for (const term of terms) {
const set = term.tag == null ? keywords : tagNameToSet[term.tag]
set?.push(term.values)
}
return new AssetQuery(
query,
keywords,
negativeKeywords,
names,
negativeNames,
labels,
negativeLabels,
types,
negativeTypes,
extensions,
negativeExtensions,
descriptions,
negativeDescriptions,
modifieds,
negativeModifieds,
owners,
negativeOwners,
nos,
negativeNos
)
}
/** Return a new array of terms, after applying the given updates. */
static updatedTerms(
original: string[][],
toAdd: string[][] | null,
toRemove: string[][] | null
) {
toAdd = toAdd?.filter(term => term.length !== 0) ?? null
toRemove = toRemove?.filter(term => term.length !== 0) ?? null
toAdd = toAdd?.length === 0 ? null : toAdd
toRemove = toRemove?.length === 0 ? null : toRemove
if (toAdd == null && (toRemove == null || original.length === 0)) {
return null
} else {
let changed = false
let terms = original
if (toAdd != null) {
const termsAfterAdditions = [
...terms,
...toAdd.filter(otherTerm =>
terms.every(
term => !array.shallowEqual([...term].sort(), [...otherTerm].sort())
)
),
]
if (termsAfterAdditions.length !== terms.length) {
terms = termsAfterAdditions
changed = true
}
}
if (toRemove != null) {
const termsAfterRemovals = terms.filter(
term =>
toRemove?.every(
otherTerm =>
!array.shallowEqual([...term].sort(), [...otherTerm].sort())
)
)
if (termsAfterRemovals.length !== terms.length) {
terms = termsAfterRemovals
changed = true
}
}
return !changed ? null : terms
}
}
/** Return a new array of terms, after applying the given updates to the last term. */
static updatedLastTerm(
original: string[][],
toAdd: string[] | null,
toRemove: string[] | null
) {
toAdd = toAdd?.filter(term => term.length !== 0) ?? null
toRemove = toRemove?.filter(term => term.length !== 0) ?? null
toAdd = toAdd?.length === 0 ? null : toAdd
toRemove = toRemove?.length === 0 ? null : toRemove
let lastTerm = original[original.length - 1]
if (toAdd == null && (toRemove == null || lastTerm == null || lastTerm.length === 0)) {
return null
} else {
lastTerm ??= []
if (lastTerm[lastTerm.length - 1] === '') {
lastTerm.pop()
}
let changed = false
if (toAdd != null) {
const lastTermAfterAdditions = [
...lastTerm,
...toAdd.filter(word => lastTerm?.includes(word) === false),
]
if (lastTermAfterAdditions.length !== lastTerm.length) {
lastTerm = lastTermAfterAdditions
changed = true
}
}
if (toRemove != null) {
const lastTermAfterRemovals = lastTerm.filter(
word => toRemove?.includes(word) === false
)
if (lastTermAfterRemovals.length !== lastTerm.length) {
lastTerm = lastTermAfterRemovals
changed = true
}
}
return !changed
? null
: original.slice(0, -1).concat(lastTerm.length !== 0 ? [lastTerm] : [])
}
}
/** Return a new array of terms, after applying the given updates to the last term. */
static updatedEveryTerm(
original: string[][],
toAdd: string[] | null,
toRemove: string[] | null
) {
toAdd = toAdd?.filter(term => term.length !== 0) ?? null
toRemove = toRemove?.filter(term => term.length !== 0) ?? null
toAdd = toAdd?.length === 0 ? null : toAdd
toRemove = toRemove?.length === 0 ? null : toRemove
if (toAdd == null && (toRemove == null || original.length === 0)) {
return null
} else {
const newTerms: string[][] = []
let changed = false
for (const term of original) {
let newTerm = term
if (toAdd != null) {
const termAfterAdditions = [
...newTerm,
...toAdd.filter(word => newTerm.includes(word) === false),
]
if (termAfterAdditions.length !== newTerm.length) {
newTerm = termAfterAdditions
changed = true
}
}
if (toRemove != null) {
const termAfterRemovals = newTerm.filter(
word => toRemove?.includes(word) === false
)
if (termAfterRemovals.length !== newTerm.length) {
newTerm = termAfterRemovals
changed = true
}
}
if (newTerm.length !== 0) {
newTerms.push(newTerm)
}
}
return !changed ? null : newTerms
}
}
/** Return a new {@link AssetQuery} with the specified keys overwritten,
* or itself if there are no keys to overwrite. */
withUpdates(updates: Partial<AssetQueryData>) {
if (Object.keys(updates).length === 0) {
return this
} else {
return new AssetQuery(
null,
updates.keywords ?? this.keywords,
updates.negativeKeywords ?? this.negativeKeywords,
updates.names ?? this.names,
updates.negativeNames ?? this.negativeNames,
updates.labels ?? this.labels,
updates.negativeLabels ?? this.negativeLabels,
updates.types ?? this.types,
updates.negativeTypes ?? this.negativeTypes,
updates.extensions ?? this.extensions,
updates.negativeExtensions ?? this.negativeExtensions,
updates.descriptions ?? this.descriptions,
updates.negativeDescriptions ?? this.negativeDescriptions,
updates.modifieds ?? this.modifieds,
updates.negativeModifieds ?? this.negativeModifieds,
updates.owners ?? this.owners,
updates.negativeOwners ?? this.negativeOwners,
updates.nos ?? this.nos,
updates.negativeNos ?? this.negativeNos
)
}
}
/** Return a new {@link AssetQuery} with the specified terms added,
* or itself if there are no terms to remove. */
* or itself if there are no terms to add. */
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 updates: Partial<AssetQueryData> = {}
for (const [key] of AssetQuery.tagNames) {
const update = AssetQuery.updatedTerms(this[key], values[key] ?? null, null)
if (update != null) {
updates[key] = update
}
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 this.withUpdates(updates)
}
/** Return a new {@link AssetQuery} with the specified terms removed,
* or itself if there are no terms to remove. */
/** Return a new {@link AssetQuery} with the specified terms deleted,
* or itself if there are no terms to delete. */
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)
const updates: Partial<AssetQueryData> = {}
for (const [key] of AssetQuery.tagNames) {
const update = AssetQuery.updatedTerms(this[key], null, values[key] ?? null)
if (update != null) {
updates[key] = update
}
}
return this.withUpdates(updates)
}
/** Return a new {@link AssetQuery} with the specified words added to the last term
* with the matching tag, or itself if there are no terms to add. */
addToLastTerm(values: Partial<AssetQueryLastTermData>): AssetQuery {
const updates: Partial<AssetQueryData> = {}
for (const [key] of AssetQuery.tagNames) {
const update = AssetQuery.updatedLastTerm(this[key], values[key] ?? null, null)
if (update != null) {
updates[key] = update
}
}
return this.withUpdates(updates)
}
/** Return a new {@link AssetQuery} with the specified terms deleted from the last term
* with the matching tag, or itself if there are no terms to delete. */
deleteFromLastTerm(values: Partial<AssetQueryLastTermData>): AssetQuery {
const updates: Partial<AssetQueryData> = {}
for (const [key] of AssetQuery.tagNames) {
const update = AssetQuery.updatedLastTerm(this[key], null, values[key] ?? null)
if (update != null) {
updates[key] = update
}
}
return this.withUpdates(updates)
}
/** Return a new {@link AssetQuery} with the specified words added to every term
* with the matching tag, or itself if there are no terms to add.
* Note that this makes little sense to use, but is added for symmetry with
* {@link AssetQuery.deleteFromEveryTerm}. */
addToEveryTerm(values: Partial<AssetQueryLastTermData>): AssetQuery {
const updates: Partial<AssetQueryData> = {}
for (const [key] of AssetQuery.tagNames) {
const update = AssetQuery.updatedEveryTerm(this[key], values[key] ?? null, null)
if (update != null) {
updates[key] = update
}
}
return this.withUpdates(updates)
}
/** Return a new {@link AssetQuery} with the specified terms deleted from the last term
* with the matching tag, or itself if there are no terms to delete. */
deleteFromEveryTerm(values: Partial<AssetQueryLastTermData>): AssetQuery {
const updates: Partial<AssetQueryData> = {}
for (const [key] of AssetQuery.tagNames) {
const update = AssetQuery.updatedEveryTerm(this[key], null, values[key] ?? null)
if (update != null) {
updates[key] = update
}
}
return this.withUpdates(updates)
}
/** Returns a string representation usable in the search bar. */
toString() {
const segments: string[] = []
for (const [key, tag] of AssetQuery.tagNames) {
for (const values of this[key]) {
segments.push(AssetQuery.termToString({ tag, values }))
}
}
return segments.join(' ')
}
}
/** Tries to cycle the label between:
* - not present
* - present as a positive search, and
* - present as a negative search. */
export function toggleLabel(query: AssetQuery, label: string, fromLastTerm = false) {
let newQuery = query
if (fromLastTerm) {
newQuery = newQuery.deleteFromLastTerm({ negativeLabels: [label] })
if (newQuery === query) {
newQuery = newQuery.deleteFromLastTerm({ labels: [label] })
newQuery = newQuery.addToLastTerm(
newQuery === query ? { labels: [label] } : { negativeLabels: [label] }
)
}
} else {
newQuery = newQuery.delete({ negativeLabels: [[label]] })
if (newQuery === query) {
newQuery = newQuery.delete({ labels: [[label]] })
newQuery = newQuery.add(
newQuery === query ? { labels: [[label]] } : { negativeLabels: [[label]] }
)
}
}
return newQuery
}

View File

@ -0,0 +1,41 @@
/** @file Basic tests for this */
import * as v from 'vitest'
import * as validation from '../validation'
// =============
// === Tests ===
// =============
/** Runs all tests. */
v.test('password validation', () => {
const pattern = new RegExp(`^(?:${validation.PASSWORD_PATTERN})$`)
const emptyPassword = ''
v.expect(emptyPassword, `'${emptyPassword}' fails validation`).not.toMatch(pattern)
const shortPassword = 'Aa0!'
v.expect(shortPassword, `'${shortPassword}' is too short`).not.toMatch(pattern)
const passwordMissingDigit = 'Aa!Aa!Aa!'
v.expect(passwordMissingDigit, `'${passwordMissingDigit}' is missing a digit`).not.toMatch(
pattern
)
const passwordMissingLowercase = 'A0!A0!A0!'
v.expect(
passwordMissingLowercase,
`'${passwordMissingLowercase}' is missing a lowercase letter`
).not.toMatch(pattern)
const passwordMissingUppercase = 'a0!a0!a0!'
v.expect(
passwordMissingUppercase,
`'${passwordMissingUppercase}' is missing an uppercase letter`
).not.toMatch(pattern)
const passwordMissingSymbol = 'Aa0Aa0Aa0'
v.expect(passwordMissingSymbol, `'${passwordMissingSymbol}' is missing a symbol`).not.toMatch(
pattern
)
const validPassword = 'Aa0!Aa0!'
v.expect(validPassword, `'${validPassword}' passes validation`).toMatch(pattern)
const basicPassword = 'Password0!'
v.expect(basicPassword, `'${basicPassword}' passes validation`).toMatch(pattern)
const issue7498Password = 'ÑéFÛÅÐåÒ.ú¿¼\u00b4N@aö¶U¹jÙÇ3'
v.expect(issue7498Password, `'${issue7498Password}' passes validation`).toMatch(pattern)
})

View File

@ -12,6 +12,7 @@ import TagIcon from 'enso-assets/tag.svg'
import TimeIcon from 'enso-assets/time.svg'
import * as assetEvent from './events/assetEvent'
import * as assetQuery from '../assetQuery'
import type * as assetTreeNode from './assetTreeNode'
import * as authProvider from '../authentication/providers/auth'
import * as backendModule from './backend'
@ -301,14 +302,11 @@ function LabelsColumn(props: AssetColumnProps) {
.map(label => (
<Label
key={label}
title="Right click to remove label."
color={labels.get(label)?.color ?? labelModule.DEFAULT_LABEL_COLOR}
active={!temporarilyRemovedLabels.has(label)}
disabled={temporarilyRemovedLabels.has(label)}
className={
temporarilyRemovedLabels.has(label)
? 'relative before:absolute before:rounded-full before:border-2 before:border-delete before:inset-0 before:w-full before:h-full'
: ''
}
negated={temporarilyRemovedLabels.has(label)}
onContextMenu={event => {
event.preventDefault()
event.stopPropagation()
@ -357,9 +355,7 @@ function LabelsColumn(props: AssetColumnProps) {
event.preventDefault()
event.stopPropagation()
setQuery(oldQuery =>
oldQuery.labels.includes(label)
? oldQuery.delete({ labels: [label] })
: oldQuery.add({ labels: [label] })
assetQuery.toggleLabel(oldQuery, label, event.shiftKey)
)
}}
>

View File

@ -62,6 +62,7 @@ export default function AssetRow(props: AssetRowProps) {
columns,
} = props
const {
visibilities,
assetEvents,
dispatchAssetEvent,
dispatchAssetListEvent,
@ -79,11 +80,14 @@ export default function AssetRow(props: AssetRowProps) {
const [item, setItem] = React.useState(rawItem)
const dragOverTimeoutHandle = React.useRef<number | null>(null)
const asset = item.item
const [visibility, setVisibility] = React.useState(visibilityModule.Visibility.visible)
const [insertionVisibility, setInsertionVisibility] = React.useState(
visibilityModule.Visibility.visible
)
const [rowState, setRowState] = React.useState<assetsTable.AssetRowState>(() => ({
...initialRowState,
setVisibility,
setVisibility: setInsertionVisibility,
}))
const visibility = visibilities.get(key) ?? insertionVisibility
React.useEffect(() => {
setItem(rawItem)
@ -96,10 +100,10 @@ export default function AssetRow(props: AssetRowProps) {
const setAsset = assetTreeNode.useSetAsset(asset, setItem)
React.useEffect(() => {
if (selected && visibility !== visibilityModule.Visibility.visible) {
if (selected && insertionVisibility !== visibilityModule.Visibility.visible) {
setSelected(false)
}
}, [selected, visibility, /* should never change */ setSelected])
}, [selected, insertionVisibility, /* should never change */ setSelected])
const doMove = React.useCallback(
async (
@ -165,7 +169,7 @@ export default function AssetRow(props: AssetRowProps) {
}, [item, isSoleSelectedItem, /* should never change */ setAssetSettingsPanelProps])
const doDelete = React.useCallback(async () => {
setVisibility(visibilityModule.Visibility.hidden)
setInsertionVisibility(visibilityModule.Visibility.hidden)
if (asset.type === backendModule.AssetType.directory) {
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.closeFolder,
@ -202,7 +206,7 @@ export default function AssetRow(props: AssetRowProps) {
key: item.key,
})
} catch (error) {
setVisibility(visibilityModule.Visibility.visible)
setInsertionVisibility(visibilityModule.Visibility.visible)
toastAndLog(
errorModule.tryGetMessage(error)?.slice(0, -1) ??
`Could not delete ${backendModule.ASSET_TYPE_NAME[asset.type]}`
@ -218,7 +222,7 @@ export default function AssetRow(props: AssetRowProps) {
const doRestore = React.useCallback(async () => {
// Visually, the asset is deleted from the Trash view.
setVisibility(visibilityModule.Visibility.hidden)
setInsertionVisibility(visibilityModule.Visibility.hidden)
try {
await backend.undoDeleteAsset(asset.id, asset.title)
dispatchAssetListEvent({
@ -226,7 +230,7 @@ export default function AssetRow(props: AssetRowProps) {
key: item.key,
})
} catch (error) {
setVisibility(visibilityModule.Visibility.visible)
setInsertionVisibility(visibilityModule.Visibility.visible)
toastAndLog(`Unable to restore ${backendModule.ASSET_TYPE_NAME[asset.type]}`, error)
}
}, [
@ -251,19 +255,19 @@ export default function AssetRow(props: AssetRowProps) {
}
case assetEventModule.AssetEventType.cut: {
if (event.ids.has(item.key)) {
setVisibility(visibilityModule.Visibility.faded)
setInsertionVisibility(visibilityModule.Visibility.faded)
}
break
}
case assetEventModule.AssetEventType.cancelCut: {
if (event.ids.has(item.key)) {
setVisibility(visibilityModule.Visibility.visible)
setInsertionVisibility(visibilityModule.Visibility.visible)
}
break
}
case assetEventModule.AssetEventType.move: {
if (event.ids.has(item.key)) {
setVisibility(visibilityModule.Visibility.visible)
setInsertionVisibility(visibilityModule.Visibility.visible)
await doMove(event.newParentKey, event.newParentId)
}
break
@ -301,7 +305,7 @@ export default function AssetRow(props: AssetRowProps) {
case assetEventModule.AssetEventType.removeSelf: {
// This is not triggered from the asset list, so it uses `item.id` instead of `key`.
if (event.id === asset.id && user != null) {
setVisibility(visibilityModule.Visibility.hidden)
setInsertionVisibility(visibilityModule.Visibility.hidden)
try {
await backend.createPermission({
action: null,
@ -313,7 +317,7 @@ export default function AssetRow(props: AssetRowProps) {
key: item.key,
})
} catch (error) {
setVisibility(visibilityModule.Visibility.visible)
setInsertionVisibility(visibilityModule.Visibility.visible)
toastAndLog(null, error)
}
}
@ -435,7 +439,9 @@ export default function AssetRow(props: AssetRowProps) {
isDraggedOver ? 'selected' : ''
}`}
{...props}
hidden={hidden || visibility === visibilityModule.Visibility.hidden}
hidden={
hidden || insertionVisibility === visibilityModule.Visibility.hidden
}
onContextMenu={(innerProps, event) => {
if (allowContextMenu) {
event.preventDefault()
@ -530,7 +536,7 @@ export default function AssetRow(props: AssetRowProps) {
/>
{selected &&
allowContextMenu &&
visibility !== visibilityModule.Visibility.hidden && (
insertionVisibility !== visibilityModule.Visibility.hidden && (
// This is a copy of the context menu, since the context menu registers keyboard
// shortcut handlers. This is a bit of a hack, however it is preferable to duplicating
// the entire context menu (once for the keyboard actions, once for the JSX).

View File

@ -0,0 +1,318 @@
/** @file A search bar containing a text input, and a list of suggestions. */
import * as React from 'react'
import FindIcon from 'enso-assets/find.svg'
import * as array from '../../array'
import * as assetQuery from '../../assetQuery'
import type * as backend from '../backend'
import * as shortcuts from '../shortcuts'
import Label from './label'
/** A suggested query based on */
export interface Suggestion {
render: () => React.ReactNode
addToQuery: (query: assetQuery.AssetQuery) => assetQuery.AssetQuery
deleteFromQuery: (query: assetQuery.AssetQuery) => assetQuery.AssetQuery
}
/** Props for a {@link AssetSearchBar}. */
export interface AssetSearchBarProps {
query: assetQuery.AssetQuery
setQuery: React.Dispatch<React.SetStateAction<assetQuery.AssetQuery>>
labels: backend.Label[]
suggestions: Suggestion[]
}
/** A search bar containing a text input, and a list of suggestions. */
export default function AssetSearchBar(props: AssetSearchBarProps) {
const { query, setQuery, labels, suggestions: rawSuggestions } = props
const [isTabbing, setIsTabbing] = React.useState(false)
/** A cached query as of the start of tabbing. */
const baseQuery = React.useRef(query)
const [suggestions, setSuggestions] = React.useState(rawSuggestions)
const suggestionsRef = React.useRef(rawSuggestions)
const [selectedIndices, setSelectedIndices] = React.useState<ReadonlySet<number>>(
new Set<number>()
)
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null)
const [areSuggestionsVisible, setAreSuggestionsVisible] = React.useState(false)
const areSuggestionsVisibleRef = React.useRef(areSuggestionsVisible)
const [wasQueryModified, setWasQueryModified] = React.useState(false)
const [isShiftPressed, setIsShiftPressed] = React.useState(false)
const rootRef = React.useRef<HTMLLabelElement>(null)
const searchRef = React.useRef<HTMLInputElement>(null)
React.useEffect(() => {
if (!isTabbing && !isShiftPressed) {
baseQuery.current = query
}
}, [isTabbing, isShiftPressed, query])
React.useEffect(() => {
if (!isTabbing && !isShiftPressed) {
setSuggestions(rawSuggestions)
suggestionsRef.current = rawSuggestions
}
}, [isTabbing, isShiftPressed, rawSuggestions])
React.useEffect(() => {
areSuggestionsVisibleRef.current = areSuggestionsVisible
}, [areSuggestionsVisible])
React.useEffect(() => {
if (selectedIndex == null) {
setQuery(baseQuery.current)
}
}, [selectedIndex, /* should never change */ setQuery])
React.useEffect(() => {
let newQuery = query
if (wasQueryModified) {
const suggestion = selectedIndex == null ? null : suggestions[selectedIndex]
if (suggestion != null) {
newQuery = suggestion.addToQuery(baseQuery.current)
setQuery(newQuery)
}
searchRef.current?.focus()
const end = searchRef.current?.value.length ?? 0
searchRef.current?.setSelectionRange(end, end)
if (searchRef.current != null) {
searchRef.current.value = newQuery.toString()
}
}
setWasQueryModified(false)
}, [
wasQueryModified,
query,
baseQuery,
selectedIndex,
suggestions,
/* should never change */ setQuery,
])
React.useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
setIsShiftPressed(event.shiftKey)
if (areSuggestionsVisibleRef.current) {
if (event.key === 'Tab') {
event.preventDefault()
setIsTabbing(true)
setSelectedIndex(oldIndex => {
if (event.shiftKey) {
return oldIndex == null
? suggestionsRef.current.length - 1
: (oldIndex + suggestionsRef.current.length - 1) %
suggestionsRef.current.length
} else {
return oldIndex == null
? 0
: (oldIndex + 1) % suggestionsRef.current.length
}
})
setWasQueryModified(true)
}
}
if (event.key === 'Escape') {
searchRef.current?.blur()
}
// Allow `alt` key to be pressed in case it is being used to enter special characters.
if (
!(event.target instanceof HTMLInputElement) &&
(!(event.target instanceof HTMLElement) || !event.target.isContentEditable) &&
(!(event.target instanceof Node) ||
rootRef.current?.contains(event.target) !== true) &&
shortcuts.isTextInputEvent(event)
) {
searchRef.current?.focus()
}
if (
event.target instanceof Node &&
rootRef.current?.contains(event.target) === true &&
shortcuts.isPotentiallyShortcut(event)
) {
searchRef.current?.focus()
}
}
const onKeyUp = (event: KeyboardEvent) => {
setIsShiftPressed(event.shiftKey)
}
document.addEventListener('keydown', onKeyDown)
document.addEventListener('keyup', onKeyUp)
return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keyup', onKeyUp)
}
}, [])
React.useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (areSuggestionsVisibleRef.current) {
if (event.key === 'Enter' || event.key === ' ') {
baseQuery.current = query
setIsTabbing(false)
setSelectedIndex(null)
searchRef.current?.focus()
const end = searchRef.current?.value.length ?? 0
searchRef.current?.setSelectionRange(end, end)
}
if (event.key === 'Enter') {
setAreSuggestionsVisible(false)
}
}
}
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('keydown', onKeyDown)
}
}, [query, setQuery])
return (
<label
ref={rootRef}
tabIndex={-1}
onFocus={() => {
setAreSuggestionsVisible(true)
}}
onBlur={event => {
if (!event.currentTarget.contains(event.relatedTarget)) {
setIsTabbing(false)
setSelectedIndex(null)
setAreSuggestionsVisible(false)
}
}}
className="group search-bar absolute flex items-center text-primary rounded-full -translate-x-1/2 gap-2.5 left-1/2 h-8 w-98.25 min-w-31.5 px-2"
>
<img src={FindIcon} className="relative z-1 opacity-80" />
<input
ref={searchRef}
type="search"
size={1}
placeholder="Type to search for projects, data connectors, users, and more."
className="peer relative z-1 grow bg-transparent leading-5 h-6 py-px"
onFocus={() => {
if (!wasQueryModified) {
setSelectedIndex(null)
}
}}
onChange={event => {
if (!wasQueryModified) {
setQuery(assetQuery.AssetQuery.fromString(event.target.value))
}
}}
/>
<div className="absolute flex flex-col top-0 left-0 overflow-hidden w-full before:absolute before:bg-frame before:inset-0 before:backdrop-blur-3xl rounded-2xl pointer-events-none transition-all duration-300">
<div className="relative padding h-8"></div>
{areSuggestionsVisible && (
<div className="relative flex flex-col gap-2">
{/* Tags (`name:`, `modified:`) */}
<div className="flex flex-wrap gap-2 whitespace-nowrap px-2 pointer-events-auto">
{assetQuery.AssetQuery.tagNames.flatMap(entry => {
const [key, tag] = entry
return tag == null || isShiftPressed !== tag.startsWith('-')
? []
: [
<button
key={key}
className="bg-frame rounded-full h-6 px-2 hover:bg-frame-selected transition-all"
onClick={() => {
setWasQueryModified(true)
setQuery(
assetQuery.AssetQuery.fromString(
`${query.toString()} ${tag}:`
)
)
}}
>
{tag}:
</button>,
]
})}
</div>
{/* Asset labels */}
<div className="flex gap-2 p-2 pointer-events-auto">
{labels.map(label => {
const negated = query.negativeLabels.some(term =>
array.shallowEqual(term, [label.value])
)
return (
<Label
key={label.id}
color={label.color}
group={false}
active={
negated ||
query.labels.some(term =>
array.shallowEqual(term, [label.value])
)
}
negated={negated}
onClick={event => {
setWasQueryModified(true)
setQuery(oldQuery =>
assetQuery.toggleLabel(
oldQuery,
label.value,
event.shiftKey
)
)
}}
>
{label.value}
</Label>
)
})}
</div>
{/* Suggestions */}
<div className="flex flex-col max-h-[16rem] overflow-y-auto">
{suggestions.map((suggestion, index) => (
<div
key={index}
ref={el => {
if (index === selectedIndex) {
el?.focus()
}
}}
tabIndex={-1}
className={`cursor-pointer px-2 py-1 mx-1 rounded-2xl text-left hover:bg-frame-selected last:mb-1 transition-colors pointer-events-auto ${
index === selectedIndex
? 'bg-frame-selected'
: selectedIndices.has(index)
? 'bg-frame'
: ''
}`}
onClick={event => {
setWasQueryModified(true)
setQuery(
selectedIndices.has(index)
? suggestion.deleteFromQuery(
event.shiftKey ? query : baseQuery.current
)
: suggestion.addToQuery(
event.shiftKey ? query : baseQuery.current
)
)
if (event.shiftKey) {
setSelectedIndices(
new Set(
selectedIndices.has(index)
? [...selectedIndices].filter(
otherIndex => otherIndex !== index
)
: [...selectedIndices, index]
)
)
}
}}
>
{suggestion.render()}
</div>
))}
</div>
</div>
)}
</div>
</label>
)
}

View File

@ -2,15 +2,16 @@
import * as React from 'react'
import * as toast from 'react-toastify'
import * as array from '../array'
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 assetQuery from '../../assetQuery'
import * as assetTreeNode from '../assetTreeNode'
import * as backendModule from '../backend'
import * as columnModule from '../column'
import * as dateTime from '../dateTime'
import * as drag from '../drag'
import * as fileInfo from '../../fileInfo'
import * as hooks from '../../hooks'
import * as localStorageModule from '../localStorage'
import * as localStorageProvider from '../../providers/localStorage'
@ -21,12 +22,13 @@ import * as shortcutsProvider from '../../providers/shortcuts'
import * as sorting from '../sorting'
import * as string from '../../string'
import * as uniqueString from '../../uniqueString'
import type * as visibilityModule from '../visibility'
import * as visibilityModule from '../visibility'
import * as authProvider from '../../authentication/providers/auth'
import * as backendProvider from '../../providers/backend'
import * as modalProvider from '../../providers/modal'
import type * as assetSearchBar from './assetSearchBar'
import type * as assetSettingsPanel from './assetSettingsPanel'
import * as categorySwitcher from './categorySwitcher'
import AssetNameColumn from './assetNameColumn'
@ -37,6 +39,7 @@ import ContextMenu from './contextMenu'
import ContextMenus from './contextMenus'
import DragModal from './dragModal'
import GlobalContextMenu from './globalContextMenu'
import Label from './label'
import MenuEntry from './menuEntry'
import Table from './table'
@ -77,6 +80,75 @@ const DIRECTORY_NAME_REGEX = /^New_Folder_(?<directoryIndex>\d+)$/
/** The default prefix of an automatically generated directory. */
const DIRECTORY_NAME_DEFAULT_PREFIX = 'New_Folder_'
const SUGGESTIONS_FOR_NO: assetSearchBar.Suggestion[] = [
{
render: () => 'no:label',
addToQuery: query => query.addToLastTerm({ nos: ['label'] }),
deleteFromQuery: query => query.deleteFromLastTerm({ nos: ['label'] }),
},
{
render: () => 'no:description',
addToQuery: query => query.addToLastTerm({ nos: ['description'] }),
deleteFromQuery: query => query.deleteFromLastTerm({ nos: ['description'] }),
},
]
const SUGGESTIONS_FOR_HAS: assetSearchBar.Suggestion[] = [
{
render: () => 'has:label',
addToQuery: query => query.addToLastTerm({ negativeNos: ['label'] }),
deleteFromQuery: query => query.deleteFromLastTerm({ negativeNos: ['label'] }),
},
{
render: () => 'has:description',
addToQuery: query => query.addToLastTerm({ negativeNos: ['description'] }),
deleteFromQuery: query => query.deleteFromLastTerm({ negativeNos: ['description'] }),
},
]
const SUGGESTIONS_FOR_TYPE: assetSearchBar.Suggestion[] = [
{
render: () => 'type:project',
addToQuery: query => query.addToLastTerm({ types: ['project'] }),
deleteFromQuery: query => query.deleteFromLastTerm({ types: ['project'] }),
},
{
render: () => 'type:folder',
addToQuery: query => query.addToLastTerm({ types: ['folder'] }),
deleteFromQuery: query => query.deleteFromLastTerm({ types: ['folder'] }),
},
{
render: () => 'type:file',
addToQuery: query => query.addToLastTerm({ types: ['file'] }),
deleteFromQuery: query => query.deleteFromLastTerm({ types: ['file'] }),
},
{
render: () => 'type:connector',
addToQuery: query => query.addToLastTerm({ types: ['connector'] }),
deleteFromQuery: query => query.deleteFromLastTerm({ types: ['connector'] }),
},
]
const SUGGESTIONS_FOR_NEGATIVE_TYPE: assetSearchBar.Suggestion[] = [
{
render: () => 'type:project',
addToQuery: query => query.addToLastTerm({ negativeTypes: ['project'] }),
deleteFromQuery: query => query.deleteFromLastTerm({ negativeTypes: ['project'] }),
},
{
render: () => 'type:folder',
addToQuery: query => query.addToLastTerm({ negativeTypes: ['folder'] }),
deleteFromQuery: query => query.deleteFromLastTerm({ negativeTypes: ['folder'] }),
},
{
render: () => 'type:file',
addToQuery: query => query.addToLastTerm({ negativeTypes: ['file'] }),
deleteFromQuery: query => query.deleteFromLastTerm({ negativeTypes: ['file'] }),
},
{
render: () => 'type:connector',
addToQuery: query => query.addToLastTerm({ negativeTypes: ['connector'] }),
deleteFromQuery: query => query.deleteFromLastTerm({ negativeTypes: ['connector'] }),
},
]
// ===================================
// === insertAssetTreeNodeChildren ===
// ===================================
@ -142,6 +214,7 @@ const CATEGORY_TO_FILTER_BY: Record<categorySwitcher.Category, backendModule.Fil
/** State passed through from a {@link AssetsTable} to every cell. */
export interface AssetsTableState {
numberOfSelectedItems: number
visibilities: ReadonlyMap<backendModule.AssetId, visibilityModule.Visibility>
category: categorySwitcher.Category
labels: Map<backendModule.LabelName, backendModule.Label>
deletedLabelNames: Set<backendModule.LabelName>
@ -208,6 +281,7 @@ export interface AssetsTableProps {
setQuery: React.Dispatch<React.SetStateAction<assetQuery.AssetQuery>>
category: categorySwitcher.Category
allLabels: Map<backendModule.LabelName, backendModule.Label>
setSuggestions: (suggestions: assetSearchBar.Suggestion[]) => void
initialProjectName: string | null
projectStartupInfo: backendModule.ProjectStartupInfo | null
deletedLabelNames: Set<backendModule.LabelName>
@ -241,6 +315,7 @@ export default function AssetsTable(props: AssetsTableProps) {
setQuery,
category,
allLabels,
setSuggestions,
deletedLabelNames,
initialProjectName,
projectStartupInfo,
@ -288,15 +363,111 @@ export default function AssetsTable(props: AssetsTableProps) {
[backend, organization]
)
const filter = React.useMemo(() => {
if (query.query === '') {
const globCache: Record<string, RegExp> = {}
if (/^\s*$/.test(query.query)) {
return null
} else {
return (node: assetTreeNode.AssetTreeNode) => {
const assetType =
node.item.type === backendModule.AssetType.directory
? 'folder'
: node.item.type === backendModule.AssetType.secret
? 'connector'
: String(node.item.type)
const assetExtension =
node.item.type !== backendModule.AssetType.file
? null
: fileInfo.fileExtension(node.item.title).toLowerCase()
const assetModifiedAt = new Date(node.item.modifiedAt)
const labels: string[] = node.item.labels ?? []
const lowercaseName = node.item.title.toLowerCase()
const lowercaseDescription = node.item.description?.toLowerCase() ?? ''
const owners =
node.item.permissions
?.filter(
permission => permission.permission === permissions.PermissionAction.own
)
.map(owner => owner.user.user_name) ?? []
const globMatch = (glob: string, match: string) => {
const regex = (globCache[glob] =
globCache[glob] ??
new RegExp(
'^' + string.regexEscape(glob).replace(/(?:\\\*)+/g, '.*') + '$',
'i'
))
return regex.test(match)
}
const isAbsent = (type: string) => {
switch (type) {
case 'label':
case 'labels': {
return labels.length === 0
}
case 'name': {
// Should never be true, but handle it just in case.
return lowercaseName === ''
}
case 'description': {
return lowercaseDescription === ''
}
case 'extension': {
// Should never be true, but handle it just in case.
return assetExtension === ''
}
}
// Things like `no:name` and `no:owner` are never true.
return false
}
const parseDate = (date: string) => {
const lowercase = date.toLowerCase()
switch (lowercase) {
case 'today': {
return new Date()
}
}
return new Date(date)
}
const matchesDate = (date: string) => {
const parsed = parseDate(date)
return (
parsed.getFullYear() === assetModifiedAt.getFullYear() &&
parsed.getMonth() === assetModifiedAt.getMonth() &&
parsed.getDate() === assetModifiedAt.getDate()
)
}
const isEmpty = (values: string[]) =>
values.length === 0 || (values.length === 1 && values[0] === '')
const filterTag = (
positive: string[][],
negative: string[][],
predicate: (value: string) => boolean
) =>
positive.every(values => isEmpty(values) || values.some(predicate)) &&
negative.every(values => !values.some(predicate))
return (
query.labels.every(label => labels.includes(label)) &&
query.keywords.every(keyword => lowercaseName.includes(keyword.toLowerCase()))
filterTag(query.nos, query.negativeNos, no => isAbsent(no.toLowerCase())) &&
filterTag(query.keywords, query.negativeKeywords, keyword =>
lowercaseName.includes(keyword.toLowerCase())
) &&
filterTag(query.names, query.negativeNames, name =>
globMatch(name, lowercaseName)
) &&
filterTag(query.labels, query.negativeLabels, label =>
labels.some(assetLabel => globMatch(label, assetLabel))
) &&
filterTag(query.types, query.negativeTypes, type => type === assetType) &&
filterTag(
query.extensions,
query.negativeExtensions,
extension => extension.toLowerCase() === assetExtension
) &&
filterTag(query.descriptions, query.negativeDescriptions, description =>
lowercaseDescription.includes(description.toLowerCase())
) &&
filterTag(query.modifieds, query.negativeModifieds, matchesDate) &&
filterTag(query.owners, query.negativeOwners, owner =>
owners.some(assetOwner => globMatch(owner, assetOwner))
)
)
}
}
@ -335,6 +506,231 @@ export default function AssetsTable(props: AssetsTableProps) {
)
}
}, [assetTree, sortColumn, sortDirection])
const visibilities = React.useMemo(() => {
const map = new Map<backendModule.AssetId, visibilityModule.Visibility>()
const processNode = (node: assetTreeNode.AssetTreeNode) => {
let displayState = visibilityModule.Visibility.hidden
const visible = filter?.(node) ?? true
for (const child of node.children ?? []) {
if (visible && child.item.type === backendModule.AssetType.specialEmpty) {
map.set(child.key, visibilityModule.Visibility.visible)
} else {
processNode(child)
}
if (map.get(child.key) !== visibilityModule.Visibility.hidden) {
displayState = visibilityModule.Visibility.faded
}
}
if (visible) {
displayState = visibilityModule.Visibility.visible
}
map.set(node.key, displayState)
return displayState
}
for (const topLevelNode of assetTree) {
processNode(topLevelNode)
}
return map
}, [assetTree, filter])
React.useEffect(() => {
const nodeToSuggestion = (
node: assetTreeNode.AssetTreeNode,
key: assetQuery.AssetQueryKey = 'names'
): assetSearchBar.Suggestion => ({
render: () => `${key === 'names' ? '' : '-:'}${node.item.title}`,
addToQuery: oldQuery => oldQuery.addToLastTerm({ [key]: [node.item.title] }),
deleteFromQuery: oldQuery => oldQuery.deleteFromLastTerm({ [key]: [node.item.title] }),
})
const allVisibleNodes = () =>
assetTreeNode
.assetTreePreorderTraversal(assetTree, children =>
children.filter(
child => visibilities.get(child.key) !== visibilityModule.Visibility.hidden
)
)
.filter(
node =>
visibilities.get(node.key) === visibilityModule.Visibility.visible &&
node.item.type !== backendModule.AssetType.specialEmpty &&
node.item.type !== backendModule.AssetType.specialLoading
)
const allVisible = (negative = false) =>
allVisibleNodes().map(node =>
nodeToSuggestion(node, negative ? 'negativeNames' : 'names')
)
const terms = assetQuery.AssetQuery.terms(query.query)
const lastTerm = terms[terms.length - 1]
const lastTermValues = lastTerm?.values ?? []
const shouldOmitNames = terms.some(term => term.tag === 'name')
if (lastTermValues.length !== 0) {
setSuggestions(shouldOmitNames ? [] : allVisible())
} else {
const negative = lastTerm?.tag?.startsWith('-') ?? false
switch (lastTerm?.tag ?? null) {
case null:
case '':
case '-':
case 'name':
case '-name': {
setSuggestions(allVisible(negative))
break
}
case 'no':
case '-has': {
setSuggestions(SUGGESTIONS_FOR_NO)
break
}
case 'has':
case '-no': {
setSuggestions(SUGGESTIONS_FOR_HAS)
break
}
case 'type': {
setSuggestions(SUGGESTIONS_FOR_TYPE)
break
}
case '-type': {
setSuggestions(SUGGESTIONS_FOR_NEGATIVE_TYPE)
break
}
case 'ext':
case '-ext':
case 'extension':
case '-extension': {
const extensions = allVisibleNodes()
.filter(node => node.item.type === backendModule.AssetType.file)
.map(node => fileInfo.fileExtension(node.item.title))
setSuggestions(
Array.from(
new Set(extensions),
(extension): assetSearchBar.Suggestion => ({
render: () =>
assetQuery.AssetQuery.termToString({
tag: `${negative ? '-' : ''}extension`,
values: [extension],
}),
addToQuery: oldQuery =>
oldQuery.addToLastTerm(
negative
? { negativeExtensions: [extension] }
: { extensions: [extension] }
),
deleteFromQuery: oldQuery =>
oldQuery.deleteFromLastTerm(
negative
? { negativeExtensions: [extension] }
: { extensions: [extension] }
),
})
)
)
break
}
case 'modified':
case '-modified': {
const modifieds = assetTreeNode
.assetTreePreorderTraversal(assetTree)
.map(node => {
const date = new Date(node.item.modifiedAt)
return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`
})
setSuggestions(
Array.from(
new Set(['today', ...modifieds]),
(modified): assetSearchBar.Suggestion => ({
render: () =>
assetQuery.AssetQuery.termToString({
tag: `${negative ? '-' : ''}modified`,
values: [modified],
}),
addToQuery: oldQuery =>
oldQuery.addToLastTerm(
negative
? { negativeModifieds: [modified] }
: { modifieds: [modified] }
),
deleteFromQuery: oldQuery =>
oldQuery.deleteFromLastTerm(
negative
? { negativeModifieds: [modified] }
: { modifieds: [modified] }
),
})
)
)
break
}
case 'owner':
case '-owner': {
const owners = assetTreeNode
.assetTreePreorderTraversal(assetTree)
.flatMap(node =>
(node.item.permissions ?? [])
.filter(
permission =>
permission.permission === permissions.PermissionAction.own
)
.map(permission => permission.user.user_name)
)
setSuggestions(
Array.from(
new Set(owners),
(owner): assetSearchBar.Suggestion => ({
render: () =>
assetQuery.AssetQuery.termToString({
tag: `${negative ? '-' : ''}owner`,
values: [owner],
}),
addToQuery: oldQuery =>
oldQuery.addToLastTerm(
negative ? { negativeOwners: [owner] } : { owners: [owner] }
),
deleteFromQuery: oldQuery =>
oldQuery.deleteFromLastTerm(
negative ? { negativeOwners: [owner] } : { owners: [owner] }
),
})
)
)
break
}
case 'label':
case '-label': {
setSuggestions(
Array.from(
allLabels.values(),
(label): assetSearchBar.Suggestion => ({
render: () => (
<Label active color={label.color} onClick={() => {}}>
{label.value}
</Label>
),
addToQuery: oldQuery =>
oldQuery.addToLastTerm(
negative
? { negativeLabels: [label.value] }
: { labels: [label.value] }
),
deleteFromQuery: oldQuery =>
oldQuery.deleteFromLastTerm(
negative
? { negativeLabels: [label.value] }
: { labels: [label.value] }
),
})
)
)
break
}
default: {
setSuggestions(shouldOmitNames ? [] : allVisible())
break
}
}
}
}, [assetTree, query, visibilities, allLabels, /* should never change */ setSuggestions])
React.useEffect(() => {
if (rawQueuedAssetEvents.length !== 0) {
@ -1307,6 +1703,7 @@ export default function AssetsTable(props: AssetsTableProps) {
const state = React.useMemo(
// The type MUST be here to trigger excess property errors at typecheck time.
(): AssetsTableState => ({
visibilities,
numberOfSelectedItems: selectedKeys.size,
category,
labels: allLabels,
@ -1333,6 +1730,7 @@ export default function AssetsTable(props: AssetsTableProps) {
doPaste,
}),
[
visibilities,
selectedKeys.size,
category,
allLabels,
@ -1428,7 +1826,9 @@ export default function AssetsTable(props: AssetsTableProps) {
}
rowComponent={AssetRow}
items={displayItems}
filter={filter}
filter={node =>
visibilities.get(node.key) !== visibilityModule.Visibility.hidden
}
isLoading={isLoading}
state={state}
initialRowState={INITIAL_ROW_STATE}

View File

@ -22,6 +22,7 @@ import * as loggerProvider from '../../providers/logger'
import * as modalProvider from '../../providers/modal'
import * as shortcutsProvider from '../../providers/shortcuts'
import type * as assetSearchBar from './assetSearchBar'
import type * as assetSettingsPanel from './assetSettingsPanel'
import * as pageSwitcher from './pageSwitcher'
import type * as spinner from './spinner'
@ -64,7 +65,6 @@ 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(() => assetQuery.AssetQuery.fromString(''))
const [isHelpChatOpen, setIsHelpChatOpen] = React.useState(false)
const [isHelpChatVisible, setIsHelpChatVisible] = React.useState(false)
const [loadingProjectManagerDidFail, setLoadingProjectManagerDidFail] = React.useState(false)
@ -74,6 +74,9 @@ export default function Dashboard(props: DashboardProps) {
const [queuedAssetEvents, setQueuedAssetEvents] = React.useState<assetEventModule.AssetEvent[]>(
[]
)
const [query, setQuery] = React.useState(() => assetQuery.AssetQuery.fromString(''))
const [labels, setLabels] = React.useState<backendModule.Label[]>([])
const [suggestions, setSuggestions] = React.useState<assetSearchBar.Suggestion[]>([])
const [projectStartupInfo, setProjectStartupInfo] =
React.useState<backendModule.ProjectStartupInfo | null>(null)
const [openProjectAbortController, setOpenProjectAbortController] =
@ -427,6 +430,8 @@ export default function Dashboard(props: DashboardProps) {
setBackendType={setBackendType}
query={query}
setQuery={setQuery}
labels={labels}
suggestions={suggestions}
canToggleSettingsPanel={assetSettingsPanelProps != null}
isSettingsPanelVisible={
isAssetSettingsPanelVisible && assetSettingsPanelProps != null
@ -451,6 +456,9 @@ export default function Dashboard(props: DashboardProps) {
initialProjectName={initialProjectName}
query={query}
setQuery={setQuery}
labels={labels}
setLabels={setLabels}
setSuggestions={setSuggestions}
projectStartupInfo={projectStartupInfo}
queuedAssetEvents={queuedAssetEvents}
assetListEvents={assetListEvents}

View File

@ -17,6 +17,7 @@ import * as modalProvider from '../../providers/modal'
import * as uniqueString from '../../uniqueString'
import * as app from '../../components/app'
import type * as assetSearchBar from './assetSearchBar'
import type * as assetSettingsPanel from './assetSettingsPanel'
import * as pageSwitcher from './pageSwitcher'
import type * as spinner from './spinner'
@ -44,6 +45,9 @@ export interface DriveProps {
dispatchAssetEvent: (directoryEvent: assetEventModule.AssetEvent) => void
query: assetQuery.AssetQuery
setQuery: React.Dispatch<React.SetStateAction<assetQuery.AssetQuery>>
labels: backendModule.Label[]
setLabels: React.Dispatch<React.SetStateAction<backendModule.Label[]>>
setSuggestions: (suggestions: assetSearchBar.Suggestion[]) => void
projectStartupInfo: backendModule.ProjectStartupInfo | null
setAssetSettingsPanelProps: React.Dispatch<
React.SetStateAction<assetSettingsPanel.AssetSettingsPanelRequiredProps | null>
@ -71,6 +75,9 @@ export default function Drive(props: DriveProps) {
queuedAssetEvents,
query,
setQuery,
labels,
setLabels,
setSuggestions,
projectStartupInfo,
assetListEvents,
dispatchAssetListEvent,
@ -97,7 +104,6 @@ export default function Drive(props: DriveProps) {
localStorage.get(localStorageModule.LocalStorageKey.driveCategory) ??
categorySwitcher.Category.home
)
const [labels, setLabels] = React.useState<backendModule.Label[]>([])
// const [currentLabels, setCurrentLabels] = React.useState<backendModule.LabelName[] | null>(null)
const [newLabelNames, setNewLabelNames] = React.useState(new Set<backendModule.LabelName>())
const [deletedLabelNames, setDeletedLabelNames] = React.useState(
@ -133,7 +139,7 @@ export default function Drive(props: DriveProps) {
setLabels(await backend.listTags())
}
})()
}, [backend, organization?.isEnabled])
}, [backend, organization?.isEnabled, /* should never change */ setLabels])
const doUploadFiles = React.useCallback(
(files: File[]) => {
@ -204,13 +210,13 @@ export default function Drive(props: DriveProps) {
new Set([...labelNames].filter(labelName => labelName !== newLabelName))
)
},
[backend, /* should never change */ toastAndLog]
[backend, /* should never change */ toastAndLog, /* should never change */ setLabels]
)
const doDeleteLabel = React.useCallback(
async (id: backendModule.TagId, value: backendModule.LabelName) => {
setDeletedLabelNames(oldNames => new Set([...oldNames, value]))
setQuery(oldQuery => oldQuery.delete({ labels: [value] }))
setQuery(oldQuery => oldQuery.deleteFromEveryTerm({ labels: [value] }))
try {
await backend.deleteTag(id, value)
dispatchAssetEvent({
@ -230,6 +236,7 @@ export default function Drive(props: DriveProps) {
/* should never change */ setQuery,
/* should never change */ dispatchAssetEvent,
/* should never change */ toastAndLog,
/* should never change */ setLabels,
]
)
@ -357,6 +364,7 @@ export default function Drive(props: DriveProps) {
setQuery={setQuery}
category={category}
allLabels={allLabels}
setSuggestions={setSuggestions}
initialProjectName={initialProjectName}
projectStartupInfo={projectStartupInfo}
deletedLabelNames={deletedLabelNames}

View File

@ -7,7 +7,7 @@ import * as backend from '../backend'
// === Constants ===
// =================
// The default color for labels (Light blue).
/** The default color for labels (Light blue). */
export const DEFAULT_LABEL_COLOR: backend.LChColor = {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
lightness: 100,
@ -28,9 +28,15 @@ interface InternalLabelProps
Required<Pick<JSX.IntrinsicElements['button'], 'onClick'>> {
/** When true, the button is not faded out even when not hovered. */
active?: boolean
/** When true, the button has a red border signifying that it will be deleted,
* or that it is excluded from search. */
negated?: boolean
/** When true, the button cannot be clicked. */
disabled?: boolean
color: backend.LChColor
/** When true, will turn opaque when the nearest ancestor `.group` is hovered.
* Otherwise, will turn opaque only when itself is hovered. */
group?: boolean
className?: string
}
@ -40,20 +46,29 @@ export default function Label(props: InternalLabelProps) {
active = false,
disabled = false,
color,
negated = false,
className = 'text-tag-text',
children,
group = true,
...passthrough
} = props
const textColorClassName = /\btext-/.test(className)
? '' // eslint-disable-next-line @typescript-eslint/no-magic-numbers
: color.lightness <= 50
? 'text-tag-text'
: active
? 'text-primary'
: 'text-not-selected'
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'} ${
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
color.lightness <= 50 ? 'text-tag-text placeholder-tag-text' : 'text-primary'
}`}
className={`flex items-center rounded-full gap-1.5 h-6 px-2.25 transition-all ${className} ${
negated
? 'relative before:absolute before:rounded-full before:border-2 before:border-delete before:inset-0 before:w-full before:h-full'
: ''
} ${active ? '' : 'opacity-50'} ${
disabled ? '' : group ? 'group-hover:opacity-100' : 'hover:opacity-100'
} ${textColorClassName}`}
style={{ backgroundColor: backend.lChColorToCssColor(color) }}
{...passthrough}
>

View File

@ -4,7 +4,8 @@ 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 * as array from '../../array'
import * as assetQuery from '../../assetQuery'
import type * as backend from '../backend'
import * as drag from '../drag'
import * as modalProvider from '../../providers/modal'
@ -42,6 +43,7 @@ export default function Labels(props: LabelsProps) {
deletedLabelNames,
} = props
const currentLabels = query.labels
const currentNegativeLabels = query.negativeLabels
const { setModal } = modalProvider.useSetModal()
return (
@ -55,69 +57,88 @@ export default function Labels(props: LabelsProps) {
{labels
.filter(label => !deletedLabelNames.has(label.value))
.sort((a, b) => (a.value > b.value ? 1 : a.value < b.value ? -1 : 0))
.map(label => (
<li key={label.id} className="group flex items-center gap-1">
<Label
draggable
color={label.color}
active={currentLabels.includes(label.value)}
disabled={newLabelNames.has(label.value)}
onClick={() => {
setQuery(oldQuery =>
oldQuery.labels.includes(label.value)
? oldQuery.delete({ labels: [label.value] })
: oldQuery.add({ labels: [label.value] })
)
}}
onDragStart={event => {
drag.setDragImageToBlank(event)
const payload: drag.LabelsDragPayload = new Set([label.value])
drag.LABELS.bind(event, payload)
setModal(
<DragModal
event={event}
doCleanup={() => {
drag.LABELS.unbind(payload)
}}
>
<Label active color={label.color} onClick={() => {}}>
{label.value}
</Label>
</DragModal>
)
}}
>
{label.value}
</Label>
{!newLabelNames.has(label.value) && (
<button
className="flex"
.map(label => {
const negated = currentNegativeLabels.some(term =>
array.shallowEqual(term, [label.value])
)
return (
<li key={label.id} className="group flex items-center gap-1">
<Label
draggable
color={label.color}
active={
negated ||
currentLabels.some(term =>
array.shallowEqual(term, [label.value])
)
}
negated={negated}
disabled={newLabelNames.has(label.value)}
onClick={event => {
event.stopPropagation()
setQuery(oldQuery =>
assetQuery.toggleLabel(
oldQuery,
label.value,
event.shiftKey
)
)
}}
onDragStart={event => {
drag.setDragImageToBlank(event)
const payload: drag.LabelsDragPayload = new Set([
label.value,
])
drag.LABELS.bind(event, payload)
setModal(
<ConfirmDeleteModal
description={`the label '${label.value}'`}
doDelete={() => {
doDeleteLabel(label.id, label.value)
<DragModal
event={event}
doCleanup={() => {
drag.LABELS.unbind(payload)
}}
/>
>
<Label
active
color={label.color}
onClick={() => {}}
>
{label.value}
</Label>
</DragModal>
)
}}
>
<SvgMask
src={Trash2Icon}
alt="Delete"
className="opacity-0 group-hover:opacity-100 text-delete w-4 h-4"
/>
</button>
)}
</li>
))}
{label.value}
</Label>
{!newLabelNames.has(label.value) && (
<button
className="flex"
onClick={event => {
event.stopPropagation()
setModal(
<ConfirmDeleteModal
description={`the label '${label.value}'`}
doDelete={() => {
doDeleteLabel(label.id, label.value)
}}
/>
)
}}
>
<SvgMask
src={Trash2Icon}
alt="Delete"
className="opacity-0 group-hover:opacity-100 text-delete w-4 h-4"
/>
</button>
)}
</li>
)
})}
<li>
<Label
active
color={labelModule.DEFAULT_LABEL_COLOR}
className="bg-frame-selected text-not-selected"
className="bg-frame text-not-selected"
onClick={event => {
event.stopPropagation()
setModal(

View File

@ -1,14 +1,13 @@
/** @file The top-bar of dashboard. */
import * as React from 'react'
import FindIcon from 'enso-assets/find.svg'
import * as assetQuery from '../../assetQuery'
import type * as assetQuery from '../../assetQuery'
import type * as backendModule from '../backend'
import * as shortcuts from '../shortcuts'
import type * as assetSearchBar from './assetSearchBar'
import PageSwitcher, * as pageSwitcher from './pageSwitcher'
import AssetInfoBar from './assetInfoBar'
import AssetSearchBar from './assetSearchBar'
import BackendSwitcher from './backendSwitcher'
import UserBar from './userBar'
@ -29,7 +28,9 @@ export interface TopBarProps {
isHelpChatOpen: boolean
setIsHelpChatOpen: (isHelpChatOpen: boolean) => void
query: assetQuery.AssetQuery
setQuery: (query: assetQuery.AssetQuery) => void
setQuery: React.Dispatch<React.SetStateAction<assetQuery.AssetQuery>>
labels: backendModule.Label[]
suggestions: assetSearchBar.Suggestion[]
canToggleSettingsPanel: boolean
isSettingsPanelVisible: boolean
setIsSettingsPanelVisible: React.Dispatch<React.SetStateAction<boolean>>
@ -52,30 +53,14 @@ export default function TopBar(props: TopBarProps) {
setIsHelpChatOpen,
query,
setQuery,
labels,
suggestions,
canToggleSettingsPanel,
isSettingsPanelVisible,
setIsSettingsPanelVisible,
doRemoveSelf,
onSignOut,
} = props
const searchRef = React.useRef<HTMLInputElement>(null)
React.useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
// Allow `alt` key to be pressed in case it is being used to enter special characters.
if (
!(event.target instanceof HTMLInputElement) &&
(!(event.target instanceof HTMLElement) || !event.target.isContentEditable) &&
shortcuts.isTextInputEvent(event)
) {
searchRef.current?.focus()
}
}
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('keydown', onKeyDown)
}
}, [])
return (
<div
@ -90,23 +75,12 @@ export default function TopBar(props: TopBarProps) {
<div className="grow" />
{page !== pageSwitcher.Page.editor && (
<>
<div className="search-bar absolute flex items-center text-primary bg-frame rounded-full -translate-x-1/2 gap-2.5 left-1/2 h-8 w-98.25 min-w-31.5 px-2">
<label htmlFor="search">
<img src={FindIcon} className="opacity-80" />
</label>
<input
ref={searchRef}
type="text"
size={1}
id="search"
placeholder="Type to search for projects, data connectors, users, and more."
value={query.query}
onChange={event => {
setQuery(assetQuery.AssetQuery.fromString(event.target.value))
}}
className="grow bg-transparent leading-5 h-6 py-px"
/>
</div>
<AssetSearchBar
query={query}
setQuery={setQuery}
labels={labels}
suggestions={suggestions}
/>
<div className="grow" />
</>
)}

View File

@ -1,7 +1,8 @@
/** @file Utility functions related to event handling. */
import type * as React from 'react'
import * as detect from 'enso-common/src/detect'
// =============================
// === Mouse event utilities ===
// =============================
@ -15,3 +16,9 @@ export function isSingleClick(event: React.MouseEvent) {
export function isDoubleClick(event: React.MouseEvent) {
return event.detail === 2
}
/** Returns `true` if and only if the event has the modifier key set
* (`Ctrl` on Windows/Linux; `Cmd` on macOS). */
export function isModKey(event: React.KeyboardEvent | React.MouseEvent) {
return detect.isOnMacOS() ? event.metaKey : event.ctrlKey
}

View File

@ -1,7 +1,7 @@
/** @file A LocalStorage data manager. */
import * as common from 'enso-common'
import * as array from './array'
import * as array from '../array'
import * as backend from './backend'
import * as column from './column'

View File

@ -142,19 +142,27 @@ export const MODIFIERS =
* keys. */
const SPECIAL_CHARACTER_KEYCODE_REGEX = /^[A-Z][a-z]/
/** Whether the modifiers match the event's modifier key states. */
export function isTextInputEvent(event: KeyboardEvent | React.KeyboardEvent) {
/** Whether `event` may trigger a shortcut. */
export function isPotentiallyShortcut(event: KeyboardEvent | React.KeyboardEvent) {
return event.ctrlKey || event.metaKey || event.altKey
}
/** Whether `event.key` is a key used in text editing. */
export function isTextInputKey(event: KeyboardEvent | React.KeyboardEvent) {
// Allow `alt` key to be pressed in case it is being used to enter special characters.
return (
!event.ctrlKey &&
!event.shiftKey &&
!event.metaKey &&
(!SPECIAL_CHARACTER_KEYCODE_REGEX.test(event.key) ||
event.key === 'Backspace' ||
event.key === 'Delete')
!SPECIAL_CHARACTER_KEYCODE_REGEX.test(event.key) ||
event.key === 'Backspace' ||
event.key === 'Delete'
)
}
/** Whether `event` will produce text. This excludes shortcuts, as they do not produce text. */
export function isTextInputEvent(event: KeyboardEvent | React.KeyboardEvent) {
// Allow `alt` key to be pressed in case it is being used to enter special characters.
return !event.ctrlKey && !event.shiftKey && !event.metaKey && isTextInputKey(event)
}
// =============================
// === makeKeyboardActionMap ===
// =============================

View File

@ -1,16 +0,0 @@
/** @file Tests for `error.ts`. */
import * as test from '@playwright/test'
import * as error from '../../../src/authentication/src/error'
// =============
// === Tests ===
// =============
test.test('tryGetMessage', () => {
const message = 'A custom error message.'
test.expect(error.tryGetMessage<unknown>(new Error(message))).toBe(message)
test.expect(error.tryGetMessage<unknown>(message)).toBeNull()
test.expect(error.tryGetMessage<unknown>({})).toBeNull()
test.expect(error.tryGetMessage<unknown>(null)).toBeNull()
})

View File

@ -1,14 +0,0 @@
/** @file Tests for `fileInfo.ts`. */
import * as test from '@playwright/test'
import * as fileInfo from '../../../src/authentication/src/fileInfo'
// =============
// === Tests ===
// =============
test.test('fileExtension', () => {
test.expect(fileInfo.fileExtension('image.png')).toBe('png')
test.expect(fileInfo.fileExtension('.gif')).toBe('gif')
test.expect(fileInfo.fileExtension('fileInfo.spec.js')).toBe('js')
})

View File

@ -1,42 +0,0 @@
/** @file Basic tests for this */
import * as test from '@playwright/test'
import * as validation from '../../../src/authentication/src/dashboard/validation'
// =============
// === Tests ===
// =============
/** Runs all tests. */
test.test('password validation', () => {
const pattern = new RegExp(`^(?:${validation.PASSWORD_PATTERN})$`)
const emptyPassword = ''
test.expect(emptyPassword, `'${emptyPassword}' fails validation`).not.toMatch(pattern)
const shortPassword = 'Aa0!'
test.expect(shortPassword, `'${shortPassword}' is too short`).not.toMatch(pattern)
const passwordMissingDigit = 'Aa!Aa!Aa!'
test.expect(passwordMissingDigit, `'${passwordMissingDigit}' is missing a digit`).not.toMatch(
pattern
)
const passwordMissingLowercase = 'A0!A0!A0!'
test.expect(
passwordMissingLowercase,
`'${passwordMissingLowercase}' is missing a lowercase letter`
).not.toMatch(pattern)
const passwordMissingUppercase = 'a0!a0!a0!'
test.expect(
passwordMissingUppercase,
`'${passwordMissingUppercase}' is missing an uppercase letter`
).not.toMatch(pattern)
const passwordMissingSymbol = 'Aa0Aa0Aa0'
test.expect(
passwordMissingSymbol,
`'${passwordMissingSymbol}' is missing a symbol`
).not.toMatch(pattern)
const validPassword = 'Aa0!Aa0!'
test.expect(validPassword, `'${validPassword}' passes validation`).toMatch(pattern)
const basicPassword = 'Password0!'
test.expect(basicPassword, `'${basicPassword}' passes validation`).toMatch(pattern)
const issue7498Password = 'ÑéFÛÅÐåÒ.ú¿¼\u00b4N@aö¶U¹jÙÇ3'
test.expect(issue7498Password, `'${issue7498Password}' passes validation`).toMatch(pattern)
})

View File

@ -0,0 +1,15 @@
/** @file Configuration for vitest. */
import * as url from 'node:url'
import * as vitestConfig from 'vitest/config'
import viteConfig from './vite.config'
export default vitestConfig.mergeConfig(
viteConfig,
vitestConfig.defineConfig({
test: {
exclude: ['**/*.spec.{ts,tsx}'],
root: url.fileURLToPath(new URL('./', import.meta.url)),
restoreMocks: true,
},
})
)

3
package-lock.json generated
View File

@ -262,7 +262,8 @@
"tailwindcss": "^3.2.7",
"tsx": "^3.12.6",
"typescript": "~5.2.2",
"vite": "^4.4.9"
"vite": "^4.4.9",
"vitest": "^0.34.4"
},
"optionalDependencies": {
"@esbuild/darwin-x64": "^0.17.15",