Skip to content

Commit

Permalink
feat: Add methods for setting Custom Members fields.
Browse files Browse the repository at this point in the history
  • Loading branch information
adam-coster committed Aug 28, 2021
1 parent 6872e0a commit 24af17d
Show file tree
Hide file tree
Showing 7 changed files with 206 additions and 50 deletions.
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,12 @@ const bravoClient = new BravoClient({
9. [Tips, Tricks, and Limitations](#tips-tricks-and-limitations)
1. [API Rate Limits](#api-rate-limits)
2. [Searching](#searching)
3. [Limited Markdown](#limited-markdown)
4. [Identifiers](#identifiers)
3. [Member fields & "completion"](#member-fields--completion)
4. [Limited Markdown](#limited-markdown)
5. [Identifiers](#identifiers)
1. [Card Sequential IDs](#card-sequential-ids)
2. [Widget-specific `cardId`s](#widget-specific-cardids)
5. [Creating Boards](#creating-boards)
6. [Creating Boards](#creating-boards)

## Authentication

Expand Down Expand Up @@ -346,6 +347,20 @@ To _find_ something via the API then requires an exhaustive search followed by l

Bravo does some caching and lazy-loading to reduce the impact of this on the number of API requests it makes, but the end result is always going to be that search functionality in Bravo has to consume a lot of API requests, especially if you have a lot of stuff in Favro.

### Member fields & "completion"

Cards have a default Members field, and also allows for Custom Members fields. You'll notice that, via the Favro webapp, if you click the "Mark as complete" button you'll get a checkmark next to your avatar *in every Members-type field*. (And all will be unchecked if you then mark as incomplete.) But via the API, you mark a user "complete" via the built-in *or* via the Custom Members field.

So what happens when you mark a user as complete via the API, given the combinations of ways a user can be assigned to any subset of the default and any Custom Members fields?

- All Member field "completion" statuses are **completely independent** via the API.
- The Favro webapp will show checkmarks correctly by field -- if you used the API to mark the default field "complete" but not a Custom Field, you'll see the checkmark in the default Members assignment but not the Custom one.
- The Favro webapp will only show the Card as "complete" if *all* Member fields are complete.
- The Favro webapp couples completion state between all fields when you use the "Mark as (in)complete" button.
- If you use the context menu for an individual Member field in the Favro webapp, you can separately manage completion state between fields via the app.
- The Favro API *does not* provide a way for you to obtain a user's "complete" status for a Custom Members field via the API. You can *set* the state but not *get* the state! ([Upvote the feature request!](https://favro.canny.io/feature-requests/p/favro-api-return-complete-state-for-custom-members-fields))


### Limited Markdown

> ⚠ Webhooks do not send Markdown. To get full Markdown content in response to a Webhook, you'll have to fetch the same card again via the API. **[Upvote the feature request!](https://favro.canny.io/bugs/p/webhooks-no-way-to-get-correct-description)**
Expand Down
3 changes: 2 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@
- ✅ Link
- ✅ Status
- ✅ Multiple select
- Members
- Members
- Timeline
- Tags
- ~~Time~~ (only used in Enterprise)
- Clean up all ONE MILLION types. Probably need to wrap in namespaces to keep it manageable.
- Are custom fields unsettable? Maybe by sending a `null`?

### Feature List
Expand Down
32 changes: 28 additions & 4 deletions src/lib/entities/BravoCard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,15 +308,39 @@ export class BravoCardInstance extends BravoEntity<DataFavroCard> {
}

async setCustomMulipleSelect(
...args: Parameters<BravoCardUpdateBuilder['setCustomMulipleSelect']>
...args: Parameters<BravoCardUpdateBuilder['setCustomMultipleSelect']>
) {
return await this.updateField('setCustomMulipleSelect', ...args);
return await this.updateField('setCustomMultipleSelect', ...args);
}

async setCustomMulipleSelectByName(
...args: Parameters<BravoCardUpdateBuilder['setCustomMulipleSelectByName']>
...args: Parameters<BravoCardUpdateBuilder['setCustomMultipleSelectByName']>
) {
return await this.updateField('setCustomMulipleSelectByName', ...args);
return await this.updateField('setCustomMultipleSelectByName', ...args);
}

async addCustomMembers(
...args: Parameters<BravoCardUpdateBuilder['addCustomMembers']>
) {
return await this.updateField('addCustomMembers', ...args);
}

async removeCustomMembers(
...args: Parameters<BravoCardUpdateBuilder['removeCustomMembers']>
) {
return await this.updateField('removeCustomMembers', ...args);
}

async completeCustomMembers(
...args: Parameters<BravoCardUpdateBuilder['completeCustomMembers']>
) {
return await this.updateField('completeCustomMembers', ...args);
}

async uncompleteCustomMembers(
...args: Parameters<BravoCardUpdateBuilder['uncompleteCustomMembers']>
) {
return await this.updateField('uncompleteCustomMembers', ...args);
}

//#endregion
Expand Down
85 changes: 78 additions & 7 deletions src/lib/entities/BravoCardUpdateBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import {
FavroApiParamsCardCustomField,
FavroApiParamsCardUpdate,
FavroApiParamsCardUpdateArrayField,
FavroApiParamsCardUpdateCustomField,
} from '$/types/FavroCardUpdateTypes.js';
import { DataFavroCustomFieldDefinition } from '$/types/FavroCustomFieldTypes.js';
import { RequiredBy } from '$/types/Utility.js';
import { assertBravoClaim } from '../errors.js';
import {
addToUniqueArrayBy,
createIsMatchFilter,
ensureArrayExistsAndAddUnique,
ensureArrayExistsAndAddUniqueBy,
Expand Down Expand Up @@ -270,7 +272,7 @@ export class BravoCardUpdateBuilder {
return this.setCustomFieldUniquely(customFieldId, { total: rating });
}

setCustomMulipleSelect(
setCustomMultipleSelect(
customFieldOrId: CustomFieldOrId<'Multiple select'>,
optionOrIds: (string | { customFieldItemId: string; name: string })[],
) {
Expand All @@ -281,8 +283,8 @@ export class BravoCardUpdateBuilder {
});
}

setCustomMulipleSelectByName(
customFieldId: CustomFieldOrId<'Multiple select'>,
setCustomMultipleSelectByName(
customFieldOrId: CustomFieldOrId<'Multiple select'>,
optionNames: (string | RegExp)[],
fieldDefinition:
| DataFavroCustomFieldDefinition
Expand All @@ -297,13 +299,84 @@ export class BravoCardUpdateBuilder {
matchingOptions.length === optionNames.length,
`Expected to find ${optionNames.length} matching options, but found ${matchingOptions.length}.`,
);
return this.setCustomMulipleSelect(
customFieldId,
return this.setCustomMultipleSelect(
customFieldOrId,
matchingOptions.map((o) => o.customFieldItemId),
);
}

// TODO: Map these onto Card methods & test
private updateCustomMembers(
customFieldOrId: CustomFieldOrId<'Members'>,
members: (string | BravoUser)[],
action: 'add' | 'remove' | 'complete' | 'uncomplete',
) {
const userIds = stringsOrObjectsToStrings(members, 'userId');
const customFieldId =
typeof customFieldOrId == 'string'
? customFieldOrId
: customFieldOrId.customFieldId;
let update = this.update.customFields.find(
(f) => f.customFieldId == customFieldId,
) as FavroApiParamsCardUpdateCustomField<'Members'> | undefined;
if (!update) {
update = {
customFieldId,
members: {
addUserIds: [],
removeUserIds: [],
completeUsers: [],
},
};
this.update.customFields.push(update);
}
if (action == 'add') {
update.members.addUserIds = userIds;
} else if (action == 'remove') {
update.members.removeUserIds = userIds;
} else if (action == 'complete') {
addToUniqueArrayBy(
update.members.completeUsers,
'userId',
userIds.map((u) => ({ userId: u, completed: true })),
);
} else if (action == 'uncomplete') {
addToUniqueArrayBy(
update.members.completeUsers,
'userId',
userIds.map((u) => ({ userId: u, completed: false })),
);
}
return this;
}

addCustomMembers(
customFieldOrId: CustomFieldOrId<'Members'>,
users: (string | BravoUser)[],
) {
return this.updateCustomMembers(customFieldOrId, users, 'add');
}

removeCustomMembers(
customFieldOrId: CustomFieldOrId<'Members'>,
users: (string | BravoUser)[],
) {
return this.updateCustomMembers(customFieldOrId, users, 'remove');
}

completeCustomMembers(
customFieldOrId: CustomFieldOrId<'Members'>,
users: (string | BravoUser)[],
) {
return this.updateCustomMembers(customFieldOrId, users, 'complete');
}

uncompleteCustomMembers(
customFieldOrId: CustomFieldOrId<'Members'>,
users: (string | BravoUser)[],
) {
return this.updateCustomMembers(customFieldOrId, users, 'uncomplete');
}

/**
* Get a plain update object that can be directly used
Expand Down Expand Up @@ -344,10 +417,8 @@ export class BravoCardUpdateBuilder {
opposingField?: FavroApiParamsCardUpdateArrayField,
) {
this.update[updateField] ||= [];
// @ts-expect-error
ensureArrayExistsAndAddUnique(this.update[updateField], values);
if (opposingField) {
// @ts-expect-error
removeFromArray(this.update[opposingField], values);
}
return this;
Expand Down
12 changes: 11 additions & 1 deletion src/lib/entities/BravoCustomField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,16 @@ export class BravoCustomField<TypeName extends DataFavroCustomFieldType> {
);
}

get assignedTo(): TypeName extends 'Members' ? string[] : never {
const { type: fieldType } = this;
assertBravoClaim(
fieldType === 'Members',
`Fields of type ${fieldType} do not have members assigned to them.`,
);
// @ts-expect-error
return this.value?.value || [];
}

get humanFriendlyValue():
| BravoHumanFriendlyFieldValues[TypeName]
| undefined {
Expand Down Expand Up @@ -218,7 +228,7 @@ export class BravoCustomField<TypeName extends DataFavroCustomFieldType> {
return (value as DataFavroCustomFieldsValues['Link']).link;
case 'Members':
// @ts-expect-error
return (value as DataFavroCustomFieldsValues['Members']).value;
return this.assignedTo;
case 'Tags':
// @ts-expect-error
return this.chosenOptions;
Expand Down
18 changes: 16 additions & 2 deletions src/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,7 +580,7 @@ describe('BravoClient', function () {
});

it('can update a Custom Status Field', async function () {
canSkip(this);
// canSkip(this);
// Find a custom text field to test
const customField = await getCustomFieldByType(
client,
Expand All @@ -598,7 +598,21 @@ describe('BravoClient', function () {

xit('can update a Custom Tags Field', async function () {});

xit('can update a Custom Members Field', async function () {});
it('can update a Custom Members Field', async function () {
canSkip(this);
const customField = await getCustomFieldByType(
client,
testCard,
'Members',
);
await testCard.addCustomMembers(customField, [testUser]);
await testCard.completeCustomMembers(customField, [testUser]);
let updatedField = await testCard.getCustomField(customField);
expect(updatedField.assignedTo).to.eql([testUser.userId]);
await testCard.removeCustomMembers(customField, [testUser]);
updatedField = await testCard.getCustomField(customField);
expect(updatedField.assignedTo).to.eql([]);
});

it('can update a Custom Muliple Select Field', async function () {
const customField = await getCustomFieldByType(
Expand Down
85 changes: 53 additions & 32 deletions src/types/FavroCardUpdateTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,31 @@ import type {
DataFavroCardFieldTimeline,
DataFavroCardFavroAttachment,
DataFavroCardFieldTimeUserReport,
DataFavroCustomFieldType,
} from './FavroCardTypes';
import { ExtractKeysByValue } from './Utility.js';

interface DataFavroCardFieldMembersUpdate {
/** The list of members, that will be added to the card custom field (array of userIds).*/
addUserIds: string[];
/** The list of members, that will be removed from card custom field (array of userIds).*/
removeUserIds: string[];
/** The list of card assignment, that will update their statuses on the custom field accordingly.*/
completeUsers: string[];
export interface DataFavroCardFieldMembersUpdate {
members: {
/** The list of members, that will be added to the card custom field (array of userIds).*/
addUserIds: string[];
/** The list of members, that will be removed from card custom field (array of userIds).*/
removeUserIds: string[];
/** The list of card assignment, that will update their statuses on the custom field accordingly.*/
completeUsers: { userId: string; completed: boolean }[];
};
}
interface DataFavroCardFieldTagsUpdate {
/** The list of tag names or card tags that will be added to this card custom field. If the tag does not exist in the organization it will be created.*/
addTags: string[];
/** A list of tagIds that will be added to this card custom field.*/
addTagIds: string[];
/** The list of tag names, that will be removed from this card custom field.*/
removeTags: string[];
/** The list of tag IDs, that will be removed from this card custom field.*/
removeTagIds: string[];
tags: {
/** The list of tag names or card tags that will be added to this card custom field. If the tag does not exist in the organization it will be created.*/
addTags: string[];
/** A list of tagIds that will be added to this card custom field.*/
addTagIds: string[];
/** The list of tag names, that will be removed from this card custom field.*/
removeTags: string[];
/** The list of tag IDs, that will be removed from this card custom field.*/
removeTagIds: string[];
};
}

interface DataFavroCardFieldTimeUpdate {
Expand All @@ -41,7 +46,7 @@ interface DataFavroCardFieldTimeUpdate {
removeUserReports: DataFavroCardFieldTimeUserReport;
}

interface DataFavroCardFieldVoteUpdate {
interface DataFavroCardFieldVotingUpdate {
/**
* The value to determine the field should be either voted or unvoted.
*
Expand All @@ -57,27 +62,43 @@ interface DataFavroCardFieldVoteUpdate {
*
* {@link https://favro.com/developer/#card-custom-field-parameters}
*/
export type FavroApiParamsCardUpdateCustomField = {
export type FavroApiParamsCardUpdateCustomField<
FieldType extends DataFavroCustomFieldType = any,
> = {
customFieldId: string;
} & FavroApiParamsCardCustomField;
} & FavroApiParamsCardCustomField<FieldType>;

export type FavroApiParamsCardCustomField =
| DataFavroCardFieldCheckbox
| DataFavroCardFieldDate
| DataFavroCardFieldLink
| DataFavroCardFieldMultipleSelect
| DataFavroCardFieldNumber
| DataFavroCardFieldRating
| DataFavroCardFieldSingleSelect
| DataFavroCardFieldText
| DataFavroCardFieldTimeline
| DataFavroCardFieldMembersUpdate
| DataFavroCardFieldTagsUpdate
| DataFavroCardFieldTimeUpdate
| DataFavroCardFieldVoteUpdate;
export type FavroApiParamsCardCustomField<
FieldType extends DataFavroCustomFieldType = any,
> = FavroApiParamsCardCustomFields[FieldType];

/**
* Map of Custom Field Types to their respective
* update object shapes.
*/
type FavroApiParamsCardCustomFields = {
Checkbox: DataFavroCardFieldCheckbox;
Date: DataFavroCardFieldDate;
Link: DataFavroCardFieldLink;
'Multiple select': DataFavroCardFieldMultipleSelect;
Number: DataFavroCardFieldNumber;
Rating: DataFavroCardFieldRating;
'Single select': DataFavroCardFieldSingleSelect;
Text: DataFavroCardFieldText;
Timeline: DataFavroCardFieldTimeline;
Members: DataFavroCardFieldMembersUpdate;
Tags: DataFavroCardFieldTagsUpdate;
Time: DataFavroCardFieldTimeUpdate;
Voting: DataFavroCardFieldVotingUpdate;
};

export type FavroApiParamsCardUpdateField = keyof FavroApiParamsCardUpdate;

/**
* Fields for a Card update request whose values are arrays.
*
* Useful for generic methods that act on arrays.
*/
export type FavroApiParamsCardUpdateArrayField = ExtractKeysByValue<
Required<FavroApiParamsCardUpdate>,
any[]
Expand Down

0 comments on commit 24af17d

Please sign in to comment.