diff --git a/assets/styles/scss/components/project/_ProjectSection.scss b/assets/styles/scss/components/project/_ProjectSection.scss
index 16e3744e..80abf98c 100644
--- a/assets/styles/scss/components/project/_ProjectSection.scss
+++ b/assets/styles/scss/components/project/_ProjectSection.scss
@@ -19,7 +19,7 @@
color: color(grey-dark);
}
- ol.tasks {
+ > ol.tasks {
list-style-type: none;
margin: 0;
padding: 0;
diff --git a/assets/styles/scss/components/task/_TaskListItem.scss b/assets/styles/scss/components/task/_TaskListItem.scss
index c8591df3..3c33f95f 100644
--- a/assets/styles/scss/components/task/_TaskListItem.scss
+++ b/assets/styles/scss/components/task/_TaskListItem.scss
@@ -259,6 +259,30 @@
}
}//.main
+ .subtasks {
+
+ ol.tasks {
+ list-style-type: none;
+ margin: 0;
+ padding: 0;
+ border-radius: 10px;
+ border: 2px solid color(grey-lighter);
+ background: color(white);
+ overflow: hidden;
+
+ > li {
+
+ &:first-child {
+ border-top: none;
+ }
+
+ &:last-child {
+ border-bottom: none;
+ }
+ }
+ }
+ }
+
.subtask-count {
display: flex;
flex-wrap: nowrap;
diff --git a/assets/styles/scss/components/task/_TaskSingleAsync.scss b/assets/styles/scss/components/task/_TaskSingleAsync.scss
new file mode 100644
index 00000000..d08bd670
--- /dev/null
+++ b/assets/styles/scss/components/task/_TaskSingleAsync.scss
@@ -0,0 +1,35 @@
+@import '../../abstracts/colors';
+@import '../../abstracts/animated';
+
+//----------------------
+
+.ptc-TaskSingleAsync {
+
+ .ptc-loader {
+ padding: 1em 2em;
+ border-radius: 10px;
+ border: 2px dashed color(grey-light);
+ background: color(white);
+ color: color(grey-dark);
+
+ @include loader;
+ }
+
+ .ptc-error {
+ padding: 1em 2em;
+ text-align: center;
+ border-radius: 10px;
+ border: 2px solid color(danger-light);
+ background: color(danger-lightest);
+ color: #b20000;
+ }
+
+ > .ptc-TaskListItem {
+ margin: 0;
+ padding: 0;
+ border-radius: 10px;
+ border: 2px solid color(grey-lighter);
+ background: color(white);
+ overflow: hidden;
+ }
+}//.ptc-TaskSingleAsync
diff --git a/changelog.md b/changelog.md
index 50a6363a..36d7f72e 100644
--- a/changelog.md
+++ b/changelog.md
@@ -2,6 +2,7 @@
#### Added
+- New shortcode `[ptc_asana_task]` to display an individual Asana task.
- Automation Actions now support dynamically pinning the newly created Asana task to the WordPress post which triggered its creation. Clear the "Pin to Post" input field value to see both available options, `{post.ID}` and `{post.post_parent}`.
#### Changed
diff --git a/completionist.php b/completionist.php
index 5a97b00b..eb0e0601 100644
--- a/completionist.php
+++ b/completionist.php
@@ -44,42 +44,42 @@
*
* @since 3.0.0
*/
-define( __NAMESPACE__ . '\PLUGIN_FILE', __FILE__ );
+define( 'PTC_Completionist\PLUGIN_FILE', __FILE__ );
/**
* The full file path to this plugin's directory ending with a slash.
*
* @since 3.0.0
*/
-define( __NAMESPACE__ . '\PLUGIN_PATH', plugin_dir_path( __FILE__ ) );
+define( 'PTC_Completionist\PLUGIN_PATH', plugin_dir_path( __FILE__ ) );
/**
* This plugin's current version.
*
* @since 3.0.0
*/
-define( __NAMESPACE__ . '\PLUGIN_VERSION', get_file_data( __FILE__, array( 'Version' => 'Version' ), 'plugin' )['Version'] );
+define( 'PTC_Completionist\PLUGIN_VERSION', get_file_data( __FILE__, array( 'Version' => 'Version' ), 'plugin' )['Version'] );
/**
* This plugin's basename.
*
* @since 3.0.0
*/
-define( __NAMESPACE__ . '\PLUGIN_BASENAME', plugin_basename( __FILE__ ) );
+define( 'PTC_Completionist\PLUGIN_BASENAME', plugin_basename( __FILE__ ) );
/**
* This plugin's directory basename.
*
* @since 3.2.0
*/
-define( __NAMESPACE__ . '\PLUGIN_SLUG', dirname( PLUGIN_BASENAME ) );
+define( 'PTC_Completionist\PLUGIN_SLUG', dirname( PLUGIN_BASENAME ) );
/**
* The full url to this plugin's directory, NOT ending with a slash.
*
* @since 3.0.0
*/
-define( __NAMESPACE__ . '\PLUGIN_URL', plugins_url( '', __FILE__ ) );
+define( 'PTC_Completionist\PLUGIN_URL', plugins_url( '', __FILE__ ) );
/**
* The namespace for all v1 REST API routes registered by this plugin.
@@ -88,7 +88,7 @@
*
* @var string REST_API_NAMESPACE_V1
*/
-define( __NAMESPACE__ . '\REST_API_NAMESPACE_V1', PLUGIN_SLUG . '/v1' );
+define( 'PTC_Completionist\REST_API_NAMESPACE_V1', PLUGIN_SLUG . '/v1' );
/* REGISTER PLUGIN FUNCTIONS ---------------------- */
diff --git a/package.json b/package.json
index 171cd838..210fc8c4 100644
--- a/package.json
+++ b/package.json
@@ -17,8 +17,8 @@
},
"scripts": {
"wp-env": "wp-env",
- "build": "wp-scripts build src/index_DashboardWidget.jsx src/index_Automations.jsx src/index_BlockEditor.jsx src/index_ShortcodeAsanaProject.jsx src/index_PinnedTasksMetabox.jsx --output-path=build && npm run styles",
- "start": "wp-scripts start src/index_DashboardWidget.jsx src/index_Automations.jsx src/index_BlockEditor.jsx src/index_ShortcodeAsanaProject.jsx src/index_PinnedTasksMetabox.jsx --output-path=build",
+ "build": "wp-scripts build src/index_DashboardWidget.jsx src/index_Automations.jsx src/index_BlockEditor.jsx src/index_ShortcodeAsanaProject.jsx src/index_ShortcodeAsanaTask.jsx src/index_PinnedTasksMetabox.jsx --output-path=build && npm run styles",
+ "start": "wp-scripts start src/index_DashboardWidget.jsx src/index_Automations.jsx src/index_BlockEditor.jsx src/index_ShortcodeAsanaProject.jsx src/index_ShortcodeAsanaTask.jsx src/index_PinnedTasksMetabox.jsx --output-path=build",
"bundle": "npm run build && bash bundle.sh",
"styles": "sass --style=compressed assets/styles/scss:assets/styles",
"watch:styles": "sass --style=expanded --watch assets/styles/scss:assets/styles"
diff --git a/phpcs.xml b/phpcs.xml
index dadd7603..339f191f 100644
--- a/phpcs.xml
+++ b/phpcs.xml
@@ -30,7 +30,8 @@
/build/*
-
+
+
diff --git a/src/components/task/TaskListItem.jsx b/src/components/task/TaskListItem.jsx
index 5d655504..0b37a629 100644
--- a/src/components/task/TaskListItem.jsx
+++ b/src/components/task/TaskListItem.jsx
@@ -28,7 +28,7 @@ import '../../../assets/styles/scss/components/task/_TaskListItem.scss';
const { useState, useEffect, useRef } = wp.element;
-export default function TaskListItem({ task, rowNumber = null }) {
+export default function TaskListItem({ task, rowNumber = null, tagName: Element = 'li' }) {
const [ isExpanded, setIsExpanded ] = useState(false);
const rootRef = useRef(null);
@@ -272,7 +272,7 @@ export default function TaskListItem({ task, rowNumber = null }) {
);
return (
-
+
{maybeExpandedDetails}
-
+
);
}
diff --git a/src/components/task/TaskSingleAsync.jsx b/src/components/task/TaskSingleAsync.jsx
new file mode 100644
index 00000000..264bd126
--- /dev/null
+++ b/src/components/task/TaskSingleAsync.jsx
@@ -0,0 +1,90 @@
+/**
+ * TaskSingleAsync component
+ *
+ * @since [unreleased]
+ */
+
+import TaskListItem from './TaskListItem.jsx';
+
+import '../../../assets/styles/scss/components/task/_TaskSingleAsync.scss';
+
+import { useEffect, useState } from '@wordpress/element';
+
+export default function TaskSingleAsync({ src }) {
+ const [ status, setStatus ] = useState('idle');
+ const [ task, setTask ] = useState(null);
+
+ useEffect(() => {
+ // Load task data from src on mount.
+ if ( 'idle' === status && null === task ) {
+
+ if ( 'string' === typeof src ) {
+
+ // Signal loading.
+ setStatus('loading');
+
+ // Request data from src URL string.
+ window
+ .fetch(src)
+ .then( res => {
+ if ( 200 !== res.status ) {
+ return Promise.reject( `Error ${res.status}. Failed to load task.` );
+ }
+ return res.json();
+ })
+ .then( data => {
+ setTask(data);
+ setStatus('success');
+ return Promise.resolve();
+ })
+ .catch( err => {
+ setStatus(err);
+ setTask(null);
+ });
+ } else if ( 'object' === typeof src ) {
+ // Use provided task src data.
+ setTask(src);
+ setStatus('success');
+ } else {
+ // Unsupported task src provided!
+ setStatus('Failed to load task due to an unexpected error.');
+ setTask(null);
+ window.console.warn('Unsupported TaskSingleAsync[src] type:', src);
+ }
+ }
+ }, []);
+
+ // Render.
+
+ let innerContent = null;
+ switch ( status ) {
+
+ case 'success':
+ if ( task ) {
+ innerContent = ;
+ }
+ break;
+
+ case 'loading':
+ innerContent = Loading task...
;
+ break;
+
+ case 'error':
+ innerContent = Failed to load task.
;
+ break;
+
+ case 'idle':
+ innerContent = null;
+ break;
+
+ default:
+ innerContent = {status}
;
+ break;
+ }
+
+ return (
+
+ {innerContent}
+
+ );
+}
diff --git a/src/includes/class-asana-interface.php b/src/includes/class-asana-interface.php
index 4ba01da0..19f3644e 100755
--- a/src/includes/class-asana-interface.php
+++ b/src/includes/class-asana-interface.php
@@ -877,7 +877,7 @@ public static function get_project_data(
)
) {
// Ensure sorting field is returned.
- // Always add "name" subfield in case its an object.
+ // Always add "name" subfield in case its an object like "assignee".
$task_fields .= ",{$args['sort_tasks_by']},{$args['sort_tasks_by']}.name";
$do_remove_tasks_sort_field = true;
}
@@ -1287,11 +1287,13 @@ function ( $err ) {
* Attempts to retrieve task data. Providing the post id of the provided
* pinned task gid will also attempt data self-healing.
*
+ * @since [unreleased] Revived $opt_fields param.
* @since 3.1.0 Marked $opt_fields param as deprecated.
* @since 1.0.0
*
* @param string $task_gid The gid of the task to retrieve.
- * @param string $opt_fields_deprecated Deprecated.
+ * @param string $opt_fields Optional. The task fields to be retrieved.
+ * Default '' to use Asana_Interface::TASK_OPT_FIELDS.
* @param int $post_id Optional. The post ID on which
* the task belongs to attempt self-healing on certain error
* responses. Default 0 to take no action on failure.
@@ -1302,14 +1304,10 @@ function ( $err ) {
* * 400: Invalid task gid - The provided task gid is invalid.
* * 410: Invalid task - The task is no longer available or relevent.
*/
- public static function maybe_get_task_data( string $task_gid, string $opt_fields_deprecated = '', int $post_id = 0 ) : \stdClass {
+ public static function maybe_get_task_data( string $task_gid, string $opt_fields = '', int $post_id = 0 ) : \stdClass {
- if ( ! empty( $opt_fields_deprecated ) ) {
- _deprecated_argument(
- __FUNCTION__,
- '3.1.0',
- '$opt_fields is now a member constant, ' . __CLASS__ . '::TASK_OPT_FIELDS'
- );
+ if ( empty( $opt_fields ) ) {
+ $opt_fields = self::TASK_OPT_FIELDS;
}
$task_gid = Options::sanitize( 'gid', $task_gid );
@@ -1319,8 +1317,17 @@ public static function maybe_get_task_data( string $task_gid, string $opt_fields
try {
- $asana = self::get_client();
- $task = $asana->tasks->findById( $task_gid, array( 'opt_fields' => self::TASK_OPT_FIELDS ) );
+ // Load Asana client.
+ $asana = null;
+ if ( ! isset( self::$asana ) ) {
+ // Might throw exception.
+ $asana = self::get_client();
+ } else {
+ $asana = self::$asana;
+ }
+
+ // Fetch the task data.
+ $task = $asana->tasks->findById( $task_gid, array( 'opt_fields' => $opt_fields ) );
if (
isset( $task->workspace->gid ) &&
diff --git a/src/includes/class-html-builder.php b/src/includes/class-html-builder.php
index 406b67a8..b81ad101 100644
--- a/src/includes/class-html-builder.php
+++ b/src/includes/class-html-builder.php
@@ -310,7 +310,7 @@ public static function get_relative_due( \stdClass $task ) : \stdClass {
} else {
- $dt_string = $dt->format( 'M j' );
+ $dt_string = $dt_due->format( 'M j' );
$relative_due->status = 'later';
}
@@ -530,7 +530,8 @@ public static function get_oembed_for_url( string $url ) : string {
}
/**
- * Gets the local API endpoint for retrieving an attachment.
+ * Gets the local API endpoint for retrieving an attachment's content
+ * for viewing.
*
* @since 3.7.0 Deprecated $post_id parameter.
* @since 3.5.0
@@ -570,6 +571,35 @@ public static function get_local_attachment_view_url(
);
}
+ /**
+ * Gets the local API endpoint for retrieving an attachment with
+ * the provided arguments.
+ *
+ * @see PTC_Completionist\REST_API\Attachments::handle_get_attachment()
+ *
+ * @since [unreleased]
+ *
+ * @param string $attachment_gid The Asana attachment's GID.
+ * @param array $args Optional. Additional arguments for the
+ * API endpoint which retrieves the attachment's data.
+ * @return string The local API endpoint URL.
+ */
+ public static function get_local_attachment_url(
+ string $attachment_gid,
+ array $args = array()
+ ) : string {
+
+ $args['_cache_key'] = 'get_local_attachment_url';
+ $args['attachment_gid'] = $attachment_gid;
+
+ $token = Request_Token::save( $args );
+
+ return add_query_arg(
+ array( 'token' => $token ),
+ rest_url( REST_API_NAMESPACE_V1 . '/attachments' )
+ );
+ }
+
/**
* Sanitizes content for allowed HTML tags for post content.
*
diff --git a/src/index_ShortcodeAsanaTask.jsx b/src/index_ShortcodeAsanaTask.jsx
new file mode 100644
index 00000000..5e337442
--- /dev/null
+++ b/src/index_ShortcodeAsanaTask.jsx
@@ -0,0 +1,39 @@
+/**
+ * Renders the [ptc_asana_task] shortcode.
+ *
+ * @since [unreleased]
+ */
+
+import TaskSingleAsync from './components/task/TaskSingleAsync.jsx';
+
+import initGlobalNamespace from './components/GlobalNamespace.jsx';
+
+import { createRoot } from '@wordpress/element';
+
+initGlobalNamespace();
+
+document.addEventListener('DOMContentLoaded', () => {
+ document
+ .querySelectorAll('.ptc-shortcode.ptc-asana-task[data-src]')
+ .forEach( rootNode => {
+ if ( rootNode.dataset.src ) {
+ /**
+ * Filters the element to be rendered for displaying the
+ * [ptc_asana_task] shortcode.
+ *
+ * @since [unreleased]
+ *
+ * @param {Object} element - The element to render.
+ * Default .
+ * @param {HTMLDivElement} rootNode - The root node where
+ * React will render the element.
+ */
+ const element = window.Completionist.hooks.applyFilters(
+ 'shortcodes_ptc_asana_task_render',
+ ,
+ rootNode
+ );
+ createRoot( rootNode ).render( element );
+ }
+ });
+});
diff --git a/src/public/class-shortcodes.php b/src/public/class-shortcodes.php
index f961454d..78ac6a91 100644
--- a/src/public/class-shortcodes.php
+++ b/src/public/class-shortcodes.php
@@ -50,7 +50,7 @@ class Shortcodes {
'render_count' => 0,
'render_callback' => __CLASS__ . '::get_ptc_asana_project',
'default_atts' => array(
- 'src' => '', // Required.
+ 'src' => '', // Required.
'layout' => '',
'auth_user' => '',
'exclude_sections' => '',
@@ -75,6 +75,28 @@ class Shortcodes {
'ptc-completionist-shortcode-asana-project',
),
),
+ 'ptc_asana_task' => array(
+ 'render_count' => 0,
+ 'render_callback' => __CLASS__ . '::get_ptc_asana_task',
+ 'default_atts' => array(
+ 'src' => '', // Required.
+ 'auth_user' => '',
+ 'show_description' => 'true',
+ 'show_assignee' => 'true',
+ 'show_subtasks' => 'true',
+ 'show_completed' => 'true',
+ 'show_due' => 'true',
+ 'show_attachments' => 'true',
+ 'show_tags' => 'true',
+ 'sort_subtasks_by' => '',
+ ),
+ 'script_handles' => array(
+ 'ptc-completionist-shortcode-asana-task',
+ ),
+ 'style_handles' => array(
+ 'ptc-completionist-shortcode-asana-task',
+ ),
+ ),
);
// *************************** //
@@ -206,6 +228,25 @@ public static function register_assets() {
array(),
$asset_file['version']
);
+
+ // Asana task assets.
+
+ $asset_file = require_once PLUGIN_PATH . 'build/index_ShortcodeAsanaTask.jsx.asset.php';
+
+ wp_register_script(
+ 'ptc-completionist-shortcode-asana-task',
+ PLUGIN_URL . '/build/index_ShortcodeAsanaTask.jsx.js',
+ $dependencies,
+ $asset_file['version'],
+ true
+ );
+
+ wp_register_style(
+ 'ptc-completionist-shortcode-asana-task',
+ PLUGIN_URL . '/build/index_ShortcodeAsanaTask.jsx.css',
+ array(),
+ $asset_file['version']
+ );
}
// **************************** //
@@ -332,4 +373,115 @@ public static function get_ptc_asana_project(
esc_attr( $layout )
);
}
+
+ /**
+ * Gets the [ptc_asana_task] shortcode content.
+ *
+ * @since [unreleased]
+ *
+ * @see \add_shortcode()
+ *
+ * @param array $atts Optional. The shortcode attribute values.
+ * Default empty array.
+ * @param string|null $content Optional. The shortcode contents.
+ * Default null.
+ * @param string $shortcode_tag Optional. The shortcode tag for
+ * processing default attributes. Default empty string.
+ *
+ * @return string The resulting HTML.
+ */
+ public static function get_ptc_asana_task(
+ $atts = array(),
+ $content = null,
+ $shortcode_tag = ''
+ ) : string {
+
+ // Collect shortcode attributes.
+
+ $atts = shortcode_atts(
+ static::$shortcodes_meta[ $shortcode_tag ]['default_atts'],
+ $atts,
+ $shortcode_tag
+ );
+
+ // Validate shortcode attributes.
+
+ if (
+ empty( $atts['auth_user'] ) &&
+ Options::get( Options::FRONTEND_AUTH_USER_ID ) <= 0
+ ) {
+ return '
+
+ ';
+ }
+
+ // Sanitize shortcode attributes.
+
+ $atts['src'] = (string) esc_url_raw( $atts['src'] );
+ $atts['auth_user'] = (int) $atts['auth_user'];
+ $atts['sort_subtasks_by'] = (string) sanitize_text_field( $atts['sort_subtasks_by'] );
+
+ foreach ( $atts as $key => &$value ) {
+ if ( 0 === strpos( $key, 'show_', 0 ) ) {
+ // Cast "show" flags to boolean value.
+ $value = (bool) rest_sanitize_boolean( $value );
+ }
+ }
+
+ // Prepare shortcode.
+
+ $task_gid = Asana_Interface::get_task_gid_from_task_link( $atts['src'] );
+ if ( empty( $task_gid ) ) {
+ return '
+
+
Failed to load Asana task. Could not determine task GID from source URL.
+
+ ';
+ }
+
+ // Convert shortcode attributes into Asana opt_fields.
+ $atts['opt_fields'] = 'name';
+ if ( $atts['show_completed'] ) {
+ $atts['opt_fields'] .= ',completed';
+ }
+ if ( $atts['show_description'] ) {
+ $atts['opt_fields'] .= ',html_notes';
+ }
+ if ( $atts['show_assignee'] ) {
+ $atts['opt_fields'] .= ',assignee,assignee.name,assignee.photo.image_36x36';
+ }
+ if ( $atts['show_due'] ) {
+ $atts['opt_fields'] .= ',due_on';
+ }
+ if ( $atts['show_attachments'] ) {
+ $atts['opt_fields'] .= ',attachments.name,attachments.host,attachments.download_url,attachments.view_url';
+ }
+ if ( $atts['show_tags'] ) {
+ $atts['opt_fields'] .= ',tags,tags.name,tags.color';
+ }
+
+ // Always remove Asana object GIDs.
+ $atts['show_gids'] = 'false';
+
+ // Specify request token key.
+ $atts['_cache_key'] = 'shortcode_ptc_asana_task';
+
+ // Generate request token for the frontend.
+ $token = Request_Token::save( $atts );
+
+ // Render frontend data.
+
+ $request_url = add_query_arg(
+ array( 'token' => $token ),
+ rest_url( REST_API_NAMESPACE_V1 . '/tasks/' . $task_gid )
+ );
+
+ static::count_render( $shortcode_tag );
+ return sprintf(
+ '',
+ esc_url( $request_url )
+ );
+ }
}
diff --git a/src/public/rest-api/class-attachments.php b/src/public/rest-api/class-attachments.php
index e851360f..9b2d3cc5 100644
--- a/src/public/rest-api/class-attachments.php
+++ b/src/public/rest-api/class-attachments.php
@@ -198,8 +198,7 @@ public static function handle_get_attachment(
// Add request token for retrieving the attachment again.
$attachment->_ptc_refresh_url = HTML_Builder::get_local_attachment_url(
$attachment->gid,
- -1,
- $args['auth_user']
+ array( 'auth_user' => $args['auth_user'] )
);
// Ensure GID is stripped.
diff --git a/src/public/rest-api/class-projects.php b/src/public/rest-api/class-projects.php
index a7820f4d..2f5651b9 100644
--- a/src/public/rest-api/class-projects.php
+++ b/src/public/rest-api/class-projects.php
@@ -9,14 +9,11 @@
defined( 'ABSPATH' ) || die();
-use const PTC_Completionist\PLUGIN_PATH;
use const PTC_Completionist\REST_API_NAMESPACE_V1;
use PTC_Completionist\Asana_Interface;
-use PTC_Completionist\Options;
use PTC_Completionist\HTML_Builder;
use PTC_Completionist\Request_Token;
-use PTC_Completionist\Util;
/**
* Class to register and handle custom REST API endpoints for Asana projects.
diff --git a/src/public/rest-api/class-tasks.php b/src/public/rest-api/class-tasks.php
index 5f435067..7b5a044f 100644
--- a/src/public/rest-api/class-tasks.php
+++ b/src/public/rest-api/class-tasks.php
@@ -15,6 +15,8 @@
use PTC_Completionist\Options;
use PTC_Completionist\HTML_Builder;
use PTC_Completionist\REST_Server;
+use PTC_Completionist\Request_Token;
+use PTC_Completionist\Util;
/**
* Class to register and handle custom REST API endpoints
@@ -53,6 +55,25 @@ public static function register_routes() {
)
);
+ register_rest_route(
+ REST_API_NAMESPACE_V1,
+ '/tasks/(?P[0-9]+)',
+ array(
+ array(
+ 'methods' => 'GET',
+ 'callback' => array( __CLASS__, 'handle_get_task' ),
+ 'permission_callback' => '__return_true',
+ 'args' => array(
+ 'token' => array(
+ 'type' => 'string',
+ 'required' => true,
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ ),
+ ),
+ )
+ );
+
register_rest_route(
REST_API_NAMESPACE_V1,
'/tasks/(?P[0-9]+)',
@@ -179,6 +200,179 @@ public static function handle_create_task(
return new \WP_REST_Response( $res, $res['code'] );
}
+ /**
+ * Handles a GET request to retrieve Asana task data.
+ *
+ * @since [unreleased]
+ *
+ * @param \WP_REST_Request $request The API request.
+ *
+ * @return \WP_REST_Response|\WP_Error The API response.
+ */
+ public static function handle_get_task(
+ \WP_REST_Request $request
+ ) {
+
+ $request_token = new Request_Token( $request['token'] );
+
+ // Abort if token is invalid.
+ if ( ! $request_token->exists() ) {
+ return new \WP_Error(
+ 'bad_token',
+ 'Failed to get Asana task. Invalid request.',
+ array( 'status' => 400 )
+ );
+ }
+
+ // Check the cached response.
+ $cached_response = $request_token->get_cache_data();
+ if ( ! empty( $cached_response ) ) {
+ // Return cached data if available.
+ return new \WP_REST_Response( $cached_response, 200 );
+ }
+
+ try {
+
+ // Get Asana authentication.
+
+ $args = $request_token->get_args();
+ if ( empty( $args['auth_user'] ) ) {
+ // There is no user for Asana authentication.
+ return new \WP_Error(
+ 'no_auth',
+ 'Failed to get Asana task. Authentication user was not specified.',
+ array( 'status' => 401 )
+ );
+ }
+
+ // Perform request.
+
+ Asana_Interface::get_client( (int) $args['auth_user'] );
+ $task = Asana_Interface::maybe_get_task_data(
+ $request['task_gid'],
+ $args['opt_fields']
+ );
+
+ if ( empty( $task ) ) {
+ // An empty response is unexpected. Do not cache this.
+ return new \WP_Error(
+ 'empty_content',
+ 'Failed to get Asana task. There is no task data.',
+ array( 'status' => 409 )
+ );
+ }
+
+ // Localize task.
+ Asana_Interface::localize_task( $task, false );
+
+ // Load subtasks if desired.
+ if ( $args['show_subtasks'] ) {
+
+ $subtask_fields = $args['opt_fields'];
+
+ $do_remove_subtasks_sort_field = false;
+ if (
+ $args['sort_subtasks_by'] &&
+ false === in_array(
+ $args['sort_subtasks_by'],
+ explode( ',', $subtask_fields )
+ )
+ ) {
+ // Ensure sorting field is returned for sorting purposes.
+ // Always add "name" subfield in case its an object like "assignee".
+ $subtask_fields .= ",{$args['sort_subtasks_by']},{$args['sort_subtasks_by']}.name";
+ $do_remove_subtasks_sort_field = true;
+ }
+
+ if ( ! $args['show_completed'] ) {
+ // Loading subtasks doesn't support requesting
+ // incomplete tasks only, so must request the
+ // 'completed' field for filtering later.
+ $subtask_fields .= ',completed';
+ }
+
+ $tasks_arr = array( $task );
+ Asana_Interface::load_subtasks( $tasks_arr, $subtask_fields );
+ $task = $tasks_arr[0];
+
+ // Process subtasks.
+ if ( isset( $task->subtasks ) ) {
+
+ foreach ( $task->subtasks as $subtasks_i => &$subtask ) {
+
+ if ( isset( $subtask->completed ) ) {
+ if ( ! $args['show_completed'] ) {
+ if ( $subtask->completed ) {
+ // Don't show completed tasks.
+ unset( $task->subtasks[ $subtasks_i ] );
+ continue;
+ } else {
+ // Don't show completed status
+ // for incomplete tasks.
+ unset( $subtask->completed );
+ }
+ }
+ }
+
+ // Now recursively localize tasks since
+ // no further subtasks will be removed.
+ //
+ // Though note that recursion isn't actually
+ // needed here since only one level of subtasks
+ // was loaded, anyways.
+ Asana_Interface::localize_task( $subtask, true );
+ }//end foreach.
+
+ // Fix index gaps from possible removals.
+ if ( ! $args['show_tasks_completed'] ) {
+ $task->subtasks = array_values( $task->subtasks );
+ }
+
+ // Asana doesn't currently sort subtasks when the
+ // view's sort is changed, but we will.
+ if ( $args['sort_subtasks_by'] ) {
+ Asana_Interface::sort_tasks_by( $task->subtasks, $args['sort_subtasks_by'] );
+ }
+ }
+ }//endif subtasks.
+
+ if (
+ $args['sort_subtasks_by'] &&
+ true === $do_remove_subtasks_sort_field
+ ) {
+ // Remove extra field only used for sorting, not for display.
+ Util::deep_unset_prop( $task, $args['sort_subtasks_by'] );
+ }
+
+ // Remove all GIDs if desired.
+ if ( ! $args['show_gids'] ) {
+ Util::deep_unset_prop( $task, 'gid' );
+ }
+
+ // Cache response and return.
+ $request_token->update_cache_data( $task );
+ return new \WP_REST_Response( $task, 200 );
+ } catch ( \Exception $e ) {
+ $error_code = HTML_Builder::get_error_code( $e );
+ if ( $error_code < 400 ) {
+ // Prevent code 0 for odd errors like "could not resolve host name".
+ $error_code = 400;
+ }
+ return new \WP_Error(
+ 'asana_error',
+ 'Failed to get Asana task. ' . HTML_Builder::get_error_message( $e ),
+ array( 'status' => $error_code )
+ );
+ }
+
+ // This shouldn't be reachable.
+ return new \WP_Error(
+ 'unknown_error',
+ 'Failed to get Asana task. Unknown error.',
+ array( 'status' => 500 )
+ );
+ }
+
/**
* Handles a request to update an Asana task.
*