mirror of
https://github.com/enso-org/enso.git
synced 2024-12-24 06:02:48 +03:00
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:
parent
d86c6c472c
commit
942e6c2305
@ -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",
|
||||
|
@ -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',
|
||||
})
|
@ -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)
|
||||
})
|
@ -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()
|
||||
})
|
@ -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')
|
||||
})
|
@ -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 ===
|
||||
// =========================
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
})
|
@ -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)
|
||||
)
|
||||
}}
|
||||
>
|
||||
|
@ -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).
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
>
|
||||
|
@ -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(
|
||||
|
@ -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" />
|
||||
</>
|
||||
)}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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'
|
||||
|
||||
|
@ -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 ===
|
||||
// =============================
|
||||
|
@ -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()
|
||||
})
|
@ -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')
|
||||
})
|
@ -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)
|
||||
})
|
15
app/ide-desktop/lib/dashboard/vitest.config.ts
Normal file
15
app/ide-desktop/lib/dashboard/vitest.config.ts
Normal 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
3
package-lock.json
generated
@ -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",
|
||||
|
Loading…
Reference in New Issue
Block a user