diff --git a/app/ide-desktop/lib/dashboard/package.json b/app/ide-desktop/lib/dashboard/package.json index c1831c1e7c9..65084a233b8 100644 --- a/app/ide-desktop/lib/dashboard/package.json +++ b/app/ide-desktop/lib/dashboard/package.json @@ -8,8 +8,8 @@ "build": "tsx bundle.ts", "dev": "vite", "start": "tsx start.ts", - "test": "npm run test:unit", - "test:unit": "playwright test", + "test": "vitest run", + "test:unit": "vitest", "test:browsers": "npx --yes playwright install && npm run test:component && npm run test:e2e-and-log", "test:component": "playwright test -c playwright-component.config.ts", "test:e2e": "npx playwright test -c playwright-e2e.config.ts", @@ -50,7 +50,8 @@ "tailwindcss": "^3.2.7", "tsx": "^3.12.6", "typescript": "~5.2.2", - "vite": "^4.4.9" + "vite": "^4.4.9", + "vitest": "^0.34.4" }, "optionalDependencies": { "@esbuild/darwin-x64": "^0.17.15", diff --git a/app/ide-desktop/lib/dashboard/playwright.config.ts b/app/ide-desktop/lib/dashboard/playwright.config.ts deleted file mode 100644 index db3cdc079f6..00000000000 --- a/app/ide-desktop/lib/dashboard/playwright.config.ts +++ /dev/null @@ -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', -}) diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/__tests__/assetQuery.test.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/__tests__/assetQuery.test.ts new file mode 100644 index 00000000000..c81e0ce35e0 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/__tests__/assetQuery.test.ts @@ -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) +}) diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/__tests__/error.test.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/__tests__/error.test.ts new file mode 100644 index 00000000000..c36b6ce9faf --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/__tests__/error.test.ts @@ -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(new Error(message))).toBe(message) + v.expect(error.tryGetMessage({ message: 'a' })).toBe('a') + v.expect(error.tryGetMessage(message)).toBeNull() + v.expect(error.tryGetMessage({})).toBeNull() + v.expect(error.tryGetMessage(null)).toBeNull() +}) diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/__tests__/fileInfo.test.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/__tests__/fileInfo.test.ts new file mode 100644 index 00000000000..b474184c92c --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/__tests__/fileInfo.test.ts @@ -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') +}) diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/array.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/array.ts similarity index 87% rename from app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/array.ts rename to app/ide-desktop/lib/dashboard/src/authentication/src/array.ts index 9991616f961..467ebaa56db 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/array.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/array.ts @@ -1,5 +1,14 @@ /** @file Utilities for manipulating arrays. */ +// ==================== +// === shallowEqual === +// ==================== + +/** Whether both arrays contain the same items. Does not recurse into the items. */ +export function shallowEqual(a: readonly T[], b: readonly T[]) { + return a.length === b.length && a.every((item, i) => item === b[i]) +} + // ========================= // === includesPredicate === // ========================= diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/assetQuery.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/assetQuery.ts index 157ba03da32..877d6250662 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/assetQuery.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/assetQuery.ts @@ -1,41 +1,122 @@ /** @file Parsing and representation of the search query. */ +import * as array from './array' + +// ===================== +// === Regex Helpers === +// ===================== + +// Control characters must be handled, in order to follow the JSON spec. +// eslint-disable-next-line no-control-regex +const JSON_VALUE_REGEX = /"(?:[^\0-\x1f\\"]|\\[\\/bfnrt"]|\\u[0-9a-fA-F]{4})*"?/.source + +/** The regex, with `` replaced with a regex subexpression matching a JSON-escaped search + * term. */ +function interpolateRegex(regex: RegExp) { + return new RegExp(regex.source.replace(//g, JSON_VALUE_REGEX), regex.flags) +} // ================== // === AssetQuery === // ================== +/** Keys of an {@Link AssetQuery} which correspond to tags. */ +export type AssetQueryKey = Exclude + /** An {@link AssetQuery}, without the query and methods. */ -interface AssetQueryData extends Omit {} +export interface AssetQueryData extends Record {} + +/** 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 {} /** An individual segment of a query string input to {@link AssetQuery}. */ interface AssetQueryTerm { tag: string | null - value: string + values: string[] } /** Parsing and representation of the search query. */ export class AssetQuery { - static termsRegex = - // Control characters must be handled, in order to follow the JSON spec. - // eslint-disable-next-line no-control-regex - /(?:([^\s:]*):)?(?:("(?:[^\0-\x1f\\"]|\\[\\/bfnrt"]|\\u[0-9a-fA-F]{4})*")|([^\s"]\S*|))/g - static plainValueRegex = /^(?:|[^"]\S*)$/u + static plainValueRegex = interpolateRegex(/^(?:|[^"]\S*)$/) + static jsonValueRegex = interpolateRegex(/^()$/) + static termsRegex = interpolateRegex(/(?:([^\s:]*):)?((?:(?:|(?:[^,\s"][^,\s]*)),?)*|)/g) + static valuesRegex = interpolateRegex(/(?:)|(?:[^,\s"][^,\s]*)/g) + // `key` MUST be a string literal type. + // eslint-disable-next-line no-restricted-syntax + static tagNames = [ + ['keywords', null], + ['negativeKeywords', '-'], + ['names', 'name'], + ['negativeNames', '-name'], + ['types', 'type'], + ['negativeTypes', '-type'], + ['extensions', 'extension'], + ['negativeExtensions', '-extension'], + ['descriptions', 'description'], + ['negativeDescriptions', '-description'], + ['modifieds', 'modified'], + ['negativeModifieds', '-modified'], + ['labels', 'label'], + ['negativeLabels', '-label'], + ['owners', 'owner'], + ['negativeOwners', '-owner'], + ['nos', 'no'], + ['negativeNos', 'has'], + ] as const satisfies readonly (readonly [keyof AssetQueryData, string | null])[] + + query /** Create an {@link AssetQuery}. */ constructor( - readonly query: string, - readonly keywords: string[], - readonly labels: string[] - ) {} + query: string | null, + readonly keywords: string[][], + readonly negativeKeywords: string[][], + readonly names: string[][], + readonly negativeNames: string[][], + readonly labels: string[][], + readonly negativeLabels: string[][], + readonly types: string[][], + readonly negativeTypes: string[][], + readonly extensions: string[][], + readonly negativeExtensions: string[][], + readonly descriptions: string[][], + readonly negativeDescriptions: string[][], + readonly modifieds: string[][], + readonly negativeModifieds: string[][], + readonly owners: string[][], + readonly negativeOwners: string[][], + readonly nos: string[][], + readonly negativeNos: string[][] + ) { + this.query = query ?? '' + if (query == null) { + this.query = this.toString() + } + } /** Return a list of {@link AssetQueryTerm}s found in the raw user input string. */ static terms(query: string): AssetQueryTerm[] { const terms: AssetQueryTerm[] = [] - for (const [, tag, jsonValue, plainValue] of query.trim().matchAll(this.termsRegex)) { - if (tag != null || plainValue == null || plainValue !== '') { + for (const [, tag, valuesRaw = ''] of query.trim().matchAll(this.termsRegex)) { + // Ignore values with a tag but without a value. + if (tag != null || valuesRaw !== '') { + const values = valuesRaw.match(AssetQuery.valuesRegex) ?? [] terms.push({ tag: tag ?? null, - value: jsonValue != null ? String(JSON.parse(jsonValue)) : plainValue ?? '', + values: + valuesRaw === '' + ? [] + : values.map(value => + AssetQuery.jsonValueRegex.test(value) + ? String( + JSON.parse( + value.endsWith('"') && value.length > 1 + ? value + : value + '"' + ) + ) + : value + ), }) } } @@ -45,106 +126,367 @@ export class AssetQuery { /** Convert an {@link AssetQueryTerm} to a string usable in a raw user input string. */ static termToString(term: AssetQueryTerm) { const tagSegment = term.tag == null ? '' : term.tag + ':' - const valueSegment = this.plainValueRegex.test(term.value) - ? term.value - : JSON.stringify(term.value) + const valueSegment = term.values + .map(value => (AssetQuery.plainValueRegex.test(value) ? value : JSON.stringify(value))) + .join(',') return tagSegment + valueSegment } /** Create an {@link AssetQuery} from a raw user input string. */ static fromString(query: string): AssetQuery { const terms = AssetQuery.terms(query) - const keywords = terms - .filter(term => term.tag == null || term.tag === '') - .map(term => term.value) - const labels = terms - .filter(term => term.tag?.toLowerCase() === 'label') - .map(term => term.value) - return new AssetQuery(query, keywords, labels) + const keywords: string[][] = [] + const negativeKeywords: string[][] = [] + const names: string[][] = [] + const negativeNames: string[][] = [] + const labels: string[][] = [] + const negativeLabels: string[][] = [] + const types: string[][] = [] + const negativeTypes: string[][] = [] + const extensions: string[][] = [] + const negativeExtensions: string[][] = [] + const descriptions: string[][] = [] + const negativeDescriptions: string[][] = [] + const modifieds: string[][] = [] + const negativeModifieds: string[][] = [] + const owners: string[][] = [] + const negativeOwners: string[][] = [] + const nos: string[][] = [] + const negativeNos: string[][] = [] + const tagNameToSet: Record = { + // 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) { + if (Object.keys(updates).length === 0) { + return this + } else { + return new AssetQuery( + null, + updates.keywords ?? this.keywords, + updates.negativeKeywords ?? this.negativeKeywords, + updates.names ?? this.names, + updates.negativeNames ?? this.negativeNames, + updates.labels ?? this.labels, + updates.negativeLabels ?? this.negativeLabels, + updates.types ?? this.types, + updates.negativeTypes ?? this.negativeTypes, + updates.extensions ?? this.extensions, + updates.negativeExtensions ?? this.negativeExtensions, + updates.descriptions ?? this.descriptions, + updates.negativeDescriptions ?? this.negativeDescriptions, + updates.modifieds ?? this.modifieds, + updates.negativeModifieds ?? this.negativeModifieds, + updates.owners ?? this.owners, + updates.negativeOwners ?? this.negativeOwners, + updates.nos ?? this.nos, + updates.negativeNos ?? this.negativeNos + ) + } } /** Return a new {@link AssetQuery} with the specified terms added, - * or itself if there are no terms to remove. */ + * or itself if there are no terms to add. */ add(values: Partial): AssetQuery { - const { keywords, labels } = values - const noKeywords = !keywords || keywords.length === 0 - const noLabels = !labels || labels.length === 0 - if (noKeywords && noLabels) { - return this - } else { - const newKeywords = this.keywords - let addedKeywords: string[] = [] - if (!noKeywords) { - const keywordsSet = new Set(this.keywords) - addedKeywords = keywords.filter(keyword => !keywordsSet.has(keyword)) - newKeywords.push(...addedKeywords) + const updates: Partial = {} + for (const [key] of AssetQuery.tagNames) { + const update = AssetQuery.updatedTerms(this[key], values[key] ?? null, null) + if (update != null) { + updates[key] = update } - const newLabels = this.labels - let addedLabels: string[] = [] - if (!noLabels) { - const labelsSet = new Set(this.labels) - addedLabels = labels.filter(keyword => !labelsSet.has(keyword)) - newLabels.push(...addedLabels) - } - const newQuery = - this.query + - (this.query === '' ? '' : ' ') + - [ - ...addedKeywords.map(keyword => - AssetQuery.termToString({ tag: null, value: keyword }) - ), - ...addedLabels.map(label => - AssetQuery.termToString({ tag: 'label', value: label }) - ), - ].join(' ') - return new AssetQuery(newQuery, newKeywords, newLabels) } + return this.withUpdates(updates) } - /** Return a new {@link AssetQuery} with the specified terms removed, - * or itself if there are no terms to remove. */ + /** Return a new {@link AssetQuery} with the specified terms deleted, + * or itself if there are no terms to delete. */ delete(values: Partial): AssetQuery { - const { keywords, labels } = values - const noKeywords = !keywords || keywords.length === 0 - const noLabels = !labels || labels.length === 0 - if (noKeywords && noLabels) { - return this - } else { - let newKeywords = this.keywords - const keywordsSet = new Set(keywords ?? []) - if (!noKeywords) { - newKeywords = newKeywords.filter(keyword => !keywordsSet.has(keyword)) - } - let newLabels = this.labels - const labelsSet = new Set(labels ?? []) - if (!noLabels) { - newLabels = newLabels.filter(label => !labelsSet.has(label)) - } - if ( - newKeywords.length === this.keywords.length && - newLabels.length === this.labels.length - ) { - return this - } else { - const newQuery = AssetQuery.terms(this.query) - .filter(term => { - switch (term.tag?.toLowerCase() ?? null) { - case null: - case '': { - return !keywordsSet.has(term.value) - } - case 'label': { - return !labelsSet.has(term.value) - } - default: { - return true - } - } - }) - .map(term => AssetQuery.termToString(term)) - .join(' ') - return new AssetQuery(newQuery, newKeywords, newLabels) + const updates: Partial = {} + for (const [key] of AssetQuery.tagNames) { + const update = AssetQuery.updatedTerms(this[key], null, values[key] ?? null) + if (update != null) { + updates[key] = update } } + return this.withUpdates(updates) + } + + /** Return a new {@link AssetQuery} with the specified words added to the last term + * with the matching tag, or itself if there are no terms to add. */ + addToLastTerm(values: Partial): AssetQuery { + const updates: Partial = {} + 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): AssetQuery { + const updates: Partial = {} + 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): AssetQuery { + const updates: Partial = {} + 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): AssetQuery { + const updates: Partial = {} + 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 +} diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/__tests__/validation.test.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/__tests__/validation.test.ts new file mode 100644 index 00000000000..34144a55f21 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/__tests__/validation.test.ts @@ -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) +}) diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/column.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/column.tsx index 076fc077457..92beb063846 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/column.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/column.tsx @@ -12,6 +12,7 @@ import TagIcon from 'enso-assets/tag.svg' import TimeIcon from 'enso-assets/time.svg' import * as assetEvent from './events/assetEvent' +import * as assetQuery from '../assetQuery' import type * as assetTreeNode from './assetTreeNode' import * as authProvider from '../authentication/providers/auth' import * as backendModule from './backend' @@ -301,14 +302,11 @@ function LabelsColumn(props: AssetColumnProps) { .map(label => ( + {!newLabelNames.has(label.value) && ( + + )} + + ) + })}