diff --git a/README.md b/README.md index 9c454d6..aa1dcde 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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)** diff --git a/ROADMAP.md b/ROADMAP.md index 0528f7f..e5cdad8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/src/lib/entities/BravoCard.ts b/src/lib/entities/BravoCard.ts index f4bc7f7..b49057e 100644 --- a/src/lib/entities/BravoCard.ts +++ b/src/lib/entities/BravoCard.ts @@ -308,15 +308,39 @@ export class BravoCardInstance extends BravoEntity { } async setCustomMulipleSelect( - ...args: Parameters + ...args: Parameters ) { - return await this.updateField('setCustomMulipleSelect', ...args); + return await this.updateField('setCustomMultipleSelect', ...args); } async setCustomMulipleSelectByName( - ...args: Parameters + ...args: Parameters ) { - return await this.updateField('setCustomMulipleSelectByName', ...args); + return await this.updateField('setCustomMultipleSelectByName', ...args); + } + + async addCustomMembers( + ...args: Parameters + ) { + return await this.updateField('addCustomMembers', ...args); + } + + async removeCustomMembers( + ...args: Parameters + ) { + return await this.updateField('removeCustomMembers', ...args); + } + + async completeCustomMembers( + ...args: Parameters + ) { + return await this.updateField('completeCustomMembers', ...args); + } + + async uncompleteCustomMembers( + ...args: Parameters + ) { + return await this.updateField('uncompleteCustomMembers', ...args); } //#endregion diff --git a/src/lib/entities/BravoCardUpdateBuilder.ts b/src/lib/entities/BravoCardUpdateBuilder.ts index 4e4b0d8..ff694a6 100644 --- a/src/lib/entities/BravoCardUpdateBuilder.ts +++ b/src/lib/entities/BravoCardUpdateBuilder.ts @@ -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, @@ -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 })[], ) { @@ -281,8 +283,8 @@ export class BravoCardUpdateBuilder { }); } - setCustomMulipleSelectByName( - customFieldId: CustomFieldOrId<'Multiple select'>, + setCustomMultipleSelectByName( + customFieldOrId: CustomFieldOrId<'Multiple select'>, optionNames: (string | RegExp)[], fieldDefinition: | DataFavroCustomFieldDefinition @@ -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 @@ -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; diff --git a/src/lib/entities/BravoCustomField.ts b/src/lib/entities/BravoCustomField.ts index 0383a5f..7aa5148 100644 --- a/src/lib/entities/BravoCustomField.ts +++ b/src/lib/entities/BravoCustomField.ts @@ -174,6 +174,16 @@ export class BravoCustomField { ); } + 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 { @@ -218,7 +228,7 @@ export class BravoCustomField { 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; diff --git a/src/test/client.test.ts b/src/test/client.test.ts index d445659..6a07907 100644 --- a/src/test/client.test.ts +++ b/src/test/client.test.ts @@ -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, @@ -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( diff --git a/src/types/FavroCardUpdateTypes.ts b/src/types/FavroCardUpdateTypes.ts index 2a20057..95eb104 100644 --- a/src/types/FavroCardUpdateTypes.ts +++ b/src/types/FavroCardUpdateTypes.ts @@ -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 { @@ -41,7 +46,7 @@ interface DataFavroCardFieldTimeUpdate { removeUserReports: DataFavroCardFieldTimeUserReport; } -interface DataFavroCardFieldVoteUpdate { +interface DataFavroCardFieldVotingUpdate { /** * The value to determine the field should be either voted or unvoted. * @@ -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; -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, any[]