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

View File

@ -1,41 +1,122 @@
/** @file Parsing and representation of the search query. */ /** @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 === // === 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. */ /** 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}. */ /** An individual segment of a query string input to {@link AssetQuery}. */
interface AssetQueryTerm { interface AssetQueryTerm {
tag: string | null tag: string | null
value: string values: string[]
} }
/** Parsing and representation of the search query. */ /** Parsing and representation of the search query. */
export class AssetQuery { export class AssetQuery {
static termsRegex = static plainValueRegex = interpolateRegex(/^(?:|[^"]\S*)$/)
// Control characters must be handled, in order to follow the JSON spec. static jsonValueRegex = interpolateRegex(/^(<json>)$/)
// eslint-disable-next-line no-control-regex static termsRegex = interpolateRegex(/(?:([^\s:]*):)?((?:(?:<json>|(?:[^,\s"][^,\s]*)),?)*|)/g)
/(?:([^\s:]*):)?(?:("(?:[^\0-\x1f\\"]|\\[\\/bfnrt"]|\\u[0-9a-fA-F]{4})*")|([^\s"]\S*|))/g static valuesRegex = interpolateRegex(/(?:<json>)|(?:[^,\s"][^,\s]*)/g)
static plainValueRegex = /^(?:|[^"]\S*)$/u // `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}. */ /** Create an {@link AssetQuery}. */
constructor( constructor(
readonly query: string, query: string | null,
readonly keywords: string[], readonly keywords: string[][],
readonly labels: 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. */ /** Return a list of {@link AssetQueryTerm}s found in the raw user input string. */
static terms(query: string): AssetQueryTerm[] { static terms(query: string): AssetQueryTerm[] {
const terms: AssetQueryTerm[] = [] const terms: AssetQueryTerm[] = []
for (const [, tag, jsonValue, plainValue] of query.trim().matchAll(this.termsRegex)) { for (const [, tag, valuesRaw = ''] of query.trim().matchAll(this.termsRegex)) {
if (tag != null || plainValue == null || plainValue !== '') { // Ignore values with a tag but without a value.
if (tag != null || valuesRaw !== '') {
const values = valuesRaw.match(AssetQuery.valuesRegex) ?? []
terms.push({ terms.push({
tag: tag ?? null, 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. */ /** Convert an {@link AssetQueryTerm} to a string usable in a raw user input string. */
static termToString(term: AssetQueryTerm) { static termToString(term: AssetQueryTerm) {
const tagSegment = term.tag == null ? '' : term.tag + ':' const tagSegment = term.tag == null ? '' : term.tag + ':'
const valueSegment = this.plainValueRegex.test(term.value) const valueSegment = term.values
? term.value .map(value => (AssetQuery.plainValueRegex.test(value) ? value : JSON.stringify(value)))
: JSON.stringify(term.value) .join(',')
return tagSegment + valueSegment return tagSegment + valueSegment
} }
/** Create an {@link AssetQuery} from a raw user input string. */ /** Create an {@link AssetQuery} from a raw user input string. */
static fromString(query: string): AssetQuery { static fromString(query: string): AssetQuery {
const terms = AssetQuery.terms(query) const terms = AssetQuery.terms(query)
const keywords = terms const keywords: string[][] = []
.filter(term => term.tag == null || term.tag === '') const negativeKeywords: string[][] = []
.map(term => term.value) const names: string[][] = []
const labels = terms const negativeNames: string[][] = []
.filter(term => term.tag?.toLowerCase() === 'label') const labels: string[][] = []
.map(term => term.value) const negativeLabels: string[][] = []
return new AssetQuery(query, keywords, labels) 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, /** 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 { add(values: Partial<AssetQueryData>): AssetQuery {
const { keywords, labels } = values const updates: Partial<AssetQueryData> = {}
const noKeywords = !keywords || keywords.length === 0 for (const [key] of AssetQuery.tagNames) {
const noLabels = !labels || labels.length === 0 const update = AssetQuery.updatedTerms(this[key], values[key] ?? null, null)
if (noKeywords && noLabels) { if (update != null) {
return this updates[key] = update
} 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 this.withUpdates(updates)
} }
/** Return a new {@link AssetQuery} with the specified terms removed, /** Return a new {@link AssetQuery} with the specified terms deleted,
* or itself if there are no terms to remove. */ * or itself if there are no terms to delete. */
delete(values: Partial<AssetQueryData>): AssetQuery { delete(values: Partial<AssetQueryData>): AssetQuery {
const { keywords, labels } = values const updates: Partial<AssetQueryData> = {}
const noKeywords = !keywords || keywords.length === 0 for (const [key] of AssetQuery.tagNames) {
const noLabels = !labels || labels.length === 0 const update = AssetQuery.updatedTerms(this[key], null, values[key] ?? null)
if (noKeywords && noLabels) { if (update != null) {
return this updates[key] = update
} 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)
} }
} }
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 TimeIcon from 'enso-assets/time.svg'
import * as assetEvent from './events/assetEvent' import * as assetEvent from './events/assetEvent'
import * as assetQuery from '../assetQuery'
import type * as assetTreeNode from './assetTreeNode' import type * as assetTreeNode from './assetTreeNode'
import * as authProvider from '../authentication/providers/auth' import * as authProvider from '../authentication/providers/auth'
import * as backendModule from './backend' import * as backendModule from './backend'
@ -301,14 +302,11 @@ function LabelsColumn(props: AssetColumnProps) {
.map(label => ( .map(label => (
<Label <Label
key={label} key={label}
title="Right click to remove label."
color={labels.get(label)?.color ?? labelModule.DEFAULT_LABEL_COLOR} color={labels.get(label)?.color ?? labelModule.DEFAULT_LABEL_COLOR}
active={!temporarilyRemovedLabels.has(label)} active={!temporarilyRemovedLabels.has(label)}
disabled={temporarilyRemovedLabels.has(label)} disabled={temporarilyRemovedLabels.has(label)}
className={ negated={temporarilyRemovedLabels.has(label)}
temporarilyRemovedLabels.has(label)
? 'relative before:absolute before:rounded-full before:border-2 before:border-delete before:inset-0 before:w-full before:h-full'
: ''
}
onContextMenu={event => { onContextMenu={event => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
@ -357,9 +355,7 @@ function LabelsColumn(props: AssetColumnProps) {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
setQuery(oldQuery => setQuery(oldQuery =>
oldQuery.labels.includes(label) assetQuery.toggleLabel(oldQuery, label, event.shiftKey)
? oldQuery.delete({ labels: [label] })
: oldQuery.add({ labels: [label] })
) )
}} }}
> >

View File

@ -62,6 +62,7 @@ export default function AssetRow(props: AssetRowProps) {
columns, columns,
} = props } = props
const { const {
visibilities,
assetEvents, assetEvents,
dispatchAssetEvent, dispatchAssetEvent,
dispatchAssetListEvent, dispatchAssetListEvent,
@ -79,11 +80,14 @@ export default function AssetRow(props: AssetRowProps) {
const [item, setItem] = React.useState(rawItem) const [item, setItem] = React.useState(rawItem)
const dragOverTimeoutHandle = React.useRef<number | null>(null) const dragOverTimeoutHandle = React.useRef<number | null>(null)
const asset = item.item 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>(() => ({ const [rowState, setRowState] = React.useState<assetsTable.AssetRowState>(() => ({
...initialRowState, ...initialRowState,
setVisibility, setVisibility: setInsertionVisibility,
})) }))
const visibility = visibilities.get(key) ?? insertionVisibility
React.useEffect(() => { React.useEffect(() => {
setItem(rawItem) setItem(rawItem)
@ -96,10 +100,10 @@ export default function AssetRow(props: AssetRowProps) {
const setAsset = assetTreeNode.useSetAsset(asset, setItem) const setAsset = assetTreeNode.useSetAsset(asset, setItem)
React.useEffect(() => { React.useEffect(() => {
if (selected && visibility !== visibilityModule.Visibility.visible) { if (selected && insertionVisibility !== visibilityModule.Visibility.visible) {
setSelected(false) setSelected(false)
} }
}, [selected, visibility, /* should never change */ setSelected]) }, [selected, insertionVisibility, /* should never change */ setSelected])
const doMove = React.useCallback( const doMove = React.useCallback(
async ( async (
@ -165,7 +169,7 @@ export default function AssetRow(props: AssetRowProps) {
}, [item, isSoleSelectedItem, /* should never change */ setAssetSettingsPanelProps]) }, [item, isSoleSelectedItem, /* should never change */ setAssetSettingsPanelProps])
const doDelete = React.useCallback(async () => { const doDelete = React.useCallback(async () => {
setVisibility(visibilityModule.Visibility.hidden) setInsertionVisibility(visibilityModule.Visibility.hidden)
if (asset.type === backendModule.AssetType.directory) { if (asset.type === backendModule.AssetType.directory) {
dispatchAssetListEvent({ dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.closeFolder, type: assetListEventModule.AssetListEventType.closeFolder,
@ -202,7 +206,7 @@ export default function AssetRow(props: AssetRowProps) {
key: item.key, key: item.key,
}) })
} catch (error) { } catch (error) {
setVisibility(visibilityModule.Visibility.visible) setInsertionVisibility(visibilityModule.Visibility.visible)
toastAndLog( toastAndLog(
errorModule.tryGetMessage(error)?.slice(0, -1) ?? errorModule.tryGetMessage(error)?.slice(0, -1) ??
`Could not delete ${backendModule.ASSET_TYPE_NAME[asset.type]}` `Could not delete ${backendModule.ASSET_TYPE_NAME[asset.type]}`
@ -218,7 +222,7 @@ export default function AssetRow(props: AssetRowProps) {
const doRestore = React.useCallback(async () => { const doRestore = React.useCallback(async () => {
// Visually, the asset is deleted from the Trash view. // Visually, the asset is deleted from the Trash view.
setVisibility(visibilityModule.Visibility.hidden) setInsertionVisibility(visibilityModule.Visibility.hidden)
try { try {
await backend.undoDeleteAsset(asset.id, asset.title) await backend.undoDeleteAsset(asset.id, asset.title)
dispatchAssetListEvent({ dispatchAssetListEvent({
@ -226,7 +230,7 @@ export default function AssetRow(props: AssetRowProps) {
key: item.key, key: item.key,
}) })
} catch (error) { } catch (error) {
setVisibility(visibilityModule.Visibility.visible) setInsertionVisibility(visibilityModule.Visibility.visible)
toastAndLog(`Unable to restore ${backendModule.ASSET_TYPE_NAME[asset.type]}`, error) 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: { case assetEventModule.AssetEventType.cut: {
if (event.ids.has(item.key)) { if (event.ids.has(item.key)) {
setVisibility(visibilityModule.Visibility.faded) setInsertionVisibility(visibilityModule.Visibility.faded)
} }
break break
} }
case assetEventModule.AssetEventType.cancelCut: { case assetEventModule.AssetEventType.cancelCut: {
if (event.ids.has(item.key)) { if (event.ids.has(item.key)) {
setVisibility(visibilityModule.Visibility.visible) setInsertionVisibility(visibilityModule.Visibility.visible)
} }
break break
} }
case assetEventModule.AssetEventType.move: { case assetEventModule.AssetEventType.move: {
if (event.ids.has(item.key)) { if (event.ids.has(item.key)) {
setVisibility(visibilityModule.Visibility.visible) setInsertionVisibility(visibilityModule.Visibility.visible)
await doMove(event.newParentKey, event.newParentId) await doMove(event.newParentKey, event.newParentId)
} }
break break
@ -301,7 +305,7 @@ export default function AssetRow(props: AssetRowProps) {
case assetEventModule.AssetEventType.removeSelf: { case assetEventModule.AssetEventType.removeSelf: {
// This is not triggered from the asset list, so it uses `item.id` instead of `key`. // This is not triggered from the asset list, so it uses `item.id` instead of `key`.
if (event.id === asset.id && user != null) { if (event.id === asset.id && user != null) {
setVisibility(visibilityModule.Visibility.hidden) setInsertionVisibility(visibilityModule.Visibility.hidden)
try { try {
await backend.createPermission({ await backend.createPermission({
action: null, action: null,
@ -313,7 +317,7 @@ export default function AssetRow(props: AssetRowProps) {
key: item.key, key: item.key,
}) })
} catch (error) { } catch (error) {
setVisibility(visibilityModule.Visibility.visible) setInsertionVisibility(visibilityModule.Visibility.visible)
toastAndLog(null, error) toastAndLog(null, error)
} }
} }
@ -435,7 +439,9 @@ export default function AssetRow(props: AssetRowProps) {
isDraggedOver ? 'selected' : '' isDraggedOver ? 'selected' : ''
}`} }`}
{...props} {...props}
hidden={hidden || visibility === visibilityModule.Visibility.hidden} hidden={
hidden || insertionVisibility === visibilityModule.Visibility.hidden
}
onContextMenu={(innerProps, event) => { onContextMenu={(innerProps, event) => {
if (allowContextMenu) { if (allowContextMenu) {
event.preventDefault() event.preventDefault()
@ -530,7 +536,7 @@ export default function AssetRow(props: AssetRowProps) {
/> />
{selected && {selected &&
allowContextMenu && allowContextMenu &&
visibility !== visibilityModule.Visibility.hidden && ( insertionVisibility !== visibilityModule.Visibility.hidden && (
// This is a copy of the context menu, since the context menu registers keyboard // 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 // 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). // 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 React from 'react'
import * as toast from 'react-toastify' import * as toast from 'react-toastify'
import * as array from '../array' import * as array from '../../array'
import * as assetEventModule from '../events/assetEvent' import * as assetEventModule from '../events/assetEvent'
import * as assetListEventModule from '../events/assetListEvent' import * as assetListEventModule from '../events/assetListEvent'
import type * as assetQuery from '../../assetQuery' import * as assetQuery from '../../assetQuery'
import * as assetTreeNode from '../assetTreeNode' import * as assetTreeNode from '../assetTreeNode'
import * as backendModule from '../backend' import * as backendModule from '../backend'
import * as columnModule from '../column' import * as columnModule from '../column'
import * as dateTime from '../dateTime' import * as dateTime from '../dateTime'
import * as drag from '../drag' import * as drag from '../drag'
import * as fileInfo from '../../fileInfo'
import * as hooks from '../../hooks' import * as hooks from '../../hooks'
import * as localStorageModule from '../localStorage' import * as localStorageModule from '../localStorage'
import * as localStorageProvider from '../../providers/localStorage' import * as localStorageProvider from '../../providers/localStorage'
@ -21,12 +22,13 @@ import * as shortcutsProvider from '../../providers/shortcuts'
import * as sorting from '../sorting' import * as sorting from '../sorting'
import * as string from '../../string' import * as string from '../../string'
import * as uniqueString from '../../uniqueString' 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 authProvider from '../../authentication/providers/auth'
import * as backendProvider from '../../providers/backend' import * as backendProvider from '../../providers/backend'
import * as modalProvider from '../../providers/modal' import * as modalProvider from '../../providers/modal'
import type * as assetSearchBar from './assetSearchBar'
import type * as assetSettingsPanel from './assetSettingsPanel' import type * as assetSettingsPanel from './assetSettingsPanel'
import * as categorySwitcher from './categorySwitcher' import * as categorySwitcher from './categorySwitcher'
import AssetNameColumn from './assetNameColumn' import AssetNameColumn from './assetNameColumn'
@ -37,6 +39,7 @@ import ContextMenu from './contextMenu'
import ContextMenus from './contextMenus' import ContextMenus from './contextMenus'
import DragModal from './dragModal' import DragModal from './dragModal'
import GlobalContextMenu from './globalContextMenu' import GlobalContextMenu from './globalContextMenu'
import Label from './label'
import MenuEntry from './menuEntry' import MenuEntry from './menuEntry'
import Table from './table' import Table from './table'
@ -77,6 +80,75 @@ const DIRECTORY_NAME_REGEX = /^New_Folder_(?<directoryIndex>\d+)$/
/** The default prefix of an automatically generated directory. */ /** The default prefix of an automatically generated directory. */
const DIRECTORY_NAME_DEFAULT_PREFIX = 'New_Folder_' 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 === // === 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. */ /** State passed through from a {@link AssetsTable} to every cell. */
export interface AssetsTableState { export interface AssetsTableState {
numberOfSelectedItems: number numberOfSelectedItems: number
visibilities: ReadonlyMap<backendModule.AssetId, visibilityModule.Visibility>
category: categorySwitcher.Category category: categorySwitcher.Category
labels: Map<backendModule.LabelName, backendModule.Label> labels: Map<backendModule.LabelName, backendModule.Label>
deletedLabelNames: Set<backendModule.LabelName> deletedLabelNames: Set<backendModule.LabelName>
@ -208,6 +281,7 @@ export interface AssetsTableProps {
setQuery: React.Dispatch<React.SetStateAction<assetQuery.AssetQuery>> setQuery: React.Dispatch<React.SetStateAction<assetQuery.AssetQuery>>
category: categorySwitcher.Category category: categorySwitcher.Category
allLabels: Map<backendModule.LabelName, backendModule.Label> allLabels: Map<backendModule.LabelName, backendModule.Label>
setSuggestions: (suggestions: assetSearchBar.Suggestion[]) => void
initialProjectName: string | null initialProjectName: string | null
projectStartupInfo: backendModule.ProjectStartupInfo | null projectStartupInfo: backendModule.ProjectStartupInfo | null
deletedLabelNames: Set<backendModule.LabelName> deletedLabelNames: Set<backendModule.LabelName>
@ -241,6 +315,7 @@ export default function AssetsTable(props: AssetsTableProps) {
setQuery, setQuery,
category, category,
allLabels, allLabels,
setSuggestions,
deletedLabelNames, deletedLabelNames,
initialProjectName, initialProjectName,
projectStartupInfo, projectStartupInfo,
@ -288,15 +363,111 @@ export default function AssetsTable(props: AssetsTableProps) {
[backend, organization] [backend, organization]
) )
const filter = React.useMemo(() => { const filter = React.useMemo(() => {
if (query.query === '') { const globCache: Record<string, RegExp> = {}
if (/^\s*$/.test(query.query)) {
return null return null
} else { } else {
return (node: assetTreeNode.AssetTreeNode) => { 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 labels: string[] = node.item.labels ?? []
const lowercaseName = node.item.title.toLowerCase() 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 ( return (
query.labels.every(label => labels.includes(label)) && filterTag(query.nos, query.negativeNos, no => isAbsent(no.toLowerCase())) &&
query.keywords.every(keyword => lowercaseName.includes(keyword.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]) }, [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(() => { React.useEffect(() => {
if (rawQueuedAssetEvents.length !== 0) { if (rawQueuedAssetEvents.length !== 0) {
@ -1307,6 +1703,7 @@ export default function AssetsTable(props: AssetsTableProps) {
const state = React.useMemo( const state = React.useMemo(
// The type MUST be here to trigger excess property errors at typecheck time. // The type MUST be here to trigger excess property errors at typecheck time.
(): AssetsTableState => ({ (): AssetsTableState => ({
visibilities,
numberOfSelectedItems: selectedKeys.size, numberOfSelectedItems: selectedKeys.size,
category, category,
labels: allLabels, labels: allLabels,
@ -1333,6 +1730,7 @@ export default function AssetsTable(props: AssetsTableProps) {
doPaste, doPaste,
}), }),
[ [
visibilities,
selectedKeys.size, selectedKeys.size,
category, category,
allLabels, allLabels,
@ -1428,7 +1826,9 @@ export default function AssetsTable(props: AssetsTableProps) {
} }
rowComponent={AssetRow} rowComponent={AssetRow}
items={displayItems} items={displayItems}
filter={filter} filter={node =>
visibilities.get(node.key) !== visibilityModule.Visibility.hidden
}
isLoading={isLoading} isLoading={isLoading}
state={state} state={state}
initialRowState={INITIAL_ROW_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 modalProvider from '../../providers/modal'
import * as shortcutsProvider from '../../providers/shortcuts' import * as shortcutsProvider from '../../providers/shortcuts'
import type * as assetSearchBar from './assetSearchBar'
import type * as assetSettingsPanel from './assetSettingsPanel' import type * as assetSettingsPanel from './assetSettingsPanel'
import * as pageSwitcher from './pageSwitcher' import * as pageSwitcher from './pageSwitcher'
import type * as spinner from './spinner' import type * as spinner from './spinner'
@ -64,7 +65,6 @@ export default function Dashboard(props: DashboardProps) {
const { localStorage } = localStorageProvider.useLocalStorage() const { localStorage } = localStorageProvider.useLocalStorage()
const { shortcuts } = shortcutsProvider.useShortcuts() const { shortcuts } = shortcutsProvider.useShortcuts()
const [initialized, setInitialized] = React.useState(false) const [initialized, setInitialized] = React.useState(false)
const [query, setQuery] = React.useState(() => assetQuery.AssetQuery.fromString(''))
const [isHelpChatOpen, setIsHelpChatOpen] = React.useState(false) const [isHelpChatOpen, setIsHelpChatOpen] = React.useState(false)
const [isHelpChatVisible, setIsHelpChatVisible] = React.useState(false) const [isHelpChatVisible, setIsHelpChatVisible] = React.useState(false)
const [loadingProjectManagerDidFail, setLoadingProjectManagerDidFail] = 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 [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] = const [projectStartupInfo, setProjectStartupInfo] =
React.useState<backendModule.ProjectStartupInfo | null>(null) React.useState<backendModule.ProjectStartupInfo | null>(null)
const [openProjectAbortController, setOpenProjectAbortController] = const [openProjectAbortController, setOpenProjectAbortController] =
@ -427,6 +430,8 @@ export default function Dashboard(props: DashboardProps) {
setBackendType={setBackendType} setBackendType={setBackendType}
query={query} query={query}
setQuery={setQuery} setQuery={setQuery}
labels={labels}
suggestions={suggestions}
canToggleSettingsPanel={assetSettingsPanelProps != null} canToggleSettingsPanel={assetSettingsPanelProps != null}
isSettingsPanelVisible={ isSettingsPanelVisible={
isAssetSettingsPanelVisible && assetSettingsPanelProps != null isAssetSettingsPanelVisible && assetSettingsPanelProps != null
@ -451,6 +456,9 @@ export default function Dashboard(props: DashboardProps) {
initialProjectName={initialProjectName} initialProjectName={initialProjectName}
query={query} query={query}
setQuery={setQuery} setQuery={setQuery}
labels={labels}
setLabels={setLabels}
setSuggestions={setSuggestions}
projectStartupInfo={projectStartupInfo} projectStartupInfo={projectStartupInfo}
queuedAssetEvents={queuedAssetEvents} queuedAssetEvents={queuedAssetEvents}
assetListEvents={assetListEvents} assetListEvents={assetListEvents}

View File

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

View File

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

View File

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

View File

@ -1,14 +1,13 @@
/** @file The top-bar of dashboard. */ /** @file The top-bar of dashboard. */
import * as React from 'react' import * as React from 'react'
import FindIcon from 'enso-assets/find.svg' import type * as assetQuery from '../../assetQuery'
import * as assetQuery from '../../assetQuery'
import type * as backendModule from '../backend' import type * as backendModule from '../backend'
import * as shortcuts from '../shortcuts'
import type * as assetSearchBar from './assetSearchBar'
import PageSwitcher, * as pageSwitcher from './pageSwitcher' import PageSwitcher, * as pageSwitcher from './pageSwitcher'
import AssetInfoBar from './assetInfoBar' import AssetInfoBar from './assetInfoBar'
import AssetSearchBar from './assetSearchBar'
import BackendSwitcher from './backendSwitcher' import BackendSwitcher from './backendSwitcher'
import UserBar from './userBar' import UserBar from './userBar'
@ -29,7 +28,9 @@ export interface TopBarProps {
isHelpChatOpen: boolean isHelpChatOpen: boolean
setIsHelpChatOpen: (isHelpChatOpen: boolean) => void setIsHelpChatOpen: (isHelpChatOpen: boolean) => void
query: assetQuery.AssetQuery query: assetQuery.AssetQuery
setQuery: (query: assetQuery.AssetQuery) => void setQuery: React.Dispatch<React.SetStateAction<assetQuery.AssetQuery>>
labels: backendModule.Label[]
suggestions: assetSearchBar.Suggestion[]
canToggleSettingsPanel: boolean canToggleSettingsPanel: boolean
isSettingsPanelVisible: boolean isSettingsPanelVisible: boolean
setIsSettingsPanelVisible: React.Dispatch<React.SetStateAction<boolean>> setIsSettingsPanelVisible: React.Dispatch<React.SetStateAction<boolean>>
@ -52,30 +53,14 @@ export default function TopBar(props: TopBarProps) {
setIsHelpChatOpen, setIsHelpChatOpen,
query, query,
setQuery, setQuery,
labels,
suggestions,
canToggleSettingsPanel, canToggleSettingsPanel,
isSettingsPanelVisible, isSettingsPanelVisible,
setIsSettingsPanelVisible, setIsSettingsPanelVisible,
doRemoveSelf, doRemoveSelf,
onSignOut, onSignOut,
} = props } = 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 ( return (
<div <div
@ -90,23 +75,12 @@ export default function TopBar(props: TopBarProps) {
<div className="grow" /> <div className="grow" />
{page !== pageSwitcher.Page.editor && ( {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"> <AssetSearchBar
<label htmlFor="search"> query={query}
<img src={FindIcon} className="opacity-80" /> setQuery={setQuery}
</label> labels={labels}
<input suggestions={suggestions}
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>
<div className="grow" /> <div className="grow" />
</> </>
)} )}

View File

@ -1,7 +1,8 @@
/** @file Utility functions related to event handling. */ /** @file Utility functions related to event handling. */
import type * as React from 'react' import type * as React from 'react'
import * as detect from 'enso-common/src/detect'
// ============================= // =============================
// === Mouse event utilities === // === Mouse event utilities ===
// ============================= // =============================
@ -15,3 +16,9 @@ export function isSingleClick(event: React.MouseEvent) {
export function isDoubleClick(event: React.MouseEvent) { export function isDoubleClick(event: React.MouseEvent) {
return event.detail === 2 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. */ /** @file A LocalStorage data manager. */
import * as common from 'enso-common' import * as common from 'enso-common'
import * as array from './array' import * as array from '../array'
import * as backend from './backend' import * as backend from './backend'
import * as column from './column' import * as column from './column'

View File

@ -142,19 +142,27 @@ export const MODIFIERS =
* keys. */ * keys. */
const SPECIAL_CHARACTER_KEYCODE_REGEX = /^[A-Z][a-z]/ const SPECIAL_CHARACTER_KEYCODE_REGEX = /^[A-Z][a-z]/
/** Whether the modifiers match the event's modifier key states. */ /** Whether `event` may trigger a shortcut. */
export function isTextInputEvent(event: KeyboardEvent | React.KeyboardEvent) { 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. // Allow `alt` key to be pressed in case it is being used to enter special characters.
return ( return (
!event.ctrlKey && !SPECIAL_CHARACTER_KEYCODE_REGEX.test(event.key) ||
!event.shiftKey && event.key === 'Backspace' ||
!event.metaKey && 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 === // === 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", "tailwindcss": "^3.2.7",
"tsx": "^3.12.6", "tsx": "^3.12.6",
"typescript": "~5.2.2", "typescript": "~5.2.2",
"vite": "^4.4.9" "vite": "^4.4.9",
"vitest": "^0.34.4"
}, },
"optionalDependencies": { "optionalDependencies": {
"@esbuild/darwin-x64": "^0.17.15", "@esbuild/darwin-x64": "^0.17.15",