Skip to content

Commit

Permalink
feat: Add Card methods for fetching associated Custom Field definitio…
Browse files Browse the repository at this point in the history
…ns and values.
  • Loading branch information
adam-coster committed Aug 21, 2021
1 parent 631630e commit de0188d
Show file tree
Hide file tree
Showing 6 changed files with 204 additions and 50 deletions.
15 changes: 10 additions & 5 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
11 changes: 7 additions & 4 deletions src/lib/clientLib/BravoResponse.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -12,8 +12,8 @@ export type BravoResponseWidgets = BravoResponseEntities<
>;

export type BravoResponseCustomFields = BravoResponseEntities<
DataFavroCustomField,
BravoCustomFieldDefinition
DataFavroCustomFieldDefinition,
BravoCustomFieldDefinition<any>
>;

export type BravoResponseEntitiesMatchFunction<Entity> = (
Expand Down Expand Up @@ -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<Entity | undefined> {
// Ensure we have the per-identifier name cache
this._entitiesCachedById[identifierName] ||= {};
const cache = this._entitiesCachedById[identifierName];
Expand Down
138 changes: 107 additions & 31 deletions src/lib/entities/BravoCard.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
DataFavroCard,
DataFavroCardFavroAttachment,
DataFavroCustomFieldType,
} from '$/types/FavroCardTypes.js';
import type {
FavroApiParamsCardUpdate,
Expand All @@ -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
Expand Down Expand Up @@ -315,18 +321,17 @@ export class BravoCardInstance extends BravoEntity<DataFavroCard> {
}

/**
* 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<any>[] = [];
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',
Expand All @@ -336,28 +341,99 @@ export class BravoCardInstance extends BravoEntity<DataFavroCard> {
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<FieldType>;
}
// 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<FieldType>(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<FieldType extends DataFavroCustomFieldType = any>(
name: string | RegExp,
type: FieldType,
) {
// Check the fields ON the Card first.
const fieldDefsOnCard = await this.getCustomFields();
const matchFilter = (
field: BravoCustomField<any> | BravoCustomFieldDefinition<FieldType>,
) => {
return field.type === type && isMatch(field.name, name);
};
const matchingFieldsOnCard = fieldDefsOnCard.filter(matchFilter);
if (matchingFieldsOnCard.length === 1) {
return matchingFieldsOnCard[0] as BravoCustomField<FieldType>;
}
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<FieldType>[] = [];
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
Expand Down
51 changes: 44 additions & 7 deletions src/lib/entities/BravoCustomField.ts
Original file line number Diff line number Diff line change
@@ -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<DataFavroCustomField> {
export class BravoCustomFieldDefinition<
TypeName extends DataFavroCustomFieldType,
> extends BravoEntity<DataFavroCustomFieldDefinition<TypeName>> {
get name() {
return this._data.name;
}
Expand Down Expand Up @@ -34,9 +37,10 @@ export class BravoCustomFieldDefinition extends BravoEntity<DataFavroCustomField
return this._data.enabled;
}

equals(org: BravoCustomFieldDefinition) {
equals(fieldDefinition: BravoCustomFieldDefinition<any>) {
return (
this.hasSameConstructor(org) && this.customFieldId === org.customFieldId
this.hasSameConstructor(fieldDefinition) &&
this.customFieldId === fieldDefinition.customFieldId
);
}

Expand All @@ -58,3 +62,36 @@ export class BravoCustomFieldDefinition extends BravoEntity<DataFavroCustomField
] as const;
}
}

/**
* A combination of a Custom Field Definition and (optional) Value,
* with helper methods for getting and setting the value on a card
* in a user-friendly way.
*/
export class BravoCustomField<TypeName extends DataFavroCustomFieldType> {
public readonly customFieldId: string;
public readonly type: TypeName;
private _value?: DataFavroCustomFieldsValues[TypeName];

constructor(
public readonly definition: BravoCustomFieldDefinition<TypeName>,
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;
}
}
31 changes: 31 additions & 0 deletions src/types/FavroCardTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}
*/
Expand Down
8 changes: 5 additions & 3 deletions src/types/FavroCustomFieldTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down

0 comments on commit de0188d

Please sign in to comment.