From de0188dc06d84f91f3f150deaffcadedc0af589b Mon Sep 17 00:00:00 2001 From: Adam Coster Date: Sat, 21 Aug 2021 12:23:53 -0500 Subject: [PATCH] feat: Add Card methods for fetching associated Custom Field definitions and values. --- ROADMAP.md | 15 ++- src/lib/clientLib/BravoResponse.ts | 11 ++- src/lib/entities/BravoCard.ts | 138 +++++++++++++++++++++------ src/lib/entities/BravoCustomField.ts | 51 ++++++++-- src/types/FavroCardTypes.ts | 31 ++++++ src/types/FavroCustomFieldTypes.ts | 8 +- 6 files changed, 204 insertions(+), 50 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 94a0c5b..d050d7c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,11 +1,16 @@ ## Bravo Roadmap -### User Stories +### TODOs -The things that we (Bscotch) want to enable via Bravo. - -- Find a card on a board by name, and update any of its Custom Status Fields to a different value (matching by *name*). - - Custom Fields are GLOBAL in the API, and there is no way to filter them by board. Thus we need to be able to fetch a SAMPLE card from a board (probably offline) and then use that as a reference to get the relevant Custom Field IDs. +- Add test cases for the new Card methods that fetch Custom Field definitions & values. + - Add early test that assumes the test-target Favro organization has Custom Fields of each type. + - Require at least one case where two fields have the same name + - Require at least one case where a name is guaranteed to be unique + - `client.findCustomFieldDefinitionById` + - `card.getCustomFields` + - `card.getCustomFieldByFieldId` + - `card.getCustomFieldByName` +- Once all of the getters are passing their tests, get to work on Custom Field Value setters. ### Feature List diff --git a/src/lib/clientLib/BravoResponse.ts b/src/lib/clientLib/BravoResponse.ts index 4695efc..5b454f1 100644 --- a/src/lib/clientLib/BravoResponse.ts +++ b/src/lib/clientLib/BravoResponse.ts @@ -1,7 +1,7 @@ import { BravoClient } from '$lib/BravoClient'; import { BravoEntity } from '$lib/BravoEntity'; import type { FavroResponse } from './FavroResponse'; -import type { DataFavroCustomField } from '$/types/FavroCustomFieldTypes.js'; +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'; @@ -12,8 +12,8 @@ export type BravoResponseWidgets = BravoResponseEntities< >; export type BravoResponseCustomFields = BravoResponseEntities< - DataFavroCustomField, - BravoCustomFieldDefinition + DataFavroCustomFieldDefinition, + BravoCustomFieldDefinition >; export type BravoResponseEntitiesMatchFunction = ( @@ -97,7 +97,10 @@ export class BravoResponseEntities< /** * Find an entity by an identifier field. */ - async findById(identifierName: string, identifierValue: string) { + async findById( + identifierName: string, + identifierValue: string, + ): Promise { // Ensure we have the per-identifier name cache this._entitiesCachedById[identifierName] ||= {}; const cache = this._entitiesCachedById[identifierName]; diff --git a/src/lib/entities/BravoCard.ts b/src/lib/entities/BravoCard.ts index 74d8f02..7d52d55 100644 --- a/src/lib/entities/BravoCard.ts +++ b/src/lib/entities/BravoCard.ts @@ -1,6 +1,7 @@ import type { DataFavroCard, DataFavroCardFavroAttachment, + DataFavroCustomFieldType, } from '$/types/FavroCardTypes.js'; import type { FavroApiParamsCardUpdate, @@ -9,13 +10,18 @@ import type { import { BravoEntity } from '$lib/BravoEntity.js'; import { assertBravoClaim } from '../errors.js'; import { + createIsMatchFilter, ensureArrayExistsAndAddUnique, ensureArrayExistsAndAddUniqueBy, + isMatch, removeFromArray, wrapIfNotArray, } from '../utility.js'; import type { BravoColumn } from './BravoColumn.js'; -import { BravoCustomFieldDefinition } from './BravoCustomField.js'; +import { + BravoCustomField, + BravoCustomFieldDefinition, +} from './BravoCustomField.js'; /** * A Card update can be pretty complex, and to save API @@ -315,18 +321,17 @@ export class BravoCardInstance extends BravoEntity { } /** - * Get the custom field definitions associated with - * the custom field values that are set on this card. + * Get the custom field definitions and values associated with + * this card. * - * Note that definitions are only discoverable for *set* - * custom field values on the card, even though in the Favro UI you - * can see others that are not set. + * (Note that these are *sparse* -- only fields for which this + * card has a value are returned.) */ - async getCustomFieldDefinitions() { - const cardFieldValueMap: { - [customFieldId: string]: BravoCustomFieldDefinition; - } = {}; + async getCustomFields() { + const cardCustomFields: BravoCustomField[] = []; const definitions = await this._client.listCustomFieldDefinitions(); + // Loop over the values set on this card and find the associated + // definitions. for (const value of this.customFieldsValuesRaw) { const definition = await definitions.findById( 'customFieldId', @@ -336,28 +341,99 @@ export class BravoCardInstance extends BravoEntity { definition, `Could not find Custom Field with ID ${value.customFieldId}`, ); - cardFieldValueMap[value.customFieldId] = definition; + cardCustomFields.push(new BravoCustomField(definition, value)); + } + return cardCustomFields; + } + + /** + * Get the current value of a Custom Status Field on this card, + * including field definition information. If the field is not + * set, will still return the definition with the value set to + * `undefined`. + * + * > 💡 *Note that this method (using `customFieldId`) is the + * only way to guarantee the desired field, since all Custom + * Fields are global in the Favro API.* + */ + async getCustomFieldByFieldId< + FieldType extends DataFavroCustomFieldType = any, + >(customFieldId: string) { + const setFields = await this.getCustomFields(); + const matchingField = setFields.find( + (field) => field.customFieldId == customFieldId, + ); + if (matchingField) { + return matchingField as BravoCustomField; + } + // Otherwise this field either doesn't exist or is not set on this card. + // Find it from the global pool. + const definition = await this._client.findCustomFieldDefinitionById( + customFieldId, + ); + return new BravoCustomField(definition); + } + + /** + * Get the current value of a Custom Status Field on this card, + * searching by the Custom Field `name` and type `type`. + * + * > ⚠ Since Custom Fields are global in Favro, names can be changed, + * and names may not be unique, this method has caveats. Read the rest + * of this doc to fully understand them, and otherwise use the safer + * {@link getCustomFieldByFieldId} where possible. + * + * This method only returns a `BravoCustomField` instance if + * *either* of the following are true: + * + * - Exactly one field of type `type` on this Card matches the `name`; or + * - Exactly one of all of the Organization's Custom Fields of type `type` matches the `name`. + * + * If neither of these are true, then it is not possible for + * this method to guarantee returning the desired field and so + * an error will be thrown. + * + */ + async getCustomFieldByName( + name: string | RegExp, + type: FieldType, + ) { + // Check the fields ON the Card first. + const fieldDefsOnCard = await this.getCustomFields(); + const matchFilter = ( + field: BravoCustomField | BravoCustomFieldDefinition, + ) => { + return field.type === type && isMatch(field.name, name); + }; + const matchingFieldsOnCard = fieldDefsOnCard.filter(matchFilter); + if (matchingFieldsOnCard.length === 1) { + return matchingFieldsOnCard[0] as BravoCustomField; } - return cardFieldValueMap; - } - - // /** - // * Get full information about the populated custom fields - // * on this Card. - // */ - // async getCustomFieldsValues() { - // const definitions = await this.getCustomFieldDefinitions(); - // const values: BravoCustomFieldValue[] = []; - // for (const rawValue of this.customFieldsValuesRaw) { - // values.push( - // new BravoCustomFieldValue( - // rawValue, - // definitions[rawValue.customFieldId], - // ), - // ); - // } - // return values; - // } + assertBravoClaim( + matchingFieldsOnCard.length == 0, + `Multiple Custom Fields on the Card match the name ${name} on this card.`, + ); + + // If exactly one Custom Field in the whole Organization matches, + // we're still giving the user what they want. (Probably.) + const allDefinitions = await this._client.listCustomFieldDefinitions(); + const matchingDefinitions: BravoCustomField[] = []; + for await (const definition of allDefinitions) { + if (matchFilter(definition)) { + matchingDefinitions.push(new BravoCustomField(definition)); + assertBravoClaim( + matchingDefinitions.length == 1, + 'No matching fields found on the Card, ' + + 'but more than one found in the global list of Custom Fields', + ); + } + } + assertBravoClaim( + matchingDefinitions.length === 1, + 'No matching fields found', + ); + return matchingDefinitions[0]; + } /** * Get the list of Columns (statuses) that this Card Instance diff --git a/src/lib/entities/BravoCustomField.ts b/src/lib/entities/BravoCustomField.ts index 421d3ec..bff6981 100644 --- a/src/lib/entities/BravoCustomField.ts +++ b/src/lib/entities/BravoCustomField.ts @@ -1,10 +1,13 @@ -import type { DataFavroCustomField } from '$/types/FavroCustomFieldTypes.js'; +import { + DataFavroCustomFieldsValues, + DataFavroCustomFieldType, +} from '$/types/FavroCardTypes.js'; +import type { DataFavroCustomFieldDefinition } from '$/types/FavroCustomFieldTypes.js'; import { BravoEntity } from '$lib/BravoEntity.js'; -export type BravoCustomFieldTypeName = - typeof BravoCustomFieldDefinition['typeNames'][number]; - -export class BravoCustomFieldDefinition extends BravoEntity { +export class BravoCustomFieldDefinition< + TypeName extends DataFavroCustomFieldType, +> extends BravoEntity> { get name() { return this._data.name; } @@ -34,9 +37,10 @@ export class BravoCustomFieldDefinition extends BravoEntity) { return ( - this.hasSameConstructor(org) && this.customFieldId === org.customFieldId + this.hasSameConstructor(fieldDefinition) && + this.customFieldId === fieldDefinition.customFieldId ); } @@ -58,3 +62,36 @@ export class BravoCustomFieldDefinition extends BravoEntity { + public readonly customFieldId: string; + public readonly type: TypeName; + private _value?: DataFavroCustomFieldsValues[TypeName]; + + constructor( + public readonly definition: BravoCustomFieldDefinition, + value?: DataFavroCustomFieldsValues[TypeName], + ) { + this.customFieldId = definition.customFieldId; + this.type = definition.type; + this._value = value; + } + get name() { + return this.definition.name; + } + set value(value: DataFavroCustomFieldsValues[TypeName]) { + this._value = value; + } + // @ts-expect-error + get value(): DataFavroCustomFieldsValues[TypeName] | undefined { + return this._value; + } + get hasValue() { + return !!this._value; + } +} diff --git a/src/types/FavroCardTypes.ts b/src/types/FavroCardTypes.ts index af6b67c..bfb4cfa 100644 --- a/src/types/FavroCardTypes.ts +++ b/src/types/FavroCardTypes.ts @@ -53,6 +53,37 @@ export type FavroApiGetCardsParams = // FOR SETTING VALUES ON A CARD +export type DataFavroCustomFieldType = + | 'Number' + | 'Time' + | 'Text' + | 'Rating' + | 'Vote' + | 'Checkbox' + | 'Date' + | 'Timeline' + | 'Link' + | 'Members' + | 'Tags' + | 'Status' + | 'Multiple select'; + +export type DataFavroCustomFieldsValues = { + Number: DataFavroCardFieldNumber; + Time: DataFavroCardFieldTime; + Text: DataFavroCardFieldText; + Rating: DataFavroCardFieldRating; + Vote: DataFavroCardFieldVote; + Checkbox: DataFavroCardFieldCheckbox; + Date: DataFavroCardFieldDate; + Timeline: DataFavroCardFieldTimeline; + Link: DataFavroCardFieldLink; + Members: DataFavroCardFieldMembers; + Tags: DataFavroCardFieldTags; + Status: DataFavroCardFieldStatus; + 'Multiple select': DataFavroCardFieldMultipleSelect; +}; + /** * {@link https://favro.com/developer/#card-custom-fields} */ diff --git a/src/types/FavroCustomFieldTypes.ts b/src/types/FavroCustomFieldTypes.ts index 9fb3707..bcfc359 100644 --- a/src/types/FavroCustomFieldTypes.ts +++ b/src/types/FavroCustomFieldTypes.ts @@ -10,20 +10,22 @@ * {@link https://favro.com/developer/#custom-field-types} */ -import type { BravoCustomFieldTypeName } from '$/lib/entities/BravoCustomField.js'; +import type { DataFavroCustomFieldType } from './FavroCardTypes.js'; /** * The definition data for a custom field. * * {@link https://favro.com/developer/#custom-field} */ -export interface DataFavroCustomField { +export interface DataFavroCustomFieldDefinition< + FieldType extends DataFavroCustomFieldType = DataFavroCustomFieldType, +> { /** The id of the organization that this custom field exists in. */ organizationId: string; /** The id of the custom field. */ customFieldId: string; /** Custom field type. */ - type: BravoCustomFieldTypeName; + type: FieldType; /** The name of the custom field. */ name: string; /** True if the custom field is currently enabled for the organization. */