Skip to content

Commit

Permalink
62: Add story links (#70)
Browse files Browse the repository at this point in the history
  • Loading branch information
JensAstrup authored Mar 31, 2024
1 parent 22861f9 commit b7d8459
Show file tree
Hide file tree
Showing 7 changed files with 190 additions and 1 deletion.
9 changes: 9 additions & 0 deletions src/base-resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ export default class ShortcutResource<T = object> {
* @throws {Error} - Throws an error if the HTTP request fails.
*/
public async update(): Promise<void> {
if (!(this.availableOperations.includes('update'))) {
throw new Error('Update operation not available for this resource')
}
const baseUrl = (this.constructor as typeof ShortcutResource).baseUrl
const url = `${baseUrl}/${this.id}`
const body = this.changedFields.reduce((acc: Record<string, unknown>, field) => {
Expand All @@ -89,6 +92,9 @@ export default class ShortcutResource<T = object> {
* @throws {Error} - Throws an error if the HTTP request fails.
*/
public async create(): Promise<this> {
if (!(this.availableOperations.includes('create'))) {
throw new Error('Create operation not available for this resource')
}
const baseUrl = (this.constructor as typeof ShortcutResource).baseUrl
const body: Record<string, unknown> = {}
Object.keys(this).forEach(key => {
Expand Down Expand Up @@ -123,6 +129,9 @@ export default class ShortcutResource<T = object> {
* @throws {Error} - Throws an error if the HTTP request fails.
*/
public async delete(): Promise<void> {
if (!(this.availableOperations.includes('delete'))) {
throw new Error('Delete operation not available for this resource')
}
const url = `${this.baseUrl}/${this.id}`
await axios.delete(url, {headers: getHeaders()}).catch((error) => {
throw new Error(`Error deleting resource: ${error}`)
Expand Down
12 changes: 12 additions & 0 deletions src/stories/links/contracts/story-link-api-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import BaseData from '@sx/base-data'

export default interface StoryLinkApiData extends BaseData {
created_at: string
entity_type: string
id: number
object_id: number
subject_id: number
type: string
updated_at: string
verb: string
}
12 changes: 12 additions & 0 deletions src/stories/links/contracts/story-link-interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import BaseInterface from '@sx/base-interface'

export default interface StoryLinkInterface extends BaseInterface {
createdAt: string
entityType: string
id: number
objectId: number
subjectId: number
type: string
updatedAt: string
verb: 'blocks' | 'duplicates' | 'relates to'
}
23 changes: 23 additions & 0 deletions src/stories/links/story-link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import ShortcutResource, {ResourceOperation} from '@sx/base-resource'


export default class StoryLink extends ShortcutResource {
public static baseUrl = 'https://api.app.shortcut.com/api/v3/story-links'
public availableOperations: ResourceOperation[] = ['delete', 'create', 'update']
public createFields: string[] = ['subjectId', 'verb', 'objectId']

constructor(init: object) {
super()
Object.assign(this, init)
this.changedFields = []
}

createdAt!: string
entityType!: string
id!: number
objectId!: number
subjectId!: number
type!: string
updatedAt!: string
verb!: 'blocks' | 'duplicates' | 'relates to'
}
27 changes: 26 additions & 1 deletion src/stories/story.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import StoryComment from '@sx/stories/comment/story-comment'
import Task from '@sx/stories/tasks/task'
import TaskInterface from '@sx/stories/tasks/contracts/task-interface'
import TaskApiData from '@sx/stories/tasks/contracts/task-api-data'
import StoryLinkInterface from '@sx/stories/links/contracts/story-link-interface'
import StoryLink from '@sx/stories/links/story-link'


/**
Expand All @@ -39,6 +41,7 @@ export default class Story extends ShortcutResource {
this.changedFields = []
this.instantiateComments()
this.instantiateTasks()
this.instantiateLinks()
}

get workflow() {
Expand Down Expand Up @@ -147,6 +150,10 @@ export default class Story extends ShortcutResource {
this.tasks = this.tasks?.map((task: TaskInterface | Task) => new Task(task))
}

private instantiateLinks() {
this.storyLinks = this.storyLinks?.map((link: StoryLinkInterface | StoryLink) => new StoryLink(link))
}

public async addTask(task: string): Promise<void> {
const url = `${Story.baseUrl}/stories/${this.id}/tasks`
const requestData = {description: task}
Expand All @@ -158,6 +165,24 @@ export default class Story extends ShortcutResource {
this.tasks.push(createdTask)
}

public async blocks(story: Story | number): Promise<void> {
const link: StoryLink = new StoryLink({objectId: this.id, verb: 'blocks', subjectId: story instanceof Story ? story.id : story})
await link.save()
this.storyLinks.push(link)
}

public async duplicates(story: Story | number): Promise<void> {
const link: StoryLink = new StoryLink({objectId: this.id, verb: 'duplicates', subjectId: story instanceof Story ? story.id : story})
await link.save()
this.storyLinks.push(link)
}

public async relatesTo(story: Story | number): Promise<void> {
const link: StoryLink = new StoryLink({objectId: this.id, verb: 'relates to', subjectId: story instanceof Story ? story.id : story})
await link.save()
this.storyLinks.push(link)
}


appUrl!: string
archived!: boolean
Expand Down Expand Up @@ -203,7 +228,7 @@ export default class Story extends ShortcutResource {
startedAt!: Date | null
startedAtOverride!: Date | null
stats!: object
storyLinks!: object[]
storyLinks!: StoryLinkInterface[] | StoryLink[]
storyTemplateId!: string | null
storyType!: string
syncedItem!: object
Expand Down
41 changes: 41 additions & 0 deletions tests/base-class.test.js → tests/base-resource.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ describe('ShortcutResource', () => {
describe('save method', () => {
it('calls update if id exists', async () => {
const resource = new ShortcutResource({id: 123})
resource.availableOperations = ['update']
resource.name = 'Updated Name'
axios.put.mockResolvedValue({data: {snake_name: 'Updated Name'}})

Expand All @@ -38,8 +39,17 @@ describe('ShortcutResource', () => {
expect(resource.name).toBe('Updated Name')
})

it('throws an error on update if operation is not available', async () => {
const resource = new ShortcutResource({id: 123})
resource.availableOperations = ['create']
resource.name = 'Updated Name'

await expect(resource.save()).rejects.toThrow('Update operation not available for this resource')
})

it('calls create if id does not exist', async () => {
const resource = new ShortcutResource()
resource.availableOperations = ['create']
resource.name = 'New Name'
axios.post.mockResolvedValue({data: {id: 123, snake_name: 'New Name'}})

Expand All @@ -49,20 +59,51 @@ describe('ShortcutResource', () => {
expect(resource.id).toBe(123)
expect(resource.name).toBe('New Name')
})

it('calls create if id does not exist and uses createFields', async () => {
const resource = new ShortcutResource()
resource.availableOperations = ['create']
resource.createFields = ['snake_name']
resource.name = 'New Name'
axios.post.mockResolvedValue({data: {id: 123, snake_name: 'New Name'}})

await resource.save()

expect(axios.post).toHaveBeenCalledWith(expect.any(String), expect.any(Object), {headers: mockHeaders})
expect(resource.id).toBe(123)
expect(resource.name).toBe('New Name')
})

it('throws an error on create if operation is not available', async () => {
const resource = new ShortcutResource()
resource.availableOperations = ['update']
resource.name = 'New Name'

await expect(resource.save()).rejects.toThrow('Create operation not available for this resource')
})
})

describe('delete method', () => {
it('sends a delete request for the resource', async () => {
const resource = new ShortcutResource({id: 123})
resource.availableOperations = ['delete']
axios.delete.mockResolvedValue({})

await resource.delete()

expect(axios.delete).toHaveBeenCalledWith(expect.any(String), {headers: mockHeaders})
})

it('throws an error if delete operation is not available', async () => {
const resource = new ShortcutResource({id: 123})
resource.availableOperations = ['update']

await expect(resource.delete()).rejects.toThrow('Delete operation not available for this resource')
})

it('throws an error on delete failure', async () => {
const resource = new ShortcutResource({id: 123})
resource.availableOperations = ['delete']
axios.delete.mockRejectedValue(new Error('Error deleting story'))

await expect(resource.delete()).rejects.toThrow('Error deleting story')
Expand Down
67 changes: 67 additions & 0 deletions tests/stories/story.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import axios from 'axios'
import {before} from 'node:test'
import Story from '../../src/stories/story'
import {convertApiFields} from '../../src/utils/convert-fields'
import {getHeaders} from '../../src/utils/headers'
Expand Down Expand Up @@ -205,6 +206,12 @@ describe('Story', () => {
expect(story.tasks[0].description).toEqual('Test task')
})

it('should instantiate story links', () => {
const story = new Story({id: 1, storyLinks: [{id: 1, verb: 'blocks', objectId: 2}]})
expect(story.storyLinks[0].verb).toEqual('blocks')
expect(story.storyLinks[0].objectId).toEqual(2)
})

describe('addTasks method', () => {
it('should post task data and add new task to tasks property', async () => {
const taskData = {description: 'Test task'}
Expand All @@ -225,4 +232,64 @@ describe('Story', () => {
await expect(story.addTask('Test task')).rejects.toThrow('Error adding task: Error: Network error')
})
})

describe('blocks method', () => {
it('should add blocking story with number argument', async () => {
axios.post.mockResolvedValue({data: {id: 2}})
const story = new Story({id: 1, storyLinks: []})
await story.blocks(2)
expect(story.storyLinks[0].objectId).toEqual(1)
expect(story.storyLinks[0].verb).toEqual('blocks')
expect(story.storyLinks[0].subjectId).toEqual(2)
})

it('should add blocking story with story object argument', async () => {
axios.post.mockResolvedValue({data: {id: 2}})
const story = new Story({id: 1, storyLinks: []})
await story.blocks(new Story({id: 2}))
expect(story.storyLinks[0].objectId).toEqual(1)
expect(story.storyLinks[0].verb).toEqual('blocks')
expect(story.storyLinks[0].subjectId).toEqual(2)
})
})

describe('duplicated method', () => {
it('should add duplicated story with number argument', async () => {
axios.post.mockResolvedValue({data: {id: 2}})
const story = new Story({id: 1, storyLinks: []})
await story.duplicates(2)
expect(story.storyLinks[0].objectId).toEqual(1)
expect(story.storyLinks[0].verb).toEqual('duplicates')
expect(story.storyLinks[0].subjectId).toEqual(2)
})

it('should add duplicated story with story object argument', async () => {
axios.post.mockResolvedValue({data: {id: 2}})
const story = new Story({id: 1, storyLinks: []})
await story.duplicates(new Story({id: 2}))
expect(story.storyLinks[0].objectId).toEqual(1)
expect(story.storyLinks[0].verb).toEqual('duplicates')
expect(story.storyLinks[0].subjectId).toEqual(2)
})
})

describe('relatedTo method', () => {
it('should add related story with number argument', async () => {
axios.post.mockResolvedValue({data: {id: 2}})
const story = new Story({id: 1, storyLinks: []})
await story.relatesTo(2)
expect(story.storyLinks[0].objectId).toEqual(1)
expect(story.storyLinks[0].verb).toEqual('relates to')
expect(story.storyLinks[0].subjectId).toEqual(2)
})

it('should add related story with story object argument', async () => {
axios.post.mockResolvedValue({data: {id: 2}})
const story = new Story({id: 1, storyLinks: []})
await story.relatesTo(new Story({id: 2}))
expect(story.storyLinks[0].objectId).toEqual(1)
expect(story.storyLinks[0].verb).toEqual('relates to')
expect(story.storyLinks[0].subjectId).toEqual(2)
})
})
})

0 comments on commit b7d8459

Please sign in to comment.