diff --git a/apps/asana/README.md b/apps/asana/README.md new file mode 100644 index 0000000000..f45b6c166f --- /dev/null +++ b/apps/asana/README.md @@ -0,0 +1,45 @@ +# Asana App + +Contentful Marketplace app scaffold for an automation-first Asana integration. + +## Current scope + +This first version provides: + +- an app configuration screen, +- secure installation parameters for an Asana personal access token, +- connection validation through a Contentful app action, +- workspace and project lookup so default destinations can be stored for later actions. + +Planned next steps: + +- create Asana task action, +- add comment to task action, +- update task action, +- UX polish and documentation hardening. + +## Local development + +```bash +cd apps/asana +npm install +npm run start +``` + +Build functions before upload: + +```bash +npm run build +``` + +Upsert actions after the app definition exists: + +```bash +npm run upsert-actions +``` + +## Current auth assumption + +This scaffold uses an Asana personal access token for initial local validation and configuration. +That keeps v1 small and testable. A fuller OAuth flow can be added in a later iteration without +changing the automation-oriented action architecture. diff --git a/apps/asana/contentful-app-manifest.json b/apps/asana/contentful-app-manifest.json new file mode 100644 index 0000000000..146c6bd003 --- /dev/null +++ b/apps/asana/contentful-app-manifest.json @@ -0,0 +1,640 @@ +{ + "locations": [ + { + "location": "app-config" + }, + { + "location": "entry-field", + "fieldTypes": [ + { + "type": "Symbol" + }, + { + "type": "Text" + }, + { + "type": "Object" + } + ] + }, + { + "location": "entry-sidebar" + }, + { + "location": "dialog" + } + ], + "functions": [ + { + "id": "validateAsanaCredentialsFunction", + "name": "Validate Asana credentials", + "description": "Validates the configured Asana personal access token by loading visible workspaces.", + "path": "functions/validateAsanaCredentials.js", + "entryFile": "functions/validateAsanaCredentials.ts", + "allowNetworks": [ + "https://app.asana.com" + ], + "accepts": [ + "appaction.call" + ] + }, + { + "id": "getAsanaWorkspacesFunction", + "name": "Get Asana workspaces", + "description": "Returns the visible Asana workspaces for the configured token.", + "path": "functions/getAsanaWorkspaces.js", + "entryFile": "functions/getAsanaWorkspaces.ts", + "allowNetworks": [ + "https://app.asana.com" + ], + "accepts": [ + "appaction.call" + ] + }, + { + "id": "getAsanaProjectsFunction", + "name": "Get Asana projects", + "description": "Searches visible Asana projects for a workspace.", + "path": "functions/getAsanaProjects.js", + "entryFile": "functions/getAsanaProjects.ts", + "allowNetworks": [ + "https://app.asana.com" + ], + "accepts": [ + "appaction.call" + ] + }, + { + "id": "createAsanaTaskFunction", + "name": "Create Asana task", + "description": "Creates an Asana task using configured defaults and per-call overrides.", + "path": "functions/createAsanaTask.js", + "entryFile": "functions/createAsanaTask.ts", + "allowNetworks": [ + "https://app.asana.com" + ], + "accepts": [ + "appaction.call" + ] + }, + { + "id": "updateAsanaTaskFunction", + "name": "Update Asana task", + "description": "Updates an existing Asana task by GID or task URL.", + "path": "functions/updateAsanaTask.js", + "entryFile": "functions/updateAsanaTask.ts", + "allowNetworks": [ + "https://app.asana.com" + ], + "accepts": [ + "appaction.call" + ] + }, + { + "id": "getAsanaTaskFunction", + "name": "Get Asana task details", + "description": "Loads details for an existing Asana task by GID or task URL.", + "path": "functions/getAsanaTask.js", + "entryFile": "functions/getAsanaTask.ts", + "allowNetworks": [ + "https://app.asana.com" + ], + "accepts": [ + "appaction.call" + ] + }, + { + "id": "getAsanaTasksFunction", + "name": "Search Asana tasks", + "description": "Searches visible Asana tasks for a workspace or project.", + "path": "functions/getAsanaTasks.js", + "entryFile": "functions/getAsanaTasks.ts", + "allowNetworks": [ + "https://app.asana.com" + ], + "accepts": [ + "appaction.call" + ] + }, + { + "id": "addAsanaCommentFunction", + "name": "Add Asana comment", + "description": "Adds a comment to an existing Asana task.", + "path": "functions/addAsanaComment.js", + "entryFile": "functions/addAsanaComment.ts", + "allowNetworks": [ + "https://app.asana.com" + ], + "accepts": [ + "appaction.call" + ] + }, + { + "id": "appEventHandler", + "name": "App event handler function", + "description": "Function to handle Contentful app events.", + "path": "functions/appEventHandler.js", + "entryFile": "functions/appEventHandler.ts", + "allowNetworks": [ + "https://app.asana.com" + ], + "accepts": [ + "appevent.handler" + ] + } + ], + "actions": [ + { + "id": "validateAsanaCredentialsAction", + "name": "Validate Asana connection", + "type": "function-invocation", + "functionId": "validateAsanaCredentialsFunction", + "category": "Custom", + "parameters": [ + { + "id": "personalAccessToken", + "name": "Personal Access Token", + "type": "Symbol", + "required": false + } + ], + "resultSchema": { + "type": "object", + "properties": { + "valid": { + "type": "boolean" + }, + "message": { + "type": "string" + } + }, + "required": [ + "valid", + "message" + ] + } + }, + { + "id": "getAsanaWorkspacesAction", + "name": "List Asana workspaces", + "type": "function-invocation", + "functionId": "getAsanaWorkspacesFunction", + "category": "Custom", + "parameters": [ + { + "id": "personalAccessToken", + "name": "Personal Access Token", + "type": "Symbol", + "required": false + } + ], + "resultSchema": { + "type": "object", + "properties": { + "workspaces": { + "type": "array", + "items": { + "type": "object", + "properties": { + "gid": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "gid", + "name" + ] + } + } + }, + "required": [ + "workspaces" + ] + } + }, + { + "id": "getAsanaProjectsAction", + "name": "Search Asana projects", + "type": "function-invocation", + "functionId": "getAsanaProjectsFunction", + "category": "Custom", + "parameters": [ + { + "id": "personalAccessToken", + "name": "Personal Access Token", + "type": "Symbol", + "required": false + }, + { + "id": "workspaceGid", + "name": "Workspace GID", + "type": "Symbol", + "required": true + }, + { + "id": "query", + "name": "Project query", + "type": "Symbol", + "required": false + } + ], + "resultSchema": { + "type": "object", + "properties": { + "projects": { + "type": "array", + "items": { + "type": "object", + "properties": { + "gid": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "gid", + "name" + ] + } + } + }, + "required": [ + "projects" + ] + } + }, + { + "id": "createAsanaTaskAction", + "name": "Create Asana task", + "type": "function-invocation", + "functionId": "createAsanaTaskFunction", + "category": "Custom", + "parameters": [ + { + "id": "title", + "name": "Task title", + "type": "Symbol", + "required": false + }, + { + "id": "notes", + "name": "Task notes", + "type": "Symbol", + "required": false + }, + { + "id": "entryId", + "name": "Contentful entry ID", + "type": "Symbol", + "required": false + }, + { + "id": "titleFieldId", + "name": "Title field ID", + "type": "Symbol", + "required": false + }, + { + "id": "projectGid", + "name": "Project GID override", + "type": "Symbol", + "required": false + }, + { + "id": "workspaceGid", + "name": "Workspace GID override", + "type": "Symbol", + "required": false + }, + { + "id": "personalAccessToken", + "name": "Personal Access Token", + "type": "Symbol", + "required": false + } + ], + "resultSchema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "task": { + "type": "object", + "properties": { + "gid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "permalinkUrl": { + "type": "string" + }, + "description": { + "type": "string" + }, + "status": { + "type": "string" + }, + "assigneeName": { + "type": "string" + }, + "dueDate": { + "type": "string" + }, + "completed": { + "type": "boolean" + } + }, + "required": [ + "gid", + "name", + "permalinkUrl" + ] + }, + "projectGid": { + "type": "string" + }, + "workspaceGid": { + "type": "string" + }, + "entryLinked": { + "type": "boolean" + } + }, + "required": [ + "success", + "message" + ] + } + }, + { + "id": "updateAsanaTaskAction", + "name": "Update Asana task", + "type": "function-invocation", + "functionId": "updateAsanaTaskFunction", + "category": "Custom", + "parameters": [ + { + "id": "taskId", + "name": "Task GID or URL", + "type": "Symbol", + "required": true + }, + { + "id": "title", + "name": "Updated task title", + "type": "Symbol", + "required": false + }, + { + "id": "notes", + "name": "Updated task notes", + "type": "Symbol", + "required": false + }, + { + "id": "completed", + "name": "Completed", + "type": "Boolean", + "required": false + }, + { + "id": "personalAccessToken", + "name": "Personal Access Token", + "type": "Symbol", + "required": false + } + ], + "resultSchema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "task": { + "type": "object", + "properties": { + "gid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "permalinkUrl": { + "type": "string" + }, + "description": { + "type": "string" + }, + "status": { + "type": "string" + }, + "assigneeName": { + "type": "string" + }, + "dueDate": { + "type": "string" + }, + "completed": { + "type": "boolean" + } + }, + "required": [ + "gid", + "name", + "permalinkUrl" + ] + } + }, + "required": [ + "success", + "message" + ] + } + }, + { + "id": "addAsanaCommentAction", + "name": "Add Asana comment", + "type": "function-invocation", + "functionId": "addAsanaCommentFunction", + "category": "Custom", + "parameters": [ + { + "id": "taskId", + "name": "Task GID or URL", + "type": "Symbol", + "required": true + }, + { + "id": "comment", + "name": "Comment", + "type": "Symbol", + "required": true + }, + { + "id": "personalAccessToken", + "name": "Personal Access Token", + "type": "Symbol", + "required": false + } + ], + "resultSchema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + } + }, + "required": [ + "success", + "message" + ] + } + }, + { + "id": "getAsanaTaskAction", + "name": "Get Asana task details", + "type": "function-invocation", + "functionId": "getAsanaTaskFunction", + "category": "Custom", + "parameters": [ + { + "id": "taskId", + "name": "Task GID or URL", + "type": "Symbol", + "required": true + }, + { + "id": "personalAccessToken", + "name": "Personal Access Token", + "type": "Symbol", + "required": false + } + ], + "resultSchema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "task": { + "type": "object", + "properties": { + "gid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "permalinkUrl": { + "type": "string" + }, + "description": { + "type": "string" + }, + "status": { + "type": "string" + }, + "assigneeName": { + "type": "string" + }, + "dueDate": { + "type": "string" + }, + "completed": { + "type": "boolean" + } + }, + "required": [ + "gid", + "name", + "permalinkUrl" + ] + } + }, + "required": [ + "success", + "message" + ] + } + }, + { + "id": "getAsanaTasksAction", + "name": "Search Asana tasks", + "type": "function-invocation", + "functionId": "getAsanaTasksFunction", + "category": "Custom", + "parameters": [ + { + "id": "personalAccessToken", + "name": "Personal Access Token", + "type": "Symbol", + "required": false + }, + { + "id": "workspaceGid", + "name": "Workspace GID", + "type": "Symbol", + "required": true + }, + { + "id": "projectGid", + "name": "Project GID", + "type": "Symbol", + "required": false + }, + { + "id": "query", + "name": "Task query", + "type": "Symbol", + "required": false + } + ], + "resultSchema": { + "type": "object", + "properties": { + "tasks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "gid": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "gid", + "name" + ] + } + } + }, + "required": [ + "tasks" + ] + } + } + ] +} \ No newline at end of file diff --git a/apps/asana/docs/v1-foundation.md b/apps/asana/docs/v1-foundation.md new file mode 100644 index 0000000000..d61acec015 --- /dev/null +++ b/apps/asana/docs/v1-foundation.md @@ -0,0 +1,44 @@ +# Asana App V1 Foundation + +## Why this first slice + +The product intent is not just to expose generic Asana actions. The app needs a durable +entry-to-task connection so Contentful users can create, inspect, and later synchronize work +without rebuilding context every time. + +The smallest useful customer-facing slice is: + +1. Create one primary Asana task from a Contentful entry. +2. Persist that task link back to Contentful-owned integration state. +3. Render the linked task summary in the entry sidebar. +4. Reuse that link for follow-up status sync, comments, and updates. + +## First link model + +Start with one primary task link per entry: + +- `entryId` +- `taskGid` +- `taskUrl` +- `taskName` +- `status` +- `assigneeName` +- `dueDate` +- `lastSyncedAt` + +This keeps the initial model simple while still supporting the most important workflows from the +product brief. + +## Near-term implementation order + +1. Add the entry sidebar location and foundation UI. +2. Decide where the primary task link is stored. +3. Create the task from the sidebar and persist the link. +4. Read the linked task on load and show status, assignee, and due date. +5. Extend from there into status sync and template-driven task creation. + +## Decision to defer + +The temporary `Entry.publish` app event handler is still present for validation. It should not +become the long-term product shape for customer workflows and should be removed once the +automation-first path and durable linking model are in place. diff --git a/apps/asana/eslint.config.mts b/apps/asana/eslint.config.mts new file mode 100644 index 0000000000..3f415e4271 --- /dev/null +++ b/apps/asana/eslint.config.mts @@ -0,0 +1,54 @@ +import js from '@eslint/js'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; +import pluginReact from 'eslint-plugin-react'; +import { defineConfig, globalIgnores } from 'eslint/config'; +import unusedImports from 'eslint-plugin-unused-imports'; + +export default defineConfig([ + globalIgnores(['**/build/']), + { + settings: { + react: { + version: 'detect', + }, + }, + }, + { + plugins: { + 'unused-imports': unusedImports, + react: pluginReact, + tseslint: tseslint, + }, + }, + { + files: ['**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + plugins: { js }, + extends: ['js/recommended'], + languageOptions: { globals: globals.browser }, + }, + { + files: ['scripts/**/*.mjs'], + languageOptions: { globals: globals.node }, + }, + tseslint.configs.recommended, + pluginReact.configs.flat.recommended, + { + rules: { + 'react/jsx-uses-react': 'off', + 'react/react-in-jsx-scope': 'off', + 'no-unused-vars': 'off', + 'unused-imports/no-unused-imports': 'error', + '@typescript-eslint/no-explicit-any': 'off', + 'unused-imports/no-unused-vars': [ + 'warn', + { + vars: 'all', + varsIgnorePattern: '^_', + args: 'after-used', + argsIgnorePattern: '^_', + }, + ], + }, + }, +]); diff --git a/apps/asana/functions/addAsanaComment.ts b/apps/asana/functions/addAsanaComment.ts new file mode 100644 index 0000000000..72f4ab9413 --- /dev/null +++ b/apps/asana/functions/addAsanaComment.ts @@ -0,0 +1,61 @@ +import type { + AppActionRequest, + FunctionEventContext, + FunctionEventHandler, + FunctionTypeEnum, +} from '@contentful/node-apps-toolkit'; +import { VALIDATION_MESSAGES } from '../src/const'; +import type { AddAsanaCommentRequest, AddAsanaCommentResponse } from '../src/types'; +import { addCommentToTask, extractTaskGid, getPersonalAccessToken } from './asanaClient'; + +function getTrimmedValue(value?: string) { + return value?.trim() ?? ''; +} + +export const handler: FunctionEventHandler = async ( + event: AppActionRequest<'Custom'>, + context: FunctionEventContext +): Promise => { + const personalAccessToken = getPersonalAccessToken(event, context); + const body = (event.body as AddAsanaCommentRequest | undefined) ?? {}; + + if (!personalAccessToken) { + return { + success: false, + message: VALIDATION_MESSAGES.tokenRequired, + }; + } + + const taskGid = extractTaskGid(body.taskId); + if (!taskGid) { + return { + success: false, + message: VALIDATION_MESSAGES.taskIdRequired, + }; + } + + const comment = getTrimmedValue(body.comment); + if (!comment) { + return { + success: false, + message: VALIDATION_MESSAGES.taskCommentRequired, + }; + } + + try { + await addCommentToTask(personalAccessToken, taskGid, comment); + + return { + success: true, + message: VALIDATION_MESSAGES.taskCommentAdded, + }; + } catch (error) { + return { + success: false, + message: + error instanceof Error && error.message + ? error.message + : VALIDATION_MESSAGES.taskCommentFailed, + }; + } +}; diff --git a/apps/asana/functions/appEventHandler.ts b/apps/asana/functions/appEventHandler.ts new file mode 100644 index 0000000000..1d72e8673d --- /dev/null +++ b/apps/asana/functions/appEventHandler.ts @@ -0,0 +1,82 @@ +import type { + AppEventRequest, + FunctionEventContext, + FunctionEventHandler, + FunctionTypeEnum, +} from '@contentful/node-apps-toolkit'; +import type { EntryProps, KeyValueMap, PlainClientAPI } from 'contentful-management'; +import { ASANA_AUTOMATION_CONFIG } from '../src/const'; +import type { AppInstallationParameters } from '../src/types'; +import { createTaskFromParameters } from './createTaskFromParameters'; + +type LocalizedFieldValue = Record | undefined; + +function getTopic(event: AppEventRequest) { + return ( + event.headers['X-Contentful-Topic'] ?? + event.headers['x-contentful-topic'] ?? + event.headers['X-CONTENTFUL-TOPIC'] + ); +} + +function getFirstLocalizedValue(field: LocalizedFieldValue) { + if (!field) { + return ''; + } + + return Object.values(field).find((value) => typeof value === 'string' && value.trim()) ?? ''; +} + +async function getEntry(cma: PlainClientAPI, entryId: string) { + return cma.entry.get({ entryId }) as Promise>; +} + +export const handler: FunctionEventHandler = async ( + event: AppEventRequest, + context: FunctionEventContext +) => { + const topic = getTopic(event); + if (!topic?.includes('Entry.publish')) { + return; + } + + const body = event.body as EntryProps; + const entryId = body?.sys?.id; + const contentTypeId = body?.sys?.contentType?.sys?.id; + + if (!entryId || contentTypeId !== ASANA_AUTOMATION_CONFIG.contentTypeId) { + return; + } + + const cma = context.cma; + if (!cma) { + throw new Error('Contentful CMA client is not available in the app event context.'); + } + + const entry = await getEntry(cma, entryId); + const status = getFirstLocalizedValue( + entry.fields[ASANA_AUTOMATION_CONFIG.statusFieldId] as LocalizedFieldValue + ); + + if (status !== ASANA_AUTOMATION_CONFIG.readyStatusValue) { + return; + } + + const installationParameters = (context.appInstallationParameters ?? + {}) as AppInstallationParameters; + + const result = await createTaskFromParameters({ + personalAccessToken: installationParameters.personalAccessToken, + title: getFirstLocalizedValue( + entry.fields[ASANA_AUTOMATION_CONFIG.taskNameFieldId] as LocalizedFieldValue + ), + notes: getFirstLocalizedValue( + entry.fields[ASANA_AUTOMATION_CONFIG.taskNotesFieldId] as LocalizedFieldValue + ), + installationParameters, + }); + + if (!result.success) { + throw new Error(result.message); + } +}; diff --git a/apps/asana/functions/asanaClient.ts b/apps/asana/functions/asanaClient.ts new file mode 100644 index 0000000000..4495cacb1c --- /dev/null +++ b/apps/asana/functions/asanaClient.ts @@ -0,0 +1,311 @@ +import type { AppActionRequest, FunctionEventContext } from '@contentful/node-apps-toolkit'; +import type { + AppInstallationParameters, + AsanaProject, + AsanaTask, + AsanaTaskOption, + AsanaWorkspace, +} from '../src/types'; +import { VALIDATION_MESSAGES } from '../src/const'; + +type AsanaEnvelope = { + data?: TData; + errors?: Array<{ message?: string }>; + next_page?: { + path?: string | null; + } | null; +}; + +function getAsanaErrorMessage(response: AsanaEnvelope) { + return response.errors + ?.map((error) => error.message) + .filter(Boolean) + .join(', '); +} + +export function getPersonalAccessToken( + event: AppActionRequest<'Custom'>, + context: FunctionEventContext +) { + const requestBody = event.body as Partial | undefined; + + if (requestBody?.personalAccessToken?.trim()) { + return requestBody.personalAccessToken.trim(); + } + + const installationParameters = context.appInstallationParameters as + | AppInstallationParameters + | undefined; + + return installationParameters?.personalAccessToken?.trim() ?? ''; +} + +export async function callAsana( + path: string, + personalAccessToken: string, + init?: RequestInit +): Promise { + const requestUrl = path.startsWith('https://app.asana.com/api/1.0') + ? path + : `https://app.asana.com/api/1.0${path}`; + + const response = await fetch(requestUrl, { + method: init?.method ?? 'GET', + headers: { + Authorization: `Bearer ${personalAccessToken}`, + Accept: 'application/json', + ...(init?.body ? { 'Content-Type': 'application/json' } : {}), + ...init?.headers, + }, + body: init?.body, + }); + + const body = (await response.json()) as AsanaEnvelope; + if (!response.ok) { + throw new Error(getAsanaErrorMessage(body) || VALIDATION_MESSAGES.invalidCredentials); + } + + if (!body.data) { + throw new Error('Asana returned an unexpected response.'); + } + + return body.data; +} + +async function callAsanaList(path: string, personalAccessToken: string): Promise { + const items: TData[] = []; + let nextPath: string | null = path; + + while (nextPath) { + const requestUrl = nextPath.startsWith('https://app.asana.com/api/1.0') + ? nextPath + : `https://app.asana.com/api/1.0${nextPath}`; + + const response = await fetch(requestUrl, { + headers: { + Authorization: `Bearer ${personalAccessToken}`, + Accept: 'application/json', + }, + }); + + const body = (await response.json()) as AsanaEnvelope; + if (!response.ok) { + throw new Error(getAsanaErrorMessage(body) || VALIDATION_MESSAGES.invalidCredentials); + } + + items.push(...(body.data ?? [])); + nextPath = body.next_page?.path ?? null; + } + + return items; +} + +export async function getWorkspaces(personalAccessToken: string): Promise { + return callAsanaList( + '/workspaces?opt_fields=gid,name&limit=100', + personalAccessToken + ); +} + +export async function getProjects( + personalAccessToken: string, + workspaceGid: string +): Promise { + const projects = await callAsanaList( + `/workspaces/${workspaceGid}/projects?opt_fields=gid,name&limit=100`, + personalAccessToken + ); + + return projects.sort((left, right) => left.name.localeCompare(right.name)); +} + +type AsanaTypeaheadResult = { + gid: string; + name: string; + resource_type?: string; +}; + +export async function searchProjects( + personalAccessToken: string, + workspaceGid: string, + query: string +): Promise { + const params = new URLSearchParams({ + resource_type: 'project', + count: query.trim() ? '50' : '20', + opt_fields: 'gid,name,resource_type', + }); + + if (query.trim()) { + params.set('query', query.trim()); + } + + const results = await callAsana( + `/workspaces/${workspaceGid}/typeahead?${params.toString()}`, + personalAccessToken + ); + + return results + .filter((item) => !item.resource_type || item.resource_type === 'project') + .map((item) => ({ gid: item.gid, name: item.name })) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +export async function searchTasks( + personalAccessToken: string, + workspaceGid: string, + query: string +): Promise { + const params = new URLSearchParams({ + resource_type: 'task', + count: query.trim() ? '20' : '10', + opt_fields: 'gid,name,resource_type', + }); + + if (query.trim()) { + params.set('query', query.trim()); + } + + const results = await callAsana( + `/workspaces/${workspaceGid}/typeahead?${params.toString()}`, + personalAccessToken + ); + + return results + .filter((item) => !item.resource_type || item.resource_type === 'task') + .map((item) => ({ gid: item.gid, name: item.name })) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +export async function getProjectTasks( + personalAccessToken: string, + projectGid: string, + query: string +): Promise { + const tasks = await callAsanaList( + `/projects/${projectGid}/tasks?opt_fields=gid,name&completed_since=now&limit=100`, + personalAccessToken + ); + + const normalizedQuery = query.trim().toLowerCase(); + + return tasks + .filter((task) => !normalizedQuery || task.name.toLowerCase().includes(normalizedQuery)) + .sort((left, right) => left.name.localeCompare(right.name)) + .slice(0, 20); +} + +type AsanaTaskRecord = { + gid: string; + name: string; + permalink_url: string; + notes?: string; + completed?: boolean; + due_on?: string | null; + assignee?: { + name?: string; + } | null; +}; + +type CreateTaskPayload = { + name: string; + notes?: string; + projects?: string[]; + workspace?: string; +}; + +type UpdateTaskPayload = { + name?: string; + notes?: string; + completed?: boolean; +}; + +export async function createTask( + personalAccessToken: string, + payload: CreateTaskPayload +): Promise { + return callAsana( + '/tasks?opt_fields=gid,name,permalink_url,notes,completed,due_on,assignee.name', + personalAccessToken, + { + method: 'POST', + body: JSON.stringify({ data: payload }), + } + ); +} + +function mapAsanaTask(task: AsanaTaskRecord): AsanaTask & { completed?: boolean } { + return { + gid: task.gid, + name: task.name, + permalinkUrl: task.permalink_url, + ...(typeof task.notes === 'string' ? { description: task.notes } : {}), + ...(typeof task.completed === 'boolean' + ? { + completed: task.completed, + status: task.completed ? 'Completed' : 'Open', + } + : {}), + ...(typeof task.assignee?.name === 'string' ? { assigneeName: task.assignee.name } : {}), + ...(typeof task.due_on === 'string' ? { dueDate: task.due_on } : {}), + }; +} + +export function extractTaskGid(taskIdOrUrl?: string) { + const trimmedValue = taskIdOrUrl?.trim() ?? ''; + if (!trimmedValue) { + return ''; + } + + const urlMatch = trimmedValue.match(/\/task\/(\d+)/); + if (urlMatch) { + return urlMatch[1]; + } + + const gidMatch = trimmedValue.match(/^\d+$/); + return gidMatch ? gidMatch[0] : ''; +} + +export async function updateTask( + personalAccessToken: string, + taskGid: string, + payload: UpdateTaskPayload +): Promise { + const task = await callAsana( + `/tasks/${taskGid}?opt_fields=gid,name,permalink_url,notes,completed,due_on,assignee.name`, + personalAccessToken, + { + method: 'PUT', + body: JSON.stringify({ data: payload }), + } + ); + + return mapAsanaTask(task); +} + +export async function getTask( + personalAccessToken: string, + taskGid: string +): Promise { + const task = await callAsana( + `/tasks/${taskGid}?opt_fields=gid,name,permalink_url,notes,completed,due_on,assignee.name`, + personalAccessToken + ); + + return mapAsanaTask(task); +} + +export async function addCommentToTask( + personalAccessToken: string, + taskGid: string, + comment: string +): Promise { + await callAsana>(`/tasks/${taskGid}/stories`, personalAccessToken, { + method: 'POST', + body: JSON.stringify({ + data: { + text: comment, + }, + }), + }); +} diff --git a/apps/asana/functions/createAsanaTask.ts b/apps/asana/functions/createAsanaTask.ts new file mode 100644 index 0000000000..4fec7709c9 --- /dev/null +++ b/apps/asana/functions/createAsanaTask.ts @@ -0,0 +1,338 @@ +import type { + AppActionRequest, + FunctionEventContext, + FunctionEventHandler, + FunctionTypeEnum, +} from '@contentful/node-apps-toolkit'; +import type { EntryProps, KeyValueMap, PlainClientAPI } from 'contentful-management'; +import type { + AppInstallationParameters, + AsanaTask, + ContentTypeFieldOption, + CreateAsanaTaskRequest, + CreateAsanaTaskResponse, + PrimaryAsanaTaskLinkValue, +} from '../src/types'; +import { + buildPrimaryTaskLinkFromEntryValues, + getDefaultPrimaryTaskLinkMapping, +} from '../src/utils/primaryTaskLink'; +import { getPersonalAccessToken } from './asanaClient'; +import { createTaskFromParameters } from './createTaskFromParameters'; + +type LocalizedFieldValue = Record | undefined; + +type EntryContext = { + entry: EntryProps; + contentTypeFields: ContentTypeFieldOption[]; + displayFieldId: string; +}; + +const ENTRY_TITLE_RETRY_ATTEMPTS = 8; +const ENTRY_TITLE_RETRY_DELAY_MS = 1500; + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function getTrimmedValue(value?: string) { + return value?.trim() ?? ''; +} + +function getFirstLocalizedString(field: LocalizedFieldValue) { + if (!field) { + return ''; + } + + for (const value of Object.values(field)) { + if (typeof value === 'string' && value.trim()) { + return value.trim(); + } + } + + return ''; +} + +function getFirstLocalizedFieldValue(field: LocalizedFieldValue) { + if (!field) { + return undefined; + } + + return Object.values(field).find((value) => value !== undefined); +} + +async function getDefaultLocale(cma: PlainClientAPI) { + try { + const locales = await cma.locale.getMany({ query: { limit: 1000 } }); + const defaultLocale = locales.items.find((locale) => locale.default); + return defaultLocale?.code ?? 'en-US'; + } catch { + return 'en-US'; + } +} + +async function getEntryContext( + cma: PlainClientAPI, + entryId?: string +): Promise { + const trimmedEntryId = getTrimmedValue(entryId); + if (!trimmedEntryId) { + return null; + } + + const entry = (await cma.entry.get({ entryId: trimmedEntryId })) as EntryProps; + const contentTypeId = entry.sys.contentType?.sys.id; + if (!contentTypeId) { + return { + entry, + contentTypeFields: [], + displayFieldId: 'title', + }; + } + + try { + const contentType = await cma.contentType.get({ contentTypeId }); + return { + entry, + contentTypeFields: contentType.fields.map((field) => ({ + id: field.id, + name: field.name, + type: field.type, + })), + displayFieldId: contentType.displayField || 'title', + }; + } catch { + return { + entry, + contentTypeFields: [], + displayFieldId: 'title', + }; + } +} + +function getEntryTitle(entryContext: EntryContext | null, titleFieldId?: string) { + if (!entryContext) { + return ''; + } + + const resolvedTitleFieldId = getTrimmedValue(titleFieldId) || entryContext.displayFieldId; + const { entry } = entryContext; + const title = getFirstLocalizedString(entry.fields[resolvedTitleFieldId] as LocalizedFieldValue); + + if (title) { + return title; + } + + for (const fallbackFieldId of ['title', 'name', 'heading', 'headline']) { + const fallbackTitle = getFirstLocalizedString( + entry.fields[fallbackFieldId] as LocalizedFieldValue + ); + + if (fallbackTitle) { + return fallbackTitle; + } + } + + return ''; +} + +async function waitForEntryTitle( + cma: PlainClientAPI, + entryContext: EntryContext | null, + entryId: string, + titleFieldId?: string +) { + let nextEntryContext = entryContext; + let entryTitle = getEntryTitle(nextEntryContext, titleFieldId); + + for (let attempt = 0; !entryTitle && attempt < ENTRY_TITLE_RETRY_ATTEMPTS; attempt += 1) { + await sleep(ENTRY_TITLE_RETRY_DELAY_MS); + nextEntryContext = await getEntryContext(cma, entryId); + + if (getExistingPrimaryTaskLink(nextEntryContext)) { + break; + } + + entryTitle = getEntryTitle(nextEntryContext, titleFieldId); + } + + return { + entryContext: nextEntryContext, + entryTitle, + }; +} + +function getExistingPrimaryTaskLink(entryContext: EntryContext | null): AsanaTask | null { + if (!entryContext) { + return null; + } + + const mapping = getDefaultPrimaryTaskLinkMapping(entryContext.contentTypeFields); + if (!mapping) { + return null; + } + + const fieldValues = Object.fromEntries( + [mapping.objectFieldId, mapping.taskGidFieldId, mapping.taskUrlFieldId, mapping.taskNameFieldId] + .filter(Boolean) + .map((fieldId) => [ + fieldId, + getFirstLocalizedFieldValue( + entryContext.entry.fields[fieldId as string] as LocalizedFieldValue + ), + ]) + ) as Record; + + const taskLink = buildPrimaryTaskLinkFromEntryValues(fieldValues, mapping); + if (!taskLink) { + return null; + } + + return { + gid: taskLink.taskGid, + name: taskLink.taskName, + permalinkUrl: taskLink.taskUrl, + ...(taskLink.taskDescription ? { description: taskLink.taskDescription } : {}), + ...(taskLink.status ? { status: taskLink.status } : {}), + ...(taskLink.assigneeName ? { assigneeName: taskLink.assigneeName } : {}), + ...(taskLink.dueDate ? { dueDate: taskLink.dueDate } : {}), + }; +} + +async function savePrimaryTaskLink( + cma: PlainClientAPI, + entryContext: EntryContext | null, + task: AsanaTask +) { + if (!entryContext) { + return false; + } + + const mapping = getDefaultPrimaryTaskLinkMapping(entryContext.contentTypeFields); + if (!mapping) { + return false; + } + + const locale = await getDefaultLocale(cma); + const taskLinkValue: PrimaryAsanaTaskLinkValue = { + taskGid: task.gid, + taskUrl: task.permalinkUrl, + taskName: task.name, + ...(typeof task.description === 'string' ? { taskDescription: task.description } : {}), + ...(typeof task.status === 'string' ? { status: task.status } : {}), + ...(typeof task.assigneeName === 'string' ? { assigneeName: task.assigneeName } : {}), + ...(typeof task.dueDate === 'string' ? { dueDate: task.dueDate } : {}), + lastSyncedAt: new Date().toISOString(), + }; + + const setLocalizedFieldValue = (fieldId: string, value: unknown) => { + entryContext.entry.fields[fieldId] = { + ...((entryContext.entry.fields[fieldId] as Record | undefined) ?? {}), + [locale]: value, + }; + }; + + if (mapping.objectFieldId) { + setLocalizedFieldValue(mapping.objectFieldId, taskLinkValue); + } + + if (mapping.taskGidFieldId) { + setLocalizedFieldValue(mapping.taskGidFieldId, task.gid); + } + + if (mapping.taskUrlFieldId) { + setLocalizedFieldValue(mapping.taskUrlFieldId, task.permalinkUrl); + } + + if (mapping.taskNameFieldId) { + setLocalizedFieldValue(mapping.taskNameFieldId, task.name); + } + + await cma.entry.update({ entryId: entryContext.entry.sys.id }, entryContext.entry); + return true; +} + +export const handler: FunctionEventHandler = async ( + event: AppActionRequest<'Custom'>, + context: FunctionEventContext +): Promise => { + const personalAccessToken = getPersonalAccessToken(event, context); + const body = (event.body as CreateAsanaTaskRequest | undefined) ?? {}; + const installationParameters = (context.appInstallationParameters ?? + {}) as AppInstallationParameters; + const cma = context.cma; + let entryContext: EntryContext | null = null; + + let entryTitle = ''; + + try { + if (body.entryId) { + if (!cma) { + throw new Error('Contentful CMA client is not available for entry lookup.'); + } + + entryContext = await getEntryContext(cma, body.entryId); + } + + entryTitle = getTrimmedValue(body.title) ? '' : getEntryTitle(entryContext, body.titleFieldId); + + if (body.entryId && !getTrimmedValue(body.title) && !entryTitle && cma) { + const resolvedEntry = await waitForEntryTitle( + cma, + entryContext, + body.entryId, + body.titleFieldId + ); + entryContext = resolvedEntry.entryContext; + entryTitle = resolvedEntry.entryTitle; + } + } catch (error) { + return { + success: false, + message: + error instanceof Error && error.message + ? error.message + : 'Could not load the Contentful entry title.', + }; + } + + const existingTask = getExistingPrimaryTaskLink(entryContext); + if (existingTask) { + return { + success: true, + message: 'Asana task is already linked to this entry.', + task: existingTask, + entryLinked: true, + }; + } + + const result = await createTaskFromParameters({ + personalAccessToken, + title: body.title || entryTitle, + notes: body.notes, + projectGid: body.projectGid, + workspaceGid: body.workspaceGid, + installationParameters, + }); + + if (!result.success || !result.task || !cma || !entryContext) { + return result; + } + + try { + const entryLinked = await savePrimaryTaskLink(cma, entryContext, result.task); + return { + ...result, + entryLinked, + }; + } catch (error) { + return { + ...result, + entryLinked: false, + message: + error instanceof Error && error.message + ? `${result.message} The task was created, but the entry could not be linked: ${error.message}` + : `${result.message} The task was created, but the entry could not be linked.`, + }; + } +}; diff --git a/apps/asana/functions/createTaskFromParameters.ts b/apps/asana/functions/createTaskFromParameters.ts new file mode 100644 index 0000000000..aafddb48b3 --- /dev/null +++ b/apps/asana/functions/createTaskFromParameters.ts @@ -0,0 +1,94 @@ +import { VALIDATION_MESSAGES } from '../src/const'; +import type { AppInstallationParameters, CreateAsanaTaskResponse } from '../src/types'; +import { createTask } from './asanaClient'; + +type CreateTaskFromParametersInput = { + personalAccessToken: string; + title?: string; + notes?: string; + projectGid?: string; + workspaceGid?: string; + installationParameters?: Partial; +}; + +function getTrimmedValue(value?: string) { + return value?.trim() ?? ''; +} + +export async function createTaskFromParameters({ + personalAccessToken, + title, + notes, + projectGid, + workspaceGid, + installationParameters, +}: CreateTaskFromParametersInput): Promise { + const trimmedToken = getTrimmedValue(personalAccessToken); + const trimmedTitle = getTrimmedValue(title); + const trimmedNotes = getTrimmedValue(notes); + const resolvedProjectGid = + getTrimmedValue(projectGid) || getTrimmedValue(installationParameters?.defaultProjectGid); + const resolvedWorkspaceGid = + getTrimmedValue(workspaceGid) || getTrimmedValue(installationParameters?.defaultWorkspaceGid); + + if (!trimmedToken) { + return { + success: false, + message: VALIDATION_MESSAGES.tokenRequired, + }; + } + + if (!trimmedTitle) { + return { + success: false, + message: VALIDATION_MESSAGES.taskTitleRequired, + }; + } + + if (!resolvedProjectGid && !resolvedWorkspaceGid) { + return { + success: false, + message: VALIDATION_MESSAGES.taskDestinationRequired, + }; + } + + try { + const task = await createTask(trimmedToken, { + name: trimmedTitle, + ...(trimmedNotes ? { notes: trimmedNotes } : {}), + ...(resolvedProjectGid ? { projects: [resolvedProjectGid] } : {}), + ...(resolvedWorkspaceGid ? { workspace: resolvedWorkspaceGid } : {}), + }); + + return { + success: true, + message: VALIDATION_MESSAGES.taskCreated, + task: { + gid: task.gid, + name: task.name, + permalinkUrl: task.permalink_url, + ...(typeof task.notes === 'string' ? { description: task.notes } : {}), + ...(typeof task.completed === 'boolean' + ? { + completed: task.completed, + status: task.completed ? 'Completed' : 'Open', + } + : {}), + ...(typeof task.assignee?.name === 'string' ? { assigneeName: task.assignee.name } : {}), + ...(typeof task.due_on === 'string' ? { dueDate: task.due_on } : {}), + }, + ...(resolvedProjectGid ? { projectGid: resolvedProjectGid } : {}), + ...(resolvedWorkspaceGid ? { workspaceGid: resolvedWorkspaceGid } : {}), + }; + } catch (error) { + return { + success: false, + message: + error instanceof Error && error.message + ? error.message + : VALIDATION_MESSAGES.taskCreateFailed, + ...(resolvedProjectGid ? { projectGid: resolvedProjectGid } : {}), + ...(resolvedWorkspaceGid ? { workspaceGid: resolvedWorkspaceGid } : {}), + }; + } +} diff --git a/apps/asana/functions/getAsanaProjects.ts b/apps/asana/functions/getAsanaProjects.ts new file mode 100644 index 0000000000..ba9957034d --- /dev/null +++ b/apps/asana/functions/getAsanaProjects.ts @@ -0,0 +1,30 @@ +import type { + AppActionRequest, + FunctionEventContext, + FunctionEventHandler, + FunctionTypeEnum, +} from '@contentful/node-apps-toolkit'; +import type { GetAsanaProjectsResponse } from '../src/types'; +import { getPersonalAccessToken, searchProjects } from './asanaClient'; + +type ProjectRequestBody = { + workspaceGid?: string; + query?: string; +}; + +export const handler: FunctionEventHandler = async ( + event: AppActionRequest<'Custom'>, + context: FunctionEventContext +): Promise => { + const personalAccessToken = getPersonalAccessToken(event, context); + const body = event.body as ProjectRequestBody | undefined; + const workspaceGid = body?.workspaceGid?.trim(); + const query = body?.query?.trim() ?? ''; + + if (!workspaceGid) { + return { projects: [] }; + } + + const projects = await searchProjects(personalAccessToken, workspaceGid, query); + return { projects }; +}; diff --git a/apps/asana/functions/getAsanaTask.ts b/apps/asana/functions/getAsanaTask.ts new file mode 100644 index 0000000000..325aa60ed6 --- /dev/null +++ b/apps/asana/functions/getAsanaTask.ts @@ -0,0 +1,48 @@ +import type { + AppActionRequest, + FunctionEventContext, + FunctionEventHandler, + FunctionTypeEnum, +} from '@contentful/node-apps-toolkit'; +import { VALIDATION_MESSAGES } from '../src/const'; +import type { GetAsanaTaskRequest, GetAsanaTaskResponse } from '../src/types'; +import { extractTaskGid, getPersonalAccessToken, getTask } from './asanaClient'; + +export const handler: FunctionEventHandler = async ( + event: AppActionRequest<'Custom'>, + context: FunctionEventContext +): Promise => { + const personalAccessToken = getPersonalAccessToken(event, context); + const body = (event.body as GetAsanaTaskRequest | undefined) ?? {}; + + if (!personalAccessToken) { + return { + success: false, + message: VALIDATION_MESSAGES.tokenRequired, + }; + } + + const taskGid = extractTaskGid(body.taskId); + if (!taskGid) { + return { + success: false, + message: VALIDATION_MESSAGES.taskIdRequired, + }; + } + + try { + const task = await getTask(personalAccessToken, taskGid); + + return { + success: true, + message: 'Asana task loaded successfully.', + task, + }; + } catch (error) { + return { + success: false, + message: + error instanceof Error && error.message ? error.message : 'Could not load the Asana task.', + }; + } +}; diff --git a/apps/asana/functions/getAsanaTasks.ts b/apps/asana/functions/getAsanaTasks.ts new file mode 100644 index 0000000000..c5240a0166 --- /dev/null +++ b/apps/asana/functions/getAsanaTasks.ts @@ -0,0 +1,44 @@ +import type { + AppActionRequest, + FunctionEventContext, + FunctionEventHandler, + FunctionTypeEnum, +} from '@contentful/node-apps-toolkit'; +import type { GetAsanaTasksResponse } from '../src/types'; +import { getPersonalAccessToken, getProjectTasks, searchTasks } from './asanaClient'; + +type GetAsanaTasksRequest = { + projectGid?: string; + workspaceGid?: string; + query?: string; + personalAccessToken?: string; +}; + +export const handler: FunctionEventHandler = async ( + event: AppActionRequest<'Custom'>, + context: FunctionEventContext +): Promise => { + const personalAccessToken = getPersonalAccessToken(event, context); + const body = (event.body as GetAsanaTasksRequest | undefined) ?? {}; + + if (!personalAccessToken) { + return { + tasks: [], + }; + } + + if (!body.workspaceGid?.trim()) { + return { + tasks: [], + }; + } + + try { + const tasks = body.projectGid?.trim() + ? await getProjectTasks(personalAccessToken, body.projectGid.trim(), body.query ?? '') + : await searchTasks(personalAccessToken, body.workspaceGid.trim(), body.query ?? ''); + return { tasks }; + } catch { + throw new Error('Could not search Asana tasks.'); + } +}; diff --git a/apps/asana/functions/getAsanaWorkspaces.ts b/apps/asana/functions/getAsanaWorkspaces.ts new file mode 100644 index 0000000000..42c651c55e --- /dev/null +++ b/apps/asana/functions/getAsanaWorkspaces.ts @@ -0,0 +1,17 @@ +import type { + AppActionRequest, + FunctionEventContext, + FunctionEventHandler, + FunctionTypeEnum, +} from '@contentful/node-apps-toolkit'; +import type { GetAsanaWorkspacesResponse } from '../src/types'; +import { getPersonalAccessToken, getWorkspaces } from './asanaClient'; + +export const handler: FunctionEventHandler = async ( + event: AppActionRequest<'Custom'>, + context: FunctionEventContext +): Promise => { + const personalAccessToken = getPersonalAccessToken(event, context); + const workspaces = await getWorkspaces(personalAccessToken); + return { workspaces }; +}; diff --git a/apps/asana/functions/tsconfig.json b/apps/asana/functions/tsconfig.json new file mode 100644 index 0000000000..ce38b6662a --- /dev/null +++ b/apps/asana/functions/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@tsconfig/recommended/tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "strict": true, + "esModuleInterop": true, + "rootDir": "." + }, + "include": ["./**/*.ts"] +} diff --git a/apps/asana/functions/updateAsanaTask.ts b/apps/asana/functions/updateAsanaTask.ts new file mode 100644 index 0000000000..be43805e67 --- /dev/null +++ b/apps/asana/functions/updateAsanaTask.ts @@ -0,0 +1,71 @@ +import type { + AppActionRequest, + FunctionEventContext, + FunctionEventHandler, + FunctionTypeEnum, +} from '@contentful/node-apps-toolkit'; +import { VALIDATION_MESSAGES } from '../src/const'; +import type { UpdateAsanaTaskRequest, UpdateAsanaTaskResponse } from '../src/types'; +import { extractTaskGid, getPersonalAccessToken, updateTask } from './asanaClient'; + +function getTrimmedValue(value?: string) { + return value?.trim() ?? ''; +} + +export const handler: FunctionEventHandler = async ( + event: AppActionRequest<'Custom'>, + context: FunctionEventContext +): Promise => { + const personalAccessToken = getPersonalAccessToken(event, context); + const body = (event.body as UpdateAsanaTaskRequest | undefined) ?? {}; + + if (!personalAccessToken) { + return { + success: false, + message: VALIDATION_MESSAGES.tokenRequired, + }; + } + + const taskGid = extractTaskGid(body.taskId); + if (!taskGid) { + return { + success: false, + message: VALIDATION_MESSAGES.taskIdRequired, + }; + } + + const title = getTrimmedValue(body.title); + const notes = getTrimmedValue(body.notes); + const hasTitleUpdate = typeof body.title === 'string'; + const hasNotesUpdate = typeof body.notes === 'string'; + const hasCompletedUpdate = typeof body.completed === 'boolean'; + + if (!hasTitleUpdate && !hasNotesUpdate && !hasCompletedUpdate) { + return { + success: false, + message: VALIDATION_MESSAGES.taskUpdateFieldsRequired, + }; + } + + try { + const task = await updateTask(personalAccessToken, taskGid, { + ...(hasTitleUpdate ? { name: title } : {}), + ...(hasNotesUpdate ? { notes } : {}), + ...(hasCompletedUpdate ? { completed: body.completed } : {}), + }); + + return { + success: true, + message: VALIDATION_MESSAGES.taskUpdated, + task, + }; + } catch (error) { + return { + success: false, + message: + error instanceof Error && error.message + ? error.message + : VALIDATION_MESSAGES.taskUpdateFailed, + }; + } +}; diff --git a/apps/asana/functions/validateAsanaCredentials.ts b/apps/asana/functions/validateAsanaCredentials.ts new file mode 100644 index 0000000000..99a0f733bb --- /dev/null +++ b/apps/asana/functions/validateAsanaCredentials.ts @@ -0,0 +1,38 @@ +import type { + AppActionRequest, + FunctionEventContext, + FunctionEventHandler, + FunctionTypeEnum, +} from '@contentful/node-apps-toolkit'; +import type { ValidateAsanaCredentialsResponse } from '../src/types'; +import { VALIDATION_MESSAGES } from '../src/const'; +import { getPersonalAccessToken, getWorkspaces } from './asanaClient'; + +export const handler: FunctionEventHandler = async ( + event: AppActionRequest<'Custom'>, + context: FunctionEventContext +): Promise => { + const personalAccessToken = getPersonalAccessToken(event, context); + + if (!personalAccessToken) { + return { + valid: false, + message: VALIDATION_MESSAGES.tokenRequired, + }; + } + + try { + const workspaces = await getWorkspaces(personalAccessToken); + return { + valid: true, + message: workspaces.length + ? VALIDATION_MESSAGES.validCredentials + : 'Your Asana token is valid, but no workspaces are visible to it.', + }; + } catch (error) { + return { + valid: false, + message: error instanceof Error ? error.message : VALIDATION_MESSAGES.invalidCredentials, + }; + } +}; diff --git a/apps/asana/index.html b/apps/asana/index.html new file mode 100644 index 0000000000..e9eee0f785 --- /dev/null +++ b/apps/asana/index.html @@ -0,0 +1,12 @@ + + + + + + Asana App + + +
+ + + diff --git a/apps/asana/package.json b/apps/asana/package.json new file mode 100644 index 0000000000..a2315cdcd4 --- /dev/null +++ b/apps/asana/package.json @@ -0,0 +1,58 @@ +{ + "name": "asana-app", + "version": "0.1.0", + "private": true, + "dependencies": { + "@contentful/app-sdk": "4.51.0", + "@contentful/f36-components": "5.6.0", + "@contentful/f36-tokens": "5.1.0", + "@contentful/react-apps-toolkit": "1.2.16", + "@testing-library/jest-dom": "^6.9.1", + "contentful-management": "11.62.0", + "happy-dom": "^20.4.0", + "prettier": "^3.6.2", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "scripts": { + "start": "vite", + "build": "tsc && vite build && npm run build:functions", + "test": "vitest", + "test:ci": "vitest run", + "create-app-definition": "contentful-app-scripts create-app-definition", + "add-locations": "contentful-app-scripts add-locations", + "upload": "contentful-app-scripts upload --bundle-dir ./build", + "upload-ci": "contentful-app-scripts upload --ci --bundle-dir ./build --organization-id $CONTENTFUL_ORG_ID --definition-id $CONTENTFUL_APP_DEF_ID --token $CONTENTFUL_ACCESS_TOKEN", + "deploy": "contentful-app-scripts upload --ci --bundle-dir ./build --organization-id ${DEFINITIONS_ORG_ID} --definition-id ${CONTENTFUL_APP_DEF_ID} --token ${CONTENTFUL_CMA_TOKEN}", + "lint": "eslint .", + "lint:write": "eslint . --fix", + "prettier:write": "prettier --write .", + "prettier:check": "prettier --check .", + "prettier:list": "prettier -l .", + "upsert-actions": "contentful-app-scripts upsert-actions --ci --organization-id $CONTENTFUL_ORG_ID --definition-id $CONTENTFUL_APP_DEF_ID --token $CONTENTFUL_ACCESS_TOKEN", + "upsert-actions:dev": "source .env && contentful-app-scripts upsert-actions --ci --organization-id \"$CONTENTFUL_ORG_ID\" --definition-id \"$CONTENTFUL_APP_DEF_ID\" --token \"$CONTENTFUL_ACCESS_TOKEN\"", + "upsert-events:dev": "set -a; source .env; set +a; node scripts/upsert-app-event-subscription.mjs", + "build:functions": "contentful-app-scripts build-functions --ci" + }, + "devDependencies": { + "@contentful/app-scripts": "^2.3.0", + "@contentful/node-apps-toolkit": "^3.11.1", + "@eslint/js": "^9.39.1", + "@testing-library/react": "16.3.0", + "@testing-library/user-event": "^14.6.1", + "@tsconfig/recommended": "1.0.8", + "@types/node": "25.1.0", + "@types/react": "18.3.1", + "@types/react-dom": "18.3.0", + "@vitejs/plugin-react": "5.1.0", + "eslint": "^9.39.1", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-unused-imports": "^4.1.4", + "globals": "^16.5.0", + "jiti": "^2.6.1", + "typescript": "5.9.2", + "typescript-eslint": "^8.46.3", + "vite": "7.2.0", + "vitest": "4.0.7" + } +} diff --git a/apps/asana/scripts/delete-stale-app-actions.mjs b/apps/asana/scripts/delete-stale-app-actions.mjs new file mode 100644 index 0000000000..83ecd44901 --- /dev/null +++ b/apps/asana/scripts/delete-stale-app-actions.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert'; +import contentfulManagement from 'contentful-management'; + +const { createClient } = contentfulManagement; + +const { + CONTENTFUL_ORG_ID: organizationId = '', + CONTENTFUL_APP_DEF_ID: appDefinitionId = '', + CONTENTFUL_ACCESS_TOKEN: accessToken = '', + CONTENTFUL_SPACE_ID: spaceId = '', + CONTENTFUL_ENVIRONMENT_ID: environmentId = 'master', +} = process.env; + +const STALE_ACTION_NAMES = new Set(['Get Asana project']); + +async function deleteStaleAppActions() { + assert.ok(accessToken !== '', 'CONTENTFUL_ACCESS_TOKEN environment variable must be defined'); + assert.ok(organizationId !== '', 'CONTENTFUL_ORG_ID environment variable must be defined'); + assert.ok(appDefinitionId !== '', 'CONTENTFUL_APP_DEF_ID environment variable must be defined'); + assert.ok(spaceId !== '', 'CONTENTFUL_SPACE_ID environment variable must be defined'); + + const client = createClient( + { + accessToken, + }, + { type: 'plain' } + ); + + const response = await client.appAction.getManyForEnvironment({ spaceId, environmentId }); + const staleActions = response.items.filter((item) => STALE_ACTION_NAMES.has(item.name)); + + if (staleActions.length === 0) { + console.log('No stale app actions found.'); + return; + } + + for (const action of staleActions) { + await client.appAction.delete({ + organizationId, + appDefinitionId, + appActionId: action.sys.id, + }); + console.log(`Deleted stale app action: ${action.name} (${action.sys.id})`); + } +} + +deleteStaleAppActions().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/apps/asana/scripts/seed-test-content.mjs b/apps/asana/scripts/seed-test-content.mjs new file mode 100644 index 0000000000..b079a2a29f --- /dev/null +++ b/apps/asana/scripts/seed-test-content.mjs @@ -0,0 +1,444 @@ +import contentful from 'contentful-management'; + +const SPACE_ID = process.env.CONTENTFUL_SPACE_ID || '5fxpz5980ld6'; +const ENVIRONMENT_ID = process.env.CONTENTFUL_ENVIRONMENT_ID || 'master'; +const ACCESS_TOKEN = process.env.CONTENTFUL_ACCESS_TOKEN; +const DEFAULT_LOCALE = 'en-US'; + +if (!ACCESS_TOKEN) { + throw new Error('Missing CONTENTFUL_ACCESS_TOKEN'); +} + +const client = contentful.createClient({ + accessToken: ACCESS_TOKEN, +}); + +const symbolField = (id, name, options = {}) => ({ + id, + name, + type: 'Symbol', + required: false, + localized: false, + omitted: false, + disabled: false, + ...options, +}); + +const textField = (id, name, options = {}) => ({ + id, + name, + type: 'Text', + required: false, + localized: false, + omitted: false, + disabled: false, + ...options, +}); + +const dateField = (id, name, options = {}) => ({ + id, + name, + type: 'Date', + required: false, + localized: false, + omitted: false, + disabled: false, + ...options, +}); + +const objectField = (id, name, options = {}) => ({ + id, + name, + type: 'Object', + required: false, + localized: false, + omitted: false, + disabled: false, + ...options, +}); + +const inValidation = (values) => [{ in: values }]; + +const contentModels = [ + { + id: 'asanaTaskRequest', + name: 'Asana Task Request', + description: + 'General smoke-test model for validating manual Contentful to Asana task creation and linking flows.', + displayField: 'title', + fields: [ + symbolField('title', 'Title', { required: true }), + symbolField('taskName', 'Task name', { required: true }), + textField('taskNotes', 'Task notes'), + symbolField('status', 'Status', { + required: true, + validations: inValidation(['Draft', 'Ready for Asana', 'Sent to Asana']), + }), + symbolField('owner', 'Owner'), + symbolField('sourceEntryUrl', 'Source entry URL'), + objectField('asanaTaskLink', 'Primary Asana Task'), + ], + entries: [ + { + title: 'Homepage hero refresh request', + taskName: 'Refresh homepage hero for May campaign', + taskNotes: + 'Create an Asana task from this entry.\n\nRequested updates:\n- Replace headline with seasonal campaign copy\n- Swap CTA to "Explore the release"\n- Confirm final copy with marketing before publishing', + status: 'Ready for Asana', + owner: 'Zachary Yankiver', + publish: true, + }, + { + title: 'Long-form testing for Asana notes', + taskName: 'Validate long notes mapping from Contentful', + taskNotes: + 'This entry is meant to test larger note bodies.\n\nAcceptance criteria:\n- Preserve blank lines\n- Preserve bullet points\n- Keep punctuation and quotes intact\n\nExample copy:\n"Contentful should hand this off cleanly to Asana."', + status: 'Ready for Asana', + owner: 'Zachary Yankiver', + publish: true, + }, + { + title: 'Special characters and duplicate-run test', + taskName: 'QA: symbols / punctuation / duplicates', + taskNotes: + 'Use this entry to test task names with special characters and repeated automation runs.\n\nCharacters to preserve: #, :, /, &, ?', + status: 'Draft', + owner: 'Zachary Yankiver', + publish: false, + }, + ], + }, + { + id: 'campaignIntake', + name: 'Campaign Intake', + description: + 'Represents campaign intake requests created from Asana forms and expanded into downstream content production work.', + displayField: 'title', + fields: [ + symbolField('title', 'Campaign name', { required: true }), + textField('objective', 'Objective', { required: true }), + textField('targetAudience', 'Target audience'), + textField('channels', 'Channels'), + textField('deliverables', 'Deliverables'), + symbolField('workflowStage', 'Workflow stage', { + required: true, + validations: inValidation([ + 'Intake', + 'Briefing', + 'In Progress', + 'Ready for Review', + 'Live', + ]), + }), + dateField('plannedPublishDate', 'Planned publish date'), + symbolField('campaignOwner', 'Campaign owner'), + objectField('asanaTaskLink', 'Primary Asana Task'), + ], + entries: [ + { + title: 'Spring Launch 2026 Campaign', + objective: + 'Drive awareness and qualified pipeline for the Spring Launch release across web, email, and paid social.', + targetAudience: + 'Mid-market SaaS buyers, existing customers exploring AI-powered content operations, and solution engineers.', + channels: 'Landing page\nLifecycle email\nLinkedIn paid social\nCustomer webinar', + deliverables: + 'Launch landing page\nEmail announcement\nPaid social copy set\nWebinar registration page', + workflowStage: 'Briefing', + plannedPublishDate: '2026-05-15T09:00:00.000Z', + campaignOwner: 'Zachary Yankiver', + publish: false, + }, + { + title: 'Customer Stories Q3 Content Sprint', + objective: + 'Collect and publish three new customer stories aligned to priority industries for Q3 pipeline acceleration.', + targetAudience: 'Enterprise marketing leaders in retail, fintech, and travel.', + channels: 'Website customer stories hub\nSales enablement email\nOrganic social', + deliverables: + '3 customer story landing pages\n1 roundup email\n3 teaser social posts', + workflowStage: 'In Progress', + plannedPublishDate: '2026-07-08T09:00:00.000Z', + campaignOwner: 'Marketing Operations', + publish: false, + }, + ], + }, + { + id: 'editorialBlogPost', + name: 'Editorial Blog Post', + description: + 'Represents editorial planning and publishing workflows where Asana tracks the calendar and Contentful stores the article.', + displayField: 'title', + fields: [ + symbolField('title', 'Title', { required: true }), + symbolField('slug', 'Slug', { required: true }), + textField('brief', 'Brief'), + symbolField('editorialStage', 'Editorial stage', { + required: true, + validations: inValidation([ + 'Backlog', + 'In Progress', + 'Ready for Review', + 'Scheduled', + 'Published', + ]), + }), + dateField('publishDate', 'Publish date'), + symbolField('primaryAuthor', 'Primary author'), + symbolField('contentOwner', 'Content owner'), + symbolField('publishedUrl', 'Published URL'), + objectField('asanaTaskLink', 'Primary Asana Task'), + ], + entries: [ + { + title: 'How AI Search Is Changing Content Operations', + slug: 'ai-search-content-operations', + brief: + 'Thought leadership post on how AI-native discovery changes content modeling, governance, and editorial operations.', + editorialStage: 'In Progress', + publishDate: '2026-04-28T08:00:00.000Z', + primaryAuthor: 'Taylor Mason', + contentOwner: 'Editorial Team', + publish: false, + }, + { + title: 'Spring Release Recap: What Marketers Should Use First', + slug: 'spring-release-recap-marketers', + brief: + 'Post-publish recap article tied to the product release campaign, optimized for the editorial calendar scheduled/published flow.', + editorialStage: 'Published', + publishDate: '2026-04-01T08:00:00.000Z', + primaryAuthor: 'Avery Chen', + contentOwner: 'Editorial Team', + publishedUrl: 'https://www.contentful.com/blog/spring-release-recap-marketers/', + publish: true, + }, + ], + }, + { + id: 'localizedCampaignEntry', + name: 'Localized Campaign Entry', + description: + 'Represents locale-specific campaign variants linked to parent Asana tasks or locale subtasks for translation and rollout tracking.', + displayField: 'title', + fields: [ + symbolField('title', 'Title', { required: true }), + symbolField('campaignName', 'Campaign name', { required: true }), + symbolField('localeCode', 'Locale', { + required: true, + validations: inValidation(['en-US', 'de-DE', 'fr-FR']), + }), + symbolField('sourceLocale', 'Source locale'), + symbolField('localizationStage', 'Localization stage', { + required: true, + validations: inValidation([ + 'Draft', + 'In Translation', + 'In Review', + 'Ready to Publish', + 'Published', + ]), + }), + symbolField('localeOwner', 'Locale owner'), + dateField('localizedPublishDate', 'Localized publish date'), + objectField('asanaTaskLink', 'Primary Asana Task'), + ], + entries: [ + { + title: 'Spring Launch landing page (en-US)', + campaignName: 'Spring Launch 2026 Campaign', + localeCode: 'en-US', + sourceLocale: 'en-US', + localizationStage: 'Published', + localeOwner: 'US Web Team', + localizedPublishDate: '2026-05-15T09:00:00.000Z', + publish: true, + }, + { + title: 'Spring Launch landing page (de-DE)', + campaignName: 'Spring Launch 2026 Campaign', + localeCode: 'de-DE', + sourceLocale: 'en-US', + localizationStage: 'In Review', + localeOwner: 'DACH Marketing', + localizedPublishDate: '2026-05-20T09:00:00.000Z', + publish: false, + }, + { + title: 'Spring Launch landing page (fr-FR)', + campaignName: 'Spring Launch 2026 Campaign', + localeCode: 'fr-FR', + sourceLocale: 'en-US', + localizationStage: 'In Translation', + localeOwner: 'France Marketing', + localizedPublishDate: '2026-05-21T09:00:00.000Z', + publish: false, + }, + ], + }, + { + id: 'reviewableLandingPage', + name: 'Reviewable Landing Page', + description: + 'Represents pages that require legal and brand review coordination with Asana review subtasks and publish readiness tracking.', + displayField: 'title', + fields: [ + symbolField('title', 'Title', { required: true }), + symbolField('contentStatus', 'Content status', { + required: true, + validations: inValidation([ + 'Draft', + 'In Review', + 'Ready to Publish', + 'Published', + 'Rolled Back', + ]), + }), + symbolField('legalReviewStatus', 'Legal review status', { + required: true, + validations: inValidation([ + 'Not Started', + 'In Progress', + 'Approved', + 'Changes Requested', + ]), + }), + symbolField('brandReviewStatus', 'Brand review status', { + required: true, + validations: inValidation([ + 'Not Started', + 'In Progress', + 'Approved', + 'Changes Requested', + ]), + }), + textField('rollbackReason', 'Rollback reason'), + symbolField('pageOwner', 'Page owner'), + dateField('plannedPublishDate', 'Planned publish date'), + objectField('asanaTaskLink', 'Primary Asana Task'), + ], + entries: [ + { + title: 'Spring Launch pricing overview page', + contentStatus: 'In Review', + legalReviewStatus: 'Approved', + brandReviewStatus: 'In Progress', + pageOwner: 'Launch Web Team', + plannedPublishDate: '2026-05-14T08:00:00.000Z', + publish: false, + }, + { + title: 'Partner co-marketing landing page', + contentStatus: 'Rolled Back', + legalReviewStatus: 'Changes Requested', + brandReviewStatus: 'Approved', + rollbackReason: + 'Rolled back after partner legal requested updated claims language for the hero section.', + pageOwner: 'Partner Marketing', + plannedPublishDate: '2026-04-24T08:00:00.000Z', + publish: false, + }, + ], + }, +]; + +function localizeEntryFields(entry) { + return Object.fromEntries( + Object.entries(entry) + .filter(([key, value]) => key !== 'publish' && value !== undefined) + .map(([key, value]) => [key, { [DEFAULT_LOCALE]: value }]) + ); +} + +async function upsertContentType(environment, model) { + try { + const existing = await environment.getContentType(model.id); + const desiredFieldIds = new Set(model.fields.map((field) => field.id)); + const fieldsToOmit = existing.fields.filter((field) => !desiredFieldIds.has(field.id)); + + if (fieldsToOmit.length) { + existing.fields = existing.fields.map((field) => + desiredFieldIds.has(field.id) ? field : { ...field, omitted: true } + ); + const omitted = await existing.update(); + await omitted.publish(); + } + + const refreshed = await environment.getContentType(model.id); + refreshed.name = model.name; + refreshed.description = model.description; + refreshed.displayField = model.displayField; + refreshed.fields = model.fields; + const updated = await refreshed.update(); + await updated.publish(); + return updated; + } catch (error) { + if (error?.name !== 'NotFound') { + throw error; + } + + const created = await environment.createContentTypeWithId(model.id, { + sys: { id: model.id }, + name: model.name, + description: model.description, + displayField: model.displayField, + fields: model.fields, + }); + await created.publish(); + return created; + } +} + +async function createEntries(environment, model) { + const createdEntries = []; + + for (const entryDefinition of model.entries) { + const entry = await environment.createEntry(model.id, { + fields: localizeEntryFields(entryDefinition), + }); + + const maybePublished = entryDefinition.publish ? await entry.publish() : entry; + createdEntries.push({ + id: maybePublished.sys.id, + title: entryDefinition.title, + published: Boolean(entryDefinition.publish), + url: `https://app.contentful.com/spaces/${SPACE_ID}/environments/${ENVIRONMENT_ID}/entries/${maybePublished.sys.id}`, + }); + } + + return createdEntries; +} + +async function main() { + const space = await client.getSpace(SPACE_ID); + const environment = await space.getEnvironment(ENVIRONMENT_ID); + const results = []; + + for (const model of contentModels) { + const contentType = await upsertContentType(environment, model); + const entries = await createEntries(environment, model); + + results.push({ + contentTypeId: contentType.sys.id, + contentTypeName: model.name, + entryCount: entries.length, + entries, + }); + } + + console.log( + JSON.stringify( + { + spaceId: SPACE_ID, + environmentId: ENVIRONMENT_ID, + contentTypesSeeded: results.length, + results, + }, + null, + 2 + ) + ); +} + +await main(); diff --git a/apps/asana/scripts/upsert-app-event-subscription.mjs b/apps/asana/scripts/upsert-app-event-subscription.mjs new file mode 100644 index 0000000000..a00af35609 --- /dev/null +++ b/apps/asana/scripts/upsert-app-event-subscription.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert'; +import contentfulManagement from 'contentful-management'; + +const { createClient } = contentfulManagement; + +const { + CONTENTFUL_ORG_ID: organizationId = '', + CONTENTFUL_APP_DEF_ID: appDefinitionId = '', + CONTENTFUL_ACCESS_TOKEN: accessToken = '', + CONTENTFUL_HOST: contentfulHost = 'api.contentful.com', + CONTENTFUL_FUNCTION_ID: functionId = 'appEventHandler', +} = process.env; + +async function upsertAppEventSubscription() { + assert.ok(organizationId !== '', 'CONTENTFUL_ORG_ID environment variable must be defined'); + assert.ok(appDefinitionId !== '', 'CONTENTFUL_APP_DEF_ID environment variable must be defined'); + assert.ok(accessToken !== '', 'CONTENTFUL_ACCESS_TOKEN environment variable must be defined'); + + const client = createClient( + { + accessToken, + host: contentfulHost, + }, + { type: 'plain' } + ); + + const eventSubscription = await client.appEventSubscription.upsert( + { + organizationId, + appDefinitionId, + }, + { + topics: ['Entry.publish'], + functions: { + handler: { + sys: { + type: 'Link', + linkType: 'Function', + id: functionId, + }, + }, + }, + } + ); + + console.log('Subscription to Entry.publish successfully upserted'); + console.dir(eventSubscription, { depth: 5 }); +} + +upsertAppEventSubscription().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/apps/asana/src/App.tsx b/apps/asana/src/App.tsx new file mode 100644 index 0000000000..6425255d5a --- /dev/null +++ b/apps/asana/src/App.tsx @@ -0,0 +1,36 @@ +import { locations } from '@contentful/app-sdk'; +import { useSDK } from '@contentful/react-apps-toolkit'; +import { Note } from '@contentful/f36-components'; +import ConfigScreen from './locations/ConfigScreen'; +import Dialog from './locations/Dialog'; +import Field from './locations/Field'; +import Sidebar from './locations/Sidebar'; + +const App = () => { + const sdk = useSDK(); + + if (sdk.location.is(locations.LOCATION_APP_CONFIG)) { + return ; + } + + if (sdk.location.is(locations.LOCATION_ENTRY_SIDEBAR)) { + return ; + } + + if (sdk.location.is(locations.LOCATION_ENTRY_FIELD)) { + return ; + } + + if (sdk.location.is(locations.LOCATION_DIALOG)) { + return ; + } + + return ( + + This version of the Asana app currently supports the app configuration screen, entry field, + entry sidebar, and dialog. + + ); +}; + +export default App; diff --git a/apps/asana/src/components/ContentTypeMultiSelect.tsx b/apps/asana/src/components/ContentTypeMultiSelect.tsx new file mode 100644 index 0000000000..7190395362 --- /dev/null +++ b/apps/asana/src/components/ContentTypeMultiSelect.tsx @@ -0,0 +1,93 @@ +import { Box, Flex, Pill, Stack } from '@contentful/f36-components'; +import { Multiselect } from '@contentful/f36-multiselect'; +import { useEffect, useState } from 'react'; +import type { ContentTypeOption } from '../types'; + +interface ContentTypeMultiSelectProps { + availableContentTypes: ContentTypeOption[]; + selectedContentTypes: ContentTypeOption[]; + onSelectionChange: (contentTypes: ContentTypeOption[]) => void; + isDisabled?: boolean; +} + +const ContentTypeMultiSelect = ({ + availableContentTypes, + selectedContentTypes, + onSelectionChange, + isDisabled = false, +}: ContentTypeMultiSelectProps) => { + const [filteredContentTypes, setFilteredContentTypes] = useState(availableContentTypes); + + useEffect(() => { + setFilteredContentTypes(availableContentTypes); + }, [availableContentTypes]); + + const getPlaceholderText = () => { + if (selectedContentTypes.length === 0) return 'Select one or more'; + if (selectedContentTypes.length === 1) return selectedContentTypes[0].name; + return `${selectedContentTypes[0].name} and ${selectedContentTypes.length - 1} more`; + }; + + return ( + + contentType.name)} + placeholder={getPlaceholderText()} + searchProps={{ + searchPlaceholder: 'Search content types', + onSearchValueChange: (event) => { + const value = event.target.value.toLowerCase(); + setFilteredContentTypes( + availableContentTypes.filter((contentType) => + contentType.name.toLowerCase().includes(value) + ) + ); + }, + }} + popoverProps={{ isFullWidth: true }} + noMatchesMessage="No content types match your search." + triggerButtonProps={{ isDisabled }}> + {filteredContentTypes.map((contentType) => ( + selected.id === contentType.id)} + onSelectItem={(event) => { + const checked = event.target.checked; + if (checked) { + onSelectionChange([...selectedContentTypes, contentType]); + return; + } + + onSelectionChange( + selectedContentTypes.filter((selected) => selected.id !== contentType.id) + ); + }} + /> + ))} + + {selectedContentTypes.length > 0 && ( + + + {selectedContentTypes.map((contentType) => ( + + onSelectionChange( + selectedContentTypes.filter((selected) => selected.id !== contentType.id) + ) + } + closeButtonAriaLabel="Remove content type" + /> + ))} + + + )} + + ); +}; + +export default ContentTypeMultiSelect; diff --git a/apps/asana/src/components/LocalhostWarning.tsx b/apps/asana/src/components/LocalhostWarning.tsx new file mode 100644 index 0000000000..51369bde00 --- /dev/null +++ b/apps/asana/src/components/LocalhostWarning.tsx @@ -0,0 +1,30 @@ +import { Flex, Note, Paragraph, TextLink } from '@contentful/f36-components'; + +const LocalhostWarning = () => { + return ( + + + + Contentful apps need to run inside the Contentful web app to function properly. Install + the app into a space and render it into one of the{' '} + + available locations + + . + +
+ + Follow{' '} + + the getting started guide + {' '} + or{' '} + open Contentful{' '} + to manage your app. + +
+
+ ); +}; + +export default LocalhostWarning; diff --git a/apps/asana/src/const.ts b/apps/asana/src/const.ts new file mode 100644 index 0000000000..248c7e87b5 --- /dev/null +++ b/apps/asana/src/const.ts @@ -0,0 +1,38 @@ +export const VALIDATION_MESSAGES = { + tokenRequired: 'Enter a valid Asana personal access token.', + saveRequired: 'Please fill in the required fields before saving.', + saveFailed: 'Configuration could not be saved.', + connectionRequired: 'Please enter an Asana personal access token before testing the connection.', + installRequired: 'Please install the app before testing the connection.', + validCredentials: 'Your Asana token is valid.', + invalidCredentials: 'Asana authentication failed. Check your token and try again.', + workspacesFailed: 'Could not load Asana workspaces.', + projectsFailed: 'Could not load Asana projects.', + taskTitleRequired: 'Enter an Asana task title.', + taskIdRequired: 'Enter an Asana task GID or Asana task URL.', + taskUpdateFieldsRequired: 'Provide at least one task field to update.', + taskDestinationRequired: + 'Provide an Asana project or workspace, or configure a default destination first.', + taskCreated: 'Asana task created successfully.', + taskCreateFailed: 'Could not create the Asana task.', + taskUpdated: 'Asana task updated successfully.', + taskUpdateFailed: 'Could not update the Asana task.', + taskCommentRequired: 'Enter a comment before posting to Asana.', + taskCommentAdded: 'Asana comment added successfully.', + taskCommentFailed: 'Could not add the Asana comment.', +}; + +export const ASANA_AUTOMATION_CONFIG = { + contentTypeId: 'asanaTaskRequest', + statusFieldId: 'status', + readyStatusValue: 'Ready for Asana', + taskNameFieldId: 'taskName', + taskNotesFieldId: 'taskNotes', +} as const; + +export const PRIMARY_TASK_LINK_FIELD_IDS = { + objectFieldId: 'asanaTaskLink', + taskGidFieldId: 'asanaTaskGid', + taskUrlFieldId: 'asanaTaskUrl', + taskNameFieldId: 'asanaTaskName', +} as const; diff --git a/apps/asana/src/index.tsx b/apps/asana/src/index.tsx new file mode 100644 index 0000000000..b5f888e322 --- /dev/null +++ b/apps/asana/src/index.tsx @@ -0,0 +1,19 @@ +import { createRoot } from 'react-dom/client'; +import { GlobalStyles } from '@contentful/f36-components'; +import { SDKProvider } from '@contentful/react-apps-toolkit'; +import App from './App'; +import LocalhostWarning from './components/LocalhostWarning'; + +const container = document.getElementById('root')!; +const root = createRoot(container); + +if (import.meta.env.DEV && window.self === window.top) { + root.render(); +} else { + root.render( + + + + + ); +} diff --git a/apps/asana/src/locations/ConfigScreen.tsx b/apps/asana/src/locations/ConfigScreen.tsx new file mode 100644 index 0000000000..b84b1b1ec9 --- /dev/null +++ b/apps/asana/src/locations/ConfigScreen.tsx @@ -0,0 +1,509 @@ +import { ConfigAppSDK } from '@contentful/app-sdk'; +import { + Autocomplete, + Badge, + Box, + Button, + Card, + Flex, + Form, + FormControl, + Heading, + Note, + Paragraph, + Pill, + Select, + Spinner, + Subheading, + TextInput, +} from '@contentful/f36-components'; +import { useSDK } from '@contentful/react-apps-toolkit'; +import { useEffect, useState } from 'react'; +import ContentTypeMultiSelect from '../components/ContentTypeMultiSelect'; +import { VALIDATION_MESSAGES } from '../const'; +import { + AppInstallationParameters, + AsanaProject, + AsanaWorkspace, + ConnectionStatus, + ContentTypeOption, + GetAsanaProjectsResponse, + GetAsanaWorkspacesResponse, + PrimaryTaskLinkFieldMapping, + ValidateAsanaCredentialsResponse, +} from '../types'; +import { buildEditorInterfaceTargetState, EditorInterfaceState } from '../utils/editorInterface'; +import { getDefaultPrimaryTaskLinkMapping } from '../utils/primaryTaskLink'; + +const emptyParameters: AppInstallationParameters = { + personalAccessToken: '', + defaultWorkspaceGid: '', + defaultWorkspaceName: '', + defaultProjectGid: '', + defaultProjectName: '', + connectionStatus: ConnectionStatus.None, + connectionMessage: '', +}; + +const ConfigScreen = () => { + const sdk = useSDK(); + const [parameters, setParameters] = useState(emptyParameters); + const [errors, setErrors] = useState>({}); + const [isInstalled, setIsInstalled] = useState(null); + const [workspaces, setWorkspaces] = useState([]); + const [projects, setProjects] = useState([]); + const [availableContentTypes, setAvailableContentTypes] = useState([]); + const [selectedContentTypes, setSelectedContentTypes] = useState([]); + const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(false); + const [isLoadingProjects, setIsLoadingProjects] = useState(false); + const [projectSearchQuery, setProjectSearchQuery] = useState(''); + + const setConnectionState = (status: ConnectionStatus, message: string) => { + setParameters((prev) => ({ + ...prev, + connectionStatus: status, + connectionMessage: message, + })); + }; + + const callAction = async ( + appActionId: string, + actionParameters: Record = {} + ): Promise => { + const response = await sdk.cma.appActionCall.createWithResponse( + { appDefinitionId: sdk.ids.app!, appActionId }, + { parameters: actionParameters } + ); + + return JSON.parse(response.response.body) as TResult; + }; + + const validateRequiredFields = (): boolean => { + if (parameters.personalAccessToken.trim()) { + setErrors({}); + return true; + } + + setErrors({ personalAccessToken: VALIDATION_MESSAGES.tokenRequired }); + return false; + }; + + const loadContentTypes = async (): Promise => { + const response = await sdk.cma.contentType.getMany({}); + + return response.items.map((contentType) => ({ + id: contentType.sys.id, + name: contentType.name, + fields: contentType.fields.map((field) => ({ + id: field.id, + name: field.name, + type: field.type, + })), + })); + }; + + const buildPrimaryTaskLinkMappings = ( + contentTypes: ContentTypeOption[] + ): Record => { + return contentTypes.reduce>( + (mappings, contentType) => { + const mapping = getDefaultPrimaryTaskLinkMapping(contentType.fields); + + if (mapping) { + mappings[contentType.id] = mapping; + } + + return mappings; + }, + {} + ); + }; + + const loadProjects = async (workspaceGid: string, personalAccessToken?: string) => { + if (!workspaceGid) { + setProjects([]); + return; + } + + setIsLoadingProjects(true); + try { + const data = await callAction('getAsanaProjectsAction', { + workspaceGid, + personalAccessToken: personalAccessToken ?? parameters.personalAccessToken, + }); + setProjects(data.projects); + } catch { + sdk.notifier.error(VALIDATION_MESSAGES.projectsFailed); + setProjects([]); + } finally { + setIsLoadingProjects(false); + } + }; + + const loadWorkspaces = async (personalAccessToken?: string) => { + setIsLoadingWorkspaces(true); + try { + const data = await callAction('getAsanaWorkspacesAction', { + personalAccessToken: personalAccessToken ?? parameters.personalAccessToken, + }); + setWorkspaces(data.workspaces); + return data.workspaces; + } catch { + sdk.notifier.error(VALIDATION_MESSAGES.workspacesFailed); + setWorkspaces([]); + return []; + } finally { + setIsLoadingWorkspaces(false); + } + }; + + const hydrateSavedOptions = async (savedParameters: AppInstallationParameters) => { + if (!savedParameters.personalAccessToken.trim()) { + return; + } + + const loadedWorkspaces = await loadWorkspaces(savedParameters.personalAccessToken); + const selectedWorkspaceGid = savedParameters.defaultWorkspaceGid; + + if ( + selectedWorkspaceGid && + loadedWorkspaces.some((workspace) => workspace.gid === selectedWorkspaceGid) + ) { + await loadProjects(selectedWorkspaceGid, savedParameters.personalAccessToken); + } + }; + + useEffect(() => { + sdk.app.onConfigure(async () => { + if (!validateRequiredFields()) { + sdk.notifier.error(VALIDATION_MESSAGES.saveRequired); + return false; + } + + const currentState = (await sdk.app.getCurrentState()) as { + EditorInterface?: Record< + string, + { + sidebar?: { position: number }; + editors?: { position: number }; + controls?: Array<{ fieldId: string; settings?: Record }>; + } + >; + } | null; + + const currentEditorInterface = (currentState?.EditorInterface ?? {}) as EditorInterfaceState; + const selectedIds = new Set(selectedContentTypes.map((contentType) => contentType.id)); + const primaryTaskLinkMappings = buildPrimaryTaskLinkMappings(selectedContentTypes); + + return { + parameters: { + ...parameters, + enabledContentTypeIds: [...selectedIds], + primaryTaskLinkMappings, + }, + targetState: { + EditorInterface: buildEditorInterfaceTargetState( + currentEditorInterface, + [...selectedIds], + primaryTaskLinkMappings + ), + }, + }; + }); + + sdk.app.onConfigurationCompleted((error) => { + if (error) { + sdk.notifier.error(VALIDATION_MESSAGES.saveFailed); + } + }); + }, [parameters, sdk, selectedContentTypes]); + + useEffect(() => { + (async () => { + const [currentParameters, installed, currentState, contentTypes] = await Promise.all([ + sdk.app.getParameters(), + sdk.app.isInstalled(), + sdk.app.getCurrentState(), + loadContentTypes(), + ]); + + const nextParameters = currentParameters + ? { ...emptyParameters, ...currentParameters } + : emptyParameters; + + setParameters(nextParameters); + setIsInstalled(installed); + setAvailableContentTypes(contentTypes); + + const selectedIds = nextParameters.enabledContentTypeIds?.length + ? nextParameters.enabledContentTypeIds + : Object.keys( + (currentState as { EditorInterface?: Record } | null) + ?.EditorInterface ?? {} + ); + setSelectedContentTypes( + contentTypes.filter((contentType) => selectedIds.includes(contentType.id)) + ); + + await hydrateSavedOptions(nextParameters); + sdk.app.setReady(); + })(); + }, [sdk]); + + const handleTokenChange = (value: string) => { + setParameters((prev) => ({ + ...prev, + personalAccessToken: value, + connectionStatus: ConnectionStatus.None, + connectionMessage: '', + defaultWorkspaceGid: '', + defaultWorkspaceName: '', + defaultProjectGid: '', + defaultProjectName: '', + })); + setWorkspaces([]); + setProjects([]); + setErrors((prev) => { + const next = { ...prev }; + delete next.personalAccessToken; + return next; + }); + }; + + const handleWorkspaceChange = async (workspaceGid: string) => { + const selectedWorkspace = + workspaces.find((workspace) => workspace.gid === workspaceGid) ?? null; + + setParameters((prev) => ({ + ...prev, + defaultWorkspaceGid: workspaceGid, + defaultWorkspaceName: selectedWorkspace?.name ?? '', + defaultProjectGid: '', + defaultProjectName: '', + })); + setProjects([]); + setProjectSearchQuery(''); + + if (workspaceGid) { + await loadProjects(workspaceGid); + } + }; + + const handleProjectChange = (projectGid: string) => { + const selectedProject = projects.find((project) => project.gid === projectGid) ?? null; + setParameters((prev) => ({ + ...prev, + defaultProjectGid: projectGid, + defaultProjectName: selectedProject?.name ?? '', + })); + setProjectSearchQuery(''); + }; + + const filteredProjects = projects.filter((project) => + project.name.toLowerCase().includes(projectSearchQuery.toLowerCase()) + ); + + const selectedProject = + projects.find((project) => project.gid === parameters.defaultProjectGid) ?? null; + + const testConnection = async () => { + if (!validateRequiredFields()) { + sdk.notifier.error(VALIDATION_MESSAGES.connectionRequired); + return; + } + + const installed = await sdk.app.isInstalled(); + if (!installed) { + sdk.notifier.error(VALIDATION_MESSAGES.installRequired); + return; + } + + setConnectionState(ConnectionStatus.Testing, ''); + + try { + const data = await callAction( + 'validateAsanaCredentialsAction', + { personalAccessToken: parameters.personalAccessToken } + ); + + const nextStatus = data.valid ? ConnectionStatus.Success : ConnectionStatus.Error; + setConnectionState(nextStatus, data.message); + + if (data.valid) { + await loadWorkspaces(parameters.personalAccessToken); + } else { + setWorkspaces([]); + setProjects([]); + } + } catch (error) { + const message = + error instanceof Error ? error.message : VALIDATION_MESSAGES.invalidCredentials; + setConnectionState(ConnectionStatus.Error, message); + setWorkspaces([]); + setProjects([]); + } + }; + + return ( + + +
+ Set up the Asana app + + Configure a secure Asana connection and choose default destinations for future + automation actions. This first version focuses on connection validation and saved + defaults so task actions can build on a stable base. + + + + Connect Asana + + Asana personal access token + handleTokenChange(event.target.value)} + /> + {errors.personalAccessToken ? ( + + {errors.personalAccessToken} + + ) : ( + + Use a personal access token for the first local version of the app. + + )} + + + + {isInstalled ? ( + + ) : ( + Install the app to test the connection. + )} + + {parameters.connectionStatus === ConnectionStatus.Success ? ( + Connected + ) : null} + {parameters.connectionStatus === ConnectionStatus.Error ? ( + Connection failed + ) : null} + + + {parameters.connectionMessage ? ( + + + {parameters.connectionMessage} + + + ) : null} + + + + Assign content types + + Limit the Asana sidebar experience to the content types where editors should create + and manage linked Asana work. + + + Enabled content types + + + The entry sidebar will only be assigned to the selected content types when you save + the app configuration. + + + + + + Default destination + + Saved defaults make later task actions easier to configure while still allowing + per-call overrides. + + + + Default workspace + + + + + Default project + + items={filteredProjects} + onInputValueChange={setProjectSearchQuery} + onSelectItem={(item) => handleProjectChange(item.gid)} + placeholder={ + !parameters.defaultWorkspaceGid + ? 'Select a workspace first' + : isLoadingProjects + ? 'Loading projects...' + : 'Type to search projects' + } + isDisabled={!parameters.defaultWorkspaceGid || isLoadingProjects} + itemToString={(item) => item.name} + renderItem={(item) => item.name} + textOnAfterSelect="clear" + closeAfterSelect + listWidth="full" + /> + {selectedProject ? ( + + Selected project: + handleProjectChange('')} + /> + + ) : null} + + + {isLoadingWorkspaces || isLoadingProjects ? ( + + + + {isLoadingProjects + ? 'Loading projects from Asana...' + : 'Loading workspaces from Asana...'} + + + ) : null} + +
+
+
+ ); +}; + +export default ConfigScreen; diff --git a/apps/asana/src/locations/Dialog.tsx b/apps/asana/src/locations/Dialog.tsx new file mode 100644 index 0000000000..f6424b27e4 --- /dev/null +++ b/apps/asana/src/locations/Dialog.tsx @@ -0,0 +1,243 @@ +import { DialogAppSDK } from '@contentful/app-sdk'; +import { + Box, + Button, + Flex, + FormControl, + Paragraph, + SectionHeading, + Text, + TextLink, + Textarea, +} from '@contentful/f36-components'; +import { useAutoResizer, useSDK } from '@contentful/react-apps-toolkit'; +import { useMemo, useState } from 'react'; +import { VALIDATION_MESSAGES } from '../const'; +import type { + AddAsanaCommentResponse, + GetAsanaTaskResponse, + TaskDetailsDialogParameters, + TaskDetailsDialogResult, + UpdateAsanaTaskResponse, +} from '../types'; + +const Dialog = () => { + const sdk = useSDK(); + useAutoResizer(); + + const invocation = (sdk.parameters.invocation ?? {}) as unknown as TaskDetailsDialogParameters; + const task = invocation.taskGid + ? { + taskGid: invocation.taskGid, + taskName: invocation.taskName, + taskUrl: invocation.taskUrl, + taskDescription: invocation.taskDescription, + status: invocation.status, + assigneeName: invocation.assigneeName, + dueDate: invocation.dueDate, + } + : null; + + const [description, setDescription] = useState(invocation.taskDescription ?? ''); + const [comment, setComment] = useState(''); + const [isSaving, setIsSaving] = useState(false); + const [isPostingComment, setIsPostingComment] = useState(false); + + const hasDescriptionChanges = useMemo( + () => description.trim() !== (task?.taskDescription ?? '').trim(), + [description, task?.taskDescription] + ); + + const callAction = async ( + appActionId: string, + actionParameters: Record = {} + ): Promise => { + const response = await sdk.cma.appActionCall.createWithResponse( + { appDefinitionId: sdk.ids.app!, appActionId }, + { parameters: actionParameters } + ); + + return JSON.parse(response.response.body) as TResult; + }; + + const refreshTask = async () => { + if (!task) { + throw new Error('No linked Asana task is available in this dialog.'); + } + + const response = await callAction('getAsanaTaskAction', { + taskId: task.taskGid, + }); + + if (!response.success || !response.task) { + throw new Error(response.message || 'Could not refresh the Asana task.'); + } + + return response.task; + }; + + const handleSaveDescription = async () => { + if (!task || !hasDescriptionChanges) { + return; + } + + setIsSaving(true); + + try { + const response = await callAction('updateAsanaTaskAction', { + taskId: task.taskGid, + notes: description.trim(), + }); + + if (!response.success || !response.task) { + throw new Error(response.message || VALIDATION_MESSAGES.taskUpdateFailed); + } + + sdk.notifier.success(VALIDATION_MESSAGES.taskUpdated); + sdk.close({ updatedTask: response.task } satisfies TaskDetailsDialogResult); + } catch (error) { + const message = error instanceof Error ? error.message : VALIDATION_MESSAGES.taskUpdateFailed; + sdk.notifier.error(message); + } finally { + setIsSaving(false); + } + }; + + const handleAddComment = async () => { + if (!task) { + return; + } + + const trimmedComment = comment.trim(); + if (!trimmedComment) { + sdk.notifier.error(VALIDATION_MESSAGES.taskCommentRequired); + return; + } + + setIsPostingComment(true); + + try { + const response = await callAction('addAsanaCommentAction', { + taskId: task.taskGid, + comment: trimmedComment, + }); + + if (!response.success) { + throw new Error(response.message || VALIDATION_MESSAGES.taskCommentFailed); + } + + const refreshedTask = await refreshTask(); + sdk.notifier.success(VALIDATION_MESSAGES.taskCommentAdded); + sdk.close({ updatedTask: refreshedTask } satisfies TaskDetailsDialogResult); + } catch (error) { + const message = + error instanceof Error ? error.message : VALIDATION_MESSAGES.taskCommentFailed; + sdk.notifier.error(message); + } finally { + setIsPostingComment(false); + } + }; + + const handleClose = () => { + sdk.close(); + }; + + if (!task) { + return ( + + + No linked Asana task was provided to this dialog. + + + + ); + } + + return ( + + + + + Linked task + + {task.taskName} + + Open in Asana + + + + + + + Status + + {task.status || 'Unknown'} + + + + Assignee + + {task.assigneeName || 'Unassigned'} + + + + Due date + + {task.dueDate || 'No due date'} + + + + + Description +