mirror of
https://github.com/enso-org/enso.git
synced 2024-12-24 21:22:49 +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",
|
"build": "tsx bundle.ts",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"start": "tsx start.ts",
|
"start": "tsx start.ts",
|
||||||
"test": "npm run test:unit",
|
"test": "vitest run",
|
||||||
"test:unit": "playwright test",
|
"test:unit": "vitest",
|
||||||
"test:browsers": "npx --yes playwright install && npm run test:component && npm run test:e2e-and-log",
|
"test:browsers": "npx --yes playwright install && npm run test:component && npm run test:e2e-and-log",
|
||||||
"test:component": "playwright test -c playwright-component.config.ts",
|
"test:component": "playwright test -c playwright-component.config.ts",
|
||||||
"test:e2e": "npx playwright test -c playwright-e2e.config.ts",
|
"test:e2e": "npx playwright test -c playwright-e2e.config.ts",
|
||||||
@ -50,7 +50,8 @@
|
|||||||
"tailwindcss": "^3.2.7",
|
"tailwindcss": "^3.2.7",
|
||||||
"tsx": "^3.12.6",
|
"tsx": "^3.12.6",
|
||||||
"typescript": "~5.2.2",
|
"typescript": "~5.2.2",
|
||||||
"vite": "^4.4.9"
|
"vite": "^4.4.9",
|
||||||
|
"vitest": "^0.34.4"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@esbuild/darwin-x64": "^0.17.15",
|
"@esbuild/darwin-x64": "^0.17.15",
|
||||||
|
@ -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. */
|
/** @file Utilities for manipulating arrays. */
|
||||||
|
|
||||||
|
// ====================
|
||||||
|
// === shallowEqual ===
|
||||||
|
// ====================
|
||||||
|
|
||||||
|
/** Whether both arrays contain the same items. Does not recurse into the items. */
|
||||||
|
export function shallowEqual<T>(a: readonly T[], b: readonly T[]) {
|
||||||
|
return a.length === b.length && a.every((item, i) => item === b[i])
|
||||||
|
}
|
||||||
|
|
||||||
// =========================
|
// =========================
|
||||||
// === includesPredicate ===
|
// === includesPredicate ===
|
||||||
// =========================
|
// =========================
|
@ -1,41 +1,122 @@
|
|||||||
/** @file Parsing and representation of the search query. */
|
/** @file Parsing and representation of the search query. */
|
||||||
|
import * as array from './array'
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// === Regex Helpers ===
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
// Control characters must be handled, in order to follow the JSON spec.
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const JSON_VALUE_REGEX = /"(?:[^\0-\x1f\\"]|\\[\\/bfnrt"]|\\u[0-9a-fA-F]{4})*"?/.source
|
||||||
|
|
||||||
|
/** The regex, with `<json>` replaced with a regex subexpression matching a JSON-escaped search
|
||||||
|
* term. */
|
||||||
|
function interpolateRegex(regex: RegExp) {
|
||||||
|
return new RegExp(regex.source.replace(/<json>/g, JSON_VALUE_REGEX), regex.flags)
|
||||||
|
}
|
||||||
|
|
||||||
// ==================
|
// ==================
|
||||||
// === AssetQuery ===
|
// === AssetQuery ===
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
|
/** Keys of an {@Link AssetQuery} which correspond to tags. */
|
||||||
|
export type AssetQueryKey = Exclude<keyof AssetQuery & `${string}s`, 'withUpdates'>
|
||||||
|
|
||||||
/** An {@link AssetQuery}, without the query and methods. */
|
/** An {@link AssetQuery}, without the query and methods. */
|
||||||
interface AssetQueryData extends Omit<AssetQuery, 'add' | 'query' | 'remove'> {}
|
export interface AssetQueryData extends Record<AssetQueryKey, string[][]> {}
|
||||||
|
|
||||||
|
/** An {@link AssetQuery}, without the query and methods, and with all the values being `string[]`s
|
||||||
|
* instead of `string[][]`s, representing the last term rather than all terms. */
|
||||||
|
export interface AssetQueryLastTermData extends Record<AssetQueryKey, string[]> {}
|
||||||
|
|
||||||
/** An individual segment of a query string input to {@link AssetQuery}. */
|
/** An individual segment of a query string input to {@link AssetQuery}. */
|
||||||
interface AssetQueryTerm {
|
interface AssetQueryTerm {
|
||||||
tag: string | null
|
tag: string | null
|
||||||
value: string
|
values: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parsing and representation of the search query. */
|
/** Parsing and representation of the search query. */
|
||||||
export class AssetQuery {
|
export class AssetQuery {
|
||||||
static termsRegex =
|
static plainValueRegex = interpolateRegex(/^(?:|[^"]\S*)$/)
|
||||||
// Control characters must be handled, in order to follow the JSON spec.
|
static jsonValueRegex = interpolateRegex(/^(<json>)$/)
|
||||||
// eslint-disable-next-line no-control-regex
|
static termsRegex = interpolateRegex(/(?:([^\s:]*):)?((?:(?:<json>|(?:[^,\s"][^,\s]*)),?)*|)/g)
|
||||||
/(?:([^\s:]*):)?(?:("(?:[^\0-\x1f\\"]|\\[\\/bfnrt"]|\\u[0-9a-fA-F]{4})*")|([^\s"]\S*|))/g
|
static valuesRegex = interpolateRegex(/(?:<json>)|(?:[^,\s"][^,\s]*)/g)
|
||||||
static plainValueRegex = /^(?:|[^"]\S*)$/u
|
// `key` MUST be a string literal type.
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
static tagNames = [
|
||||||
|
['keywords', null],
|
||||||
|
['negativeKeywords', '-'],
|
||||||
|
['names', 'name'],
|
||||||
|
['negativeNames', '-name'],
|
||||||
|
['types', 'type'],
|
||||||
|
['negativeTypes', '-type'],
|
||||||
|
['extensions', 'extension'],
|
||||||
|
['negativeExtensions', '-extension'],
|
||||||
|
['descriptions', 'description'],
|
||||||
|
['negativeDescriptions', '-description'],
|
||||||
|
['modifieds', 'modified'],
|
||||||
|
['negativeModifieds', '-modified'],
|
||||||
|
['labels', 'label'],
|
||||||
|
['negativeLabels', '-label'],
|
||||||
|
['owners', 'owner'],
|
||||||
|
['negativeOwners', '-owner'],
|
||||||
|
['nos', 'no'],
|
||||||
|
['negativeNos', 'has'],
|
||||||
|
] as const satisfies readonly (readonly [keyof AssetQueryData, string | null])[]
|
||||||
|
|
||||||
|
query
|
||||||
|
|
||||||
/** Create an {@link AssetQuery}. */
|
/** Create an {@link AssetQuery}. */
|
||||||
constructor(
|
constructor(
|
||||||
readonly query: string,
|
query: string | null,
|
||||||
readonly keywords: string[],
|
readonly keywords: string[][],
|
||||||
readonly labels: string[]
|
readonly negativeKeywords: string[][],
|
||||||
) {}
|
readonly names: string[][],
|
||||||
|
readonly negativeNames: string[][],
|
||||||
|
readonly labels: string[][],
|
||||||
|
readonly negativeLabels: string[][],
|
||||||
|
readonly types: string[][],
|
||||||
|
readonly negativeTypes: string[][],
|
||||||
|
readonly extensions: string[][],
|
||||||
|
readonly negativeExtensions: string[][],
|
||||||
|
readonly descriptions: string[][],
|
||||||
|
readonly negativeDescriptions: string[][],
|
||||||
|
readonly modifieds: string[][],
|
||||||
|
readonly negativeModifieds: string[][],
|
||||||
|
readonly owners: string[][],
|
||||||
|
readonly negativeOwners: string[][],
|
||||||
|
readonly nos: string[][],
|
||||||
|
readonly negativeNos: string[][]
|
||||||
|
) {
|
||||||
|
this.query = query ?? ''
|
||||||
|
if (query == null) {
|
||||||
|
this.query = this.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Return a list of {@link AssetQueryTerm}s found in the raw user input string. */
|
/** Return a list of {@link AssetQueryTerm}s found in the raw user input string. */
|
||||||
static terms(query: string): AssetQueryTerm[] {
|
static terms(query: string): AssetQueryTerm[] {
|
||||||
const terms: AssetQueryTerm[] = []
|
const terms: AssetQueryTerm[] = []
|
||||||
for (const [, tag, jsonValue, plainValue] of query.trim().matchAll(this.termsRegex)) {
|
for (const [, tag, valuesRaw = ''] of query.trim().matchAll(this.termsRegex)) {
|
||||||
if (tag != null || plainValue == null || plainValue !== '') {
|
// Ignore values with a tag but without a value.
|
||||||
|
if (tag != null || valuesRaw !== '') {
|
||||||
|
const values = valuesRaw.match(AssetQuery.valuesRegex) ?? []
|
||||||
terms.push({
|
terms.push({
|
||||||
tag: tag ?? null,
|
tag: tag ?? null,
|
||||||
value: jsonValue != null ? String(JSON.parse(jsonValue)) : plainValue ?? '',
|
values:
|
||||||
|
valuesRaw === ''
|
||||||
|
? []
|
||||||
|
: values.map(value =>
|
||||||
|
AssetQuery.jsonValueRegex.test(value)
|
||||||
|
? String(
|
||||||
|
JSON.parse(
|
||||||
|
value.endsWith('"') && value.length > 1
|
||||||
|
? value
|
||||||
|
: value + '"'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: value
|
||||||
|
),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -45,106 +126,367 @@ export class AssetQuery {
|
|||||||
/** Convert an {@link AssetQueryTerm} to a string usable in a raw user input string. */
|
/** Convert an {@link AssetQueryTerm} to a string usable in a raw user input string. */
|
||||||
static termToString(term: AssetQueryTerm) {
|
static termToString(term: AssetQueryTerm) {
|
||||||
const tagSegment = term.tag == null ? '' : term.tag + ':'
|
const tagSegment = term.tag == null ? '' : term.tag + ':'
|
||||||
const valueSegment = this.plainValueRegex.test(term.value)
|
const valueSegment = term.values
|
||||||
? term.value
|
.map(value => (AssetQuery.plainValueRegex.test(value) ? value : JSON.stringify(value)))
|
||||||
: JSON.stringify(term.value)
|
.join(',')
|
||||||
return tagSegment + valueSegment
|
return tagSegment + valueSegment
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create an {@link AssetQuery} from a raw user input string. */
|
/** Create an {@link AssetQuery} from a raw user input string. */
|
||||||
static fromString(query: string): AssetQuery {
|
static fromString(query: string): AssetQuery {
|
||||||
const terms = AssetQuery.terms(query)
|
const terms = AssetQuery.terms(query)
|
||||||
const keywords = terms
|
const keywords: string[][] = []
|
||||||
.filter(term => term.tag == null || term.tag === '')
|
const negativeKeywords: string[][] = []
|
||||||
.map(term => term.value)
|
const names: string[][] = []
|
||||||
const labels = terms
|
const negativeNames: string[][] = []
|
||||||
.filter(term => term.tag?.toLowerCase() === 'label')
|
const labels: string[][] = []
|
||||||
.map(term => term.value)
|
const negativeLabels: string[][] = []
|
||||||
return new AssetQuery(query, keywords, labels)
|
const types: string[][] = []
|
||||||
|
const negativeTypes: string[][] = []
|
||||||
|
const extensions: string[][] = []
|
||||||
|
const negativeExtensions: string[][] = []
|
||||||
|
const descriptions: string[][] = []
|
||||||
|
const negativeDescriptions: string[][] = []
|
||||||
|
const modifieds: string[][] = []
|
||||||
|
const negativeModifieds: string[][] = []
|
||||||
|
const owners: string[][] = []
|
||||||
|
const negativeOwners: string[][] = []
|
||||||
|
const nos: string[][] = []
|
||||||
|
const negativeNos: string[][] = []
|
||||||
|
const tagNameToSet: Record<string, string[][]> = {
|
||||||
|
// This is a dictionary, not an object.
|
||||||
|
/* eslint-disable @typescript-eslint/naming-convention */
|
||||||
|
'': keywords,
|
||||||
|
'-': negativeKeywords,
|
||||||
|
name: names,
|
||||||
|
'-name': negativeNames,
|
||||||
|
label: labels,
|
||||||
|
'-label': negativeLabels,
|
||||||
|
type: types,
|
||||||
|
'-type': negativeTypes,
|
||||||
|
extension: extensions,
|
||||||
|
'-extension': negativeExtensions,
|
||||||
|
ext: extensions,
|
||||||
|
'-ext': negativeExtensions,
|
||||||
|
description: descriptions,
|
||||||
|
'-description': negativeDescriptions,
|
||||||
|
desc: descriptions,
|
||||||
|
'-desc': negativeDescriptions,
|
||||||
|
modified: modifieds,
|
||||||
|
'-modified': negativeModifieds,
|
||||||
|
owner: owners,
|
||||||
|
'-owner': negativeOwners,
|
||||||
|
no: nos,
|
||||||
|
'-no': negativeNos,
|
||||||
|
has: negativeNos,
|
||||||
|
'-has': nos,
|
||||||
|
/* eslint-enable @typescript-eslint/naming-convention */
|
||||||
|
}
|
||||||
|
for (const term of terms) {
|
||||||
|
const set = term.tag == null ? keywords : tagNameToSet[term.tag]
|
||||||
|
set?.push(term.values)
|
||||||
|
}
|
||||||
|
return new AssetQuery(
|
||||||
|
query,
|
||||||
|
keywords,
|
||||||
|
negativeKeywords,
|
||||||
|
names,
|
||||||
|
negativeNames,
|
||||||
|
labels,
|
||||||
|
negativeLabels,
|
||||||
|
types,
|
||||||
|
negativeTypes,
|
||||||
|
extensions,
|
||||||
|
negativeExtensions,
|
||||||
|
descriptions,
|
||||||
|
negativeDescriptions,
|
||||||
|
modifieds,
|
||||||
|
negativeModifieds,
|
||||||
|
owners,
|
||||||
|
negativeOwners,
|
||||||
|
nos,
|
||||||
|
negativeNos
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return a new array of terms, after applying the given updates. */
|
||||||
|
static updatedTerms(
|
||||||
|
original: string[][],
|
||||||
|
toAdd: string[][] | null,
|
||||||
|
toRemove: string[][] | null
|
||||||
|
) {
|
||||||
|
toAdd = toAdd?.filter(term => term.length !== 0) ?? null
|
||||||
|
toRemove = toRemove?.filter(term => term.length !== 0) ?? null
|
||||||
|
toAdd = toAdd?.length === 0 ? null : toAdd
|
||||||
|
toRemove = toRemove?.length === 0 ? null : toRemove
|
||||||
|
if (toAdd == null && (toRemove == null || original.length === 0)) {
|
||||||
|
return null
|
||||||
|
} else {
|
||||||
|
let changed = false
|
||||||
|
let terms = original
|
||||||
|
if (toAdd != null) {
|
||||||
|
const termsAfterAdditions = [
|
||||||
|
...terms,
|
||||||
|
...toAdd.filter(otherTerm =>
|
||||||
|
terms.every(
|
||||||
|
term => !array.shallowEqual([...term].sort(), [...otherTerm].sort())
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]
|
||||||
|
if (termsAfterAdditions.length !== terms.length) {
|
||||||
|
terms = termsAfterAdditions
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (toRemove != null) {
|
||||||
|
const termsAfterRemovals = terms.filter(
|
||||||
|
term =>
|
||||||
|
toRemove?.every(
|
||||||
|
otherTerm =>
|
||||||
|
!array.shallowEqual([...term].sort(), [...otherTerm].sort())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (termsAfterRemovals.length !== terms.length) {
|
||||||
|
terms = termsAfterRemovals
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return !changed ? null : terms
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return a new array of terms, after applying the given updates to the last term. */
|
||||||
|
static updatedLastTerm(
|
||||||
|
original: string[][],
|
||||||
|
toAdd: string[] | null,
|
||||||
|
toRemove: string[] | null
|
||||||
|
) {
|
||||||
|
toAdd = toAdd?.filter(term => term.length !== 0) ?? null
|
||||||
|
toRemove = toRemove?.filter(term => term.length !== 0) ?? null
|
||||||
|
toAdd = toAdd?.length === 0 ? null : toAdd
|
||||||
|
toRemove = toRemove?.length === 0 ? null : toRemove
|
||||||
|
let lastTerm = original[original.length - 1]
|
||||||
|
if (toAdd == null && (toRemove == null || lastTerm == null || lastTerm.length === 0)) {
|
||||||
|
return null
|
||||||
|
} else {
|
||||||
|
lastTerm ??= []
|
||||||
|
if (lastTerm[lastTerm.length - 1] === '') {
|
||||||
|
lastTerm.pop()
|
||||||
|
}
|
||||||
|
let changed = false
|
||||||
|
if (toAdd != null) {
|
||||||
|
const lastTermAfterAdditions = [
|
||||||
|
...lastTerm,
|
||||||
|
...toAdd.filter(word => lastTerm?.includes(word) === false),
|
||||||
|
]
|
||||||
|
if (lastTermAfterAdditions.length !== lastTerm.length) {
|
||||||
|
lastTerm = lastTermAfterAdditions
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (toRemove != null) {
|
||||||
|
const lastTermAfterRemovals = lastTerm.filter(
|
||||||
|
word => toRemove?.includes(word) === false
|
||||||
|
)
|
||||||
|
if (lastTermAfterRemovals.length !== lastTerm.length) {
|
||||||
|
lastTerm = lastTermAfterRemovals
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return !changed
|
||||||
|
? null
|
||||||
|
: original.slice(0, -1).concat(lastTerm.length !== 0 ? [lastTerm] : [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return a new array of terms, after applying the given updates to the last term. */
|
||||||
|
static updatedEveryTerm(
|
||||||
|
original: string[][],
|
||||||
|
toAdd: string[] | null,
|
||||||
|
toRemove: string[] | null
|
||||||
|
) {
|
||||||
|
toAdd = toAdd?.filter(term => term.length !== 0) ?? null
|
||||||
|
toRemove = toRemove?.filter(term => term.length !== 0) ?? null
|
||||||
|
toAdd = toAdd?.length === 0 ? null : toAdd
|
||||||
|
toRemove = toRemove?.length === 0 ? null : toRemove
|
||||||
|
if (toAdd == null && (toRemove == null || original.length === 0)) {
|
||||||
|
return null
|
||||||
|
} else {
|
||||||
|
const newTerms: string[][] = []
|
||||||
|
let changed = false
|
||||||
|
for (const term of original) {
|
||||||
|
let newTerm = term
|
||||||
|
if (toAdd != null) {
|
||||||
|
const termAfterAdditions = [
|
||||||
|
...newTerm,
|
||||||
|
...toAdd.filter(word => newTerm.includes(word) === false),
|
||||||
|
]
|
||||||
|
if (termAfterAdditions.length !== newTerm.length) {
|
||||||
|
newTerm = termAfterAdditions
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (toRemove != null) {
|
||||||
|
const termAfterRemovals = newTerm.filter(
|
||||||
|
word => toRemove?.includes(word) === false
|
||||||
|
)
|
||||||
|
if (termAfterRemovals.length !== newTerm.length) {
|
||||||
|
newTerm = termAfterRemovals
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newTerm.length !== 0) {
|
||||||
|
newTerms.push(newTerm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return !changed ? null : newTerms
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return a new {@link AssetQuery} with the specified keys overwritten,
|
||||||
|
* or itself if there are no keys to overwrite. */
|
||||||
|
withUpdates(updates: Partial<AssetQueryData>) {
|
||||||
|
if (Object.keys(updates).length === 0) {
|
||||||
|
return this
|
||||||
|
} else {
|
||||||
|
return new AssetQuery(
|
||||||
|
null,
|
||||||
|
updates.keywords ?? this.keywords,
|
||||||
|
updates.negativeKeywords ?? this.negativeKeywords,
|
||||||
|
updates.names ?? this.names,
|
||||||
|
updates.negativeNames ?? this.negativeNames,
|
||||||
|
updates.labels ?? this.labels,
|
||||||
|
updates.negativeLabels ?? this.negativeLabels,
|
||||||
|
updates.types ?? this.types,
|
||||||
|
updates.negativeTypes ?? this.negativeTypes,
|
||||||
|
updates.extensions ?? this.extensions,
|
||||||
|
updates.negativeExtensions ?? this.negativeExtensions,
|
||||||
|
updates.descriptions ?? this.descriptions,
|
||||||
|
updates.negativeDescriptions ?? this.negativeDescriptions,
|
||||||
|
updates.modifieds ?? this.modifieds,
|
||||||
|
updates.negativeModifieds ?? this.negativeModifieds,
|
||||||
|
updates.owners ?? this.owners,
|
||||||
|
updates.negativeOwners ?? this.negativeOwners,
|
||||||
|
updates.nos ?? this.nos,
|
||||||
|
updates.negativeNos ?? this.negativeNos
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return a new {@link AssetQuery} with the specified terms added,
|
/** Return a new {@link AssetQuery} with the specified terms added,
|
||||||
* or itself if there are no terms to remove. */
|
* or itself if there are no terms to add. */
|
||||||
add(values: Partial<AssetQueryData>): AssetQuery {
|
add(values: Partial<AssetQueryData>): AssetQuery {
|
||||||
const { keywords, labels } = values
|
const updates: Partial<AssetQueryData> = {}
|
||||||
const noKeywords = !keywords || keywords.length === 0
|
for (const [key] of AssetQuery.tagNames) {
|
||||||
const noLabels = !labels || labels.length === 0
|
const update = AssetQuery.updatedTerms(this[key], values[key] ?? null, null)
|
||||||
if (noKeywords && noLabels) {
|
if (update != null) {
|
||||||
return this
|
updates[key] = update
|
||||||
} else {
|
|
||||||
const newKeywords = this.keywords
|
|
||||||
let addedKeywords: string[] = []
|
|
||||||
if (!noKeywords) {
|
|
||||||
const keywordsSet = new Set(this.keywords)
|
|
||||||
addedKeywords = keywords.filter(keyword => !keywordsSet.has(keyword))
|
|
||||||
newKeywords.push(...addedKeywords)
|
|
||||||
}
|
}
|
||||||
const newLabels = this.labels
|
|
||||||
let addedLabels: string[] = []
|
|
||||||
if (!noLabels) {
|
|
||||||
const labelsSet = new Set(this.labels)
|
|
||||||
addedLabels = labels.filter(keyword => !labelsSet.has(keyword))
|
|
||||||
newLabels.push(...addedLabels)
|
|
||||||
}
|
|
||||||
const newQuery =
|
|
||||||
this.query +
|
|
||||||
(this.query === '' ? '' : ' ') +
|
|
||||||
[
|
|
||||||
...addedKeywords.map(keyword =>
|
|
||||||
AssetQuery.termToString({ tag: null, value: keyword })
|
|
||||||
),
|
|
||||||
...addedLabels.map(label =>
|
|
||||||
AssetQuery.termToString({ tag: 'label', value: label })
|
|
||||||
),
|
|
||||||
].join(' ')
|
|
||||||
return new AssetQuery(newQuery, newKeywords, newLabels)
|
|
||||||
}
|
}
|
||||||
|
return this.withUpdates(updates)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return a new {@link AssetQuery} with the specified terms removed,
|
/** Return a new {@link AssetQuery} with the specified terms deleted,
|
||||||
* or itself if there are no terms to remove. */
|
* or itself if there are no terms to delete. */
|
||||||
delete(values: Partial<AssetQueryData>): AssetQuery {
|
delete(values: Partial<AssetQueryData>): AssetQuery {
|
||||||
const { keywords, labels } = values
|
const updates: Partial<AssetQueryData> = {}
|
||||||
const noKeywords = !keywords || keywords.length === 0
|
for (const [key] of AssetQuery.tagNames) {
|
||||||
const noLabels = !labels || labels.length === 0
|
const update = AssetQuery.updatedTerms(this[key], null, values[key] ?? null)
|
||||||
if (noKeywords && noLabels) {
|
if (update != null) {
|
||||||
return this
|
updates[key] = update
|
||||||
} else {
|
|
||||||
let newKeywords = this.keywords
|
|
||||||
const keywordsSet = new Set(keywords ?? [])
|
|
||||||
if (!noKeywords) {
|
|
||||||
newKeywords = newKeywords.filter(keyword => !keywordsSet.has(keyword))
|
|
||||||
}
|
|
||||||
let newLabels = this.labels
|
|
||||||
const labelsSet = new Set(labels ?? [])
|
|
||||||
if (!noLabels) {
|
|
||||||
newLabels = newLabels.filter(label => !labelsSet.has(label))
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
newKeywords.length === this.keywords.length &&
|
|
||||||
newLabels.length === this.labels.length
|
|
||||||
) {
|
|
||||||
return this
|
|
||||||
} else {
|
|
||||||
const newQuery = AssetQuery.terms(this.query)
|
|
||||||
.filter(term => {
|
|
||||||
switch (term.tag?.toLowerCase() ?? null) {
|
|
||||||
case null:
|
|
||||||
case '': {
|
|
||||||
return !keywordsSet.has(term.value)
|
|
||||||
}
|
|
||||||
case 'label': {
|
|
||||||
return !labelsSet.has(term.value)
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.map(term => AssetQuery.termToString(term))
|
|
||||||
.join(' ')
|
|
||||||
return new AssetQuery(newQuery, newKeywords, newLabels)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return this.withUpdates(updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return a new {@link AssetQuery} with the specified words added to the last term
|
||||||
|
* with the matching tag, or itself if there are no terms to add. */
|
||||||
|
addToLastTerm(values: Partial<AssetQueryLastTermData>): AssetQuery {
|
||||||
|
const updates: Partial<AssetQueryData> = {}
|
||||||
|
for (const [key] of AssetQuery.tagNames) {
|
||||||
|
const update = AssetQuery.updatedLastTerm(this[key], values[key] ?? null, null)
|
||||||
|
if (update != null) {
|
||||||
|
updates[key] = update
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.withUpdates(updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return a new {@link AssetQuery} with the specified terms deleted from the last term
|
||||||
|
* with the matching tag, or itself if there are no terms to delete. */
|
||||||
|
deleteFromLastTerm(values: Partial<AssetQueryLastTermData>): AssetQuery {
|
||||||
|
const updates: Partial<AssetQueryData> = {}
|
||||||
|
for (const [key] of AssetQuery.tagNames) {
|
||||||
|
const update = AssetQuery.updatedLastTerm(this[key], null, values[key] ?? null)
|
||||||
|
if (update != null) {
|
||||||
|
updates[key] = update
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.withUpdates(updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return a new {@link AssetQuery} with the specified words added to every term
|
||||||
|
* with the matching tag, or itself if there are no terms to add.
|
||||||
|
* Note that this makes little sense to use, but is added for symmetry with
|
||||||
|
* {@link AssetQuery.deleteFromEveryTerm}. */
|
||||||
|
addToEveryTerm(values: Partial<AssetQueryLastTermData>): AssetQuery {
|
||||||
|
const updates: Partial<AssetQueryData> = {}
|
||||||
|
for (const [key] of AssetQuery.tagNames) {
|
||||||
|
const update = AssetQuery.updatedEveryTerm(this[key], values[key] ?? null, null)
|
||||||
|
if (update != null) {
|
||||||
|
updates[key] = update
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.withUpdates(updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return a new {@link AssetQuery} with the specified terms deleted from the last term
|
||||||
|
* with the matching tag, or itself if there are no terms to delete. */
|
||||||
|
deleteFromEveryTerm(values: Partial<AssetQueryLastTermData>): AssetQuery {
|
||||||
|
const updates: Partial<AssetQueryData> = {}
|
||||||
|
for (const [key] of AssetQuery.tagNames) {
|
||||||
|
const update = AssetQuery.updatedEveryTerm(this[key], null, values[key] ?? null)
|
||||||
|
if (update != null) {
|
||||||
|
updates[key] = update
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.withUpdates(updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a string representation usable in the search bar. */
|
||||||
|
toString() {
|
||||||
|
const segments: string[] = []
|
||||||
|
for (const [key, tag] of AssetQuery.tagNames) {
|
||||||
|
for (const values of this[key]) {
|
||||||
|
segments.push(AssetQuery.termToString({ tag, values }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return segments.join(' ')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tries to cycle the label between:
|
||||||
|
* - not present
|
||||||
|
* - present as a positive search, and
|
||||||
|
* - present as a negative search. */
|
||||||
|
export function toggleLabel(query: AssetQuery, label: string, fromLastTerm = false) {
|
||||||
|
let newQuery = query
|
||||||
|
if (fromLastTerm) {
|
||||||
|
newQuery = newQuery.deleteFromLastTerm({ negativeLabels: [label] })
|
||||||
|
if (newQuery === query) {
|
||||||
|
newQuery = newQuery.deleteFromLastTerm({ labels: [label] })
|
||||||
|
newQuery = newQuery.addToLastTerm(
|
||||||
|
newQuery === query ? { labels: [label] } : { negativeLabels: [label] }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newQuery = newQuery.delete({ negativeLabels: [[label]] })
|
||||||
|
if (newQuery === query) {
|
||||||
|
newQuery = newQuery.delete({ labels: [[label]] })
|
||||||
|
newQuery = newQuery.add(
|
||||||
|
newQuery === query ? { labels: [[label]] } : { negativeLabels: [[label]] }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newQuery
|
||||||
|
}
|
||||||
|
@ -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 TimeIcon from 'enso-assets/time.svg'
|
||||||
|
|
||||||
import * as assetEvent from './events/assetEvent'
|
import * as assetEvent from './events/assetEvent'
|
||||||
|
import * as assetQuery from '../assetQuery'
|
||||||
import type * as assetTreeNode from './assetTreeNode'
|
import type * as assetTreeNode from './assetTreeNode'
|
||||||
import * as authProvider from '../authentication/providers/auth'
|
import * as authProvider from '../authentication/providers/auth'
|
||||||
import * as backendModule from './backend'
|
import * as backendModule from './backend'
|
||||||
@ -301,14 +302,11 @@ function LabelsColumn(props: AssetColumnProps) {
|
|||||||
.map(label => (
|
.map(label => (
|
||||||
<Label
|
<Label
|
||||||
key={label}
|
key={label}
|
||||||
|
title="Right click to remove label."
|
||||||
color={labels.get(label)?.color ?? labelModule.DEFAULT_LABEL_COLOR}
|
color={labels.get(label)?.color ?? labelModule.DEFAULT_LABEL_COLOR}
|
||||||
active={!temporarilyRemovedLabels.has(label)}
|
active={!temporarilyRemovedLabels.has(label)}
|
||||||
disabled={temporarilyRemovedLabels.has(label)}
|
disabled={temporarilyRemovedLabels.has(label)}
|
||||||
className={
|
negated={temporarilyRemovedLabels.has(label)}
|
||||||
temporarilyRemovedLabels.has(label)
|
|
||||||
? 'relative before:absolute before:rounded-full before:border-2 before:border-delete before:inset-0 before:w-full before:h-full'
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
onContextMenu={event => {
|
onContextMenu={event => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
@ -357,9 +355,7 @@ function LabelsColumn(props: AssetColumnProps) {
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
setQuery(oldQuery =>
|
setQuery(oldQuery =>
|
||||||
oldQuery.labels.includes(label)
|
assetQuery.toggleLabel(oldQuery, label, event.shiftKey)
|
||||||
? oldQuery.delete({ labels: [label] })
|
|
||||||
: oldQuery.add({ labels: [label] })
|
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -62,6 +62,7 @@ export default function AssetRow(props: AssetRowProps) {
|
|||||||
columns,
|
columns,
|
||||||
} = props
|
} = props
|
||||||
const {
|
const {
|
||||||
|
visibilities,
|
||||||
assetEvents,
|
assetEvents,
|
||||||
dispatchAssetEvent,
|
dispatchAssetEvent,
|
||||||
dispatchAssetListEvent,
|
dispatchAssetListEvent,
|
||||||
@ -79,11 +80,14 @@ export default function AssetRow(props: AssetRowProps) {
|
|||||||
const [item, setItem] = React.useState(rawItem)
|
const [item, setItem] = React.useState(rawItem)
|
||||||
const dragOverTimeoutHandle = React.useRef<number | null>(null)
|
const dragOverTimeoutHandle = React.useRef<number | null>(null)
|
||||||
const asset = item.item
|
const asset = item.item
|
||||||
const [visibility, setVisibility] = React.useState(visibilityModule.Visibility.visible)
|
const [insertionVisibility, setInsertionVisibility] = React.useState(
|
||||||
|
visibilityModule.Visibility.visible
|
||||||
|
)
|
||||||
const [rowState, setRowState] = React.useState<assetsTable.AssetRowState>(() => ({
|
const [rowState, setRowState] = React.useState<assetsTable.AssetRowState>(() => ({
|
||||||
...initialRowState,
|
...initialRowState,
|
||||||
setVisibility,
|
setVisibility: setInsertionVisibility,
|
||||||
}))
|
}))
|
||||||
|
const visibility = visibilities.get(key) ?? insertionVisibility
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setItem(rawItem)
|
setItem(rawItem)
|
||||||
@ -96,10 +100,10 @@ export default function AssetRow(props: AssetRowProps) {
|
|||||||
const setAsset = assetTreeNode.useSetAsset(asset, setItem)
|
const setAsset = assetTreeNode.useSetAsset(asset, setItem)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (selected && visibility !== visibilityModule.Visibility.visible) {
|
if (selected && insertionVisibility !== visibilityModule.Visibility.visible) {
|
||||||
setSelected(false)
|
setSelected(false)
|
||||||
}
|
}
|
||||||
}, [selected, visibility, /* should never change */ setSelected])
|
}, [selected, insertionVisibility, /* should never change */ setSelected])
|
||||||
|
|
||||||
const doMove = React.useCallback(
|
const doMove = React.useCallback(
|
||||||
async (
|
async (
|
||||||
@ -165,7 +169,7 @@ export default function AssetRow(props: AssetRowProps) {
|
|||||||
}, [item, isSoleSelectedItem, /* should never change */ setAssetSettingsPanelProps])
|
}, [item, isSoleSelectedItem, /* should never change */ setAssetSettingsPanelProps])
|
||||||
|
|
||||||
const doDelete = React.useCallback(async () => {
|
const doDelete = React.useCallback(async () => {
|
||||||
setVisibility(visibilityModule.Visibility.hidden)
|
setInsertionVisibility(visibilityModule.Visibility.hidden)
|
||||||
if (asset.type === backendModule.AssetType.directory) {
|
if (asset.type === backendModule.AssetType.directory) {
|
||||||
dispatchAssetListEvent({
|
dispatchAssetListEvent({
|
||||||
type: assetListEventModule.AssetListEventType.closeFolder,
|
type: assetListEventModule.AssetListEventType.closeFolder,
|
||||||
@ -202,7 +206,7 @@ export default function AssetRow(props: AssetRowProps) {
|
|||||||
key: item.key,
|
key: item.key,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setVisibility(visibilityModule.Visibility.visible)
|
setInsertionVisibility(visibilityModule.Visibility.visible)
|
||||||
toastAndLog(
|
toastAndLog(
|
||||||
errorModule.tryGetMessage(error)?.slice(0, -1) ??
|
errorModule.tryGetMessage(error)?.slice(0, -1) ??
|
||||||
`Could not delete ${backendModule.ASSET_TYPE_NAME[asset.type]}`
|
`Could not delete ${backendModule.ASSET_TYPE_NAME[asset.type]}`
|
||||||
@ -218,7 +222,7 @@ export default function AssetRow(props: AssetRowProps) {
|
|||||||
|
|
||||||
const doRestore = React.useCallback(async () => {
|
const doRestore = React.useCallback(async () => {
|
||||||
// Visually, the asset is deleted from the Trash view.
|
// Visually, the asset is deleted from the Trash view.
|
||||||
setVisibility(visibilityModule.Visibility.hidden)
|
setInsertionVisibility(visibilityModule.Visibility.hidden)
|
||||||
try {
|
try {
|
||||||
await backend.undoDeleteAsset(asset.id, asset.title)
|
await backend.undoDeleteAsset(asset.id, asset.title)
|
||||||
dispatchAssetListEvent({
|
dispatchAssetListEvent({
|
||||||
@ -226,7 +230,7 @@ export default function AssetRow(props: AssetRowProps) {
|
|||||||
key: item.key,
|
key: item.key,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setVisibility(visibilityModule.Visibility.visible)
|
setInsertionVisibility(visibilityModule.Visibility.visible)
|
||||||
toastAndLog(`Unable to restore ${backendModule.ASSET_TYPE_NAME[asset.type]}`, error)
|
toastAndLog(`Unable to restore ${backendModule.ASSET_TYPE_NAME[asset.type]}`, error)
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
@ -251,19 +255,19 @@ export default function AssetRow(props: AssetRowProps) {
|
|||||||
}
|
}
|
||||||
case assetEventModule.AssetEventType.cut: {
|
case assetEventModule.AssetEventType.cut: {
|
||||||
if (event.ids.has(item.key)) {
|
if (event.ids.has(item.key)) {
|
||||||
setVisibility(visibilityModule.Visibility.faded)
|
setInsertionVisibility(visibilityModule.Visibility.faded)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case assetEventModule.AssetEventType.cancelCut: {
|
case assetEventModule.AssetEventType.cancelCut: {
|
||||||
if (event.ids.has(item.key)) {
|
if (event.ids.has(item.key)) {
|
||||||
setVisibility(visibilityModule.Visibility.visible)
|
setInsertionVisibility(visibilityModule.Visibility.visible)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case assetEventModule.AssetEventType.move: {
|
case assetEventModule.AssetEventType.move: {
|
||||||
if (event.ids.has(item.key)) {
|
if (event.ids.has(item.key)) {
|
||||||
setVisibility(visibilityModule.Visibility.visible)
|
setInsertionVisibility(visibilityModule.Visibility.visible)
|
||||||
await doMove(event.newParentKey, event.newParentId)
|
await doMove(event.newParentKey, event.newParentId)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
@ -301,7 +305,7 @@ export default function AssetRow(props: AssetRowProps) {
|
|||||||
case assetEventModule.AssetEventType.removeSelf: {
|
case assetEventModule.AssetEventType.removeSelf: {
|
||||||
// This is not triggered from the asset list, so it uses `item.id` instead of `key`.
|
// This is not triggered from the asset list, so it uses `item.id` instead of `key`.
|
||||||
if (event.id === asset.id && user != null) {
|
if (event.id === asset.id && user != null) {
|
||||||
setVisibility(visibilityModule.Visibility.hidden)
|
setInsertionVisibility(visibilityModule.Visibility.hidden)
|
||||||
try {
|
try {
|
||||||
await backend.createPermission({
|
await backend.createPermission({
|
||||||
action: null,
|
action: null,
|
||||||
@ -313,7 +317,7 @@ export default function AssetRow(props: AssetRowProps) {
|
|||||||
key: item.key,
|
key: item.key,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setVisibility(visibilityModule.Visibility.visible)
|
setInsertionVisibility(visibilityModule.Visibility.visible)
|
||||||
toastAndLog(null, error)
|
toastAndLog(null, error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -435,7 +439,9 @@ export default function AssetRow(props: AssetRowProps) {
|
|||||||
isDraggedOver ? 'selected' : ''
|
isDraggedOver ? 'selected' : ''
|
||||||
}`}
|
}`}
|
||||||
{...props}
|
{...props}
|
||||||
hidden={hidden || visibility === visibilityModule.Visibility.hidden}
|
hidden={
|
||||||
|
hidden || insertionVisibility === visibilityModule.Visibility.hidden
|
||||||
|
}
|
||||||
onContextMenu={(innerProps, event) => {
|
onContextMenu={(innerProps, event) => {
|
||||||
if (allowContextMenu) {
|
if (allowContextMenu) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@ -530,7 +536,7 @@ export default function AssetRow(props: AssetRowProps) {
|
|||||||
/>
|
/>
|
||||||
{selected &&
|
{selected &&
|
||||||
allowContextMenu &&
|
allowContextMenu &&
|
||||||
visibility !== visibilityModule.Visibility.hidden && (
|
insertionVisibility !== visibilityModule.Visibility.hidden && (
|
||||||
// This is a copy of the context menu, since the context menu registers keyboard
|
// This is a copy of the context menu, since the context menu registers keyboard
|
||||||
// shortcut handlers. This is a bit of a hack, however it is preferable to duplicating
|
// shortcut handlers. This is a bit of a hack, however it is preferable to duplicating
|
||||||
// the entire context menu (once for the keyboard actions, once for the JSX).
|
// the entire context menu (once for the keyboard actions, once for the JSX).
|
||||||
|
@ -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 React from 'react'
|
||||||
import * as toast from 'react-toastify'
|
import * as toast from 'react-toastify'
|
||||||
|
|
||||||
import * as array from '../array'
|
import * as array from '../../array'
|
||||||
import * as assetEventModule from '../events/assetEvent'
|
import * as assetEventModule from '../events/assetEvent'
|
||||||
import * as assetListEventModule from '../events/assetListEvent'
|
import * as assetListEventModule from '../events/assetListEvent'
|
||||||
import type * as assetQuery from '../../assetQuery'
|
import * as assetQuery from '../../assetQuery'
|
||||||
import * as assetTreeNode from '../assetTreeNode'
|
import * as assetTreeNode from '../assetTreeNode'
|
||||||
import * as backendModule from '../backend'
|
import * as backendModule from '../backend'
|
||||||
import * as columnModule from '../column'
|
import * as columnModule from '../column'
|
||||||
import * as dateTime from '../dateTime'
|
import * as dateTime from '../dateTime'
|
||||||
import * as drag from '../drag'
|
import * as drag from '../drag'
|
||||||
|
import * as fileInfo from '../../fileInfo'
|
||||||
import * as hooks from '../../hooks'
|
import * as hooks from '../../hooks'
|
||||||
import * as localStorageModule from '../localStorage'
|
import * as localStorageModule from '../localStorage'
|
||||||
import * as localStorageProvider from '../../providers/localStorage'
|
import * as localStorageProvider from '../../providers/localStorage'
|
||||||
@ -21,12 +22,13 @@ import * as shortcutsProvider from '../../providers/shortcuts'
|
|||||||
import * as sorting from '../sorting'
|
import * as sorting from '../sorting'
|
||||||
import * as string from '../../string'
|
import * as string from '../../string'
|
||||||
import * as uniqueString from '../../uniqueString'
|
import * as uniqueString from '../../uniqueString'
|
||||||
import type * as visibilityModule from '../visibility'
|
import * as visibilityModule from '../visibility'
|
||||||
|
|
||||||
import * as authProvider from '../../authentication/providers/auth'
|
import * as authProvider from '../../authentication/providers/auth'
|
||||||
import * as backendProvider from '../../providers/backend'
|
import * as backendProvider from '../../providers/backend'
|
||||||
import * as modalProvider from '../../providers/modal'
|
import * as modalProvider from '../../providers/modal'
|
||||||
|
|
||||||
|
import type * as assetSearchBar from './assetSearchBar'
|
||||||
import type * as assetSettingsPanel from './assetSettingsPanel'
|
import type * as assetSettingsPanel from './assetSettingsPanel'
|
||||||
import * as categorySwitcher from './categorySwitcher'
|
import * as categorySwitcher from './categorySwitcher'
|
||||||
import AssetNameColumn from './assetNameColumn'
|
import AssetNameColumn from './assetNameColumn'
|
||||||
@ -37,6 +39,7 @@ import ContextMenu from './contextMenu'
|
|||||||
import ContextMenus from './contextMenus'
|
import ContextMenus from './contextMenus'
|
||||||
import DragModal from './dragModal'
|
import DragModal from './dragModal'
|
||||||
import GlobalContextMenu from './globalContextMenu'
|
import GlobalContextMenu from './globalContextMenu'
|
||||||
|
import Label from './label'
|
||||||
import MenuEntry from './menuEntry'
|
import MenuEntry from './menuEntry'
|
||||||
import Table from './table'
|
import Table from './table'
|
||||||
|
|
||||||
@ -77,6 +80,75 @@ const DIRECTORY_NAME_REGEX = /^New_Folder_(?<directoryIndex>\d+)$/
|
|||||||
/** The default prefix of an automatically generated directory. */
|
/** The default prefix of an automatically generated directory. */
|
||||||
const DIRECTORY_NAME_DEFAULT_PREFIX = 'New_Folder_'
|
const DIRECTORY_NAME_DEFAULT_PREFIX = 'New_Folder_'
|
||||||
|
|
||||||
|
const SUGGESTIONS_FOR_NO: assetSearchBar.Suggestion[] = [
|
||||||
|
{
|
||||||
|
render: () => 'no:label',
|
||||||
|
addToQuery: query => query.addToLastTerm({ nos: ['label'] }),
|
||||||
|
deleteFromQuery: query => query.deleteFromLastTerm({ nos: ['label'] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
render: () => 'no:description',
|
||||||
|
addToQuery: query => query.addToLastTerm({ nos: ['description'] }),
|
||||||
|
deleteFromQuery: query => query.deleteFromLastTerm({ nos: ['description'] }),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const SUGGESTIONS_FOR_HAS: assetSearchBar.Suggestion[] = [
|
||||||
|
{
|
||||||
|
render: () => 'has:label',
|
||||||
|
addToQuery: query => query.addToLastTerm({ negativeNos: ['label'] }),
|
||||||
|
deleteFromQuery: query => query.deleteFromLastTerm({ negativeNos: ['label'] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
render: () => 'has:description',
|
||||||
|
addToQuery: query => query.addToLastTerm({ negativeNos: ['description'] }),
|
||||||
|
deleteFromQuery: query => query.deleteFromLastTerm({ negativeNos: ['description'] }),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const SUGGESTIONS_FOR_TYPE: assetSearchBar.Suggestion[] = [
|
||||||
|
{
|
||||||
|
render: () => 'type:project',
|
||||||
|
addToQuery: query => query.addToLastTerm({ types: ['project'] }),
|
||||||
|
deleteFromQuery: query => query.deleteFromLastTerm({ types: ['project'] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
render: () => 'type:folder',
|
||||||
|
addToQuery: query => query.addToLastTerm({ types: ['folder'] }),
|
||||||
|
deleteFromQuery: query => query.deleteFromLastTerm({ types: ['folder'] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
render: () => 'type:file',
|
||||||
|
addToQuery: query => query.addToLastTerm({ types: ['file'] }),
|
||||||
|
deleteFromQuery: query => query.deleteFromLastTerm({ types: ['file'] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
render: () => 'type:connector',
|
||||||
|
addToQuery: query => query.addToLastTerm({ types: ['connector'] }),
|
||||||
|
deleteFromQuery: query => query.deleteFromLastTerm({ types: ['connector'] }),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const SUGGESTIONS_FOR_NEGATIVE_TYPE: assetSearchBar.Suggestion[] = [
|
||||||
|
{
|
||||||
|
render: () => 'type:project',
|
||||||
|
addToQuery: query => query.addToLastTerm({ negativeTypes: ['project'] }),
|
||||||
|
deleteFromQuery: query => query.deleteFromLastTerm({ negativeTypes: ['project'] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
render: () => 'type:folder',
|
||||||
|
addToQuery: query => query.addToLastTerm({ negativeTypes: ['folder'] }),
|
||||||
|
deleteFromQuery: query => query.deleteFromLastTerm({ negativeTypes: ['folder'] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
render: () => 'type:file',
|
||||||
|
addToQuery: query => query.addToLastTerm({ negativeTypes: ['file'] }),
|
||||||
|
deleteFromQuery: query => query.deleteFromLastTerm({ negativeTypes: ['file'] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
render: () => 'type:connector',
|
||||||
|
addToQuery: query => query.addToLastTerm({ negativeTypes: ['connector'] }),
|
||||||
|
deleteFromQuery: query => query.deleteFromLastTerm({ negativeTypes: ['connector'] }),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
// ===================================
|
// ===================================
|
||||||
// === insertAssetTreeNodeChildren ===
|
// === insertAssetTreeNodeChildren ===
|
||||||
// ===================================
|
// ===================================
|
||||||
@ -142,6 +214,7 @@ const CATEGORY_TO_FILTER_BY: Record<categorySwitcher.Category, backendModule.Fil
|
|||||||
/** State passed through from a {@link AssetsTable} to every cell. */
|
/** State passed through from a {@link AssetsTable} to every cell. */
|
||||||
export interface AssetsTableState {
|
export interface AssetsTableState {
|
||||||
numberOfSelectedItems: number
|
numberOfSelectedItems: number
|
||||||
|
visibilities: ReadonlyMap<backendModule.AssetId, visibilityModule.Visibility>
|
||||||
category: categorySwitcher.Category
|
category: categorySwitcher.Category
|
||||||
labels: Map<backendModule.LabelName, backendModule.Label>
|
labels: Map<backendModule.LabelName, backendModule.Label>
|
||||||
deletedLabelNames: Set<backendModule.LabelName>
|
deletedLabelNames: Set<backendModule.LabelName>
|
||||||
@ -208,6 +281,7 @@ export interface AssetsTableProps {
|
|||||||
setQuery: React.Dispatch<React.SetStateAction<assetQuery.AssetQuery>>
|
setQuery: React.Dispatch<React.SetStateAction<assetQuery.AssetQuery>>
|
||||||
category: categorySwitcher.Category
|
category: categorySwitcher.Category
|
||||||
allLabels: Map<backendModule.LabelName, backendModule.Label>
|
allLabels: Map<backendModule.LabelName, backendModule.Label>
|
||||||
|
setSuggestions: (suggestions: assetSearchBar.Suggestion[]) => void
|
||||||
initialProjectName: string | null
|
initialProjectName: string | null
|
||||||
projectStartupInfo: backendModule.ProjectStartupInfo | null
|
projectStartupInfo: backendModule.ProjectStartupInfo | null
|
||||||
deletedLabelNames: Set<backendModule.LabelName>
|
deletedLabelNames: Set<backendModule.LabelName>
|
||||||
@ -241,6 +315,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
setQuery,
|
setQuery,
|
||||||
category,
|
category,
|
||||||
allLabels,
|
allLabels,
|
||||||
|
setSuggestions,
|
||||||
deletedLabelNames,
|
deletedLabelNames,
|
||||||
initialProjectName,
|
initialProjectName,
|
||||||
projectStartupInfo,
|
projectStartupInfo,
|
||||||
@ -288,15 +363,111 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
[backend, organization]
|
[backend, organization]
|
||||||
)
|
)
|
||||||
const filter = React.useMemo(() => {
|
const filter = React.useMemo(() => {
|
||||||
if (query.query === '') {
|
const globCache: Record<string, RegExp> = {}
|
||||||
|
if (/^\s*$/.test(query.query)) {
|
||||||
return null
|
return null
|
||||||
} else {
|
} else {
|
||||||
return (node: assetTreeNode.AssetTreeNode) => {
|
return (node: assetTreeNode.AssetTreeNode) => {
|
||||||
|
const assetType =
|
||||||
|
node.item.type === backendModule.AssetType.directory
|
||||||
|
? 'folder'
|
||||||
|
: node.item.type === backendModule.AssetType.secret
|
||||||
|
? 'connector'
|
||||||
|
: String(node.item.type)
|
||||||
|
const assetExtension =
|
||||||
|
node.item.type !== backendModule.AssetType.file
|
||||||
|
? null
|
||||||
|
: fileInfo.fileExtension(node.item.title).toLowerCase()
|
||||||
|
const assetModifiedAt = new Date(node.item.modifiedAt)
|
||||||
const labels: string[] = node.item.labels ?? []
|
const labels: string[] = node.item.labels ?? []
|
||||||
const lowercaseName = node.item.title.toLowerCase()
|
const lowercaseName = node.item.title.toLowerCase()
|
||||||
|
const lowercaseDescription = node.item.description?.toLowerCase() ?? ''
|
||||||
|
const owners =
|
||||||
|
node.item.permissions
|
||||||
|
?.filter(
|
||||||
|
permission => permission.permission === permissions.PermissionAction.own
|
||||||
|
)
|
||||||
|
.map(owner => owner.user.user_name) ?? []
|
||||||
|
const globMatch = (glob: string, match: string) => {
|
||||||
|
const regex = (globCache[glob] =
|
||||||
|
globCache[glob] ??
|
||||||
|
new RegExp(
|
||||||
|
'^' + string.regexEscape(glob).replace(/(?:\\\*)+/g, '.*') + '$',
|
||||||
|
'i'
|
||||||
|
))
|
||||||
|
return regex.test(match)
|
||||||
|
}
|
||||||
|
const isAbsent = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'label':
|
||||||
|
case 'labels': {
|
||||||
|
return labels.length === 0
|
||||||
|
}
|
||||||
|
case 'name': {
|
||||||
|
// Should never be true, but handle it just in case.
|
||||||
|
return lowercaseName === ''
|
||||||
|
}
|
||||||
|
case 'description': {
|
||||||
|
return lowercaseDescription === ''
|
||||||
|
}
|
||||||
|
case 'extension': {
|
||||||
|
// Should never be true, but handle it just in case.
|
||||||
|
return assetExtension === ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Things like `no:name` and `no:owner` are never true.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const parseDate = (date: string) => {
|
||||||
|
const lowercase = date.toLowerCase()
|
||||||
|
switch (lowercase) {
|
||||||
|
case 'today': {
|
||||||
|
return new Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Date(date)
|
||||||
|
}
|
||||||
|
const matchesDate = (date: string) => {
|
||||||
|
const parsed = parseDate(date)
|
||||||
|
return (
|
||||||
|
parsed.getFullYear() === assetModifiedAt.getFullYear() &&
|
||||||
|
parsed.getMonth() === assetModifiedAt.getMonth() &&
|
||||||
|
parsed.getDate() === assetModifiedAt.getDate()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const isEmpty = (values: string[]) =>
|
||||||
|
values.length === 0 || (values.length === 1 && values[0] === '')
|
||||||
|
const filterTag = (
|
||||||
|
positive: string[][],
|
||||||
|
negative: string[][],
|
||||||
|
predicate: (value: string) => boolean
|
||||||
|
) =>
|
||||||
|
positive.every(values => isEmpty(values) || values.some(predicate)) &&
|
||||||
|
negative.every(values => !values.some(predicate))
|
||||||
return (
|
return (
|
||||||
query.labels.every(label => labels.includes(label)) &&
|
filterTag(query.nos, query.negativeNos, no => isAbsent(no.toLowerCase())) &&
|
||||||
query.keywords.every(keyword => lowercaseName.includes(keyword.toLowerCase()))
|
filterTag(query.keywords, query.negativeKeywords, keyword =>
|
||||||
|
lowercaseName.includes(keyword.toLowerCase())
|
||||||
|
) &&
|
||||||
|
filterTag(query.names, query.negativeNames, name =>
|
||||||
|
globMatch(name, lowercaseName)
|
||||||
|
) &&
|
||||||
|
filterTag(query.labels, query.negativeLabels, label =>
|
||||||
|
labels.some(assetLabel => globMatch(label, assetLabel))
|
||||||
|
) &&
|
||||||
|
filterTag(query.types, query.negativeTypes, type => type === assetType) &&
|
||||||
|
filterTag(
|
||||||
|
query.extensions,
|
||||||
|
query.negativeExtensions,
|
||||||
|
extension => extension.toLowerCase() === assetExtension
|
||||||
|
) &&
|
||||||
|
filterTag(query.descriptions, query.negativeDescriptions, description =>
|
||||||
|
lowercaseDescription.includes(description.toLowerCase())
|
||||||
|
) &&
|
||||||
|
filterTag(query.modifieds, query.negativeModifieds, matchesDate) &&
|
||||||
|
filterTag(query.owners, query.negativeOwners, owner =>
|
||||||
|
owners.some(assetOwner => globMatch(owner, assetOwner))
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -335,6 +506,231 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}, [assetTree, sortColumn, sortDirection])
|
}, [assetTree, sortColumn, sortDirection])
|
||||||
|
const visibilities = React.useMemo(() => {
|
||||||
|
const map = new Map<backendModule.AssetId, visibilityModule.Visibility>()
|
||||||
|
const processNode = (node: assetTreeNode.AssetTreeNode) => {
|
||||||
|
let displayState = visibilityModule.Visibility.hidden
|
||||||
|
const visible = filter?.(node) ?? true
|
||||||
|
for (const child of node.children ?? []) {
|
||||||
|
if (visible && child.item.type === backendModule.AssetType.specialEmpty) {
|
||||||
|
map.set(child.key, visibilityModule.Visibility.visible)
|
||||||
|
} else {
|
||||||
|
processNode(child)
|
||||||
|
}
|
||||||
|
if (map.get(child.key) !== visibilityModule.Visibility.hidden) {
|
||||||
|
displayState = visibilityModule.Visibility.faded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (visible) {
|
||||||
|
displayState = visibilityModule.Visibility.visible
|
||||||
|
}
|
||||||
|
map.set(node.key, displayState)
|
||||||
|
return displayState
|
||||||
|
}
|
||||||
|
for (const topLevelNode of assetTree) {
|
||||||
|
processNode(topLevelNode)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [assetTree, filter])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const nodeToSuggestion = (
|
||||||
|
node: assetTreeNode.AssetTreeNode,
|
||||||
|
key: assetQuery.AssetQueryKey = 'names'
|
||||||
|
): assetSearchBar.Suggestion => ({
|
||||||
|
render: () => `${key === 'names' ? '' : '-:'}${node.item.title}`,
|
||||||
|
addToQuery: oldQuery => oldQuery.addToLastTerm({ [key]: [node.item.title] }),
|
||||||
|
deleteFromQuery: oldQuery => oldQuery.deleteFromLastTerm({ [key]: [node.item.title] }),
|
||||||
|
})
|
||||||
|
const allVisibleNodes = () =>
|
||||||
|
assetTreeNode
|
||||||
|
.assetTreePreorderTraversal(assetTree, children =>
|
||||||
|
children.filter(
|
||||||
|
child => visibilities.get(child.key) !== visibilityModule.Visibility.hidden
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
node =>
|
||||||
|
visibilities.get(node.key) === visibilityModule.Visibility.visible &&
|
||||||
|
node.item.type !== backendModule.AssetType.specialEmpty &&
|
||||||
|
node.item.type !== backendModule.AssetType.specialLoading
|
||||||
|
)
|
||||||
|
const allVisible = (negative = false) =>
|
||||||
|
allVisibleNodes().map(node =>
|
||||||
|
nodeToSuggestion(node, negative ? 'negativeNames' : 'names')
|
||||||
|
)
|
||||||
|
const terms = assetQuery.AssetQuery.terms(query.query)
|
||||||
|
const lastTerm = terms[terms.length - 1]
|
||||||
|
const lastTermValues = lastTerm?.values ?? []
|
||||||
|
const shouldOmitNames = terms.some(term => term.tag === 'name')
|
||||||
|
if (lastTermValues.length !== 0) {
|
||||||
|
setSuggestions(shouldOmitNames ? [] : allVisible())
|
||||||
|
} else {
|
||||||
|
const negative = lastTerm?.tag?.startsWith('-') ?? false
|
||||||
|
switch (lastTerm?.tag ?? null) {
|
||||||
|
case null:
|
||||||
|
case '':
|
||||||
|
case '-':
|
||||||
|
case 'name':
|
||||||
|
case '-name': {
|
||||||
|
setSuggestions(allVisible(negative))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'no':
|
||||||
|
case '-has': {
|
||||||
|
setSuggestions(SUGGESTIONS_FOR_NO)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'has':
|
||||||
|
case '-no': {
|
||||||
|
setSuggestions(SUGGESTIONS_FOR_HAS)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'type': {
|
||||||
|
setSuggestions(SUGGESTIONS_FOR_TYPE)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case '-type': {
|
||||||
|
setSuggestions(SUGGESTIONS_FOR_NEGATIVE_TYPE)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'ext':
|
||||||
|
case '-ext':
|
||||||
|
case 'extension':
|
||||||
|
case '-extension': {
|
||||||
|
const extensions = allVisibleNodes()
|
||||||
|
.filter(node => node.item.type === backendModule.AssetType.file)
|
||||||
|
.map(node => fileInfo.fileExtension(node.item.title))
|
||||||
|
setSuggestions(
|
||||||
|
Array.from(
|
||||||
|
new Set(extensions),
|
||||||
|
(extension): assetSearchBar.Suggestion => ({
|
||||||
|
render: () =>
|
||||||
|
assetQuery.AssetQuery.termToString({
|
||||||
|
tag: `${negative ? '-' : ''}extension`,
|
||||||
|
values: [extension],
|
||||||
|
}),
|
||||||
|
addToQuery: oldQuery =>
|
||||||
|
oldQuery.addToLastTerm(
|
||||||
|
negative
|
||||||
|
? { negativeExtensions: [extension] }
|
||||||
|
: { extensions: [extension] }
|
||||||
|
),
|
||||||
|
deleteFromQuery: oldQuery =>
|
||||||
|
oldQuery.deleteFromLastTerm(
|
||||||
|
negative
|
||||||
|
? { negativeExtensions: [extension] }
|
||||||
|
: { extensions: [extension] }
|
||||||
|
),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'modified':
|
||||||
|
case '-modified': {
|
||||||
|
const modifieds = assetTreeNode
|
||||||
|
.assetTreePreorderTraversal(assetTree)
|
||||||
|
.map(node => {
|
||||||
|
const date = new Date(node.item.modifiedAt)
|
||||||
|
return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`
|
||||||
|
})
|
||||||
|
setSuggestions(
|
||||||
|
Array.from(
|
||||||
|
new Set(['today', ...modifieds]),
|
||||||
|
(modified): assetSearchBar.Suggestion => ({
|
||||||
|
render: () =>
|
||||||
|
assetQuery.AssetQuery.termToString({
|
||||||
|
tag: `${negative ? '-' : ''}modified`,
|
||||||
|
values: [modified],
|
||||||
|
}),
|
||||||
|
addToQuery: oldQuery =>
|
||||||
|
oldQuery.addToLastTerm(
|
||||||
|
negative
|
||||||
|
? { negativeModifieds: [modified] }
|
||||||
|
: { modifieds: [modified] }
|
||||||
|
),
|
||||||
|
deleteFromQuery: oldQuery =>
|
||||||
|
oldQuery.deleteFromLastTerm(
|
||||||
|
negative
|
||||||
|
? { negativeModifieds: [modified] }
|
||||||
|
: { modifieds: [modified] }
|
||||||
|
),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'owner':
|
||||||
|
case '-owner': {
|
||||||
|
const owners = assetTreeNode
|
||||||
|
.assetTreePreorderTraversal(assetTree)
|
||||||
|
.flatMap(node =>
|
||||||
|
(node.item.permissions ?? [])
|
||||||
|
.filter(
|
||||||
|
permission =>
|
||||||
|
permission.permission === permissions.PermissionAction.own
|
||||||
|
)
|
||||||
|
.map(permission => permission.user.user_name)
|
||||||
|
)
|
||||||
|
setSuggestions(
|
||||||
|
Array.from(
|
||||||
|
new Set(owners),
|
||||||
|
(owner): assetSearchBar.Suggestion => ({
|
||||||
|
render: () =>
|
||||||
|
assetQuery.AssetQuery.termToString({
|
||||||
|
tag: `${negative ? '-' : ''}owner`,
|
||||||
|
values: [owner],
|
||||||
|
}),
|
||||||
|
addToQuery: oldQuery =>
|
||||||
|
oldQuery.addToLastTerm(
|
||||||
|
negative ? { negativeOwners: [owner] } : { owners: [owner] }
|
||||||
|
),
|
||||||
|
deleteFromQuery: oldQuery =>
|
||||||
|
oldQuery.deleteFromLastTerm(
|
||||||
|
negative ? { negativeOwners: [owner] } : { owners: [owner] }
|
||||||
|
),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'label':
|
||||||
|
case '-label': {
|
||||||
|
setSuggestions(
|
||||||
|
Array.from(
|
||||||
|
allLabels.values(),
|
||||||
|
(label): assetSearchBar.Suggestion => ({
|
||||||
|
render: () => (
|
||||||
|
<Label active color={label.color} onClick={() => {}}>
|
||||||
|
{label.value}
|
||||||
|
</Label>
|
||||||
|
),
|
||||||
|
addToQuery: oldQuery =>
|
||||||
|
oldQuery.addToLastTerm(
|
||||||
|
negative
|
||||||
|
? { negativeLabels: [label.value] }
|
||||||
|
: { labels: [label.value] }
|
||||||
|
),
|
||||||
|
deleteFromQuery: oldQuery =>
|
||||||
|
oldQuery.deleteFromLastTerm(
|
||||||
|
negative
|
||||||
|
? { negativeLabels: [label.value] }
|
||||||
|
: { labels: [label.value] }
|
||||||
|
),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
setSuggestions(shouldOmitNames ? [] : allVisible())
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [assetTree, query, visibilities, allLabels, /* should never change */ setSuggestions])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (rawQueuedAssetEvents.length !== 0) {
|
if (rawQueuedAssetEvents.length !== 0) {
|
||||||
@ -1307,6 +1703,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
const state = React.useMemo(
|
const state = React.useMemo(
|
||||||
// The type MUST be here to trigger excess property errors at typecheck time.
|
// The type MUST be here to trigger excess property errors at typecheck time.
|
||||||
(): AssetsTableState => ({
|
(): AssetsTableState => ({
|
||||||
|
visibilities,
|
||||||
numberOfSelectedItems: selectedKeys.size,
|
numberOfSelectedItems: selectedKeys.size,
|
||||||
category,
|
category,
|
||||||
labels: allLabels,
|
labels: allLabels,
|
||||||
@ -1333,6 +1730,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
doPaste,
|
doPaste,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
|
visibilities,
|
||||||
selectedKeys.size,
|
selectedKeys.size,
|
||||||
category,
|
category,
|
||||||
allLabels,
|
allLabels,
|
||||||
@ -1428,7 +1826,9 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
}
|
}
|
||||||
rowComponent={AssetRow}
|
rowComponent={AssetRow}
|
||||||
items={displayItems}
|
items={displayItems}
|
||||||
filter={filter}
|
filter={node =>
|
||||||
|
visibilities.get(node.key) !== visibilityModule.Visibility.hidden
|
||||||
|
}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
state={state}
|
state={state}
|
||||||
initialRowState={INITIAL_ROW_STATE}
|
initialRowState={INITIAL_ROW_STATE}
|
||||||
|
@ -22,6 +22,7 @@ import * as loggerProvider from '../../providers/logger'
|
|||||||
import * as modalProvider from '../../providers/modal'
|
import * as modalProvider from '../../providers/modal'
|
||||||
import * as shortcutsProvider from '../../providers/shortcuts'
|
import * as shortcutsProvider from '../../providers/shortcuts'
|
||||||
|
|
||||||
|
import type * as assetSearchBar from './assetSearchBar'
|
||||||
import type * as assetSettingsPanel from './assetSettingsPanel'
|
import type * as assetSettingsPanel from './assetSettingsPanel'
|
||||||
import * as pageSwitcher from './pageSwitcher'
|
import * as pageSwitcher from './pageSwitcher'
|
||||||
import type * as spinner from './spinner'
|
import type * as spinner from './spinner'
|
||||||
@ -64,7 +65,6 @@ export default function Dashboard(props: DashboardProps) {
|
|||||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||||
const { shortcuts } = shortcutsProvider.useShortcuts()
|
const { shortcuts } = shortcutsProvider.useShortcuts()
|
||||||
const [initialized, setInitialized] = React.useState(false)
|
const [initialized, setInitialized] = React.useState(false)
|
||||||
const [query, setQuery] = React.useState(() => assetQuery.AssetQuery.fromString(''))
|
|
||||||
const [isHelpChatOpen, setIsHelpChatOpen] = React.useState(false)
|
const [isHelpChatOpen, setIsHelpChatOpen] = React.useState(false)
|
||||||
const [isHelpChatVisible, setIsHelpChatVisible] = React.useState(false)
|
const [isHelpChatVisible, setIsHelpChatVisible] = React.useState(false)
|
||||||
const [loadingProjectManagerDidFail, setLoadingProjectManagerDidFail] = React.useState(false)
|
const [loadingProjectManagerDidFail, setLoadingProjectManagerDidFail] = React.useState(false)
|
||||||
@ -74,6 +74,9 @@ export default function Dashboard(props: DashboardProps) {
|
|||||||
const [queuedAssetEvents, setQueuedAssetEvents] = React.useState<assetEventModule.AssetEvent[]>(
|
const [queuedAssetEvents, setQueuedAssetEvents] = React.useState<assetEventModule.AssetEvent[]>(
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
const [query, setQuery] = React.useState(() => assetQuery.AssetQuery.fromString(''))
|
||||||
|
const [labels, setLabels] = React.useState<backendModule.Label[]>([])
|
||||||
|
const [suggestions, setSuggestions] = React.useState<assetSearchBar.Suggestion[]>([])
|
||||||
const [projectStartupInfo, setProjectStartupInfo] =
|
const [projectStartupInfo, setProjectStartupInfo] =
|
||||||
React.useState<backendModule.ProjectStartupInfo | null>(null)
|
React.useState<backendModule.ProjectStartupInfo | null>(null)
|
||||||
const [openProjectAbortController, setOpenProjectAbortController] =
|
const [openProjectAbortController, setOpenProjectAbortController] =
|
||||||
@ -427,6 +430,8 @@ export default function Dashboard(props: DashboardProps) {
|
|||||||
setBackendType={setBackendType}
|
setBackendType={setBackendType}
|
||||||
query={query}
|
query={query}
|
||||||
setQuery={setQuery}
|
setQuery={setQuery}
|
||||||
|
labels={labels}
|
||||||
|
suggestions={suggestions}
|
||||||
canToggleSettingsPanel={assetSettingsPanelProps != null}
|
canToggleSettingsPanel={assetSettingsPanelProps != null}
|
||||||
isSettingsPanelVisible={
|
isSettingsPanelVisible={
|
||||||
isAssetSettingsPanelVisible && assetSettingsPanelProps != null
|
isAssetSettingsPanelVisible && assetSettingsPanelProps != null
|
||||||
@ -451,6 +456,9 @@ export default function Dashboard(props: DashboardProps) {
|
|||||||
initialProjectName={initialProjectName}
|
initialProjectName={initialProjectName}
|
||||||
query={query}
|
query={query}
|
||||||
setQuery={setQuery}
|
setQuery={setQuery}
|
||||||
|
labels={labels}
|
||||||
|
setLabels={setLabels}
|
||||||
|
setSuggestions={setSuggestions}
|
||||||
projectStartupInfo={projectStartupInfo}
|
projectStartupInfo={projectStartupInfo}
|
||||||
queuedAssetEvents={queuedAssetEvents}
|
queuedAssetEvents={queuedAssetEvents}
|
||||||
assetListEvents={assetListEvents}
|
assetListEvents={assetListEvents}
|
||||||
|
@ -17,6 +17,7 @@ import * as modalProvider from '../../providers/modal'
|
|||||||
import * as uniqueString from '../../uniqueString'
|
import * as uniqueString from '../../uniqueString'
|
||||||
|
|
||||||
import * as app from '../../components/app'
|
import * as app from '../../components/app'
|
||||||
|
import type * as assetSearchBar from './assetSearchBar'
|
||||||
import type * as assetSettingsPanel from './assetSettingsPanel'
|
import type * as assetSettingsPanel from './assetSettingsPanel'
|
||||||
import * as pageSwitcher from './pageSwitcher'
|
import * as pageSwitcher from './pageSwitcher'
|
||||||
import type * as spinner from './spinner'
|
import type * as spinner from './spinner'
|
||||||
@ -44,6 +45,9 @@ export interface DriveProps {
|
|||||||
dispatchAssetEvent: (directoryEvent: assetEventModule.AssetEvent) => void
|
dispatchAssetEvent: (directoryEvent: assetEventModule.AssetEvent) => void
|
||||||
query: assetQuery.AssetQuery
|
query: assetQuery.AssetQuery
|
||||||
setQuery: React.Dispatch<React.SetStateAction<assetQuery.AssetQuery>>
|
setQuery: React.Dispatch<React.SetStateAction<assetQuery.AssetQuery>>
|
||||||
|
labels: backendModule.Label[]
|
||||||
|
setLabels: React.Dispatch<React.SetStateAction<backendModule.Label[]>>
|
||||||
|
setSuggestions: (suggestions: assetSearchBar.Suggestion[]) => void
|
||||||
projectStartupInfo: backendModule.ProjectStartupInfo | null
|
projectStartupInfo: backendModule.ProjectStartupInfo | null
|
||||||
setAssetSettingsPanelProps: React.Dispatch<
|
setAssetSettingsPanelProps: React.Dispatch<
|
||||||
React.SetStateAction<assetSettingsPanel.AssetSettingsPanelRequiredProps | null>
|
React.SetStateAction<assetSettingsPanel.AssetSettingsPanelRequiredProps | null>
|
||||||
@ -71,6 +75,9 @@ export default function Drive(props: DriveProps) {
|
|||||||
queuedAssetEvents,
|
queuedAssetEvents,
|
||||||
query,
|
query,
|
||||||
setQuery,
|
setQuery,
|
||||||
|
labels,
|
||||||
|
setLabels,
|
||||||
|
setSuggestions,
|
||||||
projectStartupInfo,
|
projectStartupInfo,
|
||||||
assetListEvents,
|
assetListEvents,
|
||||||
dispatchAssetListEvent,
|
dispatchAssetListEvent,
|
||||||
@ -97,7 +104,6 @@ export default function Drive(props: DriveProps) {
|
|||||||
localStorage.get(localStorageModule.LocalStorageKey.driveCategory) ??
|
localStorage.get(localStorageModule.LocalStorageKey.driveCategory) ??
|
||||||
categorySwitcher.Category.home
|
categorySwitcher.Category.home
|
||||||
)
|
)
|
||||||
const [labels, setLabels] = React.useState<backendModule.Label[]>([])
|
|
||||||
// const [currentLabels, setCurrentLabels] = React.useState<backendModule.LabelName[] | null>(null)
|
// const [currentLabels, setCurrentLabels] = React.useState<backendModule.LabelName[] | null>(null)
|
||||||
const [newLabelNames, setNewLabelNames] = React.useState(new Set<backendModule.LabelName>())
|
const [newLabelNames, setNewLabelNames] = React.useState(new Set<backendModule.LabelName>())
|
||||||
const [deletedLabelNames, setDeletedLabelNames] = React.useState(
|
const [deletedLabelNames, setDeletedLabelNames] = React.useState(
|
||||||
@ -133,7 +139,7 @@ export default function Drive(props: DriveProps) {
|
|||||||
setLabels(await backend.listTags())
|
setLabels(await backend.listTags())
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
}, [backend, organization?.isEnabled])
|
}, [backend, organization?.isEnabled, /* should never change */ setLabels])
|
||||||
|
|
||||||
const doUploadFiles = React.useCallback(
|
const doUploadFiles = React.useCallback(
|
||||||
(files: File[]) => {
|
(files: File[]) => {
|
||||||
@ -204,13 +210,13 @@ export default function Drive(props: DriveProps) {
|
|||||||
new Set([...labelNames].filter(labelName => labelName !== newLabelName))
|
new Set([...labelNames].filter(labelName => labelName !== newLabelName))
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
[backend, /* should never change */ toastAndLog]
|
[backend, /* should never change */ toastAndLog, /* should never change */ setLabels]
|
||||||
)
|
)
|
||||||
|
|
||||||
const doDeleteLabel = React.useCallback(
|
const doDeleteLabel = React.useCallback(
|
||||||
async (id: backendModule.TagId, value: backendModule.LabelName) => {
|
async (id: backendModule.TagId, value: backendModule.LabelName) => {
|
||||||
setDeletedLabelNames(oldNames => new Set([...oldNames, value]))
|
setDeletedLabelNames(oldNames => new Set([...oldNames, value]))
|
||||||
setQuery(oldQuery => oldQuery.delete({ labels: [value] }))
|
setQuery(oldQuery => oldQuery.deleteFromEveryTerm({ labels: [value] }))
|
||||||
try {
|
try {
|
||||||
await backend.deleteTag(id, value)
|
await backend.deleteTag(id, value)
|
||||||
dispatchAssetEvent({
|
dispatchAssetEvent({
|
||||||
@ -230,6 +236,7 @@ export default function Drive(props: DriveProps) {
|
|||||||
/* should never change */ setQuery,
|
/* should never change */ setQuery,
|
||||||
/* should never change */ dispatchAssetEvent,
|
/* should never change */ dispatchAssetEvent,
|
||||||
/* should never change */ toastAndLog,
|
/* should never change */ toastAndLog,
|
||||||
|
/* should never change */ setLabels,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -357,6 +364,7 @@ export default function Drive(props: DriveProps) {
|
|||||||
setQuery={setQuery}
|
setQuery={setQuery}
|
||||||
category={category}
|
category={category}
|
||||||
allLabels={allLabels}
|
allLabels={allLabels}
|
||||||
|
setSuggestions={setSuggestions}
|
||||||
initialProjectName={initialProjectName}
|
initialProjectName={initialProjectName}
|
||||||
projectStartupInfo={projectStartupInfo}
|
projectStartupInfo={projectStartupInfo}
|
||||||
deletedLabelNames={deletedLabelNames}
|
deletedLabelNames={deletedLabelNames}
|
||||||
|
@ -7,7 +7,7 @@ import * as backend from '../backend'
|
|||||||
// === Constants ===
|
// === Constants ===
|
||||||
// =================
|
// =================
|
||||||
|
|
||||||
// The default color for labels (Light blue).
|
/** The default color for labels (Light blue). */
|
||||||
export const DEFAULT_LABEL_COLOR: backend.LChColor = {
|
export const DEFAULT_LABEL_COLOR: backend.LChColor = {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||||
lightness: 100,
|
lightness: 100,
|
||||||
@ -28,9 +28,15 @@ interface InternalLabelProps
|
|||||||
Required<Pick<JSX.IntrinsicElements['button'], 'onClick'>> {
|
Required<Pick<JSX.IntrinsicElements['button'], 'onClick'>> {
|
||||||
/** When true, the button is not faded out even when not hovered. */
|
/** When true, the button is not faded out even when not hovered. */
|
||||||
active?: boolean
|
active?: boolean
|
||||||
|
/** When true, the button has a red border signifying that it will be deleted,
|
||||||
|
* or that it is excluded from search. */
|
||||||
|
negated?: boolean
|
||||||
/** When true, the button cannot be clicked. */
|
/** When true, the button cannot be clicked. */
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
color: backend.LChColor
|
color: backend.LChColor
|
||||||
|
/** When true, will turn opaque when the nearest ancestor `.group` is hovered.
|
||||||
|
* Otherwise, will turn opaque only when itself is hovered. */
|
||||||
|
group?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -40,20 +46,29 @@ export default function Label(props: InternalLabelProps) {
|
|||||||
active = false,
|
active = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
color,
|
color,
|
||||||
|
negated = false,
|
||||||
className = 'text-tag-text',
|
className = 'text-tag-text',
|
||||||
children,
|
children,
|
||||||
|
group = true,
|
||||||
...passthrough
|
...passthrough
|
||||||
} = props
|
} = props
|
||||||
|
const textColorClassName = /\btext-/.test(className)
|
||||||
|
? '' // eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||||
|
: color.lightness <= 50
|
||||||
|
? 'text-tag-text'
|
||||||
|
: active
|
||||||
|
? 'text-primary'
|
||||||
|
: 'text-not-selected'
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
title="Right click to remove label."
|
className={`flex items-center rounded-full gap-1.5 h-6 px-2.25 transition-all ${className} ${
|
||||||
className={`flex items-center rounded-full gap-1.5 h-6 px-2.25 ${className} ${
|
negated
|
||||||
active ? '' : 'text-not-selected opacity-50'
|
? 'relative before:absolute before:rounded-full before:border-2 before:border-delete before:inset-0 before:w-full before:h-full'
|
||||||
} ${disabled ? '' : 'group-hover:opacity-100'} ${
|
: ''
|
||||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
} ${active ? '' : 'opacity-50'} ${
|
||||||
color.lightness <= 50 ? 'text-tag-text placeholder-tag-text' : 'text-primary'
|
disabled ? '' : group ? 'group-hover:opacity-100' : 'hover:opacity-100'
|
||||||
}`}
|
} ${textColorClassName}`}
|
||||||
style={{ backgroundColor: backend.lChColorToCssColor(color) }}
|
style={{ backgroundColor: backend.lChColorToCssColor(color) }}
|
||||||
{...passthrough}
|
{...passthrough}
|
||||||
>
|
>
|
||||||
|
@ -4,7 +4,8 @@ import * as React from 'react'
|
|||||||
import PlusIcon from 'enso-assets/plus.svg'
|
import PlusIcon from 'enso-assets/plus.svg'
|
||||||
import Trash2Icon from 'enso-assets/trash2.svg'
|
import Trash2Icon from 'enso-assets/trash2.svg'
|
||||||
|
|
||||||
import type * as assetQuery from '../../assetQuery'
|
import * as array from '../../array'
|
||||||
|
import * as assetQuery from '../../assetQuery'
|
||||||
import type * as backend from '../backend'
|
import type * as backend from '../backend'
|
||||||
import * as drag from '../drag'
|
import * as drag from '../drag'
|
||||||
import * as modalProvider from '../../providers/modal'
|
import * as modalProvider from '../../providers/modal'
|
||||||
@ -42,6 +43,7 @@ export default function Labels(props: LabelsProps) {
|
|||||||
deletedLabelNames,
|
deletedLabelNames,
|
||||||
} = props
|
} = props
|
||||||
const currentLabels = query.labels
|
const currentLabels = query.labels
|
||||||
|
const currentNegativeLabels = query.negativeLabels
|
||||||
const { setModal } = modalProvider.useSetModal()
|
const { setModal } = modalProvider.useSetModal()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -55,69 +57,88 @@ export default function Labels(props: LabelsProps) {
|
|||||||
{labels
|
{labels
|
||||||
.filter(label => !deletedLabelNames.has(label.value))
|
.filter(label => !deletedLabelNames.has(label.value))
|
||||||
.sort((a, b) => (a.value > b.value ? 1 : a.value < b.value ? -1 : 0))
|
.sort((a, b) => (a.value > b.value ? 1 : a.value < b.value ? -1 : 0))
|
||||||
.map(label => (
|
.map(label => {
|
||||||
<li key={label.id} className="group flex items-center gap-1">
|
const negated = currentNegativeLabels.some(term =>
|
||||||
<Label
|
array.shallowEqual(term, [label.value])
|
||||||
draggable
|
)
|
||||||
color={label.color}
|
return (
|
||||||
active={currentLabels.includes(label.value)}
|
<li key={label.id} className="group flex items-center gap-1">
|
||||||
disabled={newLabelNames.has(label.value)}
|
<Label
|
||||||
onClick={() => {
|
draggable
|
||||||
setQuery(oldQuery =>
|
color={label.color}
|
||||||
oldQuery.labels.includes(label.value)
|
active={
|
||||||
? oldQuery.delete({ labels: [label.value] })
|
negated ||
|
||||||
: oldQuery.add({ labels: [label.value] })
|
currentLabels.some(term =>
|
||||||
)
|
array.shallowEqual(term, [label.value])
|
||||||
}}
|
)
|
||||||
onDragStart={event => {
|
}
|
||||||
drag.setDragImageToBlank(event)
|
negated={negated}
|
||||||
const payload: drag.LabelsDragPayload = new Set([label.value])
|
disabled={newLabelNames.has(label.value)}
|
||||||
drag.LABELS.bind(event, payload)
|
|
||||||
setModal(
|
|
||||||
<DragModal
|
|
||||||
event={event}
|
|
||||||
doCleanup={() => {
|
|
||||||
drag.LABELS.unbind(payload)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Label active color={label.color} onClick={() => {}}>
|
|
||||||
{label.value}
|
|
||||||
</Label>
|
|
||||||
</DragModal>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label.value}
|
|
||||||
</Label>
|
|
||||||
{!newLabelNames.has(label.value) && (
|
|
||||||
<button
|
|
||||||
className="flex"
|
|
||||||
onClick={event => {
|
onClick={event => {
|
||||||
event.stopPropagation()
|
setQuery(oldQuery =>
|
||||||
|
assetQuery.toggleLabel(
|
||||||
|
oldQuery,
|
||||||
|
label.value,
|
||||||
|
event.shiftKey
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
onDragStart={event => {
|
||||||
|
drag.setDragImageToBlank(event)
|
||||||
|
const payload: drag.LabelsDragPayload = new Set([
|
||||||
|
label.value,
|
||||||
|
])
|
||||||
|
drag.LABELS.bind(event, payload)
|
||||||
setModal(
|
setModal(
|
||||||
<ConfirmDeleteModal
|
<DragModal
|
||||||
description={`the label '${label.value}'`}
|
event={event}
|
||||||
doDelete={() => {
|
doCleanup={() => {
|
||||||
doDeleteLabel(label.id, label.value)
|
drag.LABELS.unbind(payload)
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<Label
|
||||||
|
active
|
||||||
|
color={label.color}
|
||||||
|
onClick={() => {}}
|
||||||
|
>
|
||||||
|
{label.value}
|
||||||
|
</Label>
|
||||||
|
</DragModal>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SvgMask
|
{label.value}
|
||||||
src={Trash2Icon}
|
</Label>
|
||||||
alt="Delete"
|
{!newLabelNames.has(label.value) && (
|
||||||
className="opacity-0 group-hover:opacity-100 text-delete w-4 h-4"
|
<button
|
||||||
/>
|
className="flex"
|
||||||
</button>
|
onClick={event => {
|
||||||
)}
|
event.stopPropagation()
|
||||||
</li>
|
setModal(
|
||||||
))}
|
<ConfirmDeleteModal
|
||||||
|
description={`the label '${label.value}'`}
|
||||||
|
doDelete={() => {
|
||||||
|
doDeleteLabel(label.id, label.value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SvgMask
|
||||||
|
src={Trash2Icon}
|
||||||
|
alt="Delete"
|
||||||
|
className="opacity-0 group-hover:opacity-100 text-delete w-4 h-4"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
<li>
|
<li>
|
||||||
<Label
|
<Label
|
||||||
active
|
active
|
||||||
color={labelModule.DEFAULT_LABEL_COLOR}
|
color={labelModule.DEFAULT_LABEL_COLOR}
|
||||||
className="bg-frame-selected text-not-selected"
|
className="bg-frame text-not-selected"
|
||||||
onClick={event => {
|
onClick={event => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
setModal(
|
setModal(
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
/** @file The top-bar of dashboard. */
|
/** @file The top-bar of dashboard. */
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
import FindIcon from 'enso-assets/find.svg'
|
import type * as assetQuery from '../../assetQuery'
|
||||||
|
|
||||||
import * as assetQuery from '../../assetQuery'
|
|
||||||
import type * as backendModule from '../backend'
|
import type * as backendModule from '../backend'
|
||||||
import * as shortcuts from '../shortcuts'
|
|
||||||
|
|
||||||
|
import type * as assetSearchBar from './assetSearchBar'
|
||||||
import PageSwitcher, * as pageSwitcher from './pageSwitcher'
|
import PageSwitcher, * as pageSwitcher from './pageSwitcher'
|
||||||
import AssetInfoBar from './assetInfoBar'
|
import AssetInfoBar from './assetInfoBar'
|
||||||
|
import AssetSearchBar from './assetSearchBar'
|
||||||
import BackendSwitcher from './backendSwitcher'
|
import BackendSwitcher from './backendSwitcher'
|
||||||
import UserBar from './userBar'
|
import UserBar from './userBar'
|
||||||
|
|
||||||
@ -29,7 +28,9 @@ export interface TopBarProps {
|
|||||||
isHelpChatOpen: boolean
|
isHelpChatOpen: boolean
|
||||||
setIsHelpChatOpen: (isHelpChatOpen: boolean) => void
|
setIsHelpChatOpen: (isHelpChatOpen: boolean) => void
|
||||||
query: assetQuery.AssetQuery
|
query: assetQuery.AssetQuery
|
||||||
setQuery: (query: assetQuery.AssetQuery) => void
|
setQuery: React.Dispatch<React.SetStateAction<assetQuery.AssetQuery>>
|
||||||
|
labels: backendModule.Label[]
|
||||||
|
suggestions: assetSearchBar.Suggestion[]
|
||||||
canToggleSettingsPanel: boolean
|
canToggleSettingsPanel: boolean
|
||||||
isSettingsPanelVisible: boolean
|
isSettingsPanelVisible: boolean
|
||||||
setIsSettingsPanelVisible: React.Dispatch<React.SetStateAction<boolean>>
|
setIsSettingsPanelVisible: React.Dispatch<React.SetStateAction<boolean>>
|
||||||
@ -52,30 +53,14 @@ export default function TopBar(props: TopBarProps) {
|
|||||||
setIsHelpChatOpen,
|
setIsHelpChatOpen,
|
||||||
query,
|
query,
|
||||||
setQuery,
|
setQuery,
|
||||||
|
labels,
|
||||||
|
suggestions,
|
||||||
canToggleSettingsPanel,
|
canToggleSettingsPanel,
|
||||||
isSettingsPanelVisible,
|
isSettingsPanelVisible,
|
||||||
setIsSettingsPanelVisible,
|
setIsSettingsPanelVisible,
|
||||||
doRemoveSelf,
|
doRemoveSelf,
|
||||||
onSignOut,
|
onSignOut,
|
||||||
} = props
|
} = props
|
||||||
const searchRef = React.useRef<HTMLInputElement>(null)
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
|
||||||
// Allow `alt` key to be pressed in case it is being used to enter special characters.
|
|
||||||
if (
|
|
||||||
!(event.target instanceof HTMLInputElement) &&
|
|
||||||
(!(event.target instanceof HTMLElement) || !event.target.isContentEditable) &&
|
|
||||||
shortcuts.isTextInputEvent(event)
|
|
||||||
) {
|
|
||||||
searchRef.current?.focus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('keydown', onKeyDown)
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('keydown', onKeyDown)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -90,23 +75,12 @@ export default function TopBar(props: TopBarProps) {
|
|||||||
<div className="grow" />
|
<div className="grow" />
|
||||||
{page !== pageSwitcher.Page.editor && (
|
{page !== pageSwitcher.Page.editor && (
|
||||||
<>
|
<>
|
||||||
<div className="search-bar absolute flex items-center text-primary bg-frame rounded-full -translate-x-1/2 gap-2.5 left-1/2 h-8 w-98.25 min-w-31.5 px-2">
|
<AssetSearchBar
|
||||||
<label htmlFor="search">
|
query={query}
|
||||||
<img src={FindIcon} className="opacity-80" />
|
setQuery={setQuery}
|
||||||
</label>
|
labels={labels}
|
||||||
<input
|
suggestions={suggestions}
|
||||||
ref={searchRef}
|
/>
|
||||||
type="text"
|
|
||||||
size={1}
|
|
||||||
id="search"
|
|
||||||
placeholder="Type to search for projects, data connectors, users, and more."
|
|
||||||
value={query.query}
|
|
||||||
onChange={event => {
|
|
||||||
setQuery(assetQuery.AssetQuery.fromString(event.target.value))
|
|
||||||
}}
|
|
||||||
className="grow bg-transparent leading-5 h-6 py-px"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grow" />
|
<div className="grow" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
/** @file Utility functions related to event handling. */
|
/** @file Utility functions related to event handling. */
|
||||||
|
|
||||||
import type * as React from 'react'
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
import * as detect from 'enso-common/src/detect'
|
||||||
|
|
||||||
// =============================
|
// =============================
|
||||||
// === Mouse event utilities ===
|
// === Mouse event utilities ===
|
||||||
// =============================
|
// =============================
|
||||||
@ -15,3 +16,9 @@ export function isSingleClick(event: React.MouseEvent) {
|
|||||||
export function isDoubleClick(event: React.MouseEvent) {
|
export function isDoubleClick(event: React.MouseEvent) {
|
||||||
return event.detail === 2
|
return event.detail === 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns `true` if and only if the event has the modifier key set
|
||||||
|
* (`Ctrl` on Windows/Linux; `Cmd` on macOS). */
|
||||||
|
export function isModKey(event: React.KeyboardEvent | React.MouseEvent) {
|
||||||
|
return detect.isOnMacOS() ? event.metaKey : event.ctrlKey
|
||||||
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
/** @file A LocalStorage data manager. */
|
/** @file A LocalStorage data manager. */
|
||||||
import * as common from 'enso-common'
|
import * as common from 'enso-common'
|
||||||
|
|
||||||
import * as array from './array'
|
import * as array from '../array'
|
||||||
import * as backend from './backend'
|
import * as backend from './backend'
|
||||||
import * as column from './column'
|
import * as column from './column'
|
||||||
|
|
||||||
|
@ -142,19 +142,27 @@ export const MODIFIERS =
|
|||||||
* keys. */
|
* keys. */
|
||||||
const SPECIAL_CHARACTER_KEYCODE_REGEX = /^[A-Z][a-z]/
|
const SPECIAL_CHARACTER_KEYCODE_REGEX = /^[A-Z][a-z]/
|
||||||
|
|
||||||
/** Whether the modifiers match the event's modifier key states. */
|
/** Whether `event` may trigger a shortcut. */
|
||||||
export function isTextInputEvent(event: KeyboardEvent | React.KeyboardEvent) {
|
export function isPotentiallyShortcut(event: KeyboardEvent | React.KeyboardEvent) {
|
||||||
|
return event.ctrlKey || event.metaKey || event.altKey
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether `event.key` is a key used in text editing. */
|
||||||
|
export function isTextInputKey(event: KeyboardEvent | React.KeyboardEvent) {
|
||||||
// Allow `alt` key to be pressed in case it is being used to enter special characters.
|
// Allow `alt` key to be pressed in case it is being used to enter special characters.
|
||||||
return (
|
return (
|
||||||
!event.ctrlKey &&
|
!SPECIAL_CHARACTER_KEYCODE_REGEX.test(event.key) ||
|
||||||
!event.shiftKey &&
|
event.key === 'Backspace' ||
|
||||||
!event.metaKey &&
|
event.key === 'Delete'
|
||||||
(!SPECIAL_CHARACTER_KEYCODE_REGEX.test(event.key) ||
|
|
||||||
event.key === 'Backspace' ||
|
|
||||||
event.key === 'Delete')
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether `event` will produce text. This excludes shortcuts, as they do not produce text. */
|
||||||
|
export function isTextInputEvent(event: KeyboardEvent | React.KeyboardEvent) {
|
||||||
|
// Allow `alt` key to be pressed in case it is being used to enter special characters.
|
||||||
|
return !event.ctrlKey && !event.shiftKey && !event.metaKey && isTextInputKey(event)
|
||||||
|
}
|
||||||
|
|
||||||
// =============================
|
// =============================
|
||||||
// === makeKeyboardActionMap ===
|
// === makeKeyboardActionMap ===
|
||||||
// =============================
|
// =============================
|
||||||
|
@ -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",
|
"tailwindcss": "^3.2.7",
|
||||||
"tsx": "^3.12.6",
|
"tsx": "^3.12.6",
|
||||||
"typescript": "~5.2.2",
|
"typescript": "~5.2.2",
|
||||||
"vite": "^4.4.9"
|
"vite": "^4.4.9",
|
||||||
|
"vitest": "^0.34.4"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@esbuild/darwin-x64": "^0.17.15",
|
"@esbuild/darwin-x64": "^0.17.15",
|
||||||
|
Loading…
Reference in New Issue
Block a user