diff --git a/.eslintrc b/.eslintrc index 3a2b95b..712dc01 100644 --- a/.eslintrc +++ b/.eslintrc @@ -39,7 +39,7 @@ "@typescript-eslint/no-floating-promises": ["error"], "@typescript-eslint/no-inferrable-types": "error", "@typescript-eslint/no-misused-new": "error", - "@typescript-eslint/no-namespace": "error", + "@typescript-eslint/no-namespace": "off", "@typescript-eslint/no-non-null-assertion": "off", "@typescript-eslint/no-this-alias": "error", "@typescript-eslint/no-use-before-define": "off", diff --git a/src/lib/BravoClient.ts b/src/lib/BravoClient.ts index b2f5be1..7dd82eb 100644 --- a/src/lib/BravoClient.ts +++ b/src/lib/BravoClient.ts @@ -26,8 +26,8 @@ import type { } from '$/types/FavroApiTypes'; import type { OptionsBravoCreateWidget } from '$/types/ParameterOptions.js'; import { BravoColumn } from './entities/BravoColumn.js'; -import { DataFavroColumn } from '$/types/FavroColumnTypes.js'; -import { ArrayMatchFunction } from '$/types/Utility.js'; +import type { DataFavroColumn } from '$/types/FavroColumnTypes.js'; +import type { ArrayMatchFunction, RequiredBy } from '$/types/Utility.js'; import type { DataFavroCard, DataFavroCardAttachment, @@ -36,11 +36,13 @@ import type { } from '$/types/FavroCardTypes.js'; import { BravoCardInstance } from './entities/BravoCard.js'; import { BravoCustomFieldDefinition } from './entities/BravoCustomField.js'; -import { DataFavroCustomFieldDefinition } from '$/types/FavroCustomFieldTypes.js'; -import { FavroApiParamsCardUpdate } from '$/types/FavroCardUpdateTypes.js'; +import type { DataFavroCustomFieldDefinition } from '$/types/FavroCustomFieldTypes.js'; +import type { FavroApiParamsCardUpdate } from '$/types/FavroCardUpdateTypes.js'; import type { FavroResponse } from './clientLib/FavroResponse.js'; import { readFileSync } from 'fs'; import { basename } from 'path'; +import { BravoTagDefinition } from './entities/BravoTag.js'; +import type { FavroDataTypes } from '$/types/FavroTagTypes.js'; /** * The `BravoClient` class should be singly-instanced for a given @@ -160,7 +162,9 @@ export class BravoClient extends FavroClient { field: 'email' | 'name' | 'userId', value: string | RegExp, ) { - return await this.findMember(createIsMatchFilter(value, field)); + const user = await this.findMember(createIsMatchFilter(value, field)); + assertBravoClaim(user, `No user found with ${field} matching ${value}`); + return user; } //#endregion @@ -639,6 +643,89 @@ export class BravoClient extends FavroClient { //#endregion + //#region TAGS + + /** + * Get and cache *all* tags. Lazy-loads and caches to reduce API calls. + * + * {@link https://favro.com/developer/#get-all-tags} + */ + async listTagDefinitions() { + if (!this.cache.tags) { + const res = (await this.requestWithReturnedEntities( + `tags`, + { method: 'get' }, + BravoTagDefinition, + )) as BravoResponseEntities< + FavroDataTypes.Tag.Definition, + BravoTagDefinition + >; + this.cache.tags = res; + } + return this.cache.tags!; + } + + async createTagDefinition( + options: Partial< + Omit + >, + ) { + const res = (await this.requestWithReturnedEntities( + `tags`, + { + method: 'post', + body: options, + }, + BravoTagDefinition, + )) as BravoResponseEntities< + FavroDataTypes.Tag.Definition, + BravoTagDefinition + >; + return await res.getFirstEntity(); + } + + async updateTagDefinition( + options: RequiredBy< + Partial>, + 'tagId' + >, + ) { + const res = (await this.requestWithReturnedEntities( + `tags`, + { + method: 'put', + body: options, + }, + BravoTagDefinition, + )) as BravoResponseEntities< + FavroDataTypes.Tag.Definition, + BravoTagDefinition + >; + return await res.getFirstEntity(); + } + + async findTagDefinitionById(tagId: string) { + const tags = await this.listTagDefinitions(); + const tag = await tags.findById('tagId', tagId); + assertBravoClaim(tag, `No tag found with id ${tagId}`); + return tag; + } + + async findTagDefinitionByName(name: string | RegExp) { + const tags = await this.listTagDefinitions(); + return await tags.find(createIsMatchFilter(name, 'name')); + } + + async deleteTagById(tagId: string) { + await this.deleteEntity(`tags/${tagId}`); + // The caching method makes it hard to invalidate a single + // tag entry (since it's all lazy-loaded), so for now do it + // the DUMB WAY: clear the entire tags cache. + this.cache.tags = undefined; + } + + //#endregion + //#region CUSTOM FIELDS /** diff --git a/src/lib/clientLib/BravoClientCache.ts b/src/lib/clientLib/BravoClientCache.ts index a42280b..bf07132 100644 --- a/src/lib/clientLib/BravoClientCache.ts +++ b/src/lib/clientLib/BravoClientCache.ts @@ -4,6 +4,7 @@ import type { BravoUser } from '$/lib/entities/BravoUser'; import type { BravoCollection } from '$entities/BravoCollection.js'; import type { BravoResponseCustomFields, + BravoResponseTags, BravoResponseWidgets, } from './BravoResponse.js'; import type { BravoColumn } from '../entities/BravoColumn.js'; @@ -34,6 +35,12 @@ export class BravoClientCache { */ protected _customFields?: BravoResponseCustomFields; + /** + * Tags are handled like Custom Fields, since they are global + * and not meaningfully searchable via the API. + */ + protected _tags?: BravoResponseTags; + get collections() { // @ts-expect-error return this._collections ? [...this._collections] : undefined; @@ -66,6 +73,13 @@ export class BravoClientCache { this._customFields = customFields; } + get tags() { + return this._tags; + } + set tags(tags: BravoResponseTags | undefined) { + this._tags = tags; + } + /** * Get the widget paging object from a get-all-widgets * search, keyed by collectionId. If the collectionId @@ -178,5 +192,6 @@ export class BravoClientCache { this._widgets.clear(); this._columns.clear(); this._customFields = undefined; + this._tags = undefined; } } diff --git a/src/lib/clientLib/BravoResponse.ts b/src/lib/clientLib/BravoResponse.ts index 5b3aada..f529946 100644 --- a/src/lib/clientLib/BravoResponse.ts +++ b/src/lib/clientLib/BravoResponse.ts @@ -1,10 +1,12 @@ -import { BravoClient } from '$lib/BravoClient'; -import { BravoEntity } from '$lib/BravoEntity'; +import type { BravoClient } from '$lib/BravoClient'; +import type { BravoEntity } from '$lib/BravoEntity'; import type { FavroResponse } from './FavroResponse'; import type { DataFavroCustomFieldDefinition } from '$/types/FavroCustomFieldTypes.js'; import type { DataFavroWidget } from '$/types/FavroWidgetTypes.js'; import type { BravoWidget } from '$entities/BravoWidget.js'; import type { BravoCustomFieldDefinition } from '../entities/BravoCustomField.js'; +import type { FavroDataTypes } from '$/types/FavroTagTypes.js'; +import type { BravoTagDefinition } from '../entities/BravoTag.js'; export type BravoResponseWidgets = BravoResponseEntities< DataFavroWidget, @@ -16,6 +18,11 @@ export type BravoResponseCustomFields = BravoResponseEntities< BravoCustomFieldDefinition >; +export type BravoResponseTags = BravoResponseEntities< + FavroDataTypes.Tag.Definition, + BravoTagDefinition +>; + export type BravoResponseEntitiesMatchFunction = ( entity: Entity, idx?: number, diff --git a/src/lib/entities/BravoCard.ts b/src/lib/entities/BravoCard.ts index b49057e..2590805 100644 --- a/src/lib/entities/BravoCard.ts +++ b/src/lib/entities/BravoCard.ts @@ -3,7 +3,7 @@ import type { DataFavroCustomFieldType, } from '$/types/FavroCardTypes.js'; import type { FavroApiParamsCardUpdate } from '$/types/FavroCardUpdateTypes.js'; -import { ExtractKeysByValue } from '$/types/Utility.js'; +import type { ExtractKeysByValue } from '$/types/Utility.js'; import { BravoEntity } from '$lib/BravoEntity.js'; import { assertBravoClaim } from '../errors.js'; import { isMatch } from '../utility.js'; @@ -184,16 +184,12 @@ export class BravoCardInstance extends BravoEntity { return await this.updateField('removeTagsByName', ...args); } - async addTagsById( - ...args: Parameters - ) { - return await this.updateField('addTagsById', ...args); + async addTags(...args: Parameters) { + return await this.updateField('addTags', ...args); } - async removeTagsById( - ...args: Parameters - ) { - return await this.updateField('removeTagsById', ...args); + async removeTags(...args: Parameters) { + return await this.updateField('removeTags', ...args); } async setStartDate( diff --git a/src/lib/entities/BravoCardUpdateBuilder.ts b/src/lib/entities/BravoCardUpdateBuilder.ts index ff694a6..8424106 100644 --- a/src/lib/entities/BravoCardUpdateBuilder.ts +++ b/src/lib/entities/BravoCardUpdateBuilder.ts @@ -1,16 +1,16 @@ -import { +import type { DataFavroCardFavroAttachment, DataFavroCustomFieldType, DataFavroRating, } from '$/types/FavroCardTypes.js'; -import { +import type { FavroApiParamsCardCustomField, FavroApiParamsCardUpdate, FavroApiParamsCardUpdateArrayField, FavroApiParamsCardUpdateCustomField, } from '$/types/FavroCardUpdateTypes.js'; -import { DataFavroCustomFieldDefinition } from '$/types/FavroCustomFieldTypes.js'; -import { RequiredBy } from '$/types/Utility.js'; +import type { DataFavroCustomFieldDefinition } from '$/types/FavroCustomFieldTypes.js'; +import type { RequiredBy } from '$/types/Utility.js'; import { assertBravoClaim } from '../errors.js'; import { addToUniqueArrayBy, @@ -22,11 +22,12 @@ import { stringsOrObjectsToStrings, wrapIfNotArray, } from '../utility.js'; -import { +import type { BravoCustomField, BravoCustomFieldDefinition, } from './BravoCustomField.js'; -import { BravoUser } from './BravoUser.js'; +import type { BravoTagDefinition } from './BravoTag.js'; +import type { BravoUser } from './BravoUser.js'; export type CustomFieldOrId = | string @@ -95,12 +96,14 @@ export class BravoCardUpdateBuilder { return this.addToUniqueArray('removeTags', names, 'addTags'); } - addTagsById(ids: string[]) { - return this.addToUniqueArray('addTagIds', ids, 'removeTagIds'); + addTags(tagDefinitionsOrIds: (string | BravoTagDefinition)[]) { + const tagIds = stringsOrObjectsToStrings(tagDefinitionsOrIds, 'tagId'); + return this.addToUniqueArray('addTagIds', tagIds, 'removeTagIds'); } - removeTagsById(ids: string[]) { - return this.addToUniqueArray('removeTagIds', ids, 'addTagIds'); + removeTags(tagDefinitionsOrIds: (string | BravoTagDefinition)[]) { + const tagIds = stringsOrObjectsToStrings(tagDefinitionsOrIds, 'tagId'); + return this.addToUniqueArray('removeTagIds', tagIds, 'addTagIds'); } setStartDate(date: Date | null) { diff --git a/src/lib/entities/BravoTag.ts b/src/lib/entities/BravoTag.ts new file mode 100644 index 0000000..0d1a7a7 --- /dev/null +++ b/src/lib/entities/BravoTag.ts @@ -0,0 +1,29 @@ +import type { FavroDataTypes } from '$/types/FavroTagTypes.js'; +import { BravoEntity } from '$lib/BravoEntity.js'; + +/** Hydrated Favro Organization. */ +export class BravoTagDefinition extends BravoEntity { + get name() { + return this._data.name; + } + + get tagId() { + return this._data.tagId; + } + + get color() { + return this._data.color; + } + + get organizationId() { + return this._data.organizationId; + } + + async delete() { + await this._client.deleteTagById(this.tagId); + } + + equals(tag: BravoTagDefinition) { + return this.hasSameConstructor(tag) && this.tagId === tag.tagId; + } +} diff --git a/src/test/client.test.ts b/src/test/client.test.ts index 6a07907..9c29f35 100644 --- a/src/test/client.test.ts +++ b/src/test/client.test.ts @@ -45,6 +45,7 @@ import { import type { DataFavroCustomFieldType } from '$/types/FavroCardTypes.js'; import { assertBravoClaim } from '$/lib/errors.js'; import type { BravoUser } from '$/lib/entities/BravoUser.js'; +import type { BravoTagDefinition } from '$/lib/entities/BravoTag.js'; /** * @note A root .env file must be populated with the required @@ -58,6 +59,7 @@ const testCollectionName = '___BRAVO_TEST_COLLECTION'; const testWidgetName = '___BRAVO_TEST_WIDGET'; const testColumnName = '___BRAVO_TEST_COLUMN'; const testCardName = '___BRAVO_TEST_CARD'; +const testTagName = '___BRAVO_TEST_TAG'; const customFieldUniqueName = `Unique Text Field`; const customFieldRepeatedName = `Repeated Text Field`; const customFieldUniquenessTestType = 'Text'; @@ -173,6 +175,7 @@ describe('BravoClient', function () { let testColumn: BravoColumn; let testCard: BravoCardInstance; let testUser: BravoUser; + let testTag: BravoTagDefinition; // !!! // Tests are in a specific order to ensure that dependencies @@ -185,6 +188,7 @@ describe('BravoClient', function () { // Clean up any leftover remote testing content // (Since names aren't required to be unique, there could be quite a mess!) // NOTE: + await (await client.findTagDefinitionByName(testTagName))?.delete(); while (true) { const collection = await client.findCollectionByName(testCollectionName); if (!collection) { @@ -245,7 +249,36 @@ describe('BravoClient', function () { expect(me!.email).to.equal(myUserEmail); }); - describe('Collections', function () { + describe('Tags', function () { + it('can create a tag', async function () { + const tag = await client.createTagDefinition({ + name: testTagName, + color: 'purple', + }); + expect(tag.name).to.equal(testTagName); + expect(tag.color).to.equal('purple'); + testTag = tag; + }); + + it('can find tags', async function () { + const tags = await client.listTagDefinitions(); + const matchingTestTag = await tags.find( + (tag) => tag.name === testTagName, + ); + assertBravoClaim(matchingTestTag, 'Tag not found'); + expect( + testTag.equals(matchingTestTag), + 'Should find created tag with exhaustive search by name', + ).to.be.true; + + expect( + testTag.equals((await tags.findById('tagId', testTag.tagId))!), + 'Should be able to find tag using find-by-id cache', + ).to.be.true; + }); + }); + + xdescribe('Collections', function () { it('can create a collection', async function () { testCollection = await client.createCollection(testCollectionName); assertBravoTestClaim(testCollection, 'Collection not created'); diff --git a/src/types/FavroTagTypes.ts b/src/types/FavroTagTypes.ts index 832e5f7..89e9760 100644 --- a/src/types/FavroTagTypes.ts +++ b/src/types/FavroTagTypes.ts @@ -1,21 +1,25 @@ -/** {@link https://favro.com/developer/#tag-colors} */ -export type OptionFavroTagColor = - | 'blue' - | 'purple' - | 'cyan' - | 'green' - | 'lightgreen' - | 'yellow' - | 'orange' - | 'red' - | 'brown' - | 'gray' - | 'slategray'; +export namespace FavroDataTypes { + export namespace Tag { + /** {@link https://favro.com/developer/#tag-colors} */ + export type Color = + | 'blue' + | 'purple' + | 'cyan' + | 'green' + | 'lightgreen' + | 'yellow' + | 'orange' + | 'red' + | 'brown' + | 'gray' + | 'slategray'; -/** {@link https://favro.com/developer/#tag} */ -export interface DataFavroTag { - tagId: string; - organizationId: string; - name: string; - color: OptionFavroTagColor; + /** {@link https://favro.com/developer/#tag} */ + export interface Definition { + tagId: string; + organizationId: string; + name: string; + color: Color; + } + } }