From 6c336ecbd238f1e1132f06881a673e3f5b1f608f Mon Sep 17 00:00:00 2001 From: corsacca Date: Thu, 4 Dec 2025 12:06:19 +0100 Subject: [PATCH 01/18] my contacts init --- disciple-tools-homescreen-apps.php | 1 + magic-link/my-contacts.php | 1778 ++++++++++++++++++++++++++++ 2 files changed, 1779 insertions(+) create mode 100644 magic-link/my-contacts.php diff --git a/disciple-tools-homescreen-apps.php b/disciple-tools-homescreen-apps.php index a05b151..b9c2d05 100755 --- a/disciple-tools-homescreen-apps.php +++ b/disciple-tools-homescreen-apps.php @@ -93,6 +93,7 @@ private function __construct() { require_once( 'magic-link/link-zume.php' ); require_once( 'magic-link/link-waha.php' ); require_once( 'magic-link/my-coaching.php' ); + require_once( 'magic-link/my-contacts.php' ); // require_once( 'magic-link/dispatcher-contacts.php' ); require_once( 'magic-link/dispatcher-magic-link.php' ); $this->i18n(); diff --git a/magic-link/my-contacts.php b/magic-link/my-contacts.php new file mode 100644 index 0000000..eadb08d --- /dev/null +++ b/magic-link/my-contacts.php @@ -0,0 +1,1778 @@ +meta = [ + 'app_type' => 'magic_link', + 'post_type' => $this->post_type, + 'contacts_only' => true, + 'supports_create' => false, + 'icon' => 'mdi mdi-account-group', + 'show_in_home_apps' => true, + ]; + + $this->meta_key = $this->root . '_' . $this->type . '_magic_key'; + parent::__construct(); + + add_filter( 'dt_settings_apps_list', [ $this, 'dt_settings_apps_list' ], 10, 1 ); + add_action( 'rest_api_init', [ $this, 'add_endpoints' ] ); + + $url = dt_get_url_path(); + if ( strpos( $url, $this->root . '/' . $this->type ) === false ) { + return; + } + + if ( ! $this->check_parts_match() ) { + return; + } + + add_action( 'dt_blank_body', [ $this, 'body' ] ); + add_filter( 'dt_magic_url_base_allowed_css', [ $this, 'dt_magic_url_base_allowed_css' ], 10, 1 ); + add_filter( 'dt_magic_url_base_allowed_js', [ $this, 'dt_magic_url_base_allowed_js' ], 10, 1 ); + } + + public function dt_settings_apps_list( $apps_list ) { + $apps_list[ $this->meta_key ] = [ + 'key' => $this->meta_key, + 'url_base' => $this->root . '/' . $this->type, + 'label' => $this->page_title, + 'description' => $this->page_description, + 'settings_display' => true + ]; + return $apps_list; + } + + public function dt_magic_url_base_allowed_js( $allowed_js ) { + return $allowed_js; + } + + public function dt_magic_url_base_allowed_css( $allowed_css ) { + return $allowed_css; + } + + /** + * Register REST API endpoints + */ + public function add_endpoints() { + $namespace = $this->root . '/v1'; + + register_rest_route( + $namespace, '/' . $this->type . '/contacts', [ + [ + 'methods' => 'POST', + 'callback' => [ $this, 'get_my_contacts' ], + 'permission_callback' => [ $this, 'check_permission' ], + ], + ] + ); + + register_rest_route( + $namespace, '/' . $this->type . '/contact', [ + [ + 'methods' => 'POST', + 'callback' => [ $this, 'get_contact_details' ], + 'permission_callback' => [ $this, 'check_permission' ], + ], + ] + ); + + register_rest_route( + $namespace, '/' . $this->type . '/comment', [ + [ + 'methods' => 'POST', + 'callback' => [ $this, 'add_comment' ], + 'permission_callback' => [ $this, 'check_permission' ], + ], + ] + ); + + register_rest_route( + $namespace, '/' . $this->type . '/users-mention', [ + [ + 'methods' => 'POST', + 'callback' => [ $this, 'get_users_for_mention' ], + 'permission_callback' => [ $this, 'check_permission' ], + ], + ] + ); + } + + /** + * Check permission for REST endpoints + */ + public function check_permission( WP_REST_Request $request ) { + $params = $request->get_json_params(); + $params = dt_recursive_sanitize_array( $params ); + + if ( ! isset( $params['parts'], $params['parts']['public_key'], $params['parts']['meta_key'] ) ) { + return false; + } + + $post_id = $this->get_post_id_from_magic_key( $params['parts']['meta_key'], $params['parts']['public_key'] ); + return (bool) $post_id; + } + + /** + * Get post ID from magic key + */ + private function get_post_id_from_magic_key( $meta_key, $public_key ) { + global $wpdb; + $post_id = $wpdb->get_var( $wpdb->prepare( + "SELECT post_id FROM $wpdb->postmeta WHERE meta_key = %s AND meta_value = %s", + $meta_key, + $public_key + ) ); + return $post_id ? intval( $post_id ) : null; + } + + /** + * Get contacts for this magic link owner + */ + public function get_my_contacts( WP_REST_Request $request ) { + $params = $request->get_json_params(); + $params = dt_recursive_sanitize_array( $params ); + + $owner_contact_id = $this->get_post_id_from_magic_key( $params['parts']['meta_key'], $params['parts']['public_key'] ); + if ( ! $owner_contact_id ) { + return new WP_Error( 'invalid_key', 'Invalid magic link', [ 'status' => 403 ] ); + } + + $contacts = []; + $contact_ids_added = []; + + // Get the owner contact to find subassigned contacts and corresponding user + $owner_contact = DT_Posts::get_post( 'contacts', $owner_contact_id, true, false ); + if ( is_wp_error( $owner_contact ) ) { + return new WP_Error( 'contact_not_found', 'Owner contact not found', [ 'status' => 404 ] ); + } + + // 1. Get subassigned contacts (contacts where this contact is in their subassigned field) + if ( ! empty( $owner_contact['subassigned'] ) ) { + foreach ( $owner_contact['subassigned'] as $subassigned ) { + $subassigned_id = $subassigned['ID'] ?? null; + if ( $subassigned_id && ! in_array( $subassigned_id, $contact_ids_added ) ) { + $contact = DT_Posts::get_post( 'contacts', $subassigned_id, true, false ); + if ( ! is_wp_error( $contact ) ) { + $contacts[] = $this->format_contact_for_list( $contact, 'subassigned' ); + $contact_ids_added[] = $subassigned_id; + } + } + } + } + + // 2. Get contacts the corresponding user has access to + $corresponds_to_user = $owner_contact['corresponds_to_user'] ?? null; + if ( $corresponds_to_user ) { + $user_id = is_array( $corresponds_to_user ) ? ( $corresponds_to_user['ID'] ?? null ) : $corresponds_to_user; + + if ( $user_id ) { + // Get contacts assigned to this user + $user_contacts = DT_Posts::list_posts( 'contacts', [ + 'assigned_to' => [ $user_id ], + 'sort' => '-last_modified', + 'limit' => 100, + ], false ); + + if ( ! is_wp_error( $user_contacts ) && isset( $user_contacts['posts'] ) ) { + foreach ( $user_contacts['posts'] as $contact ) { + if ( ! in_array( $contact['ID'], $contact_ids_added ) && $contact['ID'] !== $owner_contact_id ) { + $contacts[] = $this->format_contact_for_list( $contact, 'assigned' ); + $contact_ids_added[] = $contact['ID']; + } + } + } + } + } + + // Sort by last_modified (most recent first) + usort( $contacts, function( $a, $b ) { + return ( $b['last_modified_timestamp'] ?? 0 ) - ( $a['last_modified_timestamp'] ?? 0 ); + }); + + return [ + 'contacts' => $contacts, + 'total' => count( $contacts ), + 'owner_contact_id' => $owner_contact_id, + ]; + } + + /** + * Format a contact for the list display + */ + private function format_contact_for_list( $contact, $source = '' ) { + $overall_status = ''; + $overall_status_color = ''; + if ( ! empty( $contact['overall_status'] ) ) { + $overall_status = $contact['overall_status']['label'] ?? ''; + $overall_status_color = $contact['overall_status']['color'] ?? ''; + } + + $seeker_path = ''; + if ( ! empty( $contact['seeker_path'] ) ) { + $seeker_path = $contact['seeker_path']['label'] ?? ''; + } + + $last_modified = ''; + $last_modified_timestamp = 0; + if ( ! empty( $contact['last_modified']['timestamp'] ) ) { + $last_modified_timestamp = $contact['last_modified']['timestamp']; + $last_modified = $contact['last_modified']['formatted'] ?? ''; + } + + return [ + 'ID' => $contact['ID'], + 'name' => $contact['name'] ?? 'Unknown', + 'overall_status' => $overall_status, + 'overall_status_color' => $overall_status_color, + 'seeker_path' => $seeker_path, + 'last_modified' => $last_modified, + 'last_modified_timestamp' => $last_modified_timestamp, + 'source' => $source, + ]; + } + + /** + * Get contact details with comments and activity + */ + public function get_contact_details( WP_REST_Request $request ) { + $params = $request->get_json_params(); + $params = dt_recursive_sanitize_array( $params ); + + $owner_contact_id = $this->get_post_id_from_magic_key( $params['parts']['meta_key'], $params['parts']['public_key'] ); + if ( ! $owner_contact_id ) { + return new WP_Error( 'invalid_key', 'Invalid magic link', [ 'status' => 403 ] ); + } + + $contact_id = intval( $params['contact_id'] ?? 0 ); + if ( ! $contact_id ) { + return new WP_Error( 'missing_contact_id', 'Contact ID is required', [ 'status' => 400 ] ); + } + + // Verify access - the contact must be in subassigned or accessible by user + if ( ! $this->verify_contact_access( $owner_contact_id, $contact_id ) ) { + return new WP_Error( 'access_denied', 'You do not have access to this contact', [ 'status' => 403 ] ); + } + + $contact = DT_Posts::get_post( 'contacts', $contact_id, true, false ); + if ( is_wp_error( $contact ) ) { + return new WP_Error( 'contact_not_found', 'Contact not found', [ 'status' => 404 ] ); + } + + $field_settings = DT_Posts::get_post_field_settings( 'contacts' ); + $tile_settings = DT_Posts::get_post_tiles( 'contacts' ); + + // Get comments and activity + $comments = DT_Posts::get_post_comments( 'contacts', $contact_id, true, 'all', [ 'number' => 50 ] ); + $activity = $this->get_post_activity( $contact_id ); + + // Fields to skip (internal/system fields) + $skip_fields = [ 'corresponds_to_user', 'duplicate_data', 'duplicate_of', 'post_author', 'record_picture', 'name' ]; + + // Group fields by tile + $tiles_with_fields = []; + foreach ( $field_settings as $field_key => $field_setting ) { + // Skip hidden, internal, or system fields + if ( in_array( $field_key, $skip_fields ) ) { + continue; + } + if ( isset( $field_setting['hidden'] ) && $field_setting['hidden'] === true ) { + continue; + } + + // Skip fields without a tile + $tile_key = $field_setting['tile'] ?? ''; + if ( empty( $tile_key ) ) { + continue; + } + + // Skip if tile doesn't exist in tile settings + if ( ! isset( $tile_settings[ $tile_key ] ) ) { + continue; + } + + $value = $contact[ $field_key ] ?? null; + $formatted_value = $this->format_field_value( $value, $field_setting ); + + // Initialize tile if not exists + if ( ! isset( $tiles_with_fields[ $tile_key ] ) ) { + $tile_order = $tile_settings[ $tile_key ]['tile_priority'] ?? 100; + $tiles_with_fields[ $tile_key ] = [ + 'key' => $tile_key, + 'label' => $tile_settings[ $tile_key ]['label'] ?? $tile_key, + 'order' => is_numeric( $tile_order ) ? intval( $tile_order ) : 100, + 'fields' => [], + ]; + } + + $field_order = $field_setting['in_create_form'] ?? 100; + $tiles_with_fields[ $tile_key ]['fields'][] = [ + 'key' => $field_key, + 'label' => $field_setting['name'] ?? $field_key, + 'value' => $formatted_value, + 'type' => $field_setting['type'] ?? 'text', + 'icon' => $field_setting['icon'] ?? '', + 'font_icon' => $field_setting['font-icon'] ?? '', + 'order' => is_numeric( $field_order ) ? intval( $field_order ) : 100, + ]; + } + + // Sort tiles by order + uasort( $tiles_with_fields, function( $a, $b ) { + $order_a = is_numeric( $a['order'] ?? 100 ) ? intval( $a['order'] ) : 100; + $order_b = is_numeric( $b['order'] ?? 100 ) ? intval( $b['order'] ) : 100; + return $order_a - $order_b; + }); + + // Sort fields within each tile by order + foreach ( $tiles_with_fields as &$tile ) { + usort( $tile['fields'], function( $a, $b ) { + $order_a = is_numeric( $a['order'] ?? 100 ) ? intval( $a['order'] ) : 100; + $order_b = is_numeric( $b['order'] ?? 100 ) ? intval( $b['order'] ) : 100; + return $order_a - $order_b; + }); + } + + // Convert to indexed array + $tiles = array_values( $tiles_with_fields ); + + // Merge comments and activity, sort by date + $all_activity = $this->merge_comments_and_activity( $comments['comments'] ?? [], $activity ); + + return [ + 'ID' => $contact['ID'], + 'name' => $contact['name'] ?? 'Unknown', + 'tiles' => $tiles, + 'created' => $contact['post_date']['formatted'] ?? '', + 'last_modified' => $contact['last_modified']['formatted'] ?? '', + 'activity' => $all_activity, + ]; + } + + /** + * Verify the owner has access to view the requested contact + */ + private function verify_contact_access( $owner_contact_id, $contact_id ) { + // Don't allow viewing self + if ( $owner_contact_id === $contact_id ) { + return false; + } + + $owner_contact = DT_Posts::get_post( 'contacts', $owner_contact_id, true, false ); + if ( is_wp_error( $owner_contact ) ) { + return false; + } + + // Check if contact is in subassigned + if ( ! empty( $owner_contact['subassigned'] ) ) { + foreach ( $owner_contact['subassigned'] as $subassigned ) { + if ( ( $subassigned['ID'] ?? null ) === $contact_id ) { + return true; + } + } + } + + // Check if corresponding user has access + $corresponds_to_user = $owner_contact['corresponds_to_user'] ?? null; + if ( $corresponds_to_user ) { + $user_id = is_array( $corresponds_to_user ) ? ( $corresponds_to_user['ID'] ?? null ) : $corresponds_to_user; + + if ( $user_id ) { + // Check if contact is assigned to this user + $contact = DT_Posts::get_post( 'contacts', $contact_id, true, false ); + if ( ! is_wp_error( $contact ) ) { + $assigned_to = $contact['assigned_to'] ?? null; + if ( $assigned_to ) { + $assigned_user_id = is_array( $assigned_to ) ? ( $assigned_to['id'] ?? null ) : $assigned_to; + if ( intval( $assigned_user_id ) === intval( $user_id ) ) { + return true; + } + } + } + } + } + + return false; + } + + /** + * Get post activity log + */ + private function get_post_activity( $post_id ) { + global $wpdb; + + $activity = $wpdb->get_results( $wpdb->prepare( + "SELECT * FROM $wpdb->dt_activity_log + WHERE object_id = %d + AND object_type = 'contacts' + ORDER BY hist_time DESC + LIMIT 50", + $post_id + ), ARRAY_A ); + + $formatted_activity = []; + foreach ( $activity as $item ) { + // Skip certain action types that are not user-facing + $skip_actions = [ 'connected to', 'disconnected from' ]; + if ( in_array( $item['action'] ?? '', $skip_actions ) ) { + continue; + } + + $formatted_activity[] = [ + 'id' => $item['histid'], + 'type' => 'activity', + 'action' => $item['action'] ?? '', + 'object_note' => $item['object_note'] ?? '', + 'meta_key' => $item['meta_key'] ?? '', + 'meta_value' => $item['meta_value'] ?? '', + 'old_value' => $item['old_value'] ?? '', + 'user_id' => $item['user_id'] ?? 0, + 'timestamp' => intval( $item['hist_time'] ?? 0 ), + 'date' => gmdate( 'Y-m-d H:i:s', intval( $item['hist_time'] ?? 0 ) ), + ]; + } + + return $formatted_activity; + } + + /** + * Merge comments and activity into a single timeline + */ + private function merge_comments_and_activity( $comments, $activity ) { + $merged = []; + + // Add comments + foreach ( $comments as $comment ) { + $timestamp = strtotime( $comment['comment_date'] ?? '' ); + $merged[] = [ + 'type' => 'comment', + 'id' => $comment['comment_ID'] ?? 0, + 'content' => $comment['comment_content'] ?? '', + 'author' => $comment['comment_author'] ?? '', + 'date' => $comment['comment_date'] ?? '', + 'timestamp' => $timestamp, + ]; + } + + // Add activity (only field changes, not comments which are already included) + foreach ( $activity as $item ) { + if ( $item['action'] === 'comment' ) { + continue; // Skip comment actions as they're already in comments + } + + $description = $this->format_activity_description( $item ); + if ( empty( $description ) ) { + continue; + } + + // Get user display name + $user_name = 'System'; + if ( ! empty( $item['user_id'] ) ) { + $user = get_userdata( $item['user_id'] ); + if ( $user ) { + $user_name = $user->display_name; + } + } + + $merged[] = [ + 'type' => 'activity', + 'id' => $item['id'], + 'content' => $description, + 'author' => $user_name, + 'date' => $item['date'], + 'timestamp' => $item['timestamp'], + ]; + } + + // Sort by timestamp descending (most recent first) + usort( $merged, function( $a, $b ) { + return ( $b['timestamp'] ?? 0 ) - ( $a['timestamp'] ?? 0 ); + }); + + return $merged; + } + + /** + * Format activity item into human-readable description + */ + private function format_activity_description( $item ) { + $action = $item['action'] ?? ''; + $meta_key = $item['meta_key'] ?? ''; + $object_note = $item['object_note'] ?? ''; + + // If there's an object note, use it + if ( ! empty( $object_note ) ) { + return $object_note; + } + + // Format based on action type + switch ( $action ) { + case 'field_update': + $field_settings = DT_Posts::get_post_field_settings( 'contacts' ); + $field_name = $field_settings[ $meta_key ]['name'] ?? $meta_key; + return sprintf( 'Updated %s', $field_name ); + + case 'created': + return 'Contact created'; + + default: + return ''; + } + } + + /** + * Format field value based on field type + */ + private function format_field_value( $value, $field_setting ) { + $type = $field_setting['type'] ?? 'text'; + + // Handle null/empty values - return empty string to display field with no value + if ( $value === null || $value === '' || ( is_array( $value ) && empty( $value ) ) ) { + return ''; + } + + switch ( $type ) { + case 'text': + case 'textarea': + case 'number': + return is_string( $value ) || is_numeric( $value ) ? $value : ''; + + case 'boolean': + return $value ? 'Yes' : 'No'; + + case 'key_select': + return $value['label'] ?? ''; + + case 'multi_select': + case 'tags': + if ( is_array( $value ) ) { + $labels = []; + $options = $field_setting['default'] ?? []; + foreach ( $value as $item ) { + if ( isset( $item['label'] ) ) { + $labels[] = $item['label']; + } elseif ( is_string( $item ) ) { + if ( isset( $options[ $item ]['label'] ) ) { + $labels[] = $options[ $item ]['label']; + } else { + $labels[] = $item; + } + } + } + return implode( ', ', $labels ); + } + return ''; + + case 'communication_channel': + if ( is_array( $value ) ) { + $channels = []; + foreach ( $value as $item ) { + if ( isset( $item['value'] ) && ! empty( $item['value'] ) ) { + $channels[] = $item['value']; + } + } + return implode( ', ', $channels ); + } + return ''; + + case 'location': + case 'location_grid': + case 'location_meta': + if ( is_array( $value ) ) { + $locations = []; + foreach ( $value as $item ) { + if ( isset( $item['label'] ) ) { + $locations[] = $item['label']; + } + } + return implode( "\n", $locations ); + } + return ''; + + case 'connection': + if ( is_array( $value ) ) { + $connections = []; + foreach ( $value as $item ) { + if ( isset( $item['post_title'] ) ) { + $connections[] = $item['post_title']; + } + } + return implode( ', ', $connections ); + } + return ''; + + case 'user_select': + if ( isset( $value['display'] ) ) { + return $value['display']; + } + return ''; + + case 'date': + if ( isset( $value['formatted'] ) ) { + return $value['formatted']; + } + return ''; + + case 'link': + if ( is_array( $value ) ) { + $links = []; + foreach ( $value as $item ) { + if ( isset( $item['value'] ) ) { + $links[] = $item['value']; + } + } + return implode( ', ', $links ); + } + return ''; + + default: + if ( is_array( $value ) ) { + if ( isset( $value['label'] ) ) { + return $value['label']; + } + return ''; + } + return is_string( $value ) ? $value : ''; + } + } + + /** + * Add a comment to a contact + */ + public function add_comment( WP_REST_Request $request ) { + $params = $request->get_json_params(); + $params = dt_recursive_sanitize_array( $params ); + + $owner_contact_id = $this->get_post_id_from_magic_key( $params['parts']['meta_key'], $params['parts']['public_key'] ); + if ( ! $owner_contact_id ) { + return new WP_Error( 'invalid_key', 'Invalid magic link', [ 'status' => 403 ] ); + } + + $contact_id = intval( $params['contact_id'] ?? 0 ); + $comment = $params['comment'] ?? ''; + + if ( ! $contact_id || empty( $comment ) ) { + return new WP_Error( 'missing_params', 'Contact ID and comment are required', [ 'status' => 400 ] ); + } + + // Verify access + if ( ! $this->verify_contact_access( $owner_contact_id, $contact_id ) ) { + return new WP_Error( 'access_denied', 'You do not have access to this contact', [ 'status' => 403 ] ); + } + + $result = DT_Posts::add_post_comment( 'contacts', $contact_id, $comment, 'comment', [], false, true ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + return [ + 'success' => true, + 'contact_id' => $contact_id, + 'comment_id' => $result, + ]; + } + + /** + * Get users for @mention autocomplete + */ + public function get_users_for_mention( WP_REST_Request $request ) { + $params = $request->get_json_params(); + $params = dt_recursive_sanitize_array( $params ); + + $search = $params['search'] ?? ''; + + global $wpdb; + + $users = $wpdb->get_results( $wpdb->prepare( " + SELECT ID, display_name + FROM $wpdb->users + WHERE display_name LIKE %s + ORDER BY display_name + LIMIT 10 + ", '%' . $wpdb->esc_like( $search ) . '%' ), ARRAY_A ); + + return [ + 'users' => $users, + ]; + } + + /** + * Custom header styles + */ + public function header_style() { + ?> + + + +
+ +
+
+ My Contacts (0) + +
+
+
+
+

Loading contacts...

+
+
+
+ + +
+
+ + Contact Details + +
+
+
+
👤
+

Select a contact to view details

+
+
+
+
+ + +
+ Comment added successfully! +
+ + + + Date: Thu, 4 Dec 2025 16:43:29 +0100 Subject: [PATCH 02/18] Implement field updating --- magic-link/my-contacts.php | 807 ++++++++++++++++++++++++++++++++++++- 1 file changed, 799 insertions(+), 8 deletions(-) diff --git a/magic-link/my-contacts.php b/magic-link/my-contacts.php index eadb08d..cf005ae 100644 --- a/magic-link/my-contacts.php +++ b/magic-link/my-contacts.php @@ -59,6 +59,7 @@ public function __construct() { add_action( 'dt_blank_body', [ $this, 'body' ] ); add_filter( 'dt_magic_url_base_allowed_css', [ $this, 'dt_magic_url_base_allowed_css' ], 10, 1 ); add_filter( 'dt_magic_url_base_allowed_js', [ $this, 'dt_magic_url_base_allowed_js' ], 10, 1 ); + add_action( 'wp_enqueue_scripts', [ $this, 'wp_enqueue_scripts' ] ); } public function dt_settings_apps_list( $apps_list ) { @@ -73,13 +74,46 @@ public function dt_settings_apps_list( $apps_list ) { } public function dt_magic_url_base_allowed_js( $allowed_js ) { + $allowed_js[] = 'dt-web-components'; return $allowed_js; } public function dt_magic_url_base_allowed_css( $allowed_css ) { + $allowed_css[] = 'dt-web-components-css'; return $allowed_css; } + /** + * Enqueue DT web components for inline field editing + */ + public function wp_enqueue_scripts() { + $theme_uri = get_template_directory_uri(); + $theme_dir = get_template_directory(); + + // Enqueue DT web components JS + $components_js = 'dt-assets/build/components/index.js'; + if ( file_exists( $theme_dir . '/' . $components_js ) ) { + wp_enqueue_script( + 'dt-web-components', + $theme_uri . '/' . $components_js, + [], + filemtime( $theme_dir . '/' . $components_js ), + true + ); + } + + // Enqueue DT web components CSS + $components_css = 'dt-assets/build/css/light.min.css'; + if ( file_exists( $theme_dir . '/' . $components_css ) ) { + wp_enqueue_style( + 'dt-web-components-css', + $theme_uri . '/' . $components_css, + [], + filemtime( $theme_dir . '/' . $components_css ) + ); + } + } + /** * Register REST API endpoints */ @@ -125,6 +159,26 @@ public function add_endpoints() { ], ] ); + + register_rest_route( + $namespace, '/' . $this->type . '/update-field', [ + [ + 'methods' => 'POST', + 'callback' => [ $this, 'update_field' ], + 'permission_callback' => [ $this, 'check_permission' ], + ], + ] + ); + + register_rest_route( + $namespace, '/' . $this->type . '/field-options', [ + [ + 'methods' => 'POST', + 'callback' => [ $this, 'get_field_options' ], + 'permission_callback' => [ $this, 'check_permission' ], + ], + ] + ); } /** @@ -335,15 +389,55 @@ public function get_contact_details( WP_REST_Request $request ) { } $field_order = $field_setting['in_create_form'] ?? 100; - $tiles_with_fields[ $tile_key ]['fields'][] = [ + $field_type = $field_setting['type'] ?? 'text'; + + // Prepare raw value for editing + $raw_value = $this->prepare_raw_value( $value, $field_type ); + + // Prepare options for select fields + $options = []; + if ( in_array( $field_type, [ 'key_select', 'multi_select' ] ) && isset( $field_setting['default'] ) ) { + foreach ( $field_setting['default'] as $option_key => $option_data ) { + // Skip deleted or hidden options + if ( ! empty( $option_data['deleted'] ) || ! empty( $option_data['hidden'] ) ) { + continue; + } + // Ensure we have a valid key + if ( $option_key === '' || $option_key === null ) { + continue; + } + $label = $option_data['label'] ?? (string) $option_key; + // Ensure label is never empty + if ( empty( $label ) ) { + $label = (string) $option_key; + } + $options[] = [ + 'id' => (string) $option_key, + 'label' => $label, + 'color' => $option_data['color'] ?? null, + 'icon' => $option_data['icon'] ?? null, + ]; + } + } + + $field_data = [ 'key' => $field_key, 'label' => $field_setting['name'] ?? $field_key, 'value' => $formatted_value, - 'type' => $field_setting['type'] ?? 'text', + 'raw_value' => $raw_value, + 'type' => $field_type, + 'options' => $options, 'icon' => $field_setting['icon'] ?? '', 'font_icon' => $field_setting['font-icon'] ?? '', 'order' => is_numeric( $field_order ) ? intval( $field_order ) : 100, ]; + + // Add post_type for connection fields + if ( $field_type === 'connection' && isset( $field_setting['post_type'] ) ) { + $field_data['post_type'] = $field_setting['post_type']; + } + + $tiles_with_fields[ $tile_key ]['fields'][] = $field_data; } // Sort tiles by order @@ -665,6 +759,192 @@ private function format_field_value( $value, $field_setting ) { } } + /** + * Prepare raw value for editing (JSON-safe format for DT components) + */ + private function prepare_raw_value( $value, $field_type ) { + if ( $value === null || $value === '' ) { + return null; + } + + switch ( $field_type ) { + case 'text': + case 'textarea': + case 'number': + return is_string( $value ) || is_numeric( $value ) ? $value : ''; + + case 'boolean': + return (bool) $value; + + case 'key_select': + return $value['key'] ?? ''; + + case 'multi_select': + case 'tags': + // Value should be an array of string IDs for the component + if ( is_array( $value ) ) { + $result = []; + foreach ( $value as $item ) { + if ( is_string( $item ) ) { + $result[] = $item; + } elseif ( is_array( $item ) && isset( $item['value'] ) ) { + $result[] = $item['value']; + } elseif ( is_array( $item ) && isset( $item['key'] ) ) { + $result[] = $item['key']; + } + } + return $result; + } + return []; + + case 'communication_channel': + if ( is_array( $value ) ) { + return array_values( $value ); + } + return []; + + case 'connection': + if ( is_array( $value ) ) { + return array_map( function( $item ) { + return [ + 'id' => (int) ( $item['ID'] ?? 0 ), + 'label' => $item['post_title'] ?? '', + 'link' => $item['permalink'] ?? '', + 'status' => $item['status'] ?? null, + ]; + }, $value ); + } + return []; + + case 'location': + case 'location_meta': + if ( is_array( $value ) ) { + return array_values( $value ); + } + return []; + + case 'user_select': + if ( isset( $value['id'] ) ) { + return $value['id']; + } + return null; + + case 'date': + if ( isset( $value['timestamp'] ) ) { + return $value['timestamp']; + } + return null; + + case 'link': + if ( is_array( $value ) ) { + return array_values( $value ); + } + return []; + + default: + return $value; + } + } + + /** + * Format a value for DT_Posts::update_post based on field type + */ + private function format_value_for_update( $value, $field_type, $contact_id, $field_key ) { + switch ( $field_type ) { + case 'text': + case 'textarea': + case 'number': + case 'boolean': + case 'key_select': + // These types can be passed directly + return $value; + + case 'date': + // Date expects a timestamp or date string + return $value; + + case 'multi_select': + case 'tags': + // multi_select and tags expect: ['values' => [['value' => 'option1'], ['value' => 'option2']]] + // The component uses '-' prefix to mark items for deletion (e.g., ["-tag1", "tag2"]) + $new_values = is_array( $value ) ? $value : []; + + $update_values = []; + foreach ( $new_values as $v ) { + // Check for deletion prefix (component marks deleted items with '-' prefix) + if ( is_string( $v ) && strpos( $v, '-' ) === 0 ) { + $update_values[] = [ 'value' => substr( $v, 1 ), 'delete' => true ]; + } else { + $update_values[] = [ 'value' => $v ]; + } + } + + return [ 'values' => $update_values ]; + + case 'communication_channel': + // communication_channel is more complex - for now return as-is + // Full implementation would need to track meta_ids for updates + if ( is_array( $value ) ) { + return $value; + } + return []; + + case 'connection': + // connection expects: ['values' => [['value' => post_id]]] + // Items with 'delete' property should be removed + if ( is_array( $value ) ) { + $formatted = []; + foreach ( $value as $item ) { + if ( isset( $item['id'] ) ) { + $entry = [ 'value' => $item['id'] ]; + if ( ! empty( $item['delete'] ) ) { + $entry['delete'] = true; + } + $formatted[] = $entry; + } elseif ( is_numeric( $item ) ) { + $formatted[] = [ 'value' => intval( $item ) ]; + } + } + return [ 'values' => $formatted ]; + } + return [ 'values' => [] ]; + + case 'location': + case 'location_meta': + // location expects: ['values' => [['value' => grid_id]]] + // Items with 'delete' property should be removed + if ( is_array( $value ) ) { + $formatted = []; + foreach ( $value as $item ) { + $entry = null; + if ( isset( $item['id'] ) ) { + $entry = [ 'value' => $item['id'] ]; + } elseif ( isset( $item['grid_id'] ) ) { + $entry = [ 'value' => $item['grid_id'] ]; + } + if ( $entry ) { + if ( ! empty( $item['delete'] ) ) { + $entry['delete'] = true; + } + $formatted[] = $entry; + } + } + return [ 'values' => $formatted ]; + } + return [ 'values' => [] ]; + + case 'user_select': + // user_select expects a user ID string like "user-123" + if ( is_numeric( $value ) ) { + return 'user-' . $value; + } + return $value; + + default: + return $value; + } + } + /** * Add a comment to a contact */ @@ -726,6 +1006,155 @@ public function get_users_for_mention( WP_REST_Request $request ) { ]; } + /** + * Update a field on a contact + */ + public function update_field( WP_REST_Request $request ) { + $params = $request->get_json_params(); + $params = dt_recursive_sanitize_array( $params ); + + $owner_contact_id = $this->get_post_id_from_magic_key( $params['parts']['meta_key'], $params['parts']['public_key'] ); + if ( ! $owner_contact_id ) { + return new WP_Error( 'invalid_key', 'Invalid magic link', [ 'status' => 403 ] ); + } + + $contact_id = intval( $params['contact_id'] ?? 0 ); + $field_key = $params['field_key'] ?? ''; + $field_value = $params['field_value'] ?? null; + + if ( ! $contact_id || empty( $field_key ) ) { + return new WP_Error( 'missing_params', 'Contact ID and field key are required', [ 'status' => 400 ] ); + } + + // Verify access + if ( ! $this->verify_contact_access( $owner_contact_id, $contact_id ) ) { + return new WP_Error( 'access_denied', 'You do not have access to this contact', [ 'status' => 403 ] ); + } + + // Get field settings to determine field type + $field_settings = DT_Posts::get_post_field_settings( 'contacts' ); + $field_setting = $field_settings[ $field_key ] ?? []; + $field_type = $field_setting['type'] ?? 'text'; + + // Transform the value to the format DT_Posts expects + $formatted_update_value = $this->format_value_for_update( $field_value, $field_type, $contact_id, $field_key ); + + // Update the field + $update_data = [ $field_key => $formatted_update_value ]; + $result = DT_Posts::update_post( 'contacts', $contact_id, $update_data, true, false ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + // Get the updated field value + $updated_value = $result[ $field_key ] ?? null; + $formatted_value = $this->format_field_value( $updated_value, $field_setting ); + + return [ + 'success' => true, + 'contact_id' => $contact_id, + 'field_key' => $field_key, + 'value' => $formatted_value, + 'raw_value' => $updated_value, + ]; + } + + /** + * Get field options for typeahead components (connections, locations, tags) + */ + public function get_field_options( WP_REST_Request $request ) { + $params = $request->get_json_params(); + $params = dt_recursive_sanitize_array( $params ); + + $owner_contact_id = $this->get_post_id_from_magic_key( $params['parts']['meta_key'], $params['parts']['public_key'] ); + if ( ! $owner_contact_id ) { + return new WP_Error( 'invalid_key', 'Invalid magic link', [ 'status' => 403 ] ); + } + + $field_key = $params['field'] ?? ''; + $query = $params['query'] ?? ''; + $post_type = $params['post_type'] ?? 'contacts'; + + if ( empty( $field_key ) ) { + return new WP_Error( 'missing_field', 'Field key is required', [ 'status' => 400 ] ); + } + + // Get field settings + $field_settings = DT_Posts::get_post_field_settings( 'contacts' ); + $field_setting = $field_settings[ $field_key ] ?? []; + $field_type = $field_setting['type'] ?? ''; + + $options = []; + + switch ( $field_type ) { + case 'connection': + // Get the post type this field connects to + $connected_post_type = $field_setting['post_type'] ?? 'contacts'; + + // Use get_viewable_compact which handles sorting properly + // (recently viewed first for empty search, recently modified for search) + $search_results = DT_Posts::get_viewable_compact( $connected_post_type, $query, [ + 'field_key' => $field_key, + ] ); + + if ( ! is_wp_error( $search_results ) && isset( $search_results['posts'] ) ) { + foreach ( $search_results['posts'] as $post ) { + $status = null; + if ( isset( $post['status'] ) ) { + $status = $post['status']; + } + $options[] = [ + 'id' => (int) $post['ID'], + 'label' => $post['name'] ?? '', + 'link' => get_permalink( $post['ID'] ), + 'status' => $status, + ]; + } + } + break; + + case 'location': + case 'location_meta': + // Search location grid + $search_results = Disciple_Tools_Mapping_Queries::search_location_grid_by_name( [ + 's' => $query, + 'limit' => 20, + ] ); + + if ( ! empty( $search_results ) && isset( $search_results['location_grid'] ) ) { + foreach ( $search_results['location_grid'] as $location ) { + $options[] = [ + 'id' => strval( $location['grid_id'] ?? $location['ID'] ), + 'label' => $location['name'] ?? $location['label'] ?? '', + ]; + } + } + break; + + case 'tags': + // Get existing tags for this field + $existing_tags = Disciple_Tools_Posts::get_multi_select_options( 'contacts', $field_key, false ); + if ( ! empty( $existing_tags ) ) { + foreach ( $existing_tags as $tag ) { + // Filter by query if provided + if ( empty( $query ) || stripos( $tag, $query ) !== false ) { + $options[] = [ + 'id' => $tag, + 'label' => $tag, + ]; + } + } + } + break; + } + + return [ + 'success' => true, + 'options' => $options, + ]; + } + /** * Custom header styles */ @@ -957,6 +1386,64 @@ public function header_style() { font-style: italic; } + /* Edit mode */ + .edit-icon { + cursor: pointer; + opacity: 0.4; + margin-left: 6px; + font-size: 12px; + transition: opacity 0.2s ease; + } + + .edit-icon:hover { + opacity: 1; + color: var(--primary-color); + } + + .detail-section .edit-mode { + display: none; + } + + .detail-section.editing .view-mode { + display: none; + } + + .detail-section.editing .edit-mode { + display: block; + } + + .detail-section.saving .edit-mode { + opacity: 0.6; + pointer-events: none; + } + + .field-saving-spinner { + display: inline-block; + width: 12px; + height: 12px; + border: 2px solid var(--border-color); + border-top-color: var(--primary-color); + border-radius: 50%; + animation: spin 1s linear infinite; + margin-left: 8px; + vertical-align: middle; + } + + /* DT Component Overrides for Magic Link */ + .edit-mode dt-text, + .edit-mode dt-textarea, + .edit-mode dt-number, + .edit-mode dt-single-select, + .edit-mode dt-multi-select, + .edit-mode dt-date, + .edit-mode dt-multi-text, + .edit-mode dt-tags, + .edit-mode dt-connection, + .edit-mode dt-location { + display: block; + width: 100%; + } + /* Activity/Comments list */ .activity-list { margin-top: 0; @@ -1317,6 +1804,49 @@ public function footer_javascript() { loadContacts(); }); + // Handle dt:get-data events from DT web components (for typeahead search) + document.addEventListener('dt:get-data', async function(e) { + if (!e.detail) return; + + const { field, query, onSuccess, onError, postType } = e.detail; + + try { + const response = await fetch( + `${myContactsApp.root}${myContactsApp.parts.root}/v1/${myContactsApp.parts.type}/field-options`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': myContactsApp.nonce + }, + body: JSON.stringify({ + parts: myContactsApp.parts, + field: field, + query: query || '', + post_type: postType || 'contacts' + }) + } + ); + + const data = await response.json(); + + if (data.success && data.options) { + if (onSuccess && typeof onSuccess === 'function') { + onSuccess(data.options); + } + } else { + if (onError && typeof onError === 'function') { + onError(new Error(data.message || 'Failed to fetch options')); + } + } + } catch (err) { + console.error('Error fetching field options:', err); + if (onError && typeof onError === 'function') { + onError(err); + } + } + }); + // Load contacts async function loadContacts() { try { @@ -1456,12 +1986,7 @@ function renderContactDetails(contact) { contact.tiles.map(tile => `
${escapeHtml(tile.label)}
- ${tile.fields.map(field => ` -
-
${renderFieldIcon(field)}${escapeHtml(field.label)}
-
${field.value ? escapeHtml(field.value) : '-'}
-
- `).join('')} + ${tile.fields.map(field => renderEditableField(field, contact.ID)).join('')}
`).join('') : '

No contact information available

'; @@ -1523,6 +2048,9 @@ function renderContactDetails(contact) { `; + // Initialize DT components with their data (value, options) + initializeDTComponents(); + // Initialize mention listeners initMentionListeners(); } @@ -1739,6 +2267,269 @@ function hideMobileDetails() { document.getElementById('contacts-panel').classList.remove('mobile-hidden'); } + // Store field data for programmatic initialization of components + window.fieldDataStore = window.fieldDataStore || {}; + + // Render an editable field with view and edit modes + function renderEditableField(field, contactId) { + const rawValue = field.raw_value; + + // Determine default based on field type (arrays for multi-value fields) + const isArrayField = ['multi_select', 'tags', 'communication_channel', 'connection', 'location', 'location_meta', 'link'].includes(field.type); + const defaultValue = isArrayField ? [] : ''; + const valueForJson = (rawValue !== null && rawValue !== undefined) ? rawValue : defaultValue; + + // Filter out any options with invalid IDs or labels, and ensure all values are strings + const validOptions = (field.options || []).filter(opt => + opt && + opt.id !== null && + opt.id !== undefined && + opt.id !== '' && + opt.label !== null && + opt.label !== undefined && + opt.label !== '' + ).map(opt => ({ + // Ensure id and label are strings + id: String(opt.id), + label: String(opt.label), + color: opt.color || null, + icon: opt.icon || null + })); + + // Store field data for later initialization + const fieldId = `field-${contactId}-${field.key}`; + window.fieldDataStore[fieldId] = { + value: valueForJson, + options: validOptions, + type: field.type + }; + + return ` +
+
+ ${renderFieldIcon(field)}${escapeHtml(field.label)} + +
+
${field.value ? escapeHtml(field.value) : '-'}
+
+ ${renderDTComponent(field, contactId)} +
+
+ `; + } + + // Render the appropriate DT component based on field type + // Components that need complex data (arrays/objects) get a data-field-id for programmatic init + function renderDTComponent(field, contactId) { + const fieldKey = escapeHtml(field.key); + const fieldId = `field-${contactId}-${field.key}`; + + switch (field.type) { + case 'text': + return ``; + + case 'textarea': + return ``; + + case 'number': + return ``; + + case 'boolean': + return ``; + + case 'date': + return ``; + + case 'key_select': + // key_select needs options set programmatically + return ``; + + case 'multi_select': + // multi_select needs value and options set programmatically + return ``; + + case 'communication_channel': + return ``; + + case 'tags': + return ``; + + case 'connection': + return ``; + + case 'location': + case 'location_meta': + return ``; + + case 'user_select': + // User select requires special permissions not available in magic link context + return `Not editable in this view`; + + default: + return ``; + } + } + + // Initialize DT components with their data after HTML is inserted + function initializeDTComponents() { + // Use requestAnimationFrame to ensure DOM is fully rendered + requestAnimationFrame(() => { + const components = document.querySelectorAll('[data-field-id]'); + + components.forEach(async (component) => { + const fieldId = component.dataset.fieldId; + const tagName = component.tagName.toLowerCase(); + const data = window.fieldDataStore[fieldId]; + + if (!data) { + return; + } + + try { + // Wait for the custom element to be defined/upgraded + if (customElements.get(tagName) === undefined) { + await customElements.whenDefined(tagName); + } + + // Set properties directly on the component + // IMPORTANT: Always set options first (even as empty array) before value + // This prevents _filterOptions from failing when value triggers willUpdate + component.options = data.options && data.options.length > 0 ? data.options : []; + + if (data.value !== null && data.value !== undefined) { + // For multi-select, ensure value is an array of valid strings + if (tagName === 'dt-multi-select' && Array.isArray(data.value)) { + const cleanValue = data.value + .filter(v => v !== null && v !== undefined && v !== '') + .map(v => String(v)); + component.value = cleanValue; + } else { + component.value = data.value; + } + } + } catch (err) { + console.error(`Error initializing component:`, err); + } + }); + }); + } + + // Toggle edit mode for a field + function toggleEditMode(fieldKey) { + const section = document.querySelector(`.detail-section[data-field-key="${fieldKey}"]`); + if (!section) return; + + const isEditing = section.classList.contains('editing'); + + // Close any other open edit modes + document.querySelectorAll('.detail-section.editing').forEach(el => { + if (el !== section) { + el.classList.remove('editing'); + } + }); + + if (isEditing) { + section.classList.remove('editing'); + } else { + section.classList.add('editing'); + // Initialize change listener for the component + initFieldChangeListener(section); + } + } + + // Close edit mode when clicking outside + document.addEventListener('click', function(e) { + // Don't close if clicking inside an editing section or its components + const editingSection = document.querySelector('.detail-section.editing'); + if (!editingSection) return; + + // Check if click is inside the editing section + if (editingSection.contains(e.target)) return; + + // Check if click is inside a dropdown/option list (these can be outside the section) + if (e.target.closest('.option-list, ul[class*="option"], li[tabindex]')) return; + + // Close the editing section + editingSection.classList.remove('editing'); + }); + + // Initialize change listener for a field's DT component + function initFieldChangeListener(section) { + const editMode = section.querySelector('.edit-mode'); + const component = editMode.querySelector('dt-text, dt-textarea, dt-number, dt-toggle, dt-date, dt-single-select, dt-multi-select, dt-multi-text, dt-tags, dt-connection, dt-location'); + + if (!component || component.hasAttribute('data-listener-added')) return; + + component.setAttribute('data-listener-added', 'true'); + + const fieldType = section.dataset.fieldType; + + component.addEventListener('change', async (e) => { + const fieldKey = section.dataset.fieldKey; + const contactId = section.dataset.contactId; + const newValue = e.detail?.newValue ?? e.detail?.value ?? component.value; + + await saveFieldValue(contactId, fieldKey, fieldType, newValue, section); + }); + } + + // Field types that allow multiple values - don't auto-close after saving + const multiValueFieldTypes = ['multi_select', 'connection', 'tags', 'location', 'location_meta', 'communication_channel']; + + // Save field value to the server + async function saveFieldValue(contactId, fieldKey, fieldType, value, section) { + section.classList.add('saving'); + + try { + const response = await fetch( + `${myContactsApp.root}${myContactsApp.parts.root}/v1/${myContactsApp.parts.type}/update-field`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': myContactsApp.nonce + }, + body: JSON.stringify({ + contact_id: parseInt(contactId), + field_key: fieldKey, + field_value: value, + parts: myContactsApp.parts + }) + } + ); + + const result = await response.json(); + + if (result.success) { + // Update the view mode value + const viewMode = section.querySelector('.view-mode'); + const displayValue = result.value || '-'; + viewMode.textContent = displayValue; + viewMode.classList.toggle('empty-value', !result.value); + + // Only auto-close for single-value fields, not multi-value fields + if (!multiValueFieldTypes.includes(fieldType)) { + section.classList.remove('editing'); + } + showSuccessToast('Field updated'); + } else { + const errorMsg = result.message || 'Failed to update field'; + alert(errorMsg); + } + } catch (error) { + console.error('Error saving field:', error); + alert('Error saving field'); + } finally { + section.classList.remove('saving'); + } + } + + // Escape HTML attribute + function escapeAttr(text) { + if (text === null || text === undefined) return ''; + return String(text).replace(/"/g, '"').replace(/'/g, ''').replace(//g, '>'); + } + // Render field icon (image URL or font-icon class) function renderFieldIcon(field) { // Check for image icon first (URL) From ca026eb92597ef3d84e33aaa1e15a93f15fd9892 Mon Sep 17 00:00:00 2001 From: corsacca Date: Fri, 5 Dec 2025 09:17:06 +0100 Subject: [PATCH 03/18] Interweave comments and activity --- magic-link/my-contacts.php | 164 ++++++++++++++++++++++++++++++++++--- 1 file changed, 151 insertions(+), 13 deletions(-) diff --git a/magic-link/my-contacts.php b/magic-link/my-contacts.php index cf005ae..8f9c885 100644 --- a/magic-link/my-contacts.php +++ b/magic-link/my-contacts.php @@ -550,8 +550,7 @@ private function get_post_activity( $post_id ) { 'meta_value' => $item['meta_value'] ?? '', 'old_value' => $item['old_value'] ?? '', 'user_id' => $item['user_id'] ?? 0, - 'timestamp' => intval( $item['hist_time'] ?? 0 ), - 'date' => gmdate( 'Y-m-d H:i:s', intval( $item['hist_time'] ?? 0 ) ), + 'timestamp' => intval( $item['hist_time'] ?? 0 ) ]; } @@ -566,7 +565,7 @@ private function merge_comments_and_activity( $comments, $activity ) { // Add comments foreach ( $comments as $comment ) { - $timestamp = strtotime( $comment['comment_date'] ?? '' ); + $timestamp = strtotime( $comment['comment_date_gmt'] ?? '' ); $merged[] = [ 'type' => 'comment', 'id' => $comment['comment_ID'] ?? 0, @@ -1493,6 +1492,68 @@ public function header_style() { color: #666; } + /* Collapsible activity group */ + .activity-group { + margin-bottom: 8px; + } + + .activity-group-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--bg-color); + border-radius: 6px; + cursor: pointer; + font-size: 13px; + color: #666; + border-left: 3px solid #ccc; + } + + .activity-group-header:hover { + background: #eee; + } + + .activity-group-arrow { + transition: transform 0.2s ease; + font-size: 10px; + } + + .activity-group.expanded .activity-group-arrow { + transform: rotate(90deg); + } + + .activity-group-content { + display: none; + padding-left: 20px; + margin-top: 4px; + } + + .activity-group.expanded .activity-group-content { + display: block; + } + + .activity-compact-item { + padding: 4px 0; + font-size: 12px; + color: #666; + border-bottom: 1px solid #f0f0f0; + } + + .activity-compact-item:last-child { + border-bottom: none; + } + + .activity-compact-author { + font-weight: 500; + color: #333; + } + + .activity-compact-date { + color: #999; + margin-left: 8px; + } + .activity-content { margin-top: 6px; font-size: 14px; @@ -2010,16 +2071,43 @@ function renderContactDetails(contact) { `; - // Render activity/comments timeline - const activityHtml = contact.activity && contact.activity.length > 0 ? - contact.activity.map(item => ` -
- ${escapeHtml(item.author)} - ${escapeHtml(item.date)} - ${item.type} -
${formatActivityContent(item.content)}
-
- `).join('') : + // Render activity/comments timeline with grouped field updates + const groupedActivity = groupActivityItems(contact.activity || []); + + const activityHtml = groupedActivity.length > 0 ? + groupedActivity.map((item, index) => { + if (item.type === 'comment') { + // Render comment as prominent card + return ` +
+ ${escapeHtml(item.author)} + ${formatTimestamp(item.timestamp)} +
${formatActivityContent(item.content)}
+
+ `; + } else { + // Render activity group as collapsible + const count = item.items.length; + const groupId = `activity-group-${index}`; + return ` +
+
+ + ${count} field update${count > 1 ? 's' : ''} +
+
+ ${item.items.map(a => ` +
+ ${formatActivityContent(a.content)} + ${escapeHtml(a.author)} + ${formatTimestamp(a.timestamp)} +
+ `).join('')} +
+
+ `; + } + }).join('') : '

No activity yet

'; container.innerHTML = ` @@ -2077,6 +2165,43 @@ function formatActivityContent(text) { return formatted; } + // Group consecutive activity items while keeping comments separate + function groupActivityItems(items) { + const grouped = []; + let currentGroup = null; + + items.forEach(item => { + if (item.type === 'comment') { + // Flush any pending activity group + if (currentGroup) { + grouped.push({ type: 'activity-group', items: currentGroup }); + currentGroup = null; + } + // Add comment as-is + grouped.push(item); + } else { + // Accumulate activity items + if (!currentGroup) currentGroup = []; + currentGroup.push(item); + } + }); + + // Flush remaining activity group + if (currentGroup) { + grouped.push({ type: 'activity-group', items: currentGroup }); + } + + return grouped; + } + + // Toggle activity group expand/collapse + function toggleActivityGroup(groupId) { + const group = document.getElementById(groupId); + if (group) { + group.classList.toggle('expanded'); + } + } + // Comment submission async function submitComment() { const textarea = document.getElementById('comment-textarea'); @@ -2551,6 +2676,19 @@ function escapeHtml(text) { return div.innerHTML; } + // Format timestamp in browser's timezone + function formatTimestamp(timestamp) { + if (!timestamp) return ''; + const date = new Date(timestamp * 1000); + return date.toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit' + }); + } + // Success toast function showSuccessToast(message = 'Success!') { const toast = document.getElementById('success-toast'); From 38190ee7b9aa1956fab9bbc8d1fa7eb043919557 Mon Sep 17 00:00:00 2001 From: corsacca Date: Fri, 5 Dec 2025 09:36:05 +0100 Subject: [PATCH 04/18] Split out js and css --- disciple-tools-homescreen-apps.php | 2 +- magic-link/my-contacts.php | 2707 ------------------------ magic-link/my-contacts/my-contacts.css | 635 ++++++ magic-link/my-contacts/my-contacts.js | 842 ++++++++ magic-link/my-contacts/my-contacts.php | 1240 +++++++++++ 5 files changed, 2718 insertions(+), 2708 deletions(-) delete mode 100644 magic-link/my-contacts.php create mode 100644 magic-link/my-contacts/my-contacts.css create mode 100644 magic-link/my-contacts/my-contacts.js create mode 100644 magic-link/my-contacts/my-contacts.php diff --git a/disciple-tools-homescreen-apps.php b/disciple-tools-homescreen-apps.php index b9c2d05..eb5762e 100755 --- a/disciple-tools-homescreen-apps.php +++ b/disciple-tools-homescreen-apps.php @@ -93,7 +93,7 @@ private function __construct() { require_once( 'magic-link/link-zume.php' ); require_once( 'magic-link/link-waha.php' ); require_once( 'magic-link/my-coaching.php' ); - require_once( 'magic-link/my-contacts.php' ); + require_once( 'magic-link/my-contacts/my-contacts.php' ); // require_once( 'magic-link/dispatcher-contacts.php' ); require_once( 'magic-link/dispatcher-magic-link.php' ); $this->i18n(); diff --git a/magic-link/my-contacts.php b/magic-link/my-contacts.php deleted file mode 100644 index 8f9c885..0000000 --- a/magic-link/my-contacts.php +++ /dev/null @@ -1,2707 +0,0 @@ -meta = [ - 'app_type' => 'magic_link', - 'post_type' => $this->post_type, - 'contacts_only' => true, - 'supports_create' => false, - 'icon' => 'mdi mdi-account-group', - 'show_in_home_apps' => true, - ]; - - $this->meta_key = $this->root . '_' . $this->type . '_magic_key'; - parent::__construct(); - - add_filter( 'dt_settings_apps_list', [ $this, 'dt_settings_apps_list' ], 10, 1 ); - add_action( 'rest_api_init', [ $this, 'add_endpoints' ] ); - - $url = dt_get_url_path(); - if ( strpos( $url, $this->root . '/' . $this->type ) === false ) { - return; - } - - if ( ! $this->check_parts_match() ) { - return; - } - - add_action( 'dt_blank_body', [ $this, 'body' ] ); - add_filter( 'dt_magic_url_base_allowed_css', [ $this, 'dt_magic_url_base_allowed_css' ], 10, 1 ); - add_filter( 'dt_magic_url_base_allowed_js', [ $this, 'dt_magic_url_base_allowed_js' ], 10, 1 ); - add_action( 'wp_enqueue_scripts', [ $this, 'wp_enqueue_scripts' ] ); - } - - public function dt_settings_apps_list( $apps_list ) { - $apps_list[ $this->meta_key ] = [ - 'key' => $this->meta_key, - 'url_base' => $this->root . '/' . $this->type, - 'label' => $this->page_title, - 'description' => $this->page_description, - 'settings_display' => true - ]; - return $apps_list; - } - - public function dt_magic_url_base_allowed_js( $allowed_js ) { - $allowed_js[] = 'dt-web-components'; - return $allowed_js; - } - - public function dt_magic_url_base_allowed_css( $allowed_css ) { - $allowed_css[] = 'dt-web-components-css'; - return $allowed_css; - } - - /** - * Enqueue DT web components for inline field editing - */ - public function wp_enqueue_scripts() { - $theme_uri = get_template_directory_uri(); - $theme_dir = get_template_directory(); - - // Enqueue DT web components JS - $components_js = 'dt-assets/build/components/index.js'; - if ( file_exists( $theme_dir . '/' . $components_js ) ) { - wp_enqueue_script( - 'dt-web-components', - $theme_uri . '/' . $components_js, - [], - filemtime( $theme_dir . '/' . $components_js ), - true - ); - } - - // Enqueue DT web components CSS - $components_css = 'dt-assets/build/css/light.min.css'; - if ( file_exists( $theme_dir . '/' . $components_css ) ) { - wp_enqueue_style( - 'dt-web-components-css', - $theme_uri . '/' . $components_css, - [], - filemtime( $theme_dir . '/' . $components_css ) - ); - } - } - - /** - * Register REST API endpoints - */ - public function add_endpoints() { - $namespace = $this->root . '/v1'; - - register_rest_route( - $namespace, '/' . $this->type . '/contacts', [ - [ - 'methods' => 'POST', - 'callback' => [ $this, 'get_my_contacts' ], - 'permission_callback' => [ $this, 'check_permission' ], - ], - ] - ); - - register_rest_route( - $namespace, '/' . $this->type . '/contact', [ - [ - 'methods' => 'POST', - 'callback' => [ $this, 'get_contact_details' ], - 'permission_callback' => [ $this, 'check_permission' ], - ], - ] - ); - - register_rest_route( - $namespace, '/' . $this->type . '/comment', [ - [ - 'methods' => 'POST', - 'callback' => [ $this, 'add_comment' ], - 'permission_callback' => [ $this, 'check_permission' ], - ], - ] - ); - - register_rest_route( - $namespace, '/' . $this->type . '/users-mention', [ - [ - 'methods' => 'POST', - 'callback' => [ $this, 'get_users_for_mention' ], - 'permission_callback' => [ $this, 'check_permission' ], - ], - ] - ); - - register_rest_route( - $namespace, '/' . $this->type . '/update-field', [ - [ - 'methods' => 'POST', - 'callback' => [ $this, 'update_field' ], - 'permission_callback' => [ $this, 'check_permission' ], - ], - ] - ); - - register_rest_route( - $namespace, '/' . $this->type . '/field-options', [ - [ - 'methods' => 'POST', - 'callback' => [ $this, 'get_field_options' ], - 'permission_callback' => [ $this, 'check_permission' ], - ], - ] - ); - } - - /** - * Check permission for REST endpoints - */ - public function check_permission( WP_REST_Request $request ) { - $params = $request->get_json_params(); - $params = dt_recursive_sanitize_array( $params ); - - if ( ! isset( $params['parts'], $params['parts']['public_key'], $params['parts']['meta_key'] ) ) { - return false; - } - - $post_id = $this->get_post_id_from_magic_key( $params['parts']['meta_key'], $params['parts']['public_key'] ); - return (bool) $post_id; - } - - /** - * Get post ID from magic key - */ - private function get_post_id_from_magic_key( $meta_key, $public_key ) { - global $wpdb; - $post_id = $wpdb->get_var( $wpdb->prepare( - "SELECT post_id FROM $wpdb->postmeta WHERE meta_key = %s AND meta_value = %s", - $meta_key, - $public_key - ) ); - return $post_id ? intval( $post_id ) : null; - } - - /** - * Get contacts for this magic link owner - */ - public function get_my_contacts( WP_REST_Request $request ) { - $params = $request->get_json_params(); - $params = dt_recursive_sanitize_array( $params ); - - $owner_contact_id = $this->get_post_id_from_magic_key( $params['parts']['meta_key'], $params['parts']['public_key'] ); - if ( ! $owner_contact_id ) { - return new WP_Error( 'invalid_key', 'Invalid magic link', [ 'status' => 403 ] ); - } - - $contacts = []; - $contact_ids_added = []; - - // Get the owner contact to find subassigned contacts and corresponding user - $owner_contact = DT_Posts::get_post( 'contacts', $owner_contact_id, true, false ); - if ( is_wp_error( $owner_contact ) ) { - return new WP_Error( 'contact_not_found', 'Owner contact not found', [ 'status' => 404 ] ); - } - - // 1. Get subassigned contacts (contacts where this contact is in their subassigned field) - if ( ! empty( $owner_contact['subassigned'] ) ) { - foreach ( $owner_contact['subassigned'] as $subassigned ) { - $subassigned_id = $subassigned['ID'] ?? null; - if ( $subassigned_id && ! in_array( $subassigned_id, $contact_ids_added ) ) { - $contact = DT_Posts::get_post( 'contacts', $subassigned_id, true, false ); - if ( ! is_wp_error( $contact ) ) { - $contacts[] = $this->format_contact_for_list( $contact, 'subassigned' ); - $contact_ids_added[] = $subassigned_id; - } - } - } - } - - // 2. Get contacts the corresponding user has access to - $corresponds_to_user = $owner_contact['corresponds_to_user'] ?? null; - if ( $corresponds_to_user ) { - $user_id = is_array( $corresponds_to_user ) ? ( $corresponds_to_user['ID'] ?? null ) : $corresponds_to_user; - - if ( $user_id ) { - // Get contacts assigned to this user - $user_contacts = DT_Posts::list_posts( 'contacts', [ - 'assigned_to' => [ $user_id ], - 'sort' => '-last_modified', - 'limit' => 100, - ], false ); - - if ( ! is_wp_error( $user_contacts ) && isset( $user_contacts['posts'] ) ) { - foreach ( $user_contacts['posts'] as $contact ) { - if ( ! in_array( $contact['ID'], $contact_ids_added ) && $contact['ID'] !== $owner_contact_id ) { - $contacts[] = $this->format_contact_for_list( $contact, 'assigned' ); - $contact_ids_added[] = $contact['ID']; - } - } - } - } - } - - // Sort by last_modified (most recent first) - usort( $contacts, function( $a, $b ) { - return ( $b['last_modified_timestamp'] ?? 0 ) - ( $a['last_modified_timestamp'] ?? 0 ); - }); - - return [ - 'contacts' => $contacts, - 'total' => count( $contacts ), - 'owner_contact_id' => $owner_contact_id, - ]; - } - - /** - * Format a contact for the list display - */ - private function format_contact_for_list( $contact, $source = '' ) { - $overall_status = ''; - $overall_status_color = ''; - if ( ! empty( $contact['overall_status'] ) ) { - $overall_status = $contact['overall_status']['label'] ?? ''; - $overall_status_color = $contact['overall_status']['color'] ?? ''; - } - - $seeker_path = ''; - if ( ! empty( $contact['seeker_path'] ) ) { - $seeker_path = $contact['seeker_path']['label'] ?? ''; - } - - $last_modified = ''; - $last_modified_timestamp = 0; - if ( ! empty( $contact['last_modified']['timestamp'] ) ) { - $last_modified_timestamp = $contact['last_modified']['timestamp']; - $last_modified = $contact['last_modified']['formatted'] ?? ''; - } - - return [ - 'ID' => $contact['ID'], - 'name' => $contact['name'] ?? 'Unknown', - 'overall_status' => $overall_status, - 'overall_status_color' => $overall_status_color, - 'seeker_path' => $seeker_path, - 'last_modified' => $last_modified, - 'last_modified_timestamp' => $last_modified_timestamp, - 'source' => $source, - ]; - } - - /** - * Get contact details with comments and activity - */ - public function get_contact_details( WP_REST_Request $request ) { - $params = $request->get_json_params(); - $params = dt_recursive_sanitize_array( $params ); - - $owner_contact_id = $this->get_post_id_from_magic_key( $params['parts']['meta_key'], $params['parts']['public_key'] ); - if ( ! $owner_contact_id ) { - return new WP_Error( 'invalid_key', 'Invalid magic link', [ 'status' => 403 ] ); - } - - $contact_id = intval( $params['contact_id'] ?? 0 ); - if ( ! $contact_id ) { - return new WP_Error( 'missing_contact_id', 'Contact ID is required', [ 'status' => 400 ] ); - } - - // Verify access - the contact must be in subassigned or accessible by user - if ( ! $this->verify_contact_access( $owner_contact_id, $contact_id ) ) { - return new WP_Error( 'access_denied', 'You do not have access to this contact', [ 'status' => 403 ] ); - } - - $contact = DT_Posts::get_post( 'contacts', $contact_id, true, false ); - if ( is_wp_error( $contact ) ) { - return new WP_Error( 'contact_not_found', 'Contact not found', [ 'status' => 404 ] ); - } - - $field_settings = DT_Posts::get_post_field_settings( 'contacts' ); - $tile_settings = DT_Posts::get_post_tiles( 'contacts' ); - - // Get comments and activity - $comments = DT_Posts::get_post_comments( 'contacts', $contact_id, true, 'all', [ 'number' => 50 ] ); - $activity = $this->get_post_activity( $contact_id ); - - // Fields to skip (internal/system fields) - $skip_fields = [ 'corresponds_to_user', 'duplicate_data', 'duplicate_of', 'post_author', 'record_picture', 'name' ]; - - // Group fields by tile - $tiles_with_fields = []; - foreach ( $field_settings as $field_key => $field_setting ) { - // Skip hidden, internal, or system fields - if ( in_array( $field_key, $skip_fields ) ) { - continue; - } - if ( isset( $field_setting['hidden'] ) && $field_setting['hidden'] === true ) { - continue; - } - - // Skip fields without a tile - $tile_key = $field_setting['tile'] ?? ''; - if ( empty( $tile_key ) ) { - continue; - } - - // Skip if tile doesn't exist in tile settings - if ( ! isset( $tile_settings[ $tile_key ] ) ) { - continue; - } - - $value = $contact[ $field_key ] ?? null; - $formatted_value = $this->format_field_value( $value, $field_setting ); - - // Initialize tile if not exists - if ( ! isset( $tiles_with_fields[ $tile_key ] ) ) { - $tile_order = $tile_settings[ $tile_key ]['tile_priority'] ?? 100; - $tiles_with_fields[ $tile_key ] = [ - 'key' => $tile_key, - 'label' => $tile_settings[ $tile_key ]['label'] ?? $tile_key, - 'order' => is_numeric( $tile_order ) ? intval( $tile_order ) : 100, - 'fields' => [], - ]; - } - - $field_order = $field_setting['in_create_form'] ?? 100; - $field_type = $field_setting['type'] ?? 'text'; - - // Prepare raw value for editing - $raw_value = $this->prepare_raw_value( $value, $field_type ); - - // Prepare options for select fields - $options = []; - if ( in_array( $field_type, [ 'key_select', 'multi_select' ] ) && isset( $field_setting['default'] ) ) { - foreach ( $field_setting['default'] as $option_key => $option_data ) { - // Skip deleted or hidden options - if ( ! empty( $option_data['deleted'] ) || ! empty( $option_data['hidden'] ) ) { - continue; - } - // Ensure we have a valid key - if ( $option_key === '' || $option_key === null ) { - continue; - } - $label = $option_data['label'] ?? (string) $option_key; - // Ensure label is never empty - if ( empty( $label ) ) { - $label = (string) $option_key; - } - $options[] = [ - 'id' => (string) $option_key, - 'label' => $label, - 'color' => $option_data['color'] ?? null, - 'icon' => $option_data['icon'] ?? null, - ]; - } - } - - $field_data = [ - 'key' => $field_key, - 'label' => $field_setting['name'] ?? $field_key, - 'value' => $formatted_value, - 'raw_value' => $raw_value, - 'type' => $field_type, - 'options' => $options, - 'icon' => $field_setting['icon'] ?? '', - 'font_icon' => $field_setting['font-icon'] ?? '', - 'order' => is_numeric( $field_order ) ? intval( $field_order ) : 100, - ]; - - // Add post_type for connection fields - if ( $field_type === 'connection' && isset( $field_setting['post_type'] ) ) { - $field_data['post_type'] = $field_setting['post_type']; - } - - $tiles_with_fields[ $tile_key ]['fields'][] = $field_data; - } - - // Sort tiles by order - uasort( $tiles_with_fields, function( $a, $b ) { - $order_a = is_numeric( $a['order'] ?? 100 ) ? intval( $a['order'] ) : 100; - $order_b = is_numeric( $b['order'] ?? 100 ) ? intval( $b['order'] ) : 100; - return $order_a - $order_b; - }); - - // Sort fields within each tile by order - foreach ( $tiles_with_fields as &$tile ) { - usort( $tile['fields'], function( $a, $b ) { - $order_a = is_numeric( $a['order'] ?? 100 ) ? intval( $a['order'] ) : 100; - $order_b = is_numeric( $b['order'] ?? 100 ) ? intval( $b['order'] ) : 100; - return $order_a - $order_b; - }); - } - - // Convert to indexed array - $tiles = array_values( $tiles_with_fields ); - - // Merge comments and activity, sort by date - $all_activity = $this->merge_comments_and_activity( $comments['comments'] ?? [], $activity ); - - return [ - 'ID' => $contact['ID'], - 'name' => $contact['name'] ?? 'Unknown', - 'tiles' => $tiles, - 'created' => $contact['post_date']['formatted'] ?? '', - 'last_modified' => $contact['last_modified']['formatted'] ?? '', - 'activity' => $all_activity, - ]; - } - - /** - * Verify the owner has access to view the requested contact - */ - private function verify_contact_access( $owner_contact_id, $contact_id ) { - // Don't allow viewing self - if ( $owner_contact_id === $contact_id ) { - return false; - } - - $owner_contact = DT_Posts::get_post( 'contacts', $owner_contact_id, true, false ); - if ( is_wp_error( $owner_contact ) ) { - return false; - } - - // Check if contact is in subassigned - if ( ! empty( $owner_contact['subassigned'] ) ) { - foreach ( $owner_contact['subassigned'] as $subassigned ) { - if ( ( $subassigned['ID'] ?? null ) === $contact_id ) { - return true; - } - } - } - - // Check if corresponding user has access - $corresponds_to_user = $owner_contact['corresponds_to_user'] ?? null; - if ( $corresponds_to_user ) { - $user_id = is_array( $corresponds_to_user ) ? ( $corresponds_to_user['ID'] ?? null ) : $corresponds_to_user; - - if ( $user_id ) { - // Check if contact is assigned to this user - $contact = DT_Posts::get_post( 'contacts', $contact_id, true, false ); - if ( ! is_wp_error( $contact ) ) { - $assigned_to = $contact['assigned_to'] ?? null; - if ( $assigned_to ) { - $assigned_user_id = is_array( $assigned_to ) ? ( $assigned_to['id'] ?? null ) : $assigned_to; - if ( intval( $assigned_user_id ) === intval( $user_id ) ) { - return true; - } - } - } - } - } - - return false; - } - - /** - * Get post activity log - */ - private function get_post_activity( $post_id ) { - global $wpdb; - - $activity = $wpdb->get_results( $wpdb->prepare( - "SELECT * FROM $wpdb->dt_activity_log - WHERE object_id = %d - AND object_type = 'contacts' - ORDER BY hist_time DESC - LIMIT 50", - $post_id - ), ARRAY_A ); - - $formatted_activity = []; - foreach ( $activity as $item ) { - // Skip certain action types that are not user-facing - $skip_actions = [ 'connected to', 'disconnected from' ]; - if ( in_array( $item['action'] ?? '', $skip_actions ) ) { - continue; - } - - $formatted_activity[] = [ - 'id' => $item['histid'], - 'type' => 'activity', - 'action' => $item['action'] ?? '', - 'object_note' => $item['object_note'] ?? '', - 'meta_key' => $item['meta_key'] ?? '', - 'meta_value' => $item['meta_value'] ?? '', - 'old_value' => $item['old_value'] ?? '', - 'user_id' => $item['user_id'] ?? 0, - 'timestamp' => intval( $item['hist_time'] ?? 0 ) - ]; - } - - return $formatted_activity; - } - - /** - * Merge comments and activity into a single timeline - */ - private function merge_comments_and_activity( $comments, $activity ) { - $merged = []; - - // Add comments - foreach ( $comments as $comment ) { - $timestamp = strtotime( $comment['comment_date_gmt'] ?? '' ); - $merged[] = [ - 'type' => 'comment', - 'id' => $comment['comment_ID'] ?? 0, - 'content' => $comment['comment_content'] ?? '', - 'author' => $comment['comment_author'] ?? '', - 'date' => $comment['comment_date'] ?? '', - 'timestamp' => $timestamp, - ]; - } - - // Add activity (only field changes, not comments which are already included) - foreach ( $activity as $item ) { - if ( $item['action'] === 'comment' ) { - continue; // Skip comment actions as they're already in comments - } - - $description = $this->format_activity_description( $item ); - if ( empty( $description ) ) { - continue; - } - - // Get user display name - $user_name = 'System'; - if ( ! empty( $item['user_id'] ) ) { - $user = get_userdata( $item['user_id'] ); - if ( $user ) { - $user_name = $user->display_name; - } - } - - $merged[] = [ - 'type' => 'activity', - 'id' => $item['id'], - 'content' => $description, - 'author' => $user_name, - 'date' => $item['date'], - 'timestamp' => $item['timestamp'], - ]; - } - - // Sort by timestamp descending (most recent first) - usort( $merged, function( $a, $b ) { - return ( $b['timestamp'] ?? 0 ) - ( $a['timestamp'] ?? 0 ); - }); - - return $merged; - } - - /** - * Format activity item into human-readable description - */ - private function format_activity_description( $item ) { - $action = $item['action'] ?? ''; - $meta_key = $item['meta_key'] ?? ''; - $object_note = $item['object_note'] ?? ''; - - // If there's an object note, use it - if ( ! empty( $object_note ) ) { - return $object_note; - } - - // Format based on action type - switch ( $action ) { - case 'field_update': - $field_settings = DT_Posts::get_post_field_settings( 'contacts' ); - $field_name = $field_settings[ $meta_key ]['name'] ?? $meta_key; - return sprintf( 'Updated %s', $field_name ); - - case 'created': - return 'Contact created'; - - default: - return ''; - } - } - - /** - * Format field value based on field type - */ - private function format_field_value( $value, $field_setting ) { - $type = $field_setting['type'] ?? 'text'; - - // Handle null/empty values - return empty string to display field with no value - if ( $value === null || $value === '' || ( is_array( $value ) && empty( $value ) ) ) { - return ''; - } - - switch ( $type ) { - case 'text': - case 'textarea': - case 'number': - return is_string( $value ) || is_numeric( $value ) ? $value : ''; - - case 'boolean': - return $value ? 'Yes' : 'No'; - - case 'key_select': - return $value['label'] ?? ''; - - case 'multi_select': - case 'tags': - if ( is_array( $value ) ) { - $labels = []; - $options = $field_setting['default'] ?? []; - foreach ( $value as $item ) { - if ( isset( $item['label'] ) ) { - $labels[] = $item['label']; - } elseif ( is_string( $item ) ) { - if ( isset( $options[ $item ]['label'] ) ) { - $labels[] = $options[ $item ]['label']; - } else { - $labels[] = $item; - } - } - } - return implode( ', ', $labels ); - } - return ''; - - case 'communication_channel': - if ( is_array( $value ) ) { - $channels = []; - foreach ( $value as $item ) { - if ( isset( $item['value'] ) && ! empty( $item['value'] ) ) { - $channels[] = $item['value']; - } - } - return implode( ', ', $channels ); - } - return ''; - - case 'location': - case 'location_grid': - case 'location_meta': - if ( is_array( $value ) ) { - $locations = []; - foreach ( $value as $item ) { - if ( isset( $item['label'] ) ) { - $locations[] = $item['label']; - } - } - return implode( "\n", $locations ); - } - return ''; - - case 'connection': - if ( is_array( $value ) ) { - $connections = []; - foreach ( $value as $item ) { - if ( isset( $item['post_title'] ) ) { - $connections[] = $item['post_title']; - } - } - return implode( ', ', $connections ); - } - return ''; - - case 'user_select': - if ( isset( $value['display'] ) ) { - return $value['display']; - } - return ''; - - case 'date': - if ( isset( $value['formatted'] ) ) { - return $value['formatted']; - } - return ''; - - case 'link': - if ( is_array( $value ) ) { - $links = []; - foreach ( $value as $item ) { - if ( isset( $item['value'] ) ) { - $links[] = $item['value']; - } - } - return implode( ', ', $links ); - } - return ''; - - default: - if ( is_array( $value ) ) { - if ( isset( $value['label'] ) ) { - return $value['label']; - } - return ''; - } - return is_string( $value ) ? $value : ''; - } - } - - /** - * Prepare raw value for editing (JSON-safe format for DT components) - */ - private function prepare_raw_value( $value, $field_type ) { - if ( $value === null || $value === '' ) { - return null; - } - - switch ( $field_type ) { - case 'text': - case 'textarea': - case 'number': - return is_string( $value ) || is_numeric( $value ) ? $value : ''; - - case 'boolean': - return (bool) $value; - - case 'key_select': - return $value['key'] ?? ''; - - case 'multi_select': - case 'tags': - // Value should be an array of string IDs for the component - if ( is_array( $value ) ) { - $result = []; - foreach ( $value as $item ) { - if ( is_string( $item ) ) { - $result[] = $item; - } elseif ( is_array( $item ) && isset( $item['value'] ) ) { - $result[] = $item['value']; - } elseif ( is_array( $item ) && isset( $item['key'] ) ) { - $result[] = $item['key']; - } - } - return $result; - } - return []; - - case 'communication_channel': - if ( is_array( $value ) ) { - return array_values( $value ); - } - return []; - - case 'connection': - if ( is_array( $value ) ) { - return array_map( function( $item ) { - return [ - 'id' => (int) ( $item['ID'] ?? 0 ), - 'label' => $item['post_title'] ?? '', - 'link' => $item['permalink'] ?? '', - 'status' => $item['status'] ?? null, - ]; - }, $value ); - } - return []; - - case 'location': - case 'location_meta': - if ( is_array( $value ) ) { - return array_values( $value ); - } - return []; - - case 'user_select': - if ( isset( $value['id'] ) ) { - return $value['id']; - } - return null; - - case 'date': - if ( isset( $value['timestamp'] ) ) { - return $value['timestamp']; - } - return null; - - case 'link': - if ( is_array( $value ) ) { - return array_values( $value ); - } - return []; - - default: - return $value; - } - } - - /** - * Format a value for DT_Posts::update_post based on field type - */ - private function format_value_for_update( $value, $field_type, $contact_id, $field_key ) { - switch ( $field_type ) { - case 'text': - case 'textarea': - case 'number': - case 'boolean': - case 'key_select': - // These types can be passed directly - return $value; - - case 'date': - // Date expects a timestamp or date string - return $value; - - case 'multi_select': - case 'tags': - // multi_select and tags expect: ['values' => [['value' => 'option1'], ['value' => 'option2']]] - // The component uses '-' prefix to mark items for deletion (e.g., ["-tag1", "tag2"]) - $new_values = is_array( $value ) ? $value : []; - - $update_values = []; - foreach ( $new_values as $v ) { - // Check for deletion prefix (component marks deleted items with '-' prefix) - if ( is_string( $v ) && strpos( $v, '-' ) === 0 ) { - $update_values[] = [ 'value' => substr( $v, 1 ), 'delete' => true ]; - } else { - $update_values[] = [ 'value' => $v ]; - } - } - - return [ 'values' => $update_values ]; - - case 'communication_channel': - // communication_channel is more complex - for now return as-is - // Full implementation would need to track meta_ids for updates - if ( is_array( $value ) ) { - return $value; - } - return []; - - case 'connection': - // connection expects: ['values' => [['value' => post_id]]] - // Items with 'delete' property should be removed - if ( is_array( $value ) ) { - $formatted = []; - foreach ( $value as $item ) { - if ( isset( $item['id'] ) ) { - $entry = [ 'value' => $item['id'] ]; - if ( ! empty( $item['delete'] ) ) { - $entry['delete'] = true; - } - $formatted[] = $entry; - } elseif ( is_numeric( $item ) ) { - $formatted[] = [ 'value' => intval( $item ) ]; - } - } - return [ 'values' => $formatted ]; - } - return [ 'values' => [] ]; - - case 'location': - case 'location_meta': - // location expects: ['values' => [['value' => grid_id]]] - // Items with 'delete' property should be removed - if ( is_array( $value ) ) { - $formatted = []; - foreach ( $value as $item ) { - $entry = null; - if ( isset( $item['id'] ) ) { - $entry = [ 'value' => $item['id'] ]; - } elseif ( isset( $item['grid_id'] ) ) { - $entry = [ 'value' => $item['grid_id'] ]; - } - if ( $entry ) { - if ( ! empty( $item['delete'] ) ) { - $entry['delete'] = true; - } - $formatted[] = $entry; - } - } - return [ 'values' => $formatted ]; - } - return [ 'values' => [] ]; - - case 'user_select': - // user_select expects a user ID string like "user-123" - if ( is_numeric( $value ) ) { - return 'user-' . $value; - } - return $value; - - default: - return $value; - } - } - - /** - * Add a comment to a contact - */ - public function add_comment( WP_REST_Request $request ) { - $params = $request->get_json_params(); - $params = dt_recursive_sanitize_array( $params ); - - $owner_contact_id = $this->get_post_id_from_magic_key( $params['parts']['meta_key'], $params['parts']['public_key'] ); - if ( ! $owner_contact_id ) { - return new WP_Error( 'invalid_key', 'Invalid magic link', [ 'status' => 403 ] ); - } - - $contact_id = intval( $params['contact_id'] ?? 0 ); - $comment = $params['comment'] ?? ''; - - if ( ! $contact_id || empty( $comment ) ) { - return new WP_Error( 'missing_params', 'Contact ID and comment are required', [ 'status' => 400 ] ); - } - - // Verify access - if ( ! $this->verify_contact_access( $owner_contact_id, $contact_id ) ) { - return new WP_Error( 'access_denied', 'You do not have access to this contact', [ 'status' => 403 ] ); - } - - $result = DT_Posts::add_post_comment( 'contacts', $contact_id, $comment, 'comment', [], false, true ); - - if ( is_wp_error( $result ) ) { - return $result; - } - - return [ - 'success' => true, - 'contact_id' => $contact_id, - 'comment_id' => $result, - ]; - } - - /** - * Get users for @mention autocomplete - */ - public function get_users_for_mention( WP_REST_Request $request ) { - $params = $request->get_json_params(); - $params = dt_recursive_sanitize_array( $params ); - - $search = $params['search'] ?? ''; - - global $wpdb; - - $users = $wpdb->get_results( $wpdb->prepare( " - SELECT ID, display_name - FROM $wpdb->users - WHERE display_name LIKE %s - ORDER BY display_name - LIMIT 10 - ", '%' . $wpdb->esc_like( $search ) . '%' ), ARRAY_A ); - - return [ - 'users' => $users, - ]; - } - - /** - * Update a field on a contact - */ - public function update_field( WP_REST_Request $request ) { - $params = $request->get_json_params(); - $params = dt_recursive_sanitize_array( $params ); - - $owner_contact_id = $this->get_post_id_from_magic_key( $params['parts']['meta_key'], $params['parts']['public_key'] ); - if ( ! $owner_contact_id ) { - return new WP_Error( 'invalid_key', 'Invalid magic link', [ 'status' => 403 ] ); - } - - $contact_id = intval( $params['contact_id'] ?? 0 ); - $field_key = $params['field_key'] ?? ''; - $field_value = $params['field_value'] ?? null; - - if ( ! $contact_id || empty( $field_key ) ) { - return new WP_Error( 'missing_params', 'Contact ID and field key are required', [ 'status' => 400 ] ); - } - - // Verify access - if ( ! $this->verify_contact_access( $owner_contact_id, $contact_id ) ) { - return new WP_Error( 'access_denied', 'You do not have access to this contact', [ 'status' => 403 ] ); - } - - // Get field settings to determine field type - $field_settings = DT_Posts::get_post_field_settings( 'contacts' ); - $field_setting = $field_settings[ $field_key ] ?? []; - $field_type = $field_setting['type'] ?? 'text'; - - // Transform the value to the format DT_Posts expects - $formatted_update_value = $this->format_value_for_update( $field_value, $field_type, $contact_id, $field_key ); - - // Update the field - $update_data = [ $field_key => $formatted_update_value ]; - $result = DT_Posts::update_post( 'contacts', $contact_id, $update_data, true, false ); - - if ( is_wp_error( $result ) ) { - return $result; - } - - // Get the updated field value - $updated_value = $result[ $field_key ] ?? null; - $formatted_value = $this->format_field_value( $updated_value, $field_setting ); - - return [ - 'success' => true, - 'contact_id' => $contact_id, - 'field_key' => $field_key, - 'value' => $formatted_value, - 'raw_value' => $updated_value, - ]; - } - - /** - * Get field options for typeahead components (connections, locations, tags) - */ - public function get_field_options( WP_REST_Request $request ) { - $params = $request->get_json_params(); - $params = dt_recursive_sanitize_array( $params ); - - $owner_contact_id = $this->get_post_id_from_magic_key( $params['parts']['meta_key'], $params['parts']['public_key'] ); - if ( ! $owner_contact_id ) { - return new WP_Error( 'invalid_key', 'Invalid magic link', [ 'status' => 403 ] ); - } - - $field_key = $params['field'] ?? ''; - $query = $params['query'] ?? ''; - $post_type = $params['post_type'] ?? 'contacts'; - - if ( empty( $field_key ) ) { - return new WP_Error( 'missing_field', 'Field key is required', [ 'status' => 400 ] ); - } - - // Get field settings - $field_settings = DT_Posts::get_post_field_settings( 'contacts' ); - $field_setting = $field_settings[ $field_key ] ?? []; - $field_type = $field_setting['type'] ?? ''; - - $options = []; - - switch ( $field_type ) { - case 'connection': - // Get the post type this field connects to - $connected_post_type = $field_setting['post_type'] ?? 'contacts'; - - // Use get_viewable_compact which handles sorting properly - // (recently viewed first for empty search, recently modified for search) - $search_results = DT_Posts::get_viewable_compact( $connected_post_type, $query, [ - 'field_key' => $field_key, - ] ); - - if ( ! is_wp_error( $search_results ) && isset( $search_results['posts'] ) ) { - foreach ( $search_results['posts'] as $post ) { - $status = null; - if ( isset( $post['status'] ) ) { - $status = $post['status']; - } - $options[] = [ - 'id' => (int) $post['ID'], - 'label' => $post['name'] ?? '', - 'link' => get_permalink( $post['ID'] ), - 'status' => $status, - ]; - } - } - break; - - case 'location': - case 'location_meta': - // Search location grid - $search_results = Disciple_Tools_Mapping_Queries::search_location_grid_by_name( [ - 's' => $query, - 'limit' => 20, - ] ); - - if ( ! empty( $search_results ) && isset( $search_results['location_grid'] ) ) { - foreach ( $search_results['location_grid'] as $location ) { - $options[] = [ - 'id' => strval( $location['grid_id'] ?? $location['ID'] ), - 'label' => $location['name'] ?? $location['label'] ?? '', - ]; - } - } - break; - - case 'tags': - // Get existing tags for this field - $existing_tags = Disciple_Tools_Posts::get_multi_select_options( 'contacts', $field_key, false ); - if ( ! empty( $existing_tags ) ) { - foreach ( $existing_tags as $tag ) { - // Filter by query if provided - if ( empty( $query ) || stripos( $tag, $query ) !== false ) { - $options[] = [ - 'id' => $tag, - 'label' => $tag, - ]; - } - } - } - break; - } - - return [ - 'success' => true, - 'options' => $options, - ]; - } - - /** - * Custom header styles - */ - public function header_style() { - ?> - - - -
- -
-
- My Contacts (0) - -
-
-
-
-

Loading contacts...

-
-
-
- - -
-
- - Contact Details - -
-
-
-
👤
-

Select a contact to view details

-
-
-
-
- - -
- Comment added successfully! -
- - - -

Error loading contacts

'; + } +} + +// Render contacts list +function renderContacts() { + const container = document.getElementById('contacts-list'); + const count = document.getElementById('contacts-count'); + + count.textContent = `(${filteredContacts.length})`; + + if (filteredContacts.length === 0) { + container.innerHTML = '

No contacts found

'; + return; + } + + container.innerHTML = filteredContacts.map(contact => { + const statusStyle = contact.overall_status_color + ? `background: ${contact.overall_status_color}20; color: ${contact.overall_status_color};` + : ''; + + const sourceLabel = contact.source === 'subassigned' ? 'Subassigned' : 'Assigned'; + + return ` +
+
+ ${escapeHtml(contact.name)} + ${contact.overall_status ? `${escapeHtml(contact.overall_status)}` : ''} +
+
+ ${contact.seeker_path ? escapeHtml(contact.seeker_path) + ' • ' : ''} + ${contact.last_modified ? escapeHtml(contact.last_modified) : ''} + ${sourceLabel} +
+
+ `; + }).join(''); +} + +// Filter contacts by search term +function filterContacts(searchTerm) { + if (!searchTerm) { + filteredContacts = contacts; + } else { + const term = searchTerm.toLowerCase(); + filteredContacts = contacts.filter(c => + c.name.toLowerCase().includes(term) || + (c.overall_status && c.overall_status.toLowerCase().includes(term)) || + (c.seeker_path && c.seeker_path.toLowerCase().includes(term)) + ); + } + renderContacts(); +} + +// Select contact and show details +async function selectContact(contactId) { + selectedContactId = contactId; + + // Highlight selected contact + document.querySelectorAll('.contact-item').forEach(el => { + el.classList.toggle('selected', parseInt(el.dataset.contactId) === contactId); + }); + + // Show details panel on mobile + if (isMobile()) { + document.getElementById('details-panel').classList.add('mobile-visible'); + document.getElementById('contacts-panel').classList.add('mobile-hidden'); + } + + // Show loading + const detailsContainer = document.getElementById('contact-details'); + detailsContainer.innerHTML = '
'; + + try { + const response = await fetch( + `${myContactsApp.root}${myContactsApp.parts.root}/v1/${myContactsApp.parts.type}/contact`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': myContactsApp.nonce + }, + body: JSON.stringify({ + contact_id: contactId, + parts: myContactsApp.parts + }) + } + ); + const contact = await response.json(); + + if (contact.code) { + detailsContainer.innerHTML = `

Error: ${escapeHtml(contact.message || 'Unknown error')}

`; + return; + } + + renderContactDetails(contact); + + // Update mobile header + document.getElementById('mobile-contact-name').textContent = contact.name; + } catch (error) { + console.error('Error loading contact details:', error); + detailsContainer.innerHTML = '

Error loading details

'; + } +} + +// Render contact details +function renderContactDetails(contact) { + const container = document.getElementById('contact-details'); + + // Render tiles with their fields + const tilesHtml = contact.tiles && contact.tiles.length > 0 ? + contact.tiles.map(tile => ` +
+
${escapeHtml(tile.label)}
+ ${tile.fields.map(field => renderEditableField(field, contact.ID)).join('')} +
+ `).join('') : + '

No contact information available

'; + + // Render record info + const recordInfoHtml = ` +
+
Record Info
+ ${contact.created ? ` +
+
Created
+
${escapeHtml(contact.created)}
+
+ ` : ''} + ${contact.last_modified ? ` +
+
Last Modified
+
${escapeHtml(contact.last_modified)}
+
+ ` : ''} +
+ `; + + // Render activity/comments timeline with grouped field updates + const groupedActivity = groupActivityItems(contact.activity || []); + + const activityHtml = groupedActivity.length > 0 ? + groupedActivity.map((item, index) => { + if (item.type === 'comment') { + // Render comment as prominent card + return ` +
+ ${escapeHtml(item.author)} + ${formatTimestamp(item.timestamp)} +
${formatActivityContent(item.content)}
+
+ `; + } else { + // Render activity group as collapsible + const count = item.items.length; + const groupId = `activity-group-${index}`; + return ` +
+
+ + ${count} field update${count > 1 ? 's' : ''} +
+
+ ${item.items.map(a => ` +
+ ${formatActivityContent(a.content)} + ${escapeHtml(a.author)} + ${formatTimestamp(a.timestamp)} +
+ `).join('')} +
+
+ `; + } + }).join('') : + '

No activity yet

'; + + container.innerHTML = ` +
+
+

${escapeHtml(contact.name)}

+ ${tilesHtml} + ${recordInfoHtml} +
+ +
+

Comments & Activity

+
+
+
+ +
+
+ +
+
+
+ ${activityHtml} +
+
+
+ `; + + // Initialize DT components with their data (value, options) + initializeDTComponents(); + + // Initialize mention listeners + initMentionListeners(); +} + +// Format activity content (mentions and links) +function formatActivityContent(text) { + if (!text) return ''; + + let formatted = escapeHtml(text); + + // Format @mentions: @[Name](id) -> styled span + formatted = formatted.replace( + /@\[([^\]]+)\]\((\d+)\)/g, + '@$1' + ); + + // Format URLs to clickable links + const urlRegex = /(https?:\/\/[^\s<]+)/g; + formatted = formatted.replace( + urlRegex, + '$1' + ); + + return formatted; +} + +// Group consecutive activity items while keeping comments separate +function groupActivityItems(items) { + const grouped = []; + let currentGroup = null; + + items.forEach(item => { + if (item.type === 'comment') { + // Flush any pending activity group + if (currentGroup) { + grouped.push({ type: 'activity-group', items: currentGroup }); + currentGroup = null; + } + // Add comment as-is + grouped.push(item); + } else { + // Accumulate activity items + if (!currentGroup) currentGroup = []; + currentGroup.push(item); + } + }); + + // Flush remaining activity group + if (currentGroup) { + grouped.push({ type: 'activity-group', items: currentGroup }); + } + + return grouped; +} + +// Toggle activity group expand/collapse +function toggleActivityGroup(groupId) { + const group = document.getElementById(groupId); + if (group) { + group.classList.toggle('expanded'); + } +} + +// Comment submission +async function submitComment() { + const textarea = document.getElementById('comment-textarea'); + const submitBtn = document.getElementById('comment-submit-btn'); + const comment = textarea.value.trim(); + + if (!comment || !selectedContactId) return; + + submitBtn.disabled = true; + submitBtn.textContent = 'Posting...'; + + try { + const response = await fetch( + `${myContactsApp.root}${myContactsApp.parts.root}/v1/${myContactsApp.parts.type}/comment`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': myContactsApp.nonce + }, + body: JSON.stringify({ + contact_id: selectedContactId, + comment: comment, + parts: myContactsApp.parts + }) + } + ); + + const result = await response.json(); + + if (result.success || result.comment_id) { + textarea.value = ''; + selectContact(selectedContactId); + showSuccessToast('Comment added successfully!'); + } else { + alert('Failed to add comment: ' + (result.message || 'Unknown error')); + } + } catch (error) { + console.error('Error posting comment:', error); + alert('Error posting comment'); + } finally { + submitBtn.disabled = false; + submitBtn.textContent = 'Add Comment'; + } +} + +// @mention functionality +let mentionSearchTimeout = null; +let mentionStartPos = -1; +let mentionUsers = []; +let mentionActiveIndex = 0; + +function initMentionListeners() { + const commentTextarea = document.getElementById('comment-textarea'); + const mentionDropdown = document.getElementById('mention-dropdown'); + + if (!commentTextarea || !mentionDropdown) return; + + commentTextarea.addEventListener('input', function(e) { + const text = this.value; + const cursorPos = this.selectionStart; + + const textBeforeCursor = text.substring(0, cursorPos); + const lastAtIndex = textBeforeCursor.lastIndexOf('@'); + + if (lastAtIndex !== -1) { + const textAfterAt = textBeforeCursor.substring(lastAtIndex + 1); + + if (!textAfterAt.includes(' ') && !textAfterAt.includes('\n')) { + mentionStartPos = lastAtIndex; + + clearTimeout(mentionSearchTimeout); + mentionSearchTimeout = setTimeout(() => { + searchMentionUsers(textAfterAt); + }, 200); + return; + } + } + + hideMentionDropdown(); + }); + + commentTextarea.addEventListener('keydown', function(e) { + const dropdown = document.getElementById('mention-dropdown'); + if (!dropdown || !dropdown.classList.contains('show')) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + mentionActiveIndex = Math.min(mentionActiveIndex + 1, mentionUsers.length - 1); + renderMentionDropdown(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + mentionActiveIndex = Math.max(mentionActiveIndex - 1, 0); + renderMentionDropdown(); + } else if (e.key === 'Enter' && mentionUsers.length > 0) { + e.preventDefault(); + selectMention(mentionUsers[mentionActiveIndex]); + } else if (e.key === 'Escape') { + hideMentionDropdown(); + } + }); +} + +async function searchMentionUsers(search) { + if (search.length < 1) { + hideMentionDropdown(); + return; + } + + try { + const response = await fetch( + `${myContactsApp.root}${myContactsApp.parts.root}/v1/${myContactsApp.parts.type}/users-mention`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': myContactsApp.nonce + }, + body: JSON.stringify({ + search: search, + parts: myContactsApp.parts + }) + } + ); + + const data = await response.json(); + mentionUsers = data.users || []; + mentionActiveIndex = 0; + + if (mentionUsers.length > 0) { + renderMentionDropdown(); + document.getElementById('mention-dropdown').classList.add('show'); + } else { + hideMentionDropdown(); + } + } catch (error) { + console.error('Error searching users:', error); + hideMentionDropdown(); + } +} + +function renderMentionDropdown() { + const dropdown = document.getElementById('mention-dropdown'); + if (!dropdown) return; + + dropdown.innerHTML = mentionUsers.map((user, index) => ` +
+ ${escapeHtml(user.display_name)} +
+ `).join(''); +} + +function selectMention(user) { + const textarea = document.getElementById('comment-textarea'); + const text = textarea.value; + const cursorPos = textarea.selectionStart; + + const beforeMention = text.substring(0, mentionStartPos); + const afterCursor = text.substring(cursorPos); + + const mentionText = `@[${user.display_name}](${user.ID}) `; + textarea.value = beforeMention + mentionText + afterCursor; + + const newCursorPos = beforeMention.length + mentionText.length; + textarea.setSelectionRange(newCursorPos, newCursorPos); + textarea.focus(); + + hideMentionDropdown(); +} + +function hideMentionDropdown() { + const dropdown = document.getElementById('mention-dropdown'); + if (dropdown) { + dropdown.classList.remove('show'); + } + mentionUsers = []; + mentionStartPos = -1; +} + +// Mobile functionality +function isMobile() { + return window.innerWidth <= 768; +} + +function hideMobileDetails() { + document.getElementById('details-panel').classList.remove('mobile-visible'); + document.getElementById('contacts-panel').classList.remove('mobile-hidden'); +} + +// Store field data for programmatic initialization of components +window.fieldDataStore = window.fieldDataStore || {}; + +// Render an editable field with view and edit modes +function renderEditableField(field, contactId) { + const rawValue = field.raw_value; + + // Determine default based on field type (arrays for multi-value fields) + const isArrayField = ['multi_select', 'tags', 'communication_channel', 'connection', 'location', 'location_meta', 'link'].includes(field.type); + const defaultValue = isArrayField ? [] : ''; + const valueForJson = (rawValue !== null && rawValue !== undefined) ? rawValue : defaultValue; + + // Filter out any options with invalid IDs or labels, and ensure all values are strings + const validOptions = (field.options || []).filter(opt => + opt && + opt.id !== null && + opt.id !== undefined && + opt.id !== '' && + opt.label !== null && + opt.label !== undefined && + opt.label !== '' + ).map(opt => ({ + // Ensure id and label are strings + id: String(opt.id), + label: String(opt.label), + color: opt.color || null, + icon: opt.icon || null + })); + + // Store field data for later initialization + const fieldId = `field-${contactId}-${field.key}`; + window.fieldDataStore[fieldId] = { + value: valueForJson, + options: validOptions, + type: field.type + }; + + return ` +
+
+ ${renderFieldIcon(field)}${escapeHtml(field.label)} + +
+
${field.value ? escapeHtml(field.value) : '-'}
+
+ ${renderDTComponent(field, contactId)} +
+
+ `; +} + +// Render the appropriate DT component based on field type +// Components that need complex data (arrays/objects) get a data-field-id for programmatic init +function renderDTComponent(field, contactId) { + const fieldKey = escapeHtml(field.key); + const fieldId = `field-${contactId}-${field.key}`; + + switch (field.type) { + case 'text': + return ``; + + case 'textarea': + return ``; + + case 'number': + return ``; + + case 'boolean': + return ``; + + case 'date': + return ``; + + case 'key_select': + // key_select needs options set programmatically + return ``; + + case 'multi_select': + // multi_select needs value and options set programmatically + return ``; + + case 'communication_channel': + return ``; + + case 'tags': + return ``; + + case 'connection': + return ``; + + case 'location': + case 'location_meta': + return ``; + + case 'user_select': + // User select requires special permissions not available in magic link context + return `Not editable in this view`; + + default: + return ``; + } +} + +// Initialize DT components with their data after HTML is inserted +function initializeDTComponents() { + // Use requestAnimationFrame to ensure DOM is fully rendered + requestAnimationFrame(() => { + const components = document.querySelectorAll('[data-field-id]'); + + components.forEach(async (component) => { + const fieldId = component.dataset.fieldId; + const tagName = component.tagName.toLowerCase(); + const data = window.fieldDataStore[fieldId]; + + if (!data) { + return; + } + + try { + // Wait for the custom element to be defined/upgraded + if (customElements.get(tagName) === undefined) { + await customElements.whenDefined(tagName); + } + + // Set properties directly on the component + // IMPORTANT: Always set options first (even as empty array) before value + // This prevents _filterOptions from failing when value triggers willUpdate + component.options = data.options && data.options.length > 0 ? data.options : []; + + if (data.value !== null && data.value !== undefined) { + // For multi-select, ensure value is an array of valid strings + if (tagName === 'dt-multi-select' && Array.isArray(data.value)) { + const cleanValue = data.value + .filter(v => v !== null && v !== undefined && v !== '') + .map(v => String(v)); + component.value = cleanValue; + } else { + component.value = data.value; + } + } + } catch (err) { + console.error(`Error initializing component:`, err); + } + }); + }); +} + +// Toggle edit mode for a field +function toggleEditMode(fieldKey) { + const section = document.querySelector(`.detail-section[data-field-key="${fieldKey}"]`); + if (!section) return; + + const isEditing = section.classList.contains('editing'); + + // Close any other open edit modes + document.querySelectorAll('.detail-section.editing').forEach(el => { + if (el !== section) { + el.classList.remove('editing'); + } + }); + + if (isEditing) { + section.classList.remove('editing'); + } else { + section.classList.add('editing'); + // Initialize change listener for the component + initFieldChangeListener(section); + } +} + +// Close edit mode when clicking outside +document.addEventListener('click', function(e) { + // Don't close if clicking inside an editing section or its components + const editingSection = document.querySelector('.detail-section.editing'); + if (!editingSection) return; + + // Check if click is inside the editing section + if (editingSection.contains(e.target)) return; + + // Check if click is inside a dropdown/option list (these can be outside the section) + if (e.target.closest('.option-list, ul[class*="option"], li[tabindex]')) return; + + // Close the editing section + editingSection.classList.remove('editing'); +}); + +// Initialize change listener for a field's DT component +function initFieldChangeListener(section) { + const editMode = section.querySelector('.edit-mode'); + const component = editMode.querySelector('dt-text, dt-textarea, dt-number, dt-toggle, dt-date, dt-single-select, dt-multi-select, dt-multi-text, dt-tags, dt-connection, dt-location'); + + if (!component || component.hasAttribute('data-listener-added')) return; + + component.setAttribute('data-listener-added', 'true'); + + const fieldType = section.dataset.fieldType; + + component.addEventListener('change', async (e) => { + const fieldKey = section.dataset.fieldKey; + const contactId = section.dataset.contactId; + const newValue = e.detail?.newValue ?? e.detail?.value ?? component.value; + + await saveFieldValue(contactId, fieldKey, fieldType, newValue, section); + }); +} + +// Field types that allow multiple values - don't auto-close after saving +const multiValueFieldTypes = ['multi_select', 'connection', 'tags', 'location', 'location_meta', 'communication_channel']; + +// Save field value to the server +async function saveFieldValue(contactId, fieldKey, fieldType, value, section) { + section.classList.add('saving'); + + try { + const response = await fetch( + `${myContactsApp.root}${myContactsApp.parts.root}/v1/${myContactsApp.parts.type}/update-field`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': myContactsApp.nonce + }, + body: JSON.stringify({ + contact_id: parseInt(contactId), + field_key: fieldKey, + field_value: value, + parts: myContactsApp.parts + }) + } + ); + + const result = await response.json(); + + if (result.success) { + // Update the view mode value + const viewMode = section.querySelector('.view-mode'); + const displayValue = result.value || '-'; + viewMode.textContent = displayValue; + viewMode.classList.toggle('empty-value', !result.value); + + // Only auto-close for single-value fields, not multi-value fields + if (!multiValueFieldTypes.includes(fieldType)) { + section.classList.remove('editing'); + } + showSuccessToast('Field updated'); + } else { + const errorMsg = result.message || 'Failed to update field'; + alert(errorMsg); + } + } catch (error) { + console.error('Error saving field:', error); + alert('Error saving field'); + } finally { + section.classList.remove('saving'); + } +} + +// Escape HTML attribute +function escapeAttr(text) { + if (text === null || text === undefined) return ''; + return String(text).replace(/"/g, '"').replace(/'/g, ''').replace(//g, '>'); +} + +// Render field icon (image URL or font-icon class) +function renderFieldIcon(field) { + // Check for image icon first (URL) + if (field.icon && !field.icon.includes('undefined')) { + return ``; + } + // Check for font icon (CSS class like mdi mdi-account) + if (field.font_icon && !field.font_icon.includes('undefined')) { + return ``; + } + return ''; +} + +// Utility: escape HTML +function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// Format timestamp in browser's timezone +function formatTimestamp(timestamp) { + if (!timestamp) return ''; + const date = new Date(timestamp * 1000); + return date.toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit' + }); +} + +// Success toast +function showSuccessToast(message = 'Success!') { + const toast = document.getElementById('success-toast'); + toast.textContent = message; + toast.classList.add('show'); + setTimeout(() => { + toast.classList.remove('show'); + }, 3000); +} diff --git a/magic-link/my-contacts/my-contacts.php b/magic-link/my-contacts/my-contacts.php new file mode 100644 index 0000000..7145e13 --- /dev/null +++ b/magic-link/my-contacts/my-contacts.php @@ -0,0 +1,1240 @@ +meta = [ + 'app_type' => 'magic_link', + 'post_type' => $this->post_type, + 'contacts_only' => true, + 'supports_create' => false, + 'icon' => 'mdi mdi-account-group', + 'show_in_home_apps' => true, + ]; + + $this->meta_key = $this->root . '_' . $this->type . '_magic_key'; + parent::__construct(); + + add_filter( 'dt_settings_apps_list', [ $this, 'dt_settings_apps_list' ], 10, 1 ); + add_action( 'rest_api_init', [ $this, 'add_endpoints' ] ); + + $url = dt_get_url_path(); + if ( strpos( $url, $this->root . '/' . $this->type ) === false ) { + return; + } + + if ( ! $this->check_parts_match() ) { + return; + } + + add_action( 'dt_blank_body', [ $this, 'body' ] ); + add_filter( 'dt_magic_url_base_allowed_css', [ $this, 'dt_magic_url_base_allowed_css' ], 10, 1 ); + add_filter( 'dt_magic_url_base_allowed_js', [ $this, 'dt_magic_url_base_allowed_js' ], 10, 1 ); + add_action( 'wp_enqueue_scripts', [ $this, 'wp_enqueue_scripts' ] ); + } + + public function dt_settings_apps_list( $apps_list ) { + $apps_list[ $this->meta_key ] = [ + 'key' => $this->meta_key, + 'url_base' => $this->root . '/' . $this->type, + 'label' => $this->page_title, + 'description' => $this->page_description, + 'settings_display' => true + ]; + return $apps_list; + } + + public function dt_magic_url_base_allowed_js( $allowed_js ) { + $allowed_js[] = 'dt-web-components'; + $allowed_js[] = 'my-contacts-js'; + return $allowed_js; + } + + public function dt_magic_url_base_allowed_css( $allowed_css ) { + $allowed_css[] = 'dt-web-components-css'; + $allowed_css[] = 'my-contacts-css'; + return $allowed_css; + } + + /** + * Enqueue scripts and styles + */ + public function wp_enqueue_scripts() { + $theme_uri = get_template_directory_uri(); + $theme_dir = get_template_directory(); + + // Enqueue DT web components JS + $components_js = 'dt-assets/build/components/index.js'; + if ( file_exists( $theme_dir . '/' . $components_js ) ) { + wp_enqueue_script( + 'dt-web-components', + $theme_uri . '/' . $components_js, + [], + filemtime( $theme_dir . '/' . $components_js ), + true + ); + } + + // Enqueue DT web components CSS + $components_css = 'dt-assets/build/css/light.min.css'; + if ( file_exists( $theme_dir . '/' . $components_css ) ) { + wp_enqueue_style( + 'dt-web-components-css', + $theme_uri . '/' . $components_css, + [], + filemtime( $theme_dir . '/' . $components_css ) + ); + } + + // Enqueue magic link JS + wp_enqueue_script( + 'my-contacts-js', + trailingslashit( plugin_dir_url( __FILE__ ) ) . 'my-contacts.js', + [], + filemtime( plugin_dir_path( __FILE__ ) . 'my-contacts.js' ), + true + ); + wp_localize_script( + 'my-contacts-js', + 'myContactsApp', + [ + 'root' => esc_url_raw( rest_url() ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'parts' => $this->parts, + ] + ); + + // Enqueue magic link CSS + wp_enqueue_style( + 'my-contacts-css', + trailingslashit( plugin_dir_url( __FILE__ ) ) . 'my-contacts.css', + [], + filemtime( plugin_dir_path( __FILE__ ) . 'my-contacts.css' ) + ); + } + + /** + * Register REST API endpoints + */ + public function add_endpoints() { + $namespace = $this->root . '/v1'; + + register_rest_route( + $namespace, '/' . $this->type . '/contacts', [ + [ + 'methods' => 'POST', + 'callback' => [ $this, 'get_my_contacts' ], + 'permission_callback' => [ $this, 'check_permission' ], + ], + ] + ); + + register_rest_route( + $namespace, '/' . $this->type . '/contact', [ + [ + 'methods' => 'POST', + 'callback' => [ $this, 'get_contact_details' ], + 'permission_callback' => [ $this, 'check_permission' ], + ], + ] + ); + + register_rest_route( + $namespace, '/' . $this->type . '/comment', [ + [ + 'methods' => 'POST', + 'callback' => [ $this, 'add_comment' ], + 'permission_callback' => [ $this, 'check_permission' ], + ], + ] + ); + + register_rest_route( + $namespace, '/' . $this->type . '/users-mention', [ + [ + 'methods' => 'POST', + 'callback' => [ $this, 'get_users_for_mention' ], + 'permission_callback' => [ $this, 'check_permission' ], + ], + ] + ); + + register_rest_route( + $namespace, '/' . $this->type . '/update-field', [ + [ + 'methods' => 'POST', + 'callback' => [ $this, 'update_field' ], + 'permission_callback' => [ $this, 'check_permission' ], + ], + ] + ); + + register_rest_route( + $namespace, '/' . $this->type . '/field-options', [ + [ + 'methods' => 'POST', + 'callback' => [ $this, 'get_field_options' ], + 'permission_callback' => [ $this, 'check_permission' ], + ], + ] + ); + } + + /** + * Check permission for REST endpoints + */ + public function check_permission( WP_REST_Request $request ) { + $params = $request->get_json_params(); + $params = dt_recursive_sanitize_array( $params ); + + if ( ! isset( $params['parts'], $params['parts']['public_key'], $params['parts']['meta_key'] ) ) { + return false; + } + + $post_id = $this->get_post_id_from_magic_key( $params['parts']['meta_key'], $params['parts']['public_key'] ); + return (bool) $post_id; + } + + /** + * Get post ID from magic key + */ + private function get_post_id_from_magic_key( $meta_key, $public_key ) { + global $wpdb; + $post_id = $wpdb->get_var( $wpdb->prepare( + "SELECT post_id FROM $wpdb->postmeta WHERE meta_key = %s AND meta_value = %s", + $meta_key, + $public_key + ) ); + return $post_id ? intval( $post_id ) : null; + } + + /** + * Get contacts for this magic link owner + */ + public function get_my_contacts( WP_REST_Request $request ) { + $params = $request->get_json_params(); + $params = dt_recursive_sanitize_array( $params ); + + $owner_contact_id = $this->get_post_id_from_magic_key( $params['parts']['meta_key'], $params['parts']['public_key'] ); + if ( ! $owner_contact_id ) { + return new WP_Error( 'invalid_key', 'Invalid magic link', [ 'status' => 403 ] ); + } + + $contacts = []; + $contact_ids_added = []; + + // Get the owner contact to find subassigned contacts and corresponding user + $owner_contact = DT_Posts::get_post( 'contacts', $owner_contact_id, true, false ); + if ( is_wp_error( $owner_contact ) ) { + return new WP_Error( 'contact_not_found', 'Owner contact not found', [ 'status' => 404 ] ); + } + + // 1. Get subassigned contacts (contacts where this contact is in their subassigned field) + if ( ! empty( $owner_contact['subassigned'] ) ) { + foreach ( $owner_contact['subassigned'] as $subassigned ) { + $subassigned_id = $subassigned['ID'] ?? null; + if ( $subassigned_id && ! in_array( $subassigned_id, $contact_ids_added ) ) { + $contact = DT_Posts::get_post( 'contacts', $subassigned_id, true, false ); + if ( ! is_wp_error( $contact ) ) { + $contacts[] = $this->format_contact_for_list( $contact, 'subassigned' ); + $contact_ids_added[] = $subassigned_id; + } + } + } + } + + // 2. Get contacts the corresponding user has access to + $corresponds_to_user = $owner_contact['corresponds_to_user'] ?? null; + if ( $corresponds_to_user ) { + $user_id = is_array( $corresponds_to_user ) ? ( $corresponds_to_user['ID'] ?? null ) : $corresponds_to_user; + + if ( $user_id ) { + // Get contacts assigned to this user + $user_contacts = DT_Posts::list_posts( 'contacts', [ + 'assigned_to' => [ $user_id ], + 'sort' => '-last_modified', + 'limit' => 100, + ], false ); + + if ( ! is_wp_error( $user_contacts ) && isset( $user_contacts['posts'] ) ) { + foreach ( $user_contacts['posts'] as $contact ) { + if ( ! in_array( $contact['ID'], $contact_ids_added ) && $contact['ID'] !== $owner_contact_id ) { + $contacts[] = $this->format_contact_for_list( $contact, 'assigned' ); + $contact_ids_added[] = $contact['ID']; + } + } + } + } + } + + // Sort by last_modified (most recent first) + usort( $contacts, function( $a, $b ) { + return ( $b['last_modified_timestamp'] ?? 0 ) - ( $a['last_modified_timestamp'] ?? 0 ); + }); + + return [ + 'contacts' => $contacts, + 'total' => count( $contacts ), + 'owner_contact_id' => $owner_contact_id, + ]; + } + + /** + * Format a contact for the list display + */ + private function format_contact_for_list( $contact, $source = '' ) { + $overall_status = ''; + $overall_status_color = ''; + if ( ! empty( $contact['overall_status'] ) ) { + $overall_status = $contact['overall_status']['label'] ?? ''; + $overall_status_color = $contact['overall_status']['color'] ?? ''; + } + + $seeker_path = ''; + if ( ! empty( $contact['seeker_path'] ) ) { + $seeker_path = $contact['seeker_path']['label'] ?? ''; + } + + $last_modified = ''; + $last_modified_timestamp = 0; + if ( ! empty( $contact['last_modified']['timestamp'] ) ) { + $last_modified_timestamp = $contact['last_modified']['timestamp']; + $last_modified = $contact['last_modified']['formatted'] ?? ''; + } + + return [ + 'ID' => $contact['ID'], + 'name' => $contact['name'] ?? 'Unknown', + 'overall_status' => $overall_status, + 'overall_status_color' => $overall_status_color, + 'seeker_path' => $seeker_path, + 'last_modified' => $last_modified, + 'last_modified_timestamp' => $last_modified_timestamp, + 'source' => $source, + ]; + } + + /** + * Get contact details with comments and activity + */ + public function get_contact_details( WP_REST_Request $request ) { + $params = $request->get_json_params(); + $params = dt_recursive_sanitize_array( $params ); + + $owner_contact_id = $this->get_post_id_from_magic_key( $params['parts']['meta_key'], $params['parts']['public_key'] ); + if ( ! $owner_contact_id ) { + return new WP_Error( 'invalid_key', 'Invalid magic link', [ 'status' => 403 ] ); + } + + $contact_id = intval( $params['contact_id'] ?? 0 ); + if ( ! $contact_id ) { + return new WP_Error( 'missing_contact_id', 'Contact ID is required', [ 'status' => 400 ] ); + } + + // Verify access - the contact must be in subassigned or accessible by user + if ( ! $this->verify_contact_access( $owner_contact_id, $contact_id ) ) { + return new WP_Error( 'access_denied', 'You do not have access to this contact', [ 'status' => 403 ] ); + } + + $contact = DT_Posts::get_post( 'contacts', $contact_id, true, false ); + if ( is_wp_error( $contact ) ) { + return new WP_Error( 'contact_not_found', 'Contact not found', [ 'status' => 404 ] ); + } + + $field_settings = DT_Posts::get_post_field_settings( 'contacts' ); + $tile_settings = DT_Posts::get_post_tiles( 'contacts' ); + + // Get comments and activity + $comments = DT_Posts::get_post_comments( 'contacts', $contact_id, true, 'all', [ 'number' => 50 ] ); + $activity = $this->get_post_activity( $contact_id ); + + // Fields to skip (internal/system fields) + $skip_fields = [ 'corresponds_to_user', 'duplicate_data', 'duplicate_of', 'post_author', 'record_picture', 'name' ]; + + // Group fields by tile + $tiles_with_fields = []; + foreach ( $field_settings as $field_key => $field_setting ) { + // Skip hidden, internal, or system fields + if ( in_array( $field_key, $skip_fields ) ) { + continue; + } + if ( isset( $field_setting['hidden'] ) && $field_setting['hidden'] === true ) { + continue; + } + + // Skip fields without a tile + $tile_key = $field_setting['tile'] ?? ''; + if ( empty( $tile_key ) ) { + continue; + } + + // Skip if tile doesn't exist in tile settings + if ( ! isset( $tile_settings[ $tile_key ] ) ) { + continue; + } + + $value = $contact[ $field_key ] ?? null; + $formatted_value = $this->format_field_value( $value, $field_setting ); + + // Initialize tile if not exists + if ( ! isset( $tiles_with_fields[ $tile_key ] ) ) { + $tile_order = $tile_settings[ $tile_key ]['tile_priority'] ?? 100; + $tiles_with_fields[ $tile_key ] = [ + 'key' => $tile_key, + 'label' => $tile_settings[ $tile_key ]['label'] ?? $tile_key, + 'order' => is_numeric( $tile_order ) ? intval( $tile_order ) : 100, + 'fields' => [], + ]; + } + + $field_order = $field_setting['in_create_form'] ?? 100; + $field_type = $field_setting['type'] ?? 'text'; + + // Prepare raw value for editing + $raw_value = $this->prepare_raw_value( $value, $field_type ); + + // Prepare options for select fields + $options = []; + if ( in_array( $field_type, [ 'key_select', 'multi_select' ] ) && isset( $field_setting['default'] ) ) { + foreach ( $field_setting['default'] as $option_key => $option_data ) { + // Skip deleted or hidden options + if ( ! empty( $option_data['deleted'] ) || ! empty( $option_data['hidden'] ) ) { + continue; + } + // Ensure we have a valid key + if ( $option_key === '' || $option_key === null ) { + continue; + } + $label = $option_data['label'] ?? (string) $option_key; + // Ensure label is never empty + if ( empty( $label ) ) { + $label = (string) $option_key; + } + $options[] = [ + 'id' => (string) $option_key, + 'label' => $label, + 'color' => $option_data['color'] ?? null, + 'icon' => $option_data['icon'] ?? null, + ]; + } + } + + $field_data = [ + 'key' => $field_key, + 'label' => $field_setting['name'] ?? $field_key, + 'value' => $formatted_value, + 'raw_value' => $raw_value, + 'type' => $field_type, + 'options' => $options, + 'icon' => $field_setting['icon'] ?? '', + 'font_icon' => $field_setting['font-icon'] ?? '', + 'order' => is_numeric( $field_order ) ? intval( $field_order ) : 100, + ]; + + // Add post_type for connection fields + if ( $field_type === 'connection' && isset( $field_setting['post_type'] ) ) { + $field_data['post_type'] = $field_setting['post_type']; + } + + $tiles_with_fields[ $tile_key ]['fields'][] = $field_data; + } + + // Sort tiles by order + uasort( $tiles_with_fields, function( $a, $b ) { + $order_a = is_numeric( $a['order'] ?? 100 ) ? intval( $a['order'] ) : 100; + $order_b = is_numeric( $b['order'] ?? 100 ) ? intval( $b['order'] ) : 100; + return $order_a - $order_b; + }); + + // Sort fields within each tile by order + foreach ( $tiles_with_fields as &$tile ) { + usort( $tile['fields'], function( $a, $b ) { + $order_a = is_numeric( $a['order'] ?? 100 ) ? intval( $a['order'] ) : 100; + $order_b = is_numeric( $b['order'] ?? 100 ) ? intval( $b['order'] ) : 100; + return $order_a - $order_b; + }); + } + + // Convert to indexed array + $tiles = array_values( $tiles_with_fields ); + + // Merge comments and activity, sort by date + $all_activity = $this->merge_comments_and_activity( $comments['comments'] ?? [], $activity ); + + return [ + 'ID' => $contact['ID'], + 'name' => $contact['name'] ?? 'Unknown', + 'tiles' => $tiles, + 'created' => $contact['post_date']['formatted'] ?? '', + 'last_modified' => $contact['last_modified']['formatted'] ?? '', + 'activity' => $all_activity, + ]; + } + + /** + * Verify the owner has access to view the requested contact + */ + private function verify_contact_access( $owner_contact_id, $contact_id ) { + // Don't allow viewing self + if ( $owner_contact_id === $contact_id ) { + return false; + } + + $owner_contact = DT_Posts::get_post( 'contacts', $owner_contact_id, true, false ); + if ( is_wp_error( $owner_contact ) ) { + return false; + } + + // Check if contact is in subassigned + if ( ! empty( $owner_contact['subassigned'] ) ) { + foreach ( $owner_contact['subassigned'] as $subassigned ) { + if ( ( $subassigned['ID'] ?? null ) === $contact_id ) { + return true; + } + } + } + + // Check if corresponding user has access + $corresponds_to_user = $owner_contact['corresponds_to_user'] ?? null; + if ( $corresponds_to_user ) { + $user_id = is_array( $corresponds_to_user ) ? ( $corresponds_to_user['ID'] ?? null ) : $corresponds_to_user; + + if ( $user_id ) { + // Check if contact is assigned to this user + $contact = DT_Posts::get_post( 'contacts', $contact_id, true, false ); + if ( ! is_wp_error( $contact ) ) { + $assigned_to = $contact['assigned_to'] ?? null; + if ( $assigned_to ) { + $assigned_user_id = is_array( $assigned_to ) ? ( $assigned_to['id'] ?? null ) : $assigned_to; + if ( intval( $assigned_user_id ) === intval( $user_id ) ) { + return true; + } + } + } + } + } + + return false; + } + + /** + * Get post activity log + */ + private function get_post_activity( $post_id ) { + global $wpdb; + + $activity = $wpdb->get_results( $wpdb->prepare( + "SELECT * FROM $wpdb->dt_activity_log + WHERE object_id = %d + AND object_type = 'contacts' + ORDER BY hist_time DESC + LIMIT 50", + $post_id + ), ARRAY_A ); + + $formatted_activity = []; + foreach ( $activity as $item ) { + // Skip certain action types that are not user-facing + $skip_actions = [ 'connected to', 'disconnected from' ]; + if ( in_array( $item['action'] ?? '', $skip_actions ) ) { + continue; + } + + $formatted_activity[] = [ + 'id' => $item['histid'], + 'type' => 'activity', + 'action' => $item['action'] ?? '', + 'object_note' => $item['object_note'] ?? '', + 'meta_key' => $item['meta_key'] ?? '', + 'meta_value' => $item['meta_value'] ?? '', + 'old_value' => $item['old_value'] ?? '', + 'user_id' => $item['user_id'] ?? 0, + 'timestamp' => intval( $item['hist_time'] ?? 0 ) + ]; + } + + return $formatted_activity; + } + + /** + * Merge comments and activity into a single timeline + */ + private function merge_comments_and_activity( $comments, $activity ) { + $merged = []; + + // Add comments + foreach ( $comments as $comment ) { + $timestamp = strtotime( $comment['comment_date_gmt'] ?? '' ); + $merged[] = [ + 'type' => 'comment', + 'id' => $comment['comment_ID'] ?? 0, + 'content' => $comment['comment_content'] ?? '', + 'author' => $comment['comment_author'] ?? '', + 'date' => $comment['comment_date'] ?? '', + 'timestamp' => $timestamp, + ]; + } + + // Add activity (only field changes, not comments which are already included) + foreach ( $activity as $item ) { + if ( $item['action'] === 'comment' ) { + continue; // Skip comment actions as they're already in comments + } + + $description = $this->format_activity_description( $item ); + if ( empty( $description ) ) { + continue; + } + + // Get user display name + $user_name = 'System'; + if ( ! empty( $item['user_id'] ) ) { + $user = get_userdata( $item['user_id'] ); + if ( $user ) { + $user_name = $user->display_name; + } + } + + $merged[] = [ + 'type' => 'activity', + 'id' => $item['id'], + 'content' => $description, + 'author' => $user_name, + 'date' => $item['date'], + 'timestamp' => $item['timestamp'], + ]; + } + + // Sort by timestamp descending (most recent first) + usort( $merged, function( $a, $b ) { + return ( $b['timestamp'] ?? 0 ) - ( $a['timestamp'] ?? 0 ); + }); + + return $merged; + } + + /** + * Format activity item into human-readable description + */ + private function format_activity_description( $item ) { + $action = $item['action'] ?? ''; + $meta_key = $item['meta_key'] ?? ''; + $object_note = $item['object_note'] ?? ''; + + // If there's an object note, use it + if ( ! empty( $object_note ) ) { + return $object_note; + } + + // Format based on action type + switch ( $action ) { + case 'field_update': + $field_settings = DT_Posts::get_post_field_settings( 'contacts' ); + $field_name = $field_settings[ $meta_key ]['name'] ?? $meta_key; + return sprintf( 'Updated %s', $field_name ); + + case 'created': + return 'Contact created'; + + default: + return ''; + } + } + + /** + * Format field value based on field type + */ + private function format_field_value( $value, $field_setting ) { + $type = $field_setting['type'] ?? 'text'; + + // Handle null/empty values - return empty string to display field with no value + if ( $value === null || $value === '' || ( is_array( $value ) && empty( $value ) ) ) { + return ''; + } + + switch ( $type ) { + case 'text': + case 'textarea': + case 'number': + return is_string( $value ) || is_numeric( $value ) ? $value : ''; + + case 'boolean': + return $value ? 'Yes' : 'No'; + + case 'key_select': + return $value['label'] ?? ''; + + case 'multi_select': + case 'tags': + if ( is_array( $value ) ) { + $labels = []; + $options = $field_setting['default'] ?? []; + foreach ( $value as $item ) { + if ( isset( $item['label'] ) ) { + $labels[] = $item['label']; + } elseif ( is_string( $item ) ) { + if ( isset( $options[ $item ]['label'] ) ) { + $labels[] = $options[ $item ]['label']; + } else { + $labels[] = $item; + } + } + } + return implode( ', ', $labels ); + } + return ''; + + case 'communication_channel': + if ( is_array( $value ) ) { + $channels = []; + foreach ( $value as $item ) { + if ( isset( $item['value'] ) && ! empty( $item['value'] ) ) { + $channels[] = $item['value']; + } + } + return implode( ', ', $channels ); + } + return ''; + + case 'location': + case 'location_grid': + case 'location_meta': + if ( is_array( $value ) ) { + $locations = []; + foreach ( $value as $item ) { + if ( isset( $item['label'] ) ) { + $locations[] = $item['label']; + } + } + return implode( "\n", $locations ); + } + return ''; + + case 'connection': + if ( is_array( $value ) ) { + $connections = []; + foreach ( $value as $item ) { + if ( isset( $item['post_title'] ) ) { + $connections[] = $item['post_title']; + } + } + return implode( ', ', $connections ); + } + return ''; + + case 'user_select': + if ( isset( $value['display'] ) ) { + return $value['display']; + } + return ''; + + case 'date': + if ( isset( $value['formatted'] ) ) { + return $value['formatted']; + } + return ''; + + case 'link': + if ( is_array( $value ) ) { + $links = []; + foreach ( $value as $item ) { + if ( isset( $item['value'] ) ) { + $links[] = $item['value']; + } + } + return implode( ', ', $links ); + } + return ''; + + default: + if ( is_array( $value ) ) { + if ( isset( $value['label'] ) ) { + return $value['label']; + } + return ''; + } + return is_string( $value ) ? $value : ''; + } + } + + /** + * Prepare raw value for editing (JSON-safe format for DT components) + */ + private function prepare_raw_value( $value, $field_type ) { + if ( $value === null || $value === '' ) { + return null; + } + + switch ( $field_type ) { + case 'text': + case 'textarea': + case 'number': + return is_string( $value ) || is_numeric( $value ) ? $value : ''; + + case 'boolean': + return (bool) $value; + + case 'key_select': + return $value['key'] ?? ''; + + case 'multi_select': + case 'tags': + // Value should be an array of string IDs for the component + if ( is_array( $value ) ) { + $result = []; + foreach ( $value as $item ) { + if ( is_string( $item ) ) { + $result[] = $item; + } elseif ( is_array( $item ) && isset( $item['value'] ) ) { + $result[] = $item['value']; + } elseif ( is_array( $item ) && isset( $item['key'] ) ) { + $result[] = $item['key']; + } + } + return $result; + } + return []; + + case 'communication_channel': + if ( is_array( $value ) ) { + return array_values( $value ); + } + return []; + + case 'connection': + if ( is_array( $value ) ) { + return array_map( function( $item ) { + return [ + 'id' => (int) ( $item['ID'] ?? 0 ), + 'label' => $item['post_title'] ?? '', + 'link' => $item['permalink'] ?? '', + 'status' => $item['status'] ?? null, + ]; + }, $value ); + } + return []; + + case 'location': + case 'location_meta': + if ( is_array( $value ) ) { + return array_values( $value ); + } + return []; + + case 'user_select': + if ( isset( $value['id'] ) ) { + return $value['id']; + } + return null; + + case 'date': + if ( isset( $value['timestamp'] ) ) { + return $value['timestamp']; + } + return null; + + case 'link': + if ( is_array( $value ) ) { + return array_values( $value ); + } + return []; + + default: + return $value; + } + } + + /** + * Format a value for DT_Posts::update_post based on field type + */ + private function format_value_for_update( $value, $field_type, $contact_id, $field_key ) { + switch ( $field_type ) { + case 'text': + case 'textarea': + case 'number': + case 'boolean': + case 'key_select': + // These types can be passed directly + return $value; + + case 'date': + // Date expects a timestamp or date string + return $value; + + case 'multi_select': + case 'tags': + // multi_select and tags expect: ['values' => [['value' => 'option1'], ['value' => 'option2']]] + // The component uses '-' prefix to mark items for deletion (e.g., ["-tag1", "tag2"]) + $new_values = is_array( $value ) ? $value : []; + + $update_values = []; + foreach ( $new_values as $v ) { + // Check for deletion prefix (component marks deleted items with '-' prefix) + if ( is_string( $v ) && strpos( $v, '-' ) === 0 ) { + $update_values[] = [ 'value' => substr( $v, 1 ), 'delete' => true ]; + } else { + $update_values[] = [ 'value' => $v ]; + } + } + + return [ 'values' => $update_values ]; + + case 'communication_channel': + // communication_channel is more complex - for now return as-is + // Full implementation would need to track meta_ids for updates + if ( is_array( $value ) ) { + return $value; + } + return []; + + case 'connection': + // connection expects: ['values' => [['value' => post_id]]] + // Items with 'delete' property should be removed + if ( is_array( $value ) ) { + $formatted = []; + foreach ( $value as $item ) { + if ( isset( $item['id'] ) ) { + $entry = [ 'value' => $item['id'] ]; + if ( ! empty( $item['delete'] ) ) { + $entry['delete'] = true; + } + $formatted[] = $entry; + } elseif ( is_numeric( $item ) ) { + $formatted[] = [ 'value' => intval( $item ) ]; + } + } + return [ 'values' => $formatted ]; + } + return [ 'values' => [] ]; + + case 'location': + case 'location_meta': + // location expects: ['values' => [['value' => grid_id]]] + // Items with 'delete' property should be removed + if ( is_array( $value ) ) { + $formatted = []; + foreach ( $value as $item ) { + $entry = null; + if ( isset( $item['id'] ) ) { + $entry = [ 'value' => $item['id'] ]; + } elseif ( isset( $item['grid_id'] ) ) { + $entry = [ 'value' => $item['grid_id'] ]; + } + if ( $entry ) { + if ( ! empty( $item['delete'] ) ) { + $entry['delete'] = true; + } + $formatted[] = $entry; + } + } + return [ 'values' => $formatted ]; + } + return [ 'values' => [] ]; + + case 'user_select': + // user_select expects a user ID string like "user-123" + if ( is_numeric( $value ) ) { + return 'user-' . $value; + } + return $value; + + default: + return $value; + } + } + + /** + * Add a comment to a contact + */ + public function add_comment( WP_REST_Request $request ) { + $params = $request->get_json_params(); + $params = dt_recursive_sanitize_array( $params ); + + $owner_contact_id = $this->get_post_id_from_magic_key( $params['parts']['meta_key'], $params['parts']['public_key'] ); + if ( ! $owner_contact_id ) { + return new WP_Error( 'invalid_key', 'Invalid magic link', [ 'status' => 403 ] ); + } + + $contact_id = intval( $params['contact_id'] ?? 0 ); + $comment = $params['comment'] ?? ''; + + if ( ! $contact_id || empty( $comment ) ) { + return new WP_Error( 'missing_params', 'Contact ID and comment are required', [ 'status' => 400 ] ); + } + + // Verify access + if ( ! $this->verify_contact_access( $owner_contact_id, $contact_id ) ) { + return new WP_Error( 'access_denied', 'You do not have access to this contact', [ 'status' => 403 ] ); + } + + $result = DT_Posts::add_post_comment( 'contacts', $contact_id, $comment, 'comment', [], false, true ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + return [ + 'success' => true, + 'contact_id' => $contact_id, + 'comment_id' => $result, + ]; + } + + /** + * Get users for @mention autocomplete + */ + public function get_users_for_mention( WP_REST_Request $request ) { + $params = $request->get_json_params(); + $params = dt_recursive_sanitize_array( $params ); + + $search = $params['search'] ?? ''; + + global $wpdb; + + $users = $wpdb->get_results( $wpdb->prepare( " + SELECT ID, display_name + FROM $wpdb->users + WHERE display_name LIKE %s + ORDER BY display_name + LIMIT 10 + ", '%' . $wpdb->esc_like( $search ) . '%' ), ARRAY_A ); + + return [ + 'users' => $users, + ]; + } + + /** + * Update a field on a contact + */ + public function update_field( WP_REST_Request $request ) { + $params = $request->get_json_params(); + $params = dt_recursive_sanitize_array( $params ); + + $owner_contact_id = $this->get_post_id_from_magic_key( $params['parts']['meta_key'], $params['parts']['public_key'] ); + if ( ! $owner_contact_id ) { + return new WP_Error( 'invalid_key', 'Invalid magic link', [ 'status' => 403 ] ); + } + + $contact_id = intval( $params['contact_id'] ?? 0 ); + $field_key = $params['field_key'] ?? ''; + $field_value = $params['field_value'] ?? null; + + if ( ! $contact_id || empty( $field_key ) ) { + return new WP_Error( 'missing_params', 'Contact ID and field key are required', [ 'status' => 400 ] ); + } + + // Verify access + if ( ! $this->verify_contact_access( $owner_contact_id, $contact_id ) ) { + return new WP_Error( 'access_denied', 'You do not have access to this contact', [ 'status' => 403 ] ); + } + + // Get field settings to determine field type + $field_settings = DT_Posts::get_post_field_settings( 'contacts' ); + $field_setting = $field_settings[ $field_key ] ?? []; + $field_type = $field_setting['type'] ?? 'text'; + + // Transform the value to the format DT_Posts expects + $formatted_update_value = $this->format_value_for_update( $field_value, $field_type, $contact_id, $field_key ); + + // Update the field + $update_data = [ $field_key => $formatted_update_value ]; + $result = DT_Posts::update_post( 'contacts', $contact_id, $update_data, true, false ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + // Get the updated field value + $updated_value = $result[ $field_key ] ?? null; + $formatted_value = $this->format_field_value( $updated_value, $field_setting ); + + return [ + 'success' => true, + 'contact_id' => $contact_id, + 'field_key' => $field_key, + 'value' => $formatted_value, + 'raw_value' => $updated_value, + ]; + } + + /** + * Get field options for typeahead components (connections, locations, tags) + */ + public function get_field_options( WP_REST_Request $request ) { + $params = $request->get_json_params(); + $params = dt_recursive_sanitize_array( $params ); + + $owner_contact_id = $this->get_post_id_from_magic_key( $params['parts']['meta_key'], $params['parts']['public_key'] ); + if ( ! $owner_contact_id ) { + return new WP_Error( 'invalid_key', 'Invalid magic link', [ 'status' => 403 ] ); + } + + $field_key = $params['field'] ?? ''; + $query = $params['query'] ?? ''; + $post_type = $params['post_type'] ?? 'contacts'; + + if ( empty( $field_key ) ) { + return new WP_Error( 'missing_field', 'Field key is required', [ 'status' => 400 ] ); + } + + // Get field settings + $field_settings = DT_Posts::get_post_field_settings( 'contacts' ); + $field_setting = $field_settings[ $field_key ] ?? []; + $field_type = $field_setting['type'] ?? ''; + + $options = []; + + switch ( $field_type ) { + case 'connection': + // Get the post type this field connects to + $connected_post_type = $field_setting['post_type'] ?? 'contacts'; + + // Use get_viewable_compact which handles sorting properly + // (recently viewed first for empty search, recently modified for search) + $search_results = DT_Posts::get_viewable_compact( $connected_post_type, $query, [ + 'field_key' => $field_key, + ] ); + + if ( ! is_wp_error( $search_results ) && isset( $search_results['posts'] ) ) { + foreach ( $search_results['posts'] as $post ) { + $status = null; + if ( isset( $post['status'] ) ) { + $status = $post['status']; + } + $options[] = [ + 'id' => (int) $post['ID'], + 'label' => $post['name'] ?? '', + 'link' => get_permalink( $post['ID'] ), + 'status' => $status, + ]; + } + } + break; + + case 'location': + case 'location_meta': + // Search location grid + $search_results = Disciple_Tools_Mapping_Queries::search_location_grid_by_name( [ + 's' => $query, + 'limit' => 20, + ] ); + + if ( ! empty( $search_results ) && isset( $search_results['location_grid'] ) ) { + foreach ( $search_results['location_grid'] as $location ) { + $options[] = [ + 'id' => strval( $location['grid_id'] ?? $location['ID'] ), + 'label' => $location['name'] ?? $location['label'] ?? '', + ]; + } + } + break; + + case 'tags': + // Get existing tags for this field + $existing_tags = Disciple_Tools_Posts::get_multi_select_options( 'contacts', $field_key, false ); + if ( ! empty( $existing_tags ) ) { + foreach ( $existing_tags as $tag ) { + // Filter by query if provided + if ( empty( $query ) || stripos( $tag, $query ) !== false ) { + $options[] = [ + 'id' => $tag, + 'label' => $tag, + ]; + } + } + } + break; + } + + return [ + 'success' => true, + 'options' => $options, + ]; + } + + /** + * Custom header styles + */ + public function header_style() { + ?> + + +
+ +
+
+ My Contacts (0) + +
+
+
+
+

Loading contacts...

+
+
+
+ + +
+
+ + Contact Details + +
+
+
+
👤
+

Select a contact to view details

+
+
+
+
+ + +
+ Comment added successfully! +
+ + Date: Fri, 5 Dec 2025 10:08:09 +0100 Subject: [PATCH 05/18] Use existing functions --- magic-link/my-contacts/my-contacts.js | 15 +- magic-link/my-contacts/my-contacts.php | 249 ++++++------------------- 2 files changed, 64 insertions(+), 200 deletions(-) diff --git a/magic-link/my-contacts/my-contacts.js b/magic-link/my-contacts/my-contacts.js index 199b8f5..93d3db1 100644 --- a/magic-link/my-contacts/my-contacts.js +++ b/magic-link/my-contacts/my-contacts.js @@ -492,7 +492,7 @@ function renderMentionDropdown() { dropdown.innerHTML = mentionUsers.map((user, index) => `
- ${escapeHtml(user.display_name)} + ${escapeHtml(user.name)}
`).join(''); } @@ -505,7 +505,7 @@ function selectMention(user) { const beforeMention = text.substring(0, mentionStartPos); const afterCursor = text.substring(cursorPos); - const mentionText = `@[${user.display_name}](${user.ID}) `; + const mentionText = `@[${user.name}](${user.ID}) `; textarea.value = beforeMention + mentionText + afterCursor; const newCursorPos = beforeMention.length + mentionText.length; @@ -734,7 +734,16 @@ function initFieldChangeListener(section) { component.addEventListener('change', async (e) => { const fieldKey = section.dataset.fieldKey; const contactId = section.dataset.contactId; - const newValue = e.detail?.newValue ?? e.detail?.value ?? component.value; + const rawValue = e.detail?.newValue ?? e.detail?.value ?? component.value; + + // Use ComponentService.convertValue to format the value for the DT API + let newValue = rawValue; + if (window.DtWebComponents?.ComponentService?.convertValue) { + newValue = window.DtWebComponents.ComponentService.convertValue( + component.tagName, + rawValue + ); + } await saveFieldValue(contactId, fieldKey, fieldType, newValue, section); }); diff --git a/magic-link/my-contacts/my-contacts.php b/magic-link/my-contacts/my-contacts.php index 7145e13..bfa3ef3 100644 --- a/magic-link/my-contacts/my-contacts.php +++ b/magic-link/my-contacts/my-contacts.php @@ -375,7 +375,7 @@ public function get_contact_details( WP_REST_Request $request ) { // Get comments and activity $comments = DT_Posts::get_post_comments( 'contacts', $contact_id, true, 'all', [ 'number' => 50 ] ); - $activity = $this->get_post_activity( $contact_id ); + $activity = DT_Posts::get_post_activity( 'contacts', $contact_id ); // Fields to skip (internal/system fields) $skip_fields = [ 'corresponds_to_user', 'duplicate_data', 'duplicate_of', 'post_author', 'record_picture', 'name' ]; @@ -546,45 +546,6 @@ private function verify_contact_access( $owner_contact_id, $contact_id ) { return false; } - /** - * Get post activity log - */ - private function get_post_activity( $post_id ) { - global $wpdb; - - $activity = $wpdb->get_results( $wpdb->prepare( - "SELECT * FROM $wpdb->dt_activity_log - WHERE object_id = %d - AND object_type = 'contacts' - ORDER BY hist_time DESC - LIMIT 50", - $post_id - ), ARRAY_A ); - - $formatted_activity = []; - foreach ( $activity as $item ) { - // Skip certain action types that are not user-facing - $skip_actions = [ 'connected to', 'disconnected from' ]; - if ( in_array( $item['action'] ?? '', $skip_actions ) ) { - continue; - } - - $formatted_activity[] = [ - 'id' => $item['histid'], - 'type' => 'activity', - 'action' => $item['action'] ?? '', - 'object_note' => $item['object_note'] ?? '', - 'meta_key' => $item['meta_key'] ?? '', - 'meta_value' => $item['meta_value'] ?? '', - 'old_value' => $item['old_value'] ?? '', - 'user_id' => $item['user_id'] ?? 0, - 'timestamp' => intval( $item['hist_time'] ?? 0 ) - ]; - } - - return $formatted_activity; - } - /** * Merge comments and activity into a single timeline */ @@ -604,33 +565,32 @@ private function merge_comments_and_activity( $comments, $activity ) { ]; } - // Add activity (only field changes, not comments which are already included) - foreach ( $activity as $item ) { - if ( $item['action'] === 'comment' ) { - continue; // Skip comment actions as they're already in comments + // Add activity from DT_Posts::get_post_activity() format + $activity_items = $activity['activity'] ?? []; + foreach ( $activity_items as $item ) { + // Skip comment actions as they're already in comments + if ( ( $item['action'] ?? '' ) === 'comment' ) { + continue; } - $description = $this->format_activity_description( $item ); + // Use object_note as the description + $description = $item['object_note'] ?? ''; if ( empty( $description ) ) { continue; } // Get user display name - $user_name = 'System'; - if ( ! empty( $item['user_id'] ) ) { - $user = get_userdata( $item['user_id'] ); - if ( $user ) { - $user_name = $user->display_name; - } - } + $user_name = $item['name'] ?? 'System'; + + $timestamp = intval( $item['hist_time'] ?? 0 ); $merged[] = [ 'type' => 'activity', - 'id' => $item['id'], + 'id' => $item['histid'] ?? 0, 'content' => $description, 'author' => $user_name, - 'date' => $item['date'], - 'timestamp' => $item['timestamp'], + 'date' => $item['date'] ?? '', + 'timestamp' => $timestamp, ]; } @@ -642,34 +602,6 @@ private function merge_comments_and_activity( $comments, $activity ) { return $merged; } - /** - * Format activity item into human-readable description - */ - private function format_activity_description( $item ) { - $action = $item['action'] ?? ''; - $meta_key = $item['meta_key'] ?? ''; - $object_note = $item['object_note'] ?? ''; - - // If there's an object note, use it - if ( ! empty( $object_note ) ) { - return $object_note; - } - - // Format based on action type - switch ( $action ) { - case 'field_update': - $field_settings = DT_Posts::get_post_field_settings( 'contacts' ); - $field_name = $field_settings[ $meta_key ]['name'] ?? $meta_key; - return sprintf( 'Updated %s', $field_name ); - - case 'created': - return 'Contact created'; - - default: - return ''; - } - } - /** * Format field value based on field type */ @@ -873,105 +805,6 @@ private function prepare_raw_value( $value, $field_type ) { } } - /** - * Format a value for DT_Posts::update_post based on field type - */ - private function format_value_for_update( $value, $field_type, $contact_id, $field_key ) { - switch ( $field_type ) { - case 'text': - case 'textarea': - case 'number': - case 'boolean': - case 'key_select': - // These types can be passed directly - return $value; - - case 'date': - // Date expects a timestamp or date string - return $value; - - case 'multi_select': - case 'tags': - // multi_select and tags expect: ['values' => [['value' => 'option1'], ['value' => 'option2']]] - // The component uses '-' prefix to mark items for deletion (e.g., ["-tag1", "tag2"]) - $new_values = is_array( $value ) ? $value : []; - - $update_values = []; - foreach ( $new_values as $v ) { - // Check for deletion prefix (component marks deleted items with '-' prefix) - if ( is_string( $v ) && strpos( $v, '-' ) === 0 ) { - $update_values[] = [ 'value' => substr( $v, 1 ), 'delete' => true ]; - } else { - $update_values[] = [ 'value' => $v ]; - } - } - - return [ 'values' => $update_values ]; - - case 'communication_channel': - // communication_channel is more complex - for now return as-is - // Full implementation would need to track meta_ids for updates - if ( is_array( $value ) ) { - return $value; - } - return []; - - case 'connection': - // connection expects: ['values' => [['value' => post_id]]] - // Items with 'delete' property should be removed - if ( is_array( $value ) ) { - $formatted = []; - foreach ( $value as $item ) { - if ( isset( $item['id'] ) ) { - $entry = [ 'value' => $item['id'] ]; - if ( ! empty( $item['delete'] ) ) { - $entry['delete'] = true; - } - $formatted[] = $entry; - } elseif ( is_numeric( $item ) ) { - $formatted[] = [ 'value' => intval( $item ) ]; - } - } - return [ 'values' => $formatted ]; - } - return [ 'values' => [] ]; - - case 'location': - case 'location_meta': - // location expects: ['values' => [['value' => grid_id]]] - // Items with 'delete' property should be removed - if ( is_array( $value ) ) { - $formatted = []; - foreach ( $value as $item ) { - $entry = null; - if ( isset( $item['id'] ) ) { - $entry = [ 'value' => $item['id'] ]; - } elseif ( isset( $item['grid_id'] ) ) { - $entry = [ 'value' => $item['grid_id'] ]; - } - if ( $entry ) { - if ( ! empty( $item['delete'] ) ) { - $entry['delete'] = true; - } - $formatted[] = $entry; - } - } - return [ 'values' => $formatted ]; - } - return [ 'values' => [] ]; - - case 'user_select': - // user_select expects a user ID string like "user-123" - if ( is_numeric( $value ) ) { - return 'user-' . $value; - } - return $value; - - default: - return $value; - } - } - /** * Add a comment to a contact */ @@ -1011,22 +844,47 @@ public function add_comment( WP_REST_Request $request ) { /** * Get users for @mention autocomplete + * Uses the existing Disciple_Tools_Users::get_assignable_users_compact() function */ public function get_users_for_mention( WP_REST_Request $request ) { $params = $request->get_json_params(); $params = dt_recursive_sanitize_array( $params ); + $owner_contact_id = $this->get_post_id_from_magic_key( $params['parts']['meta_key'], $params['parts']['public_key'] ); + if ( ! $owner_contact_id ) { + return new WP_Error( 'invalid_key', 'Invalid magic link', [ 'status' => 403 ] ); + } + $search = $params['search'] ?? ''; - global $wpdb; + // Get the corresponding user for the magic link owner + $owner_contact = DT_Posts::get_post( 'contacts', $owner_contact_id, true, false ); + if ( is_wp_error( $owner_contact ) ) { + return [ 'users' => [] ]; + } + + $corresponds_to_user = $owner_contact['corresponds_to_user'] ?? null; + if ( ! $corresponds_to_user ) { + return [ 'users' => [] ]; + } + + $user_id = is_array( $corresponds_to_user ) ? ( $corresponds_to_user['ID'] ?? null ) : $corresponds_to_user; + if ( ! $user_id ) { + return [ 'users' => [] ]; + } - $users = $wpdb->get_results( $wpdb->prepare( " - SELECT ID, display_name - FROM $wpdb->users - WHERE display_name LIKE %s - ORDER BY display_name - LIMIT 10 - ", '%' . $wpdb->esc_like( $search ) . '%' ), ARRAY_A ); + // Temporarily set the current user to get assignable users + $original_user_id = get_current_user_id(); + wp_set_current_user( $user_id ); + + $users = Disciple_Tools_Users::get_assignable_users_compact( $search ); + + // Restore the original user + wp_set_current_user( $original_user_id ); + + if ( is_wp_error( $users ) ) { + return [ 'users' => [] ]; + } return [ 'users' => $users, @@ -1058,16 +916,13 @@ public function update_field( WP_REST_Request $request ) { return new WP_Error( 'access_denied', 'You do not have access to this contact', [ 'status' => 403 ] ); } - // Get field settings to determine field type + // Get field settings $field_settings = DT_Posts::get_post_field_settings( 'contacts' ); $field_setting = $field_settings[ $field_key ] ?? []; - $field_type = $field_setting['type'] ?? 'text'; - - // Transform the value to the format DT_Posts expects - $formatted_update_value = $this->format_value_for_update( $field_value, $field_type, $contact_id, $field_key ); - // Update the field - $update_data = [ $field_key => $formatted_update_value ]; + // The JS uses ComponentService.convertValue() to format the value correctly for DT_Posts + // so we can pass it directly without transformation + $update_data = [ $field_key => $field_value ]; $result = DT_Posts::update_post( 'contacts', $contact_id, $update_data, true, false ); if ( is_wp_error( $result ) ) { From 8546a9ba6aee896c599d9c3713be10711cd3e9a2 Mon Sep 17 00:00:00 2001 From: corsacca Date: Fri, 5 Dec 2025 10:36:02 +0100 Subject: [PATCH 06/18] Simplify rest verification --- magic-link/my-contacts/my-contacts.php | 90 +++++++------------------- 1 file changed, 23 insertions(+), 67 deletions(-) diff --git a/magic-link/my-contacts/my-contacts.php b/magic-link/my-contacts/my-contacts.php index bfa3ef3..7a1c851 100644 --- a/magic-link/my-contacts/my-contacts.php +++ b/magic-link/my-contacts/my-contacts.php @@ -74,12 +74,14 @@ public function dt_settings_apps_list( $apps_list ) { } public function dt_magic_url_base_allowed_js( $allowed_js ) { + $allowed_js = []; $allowed_js[] = 'dt-web-components'; $allowed_js[] = 'my-contacts-js'; return $allowed_js; } public function dt_magic_url_base_allowed_css( $allowed_css ) { + $allowed_css = []; $allowed_css[] = 'dt-web-components-css'; $allowed_css[] = 'my-contacts-css'; return $allowed_css; @@ -148,12 +150,22 @@ public function wp_enqueue_scripts() { public function add_endpoints() { $namespace = $this->root . '/v1'; + $permission_callback = function ( WP_REST_Request $request ) { + $magic = new DT_Magic_URL( $this->root ); + $valid_parts = $magic->verify_rest_endpoint_permissions_on_post( $request, true ); + if ( ! $valid_parts ) { + return false; + } + $this->parts = $valid_parts; + return true; + }; + register_rest_route( $namespace, '/' . $this->type . '/contacts', [ [ 'methods' => 'POST', 'callback' => [ $this, 'get_my_contacts' ], - 'permission_callback' => [ $this, 'check_permission' ], + 'permission_callback' => $permission_callback, ], ] ); @@ -163,7 +175,7 @@ public function add_endpoints() { [ 'methods' => 'POST', 'callback' => [ $this, 'get_contact_details' ], - 'permission_callback' => [ $this, 'check_permission' ], + 'permission_callback' => $permission_callback, ], ] ); @@ -173,7 +185,7 @@ public function add_endpoints() { [ 'methods' => 'POST', 'callback' => [ $this, 'add_comment' ], - 'permission_callback' => [ $this, 'check_permission' ], + 'permission_callback' => $permission_callback, ], ] ); @@ -183,7 +195,7 @@ public function add_endpoints() { [ 'methods' => 'POST', 'callback' => [ $this, 'get_users_for_mention' ], - 'permission_callback' => [ $this, 'check_permission' ], + 'permission_callback' => $permission_callback, ], ] ); @@ -193,7 +205,7 @@ public function add_endpoints() { [ 'methods' => 'POST', 'callback' => [ $this, 'update_field' ], - 'permission_callback' => [ $this, 'check_permission' ], + 'permission_callback' => $permission_callback, ], ] ); @@ -203,51 +215,17 @@ public function add_endpoints() { [ 'methods' => 'POST', 'callback' => [ $this, 'get_field_options' ], - 'permission_callback' => [ $this, 'check_permission' ], + 'permission_callback' => $permission_callback, ], ] ); } - /** - * Check permission for REST endpoints - */ - public function check_permission( WP_REST_Request $request ) { - $params = $request->get_json_params(); - $params = dt_recursive_sanitize_array( $params ); - - if ( ! isset( $params['parts'], $params['parts']['public_key'], $params['parts']['meta_key'] ) ) { - return false; - } - - $post_id = $this->get_post_id_from_magic_key( $params['parts']['meta_key'], $params['parts']['public_key'] ); - return (bool) $post_id; - } - - /** - * Get post ID from magic key - */ - private function get_post_id_from_magic_key( $meta_key, $public_key ) { - global $wpdb; - $post_id = $wpdb->get_var( $wpdb->prepare( - "SELECT post_id FROM $wpdb->postmeta WHERE meta_key = %s AND meta_value = %s", - $meta_key, - $public_key - ) ); - return $post_id ? intval( $post_id ) : null; - } - /** * Get contacts for this magic link owner */ public function get_my_contacts( WP_REST_Request $request ) { - $params = $request->get_json_params(); - $params = dt_recursive_sanitize_array( $params ); - - $owner_contact_id = $this->get_post_id_from_magic_key( $params['parts']['meta_key'], $params['parts']['public_key'] ); - if ( ! $owner_contact_id ) { - return new WP_Error( 'invalid_key', 'Invalid magic link', [ 'status' => 403 ] ); - } + $owner_contact_id = $this->parts['post_id']; $contacts = []; $contact_ids_added = []; @@ -350,11 +328,7 @@ public function get_contact_details( WP_REST_Request $request ) { $params = $request->get_json_params(); $params = dt_recursive_sanitize_array( $params ); - $owner_contact_id = $this->get_post_id_from_magic_key( $params['parts']['meta_key'], $params['parts']['public_key'] ); - if ( ! $owner_contact_id ) { - return new WP_Error( 'invalid_key', 'Invalid magic link', [ 'status' => 403 ] ); - } - + $owner_contact_id = $this->parts['post_id']; $contact_id = intval( $params['contact_id'] ?? 0 ); if ( ! $contact_id ) { return new WP_Error( 'missing_contact_id', 'Contact ID is required', [ 'status' => 400 ] ); @@ -812,11 +786,7 @@ public function add_comment( WP_REST_Request $request ) { $params = $request->get_json_params(); $params = dt_recursive_sanitize_array( $params ); - $owner_contact_id = $this->get_post_id_from_magic_key( $params['parts']['meta_key'], $params['parts']['public_key'] ); - if ( ! $owner_contact_id ) { - return new WP_Error( 'invalid_key', 'Invalid magic link', [ 'status' => 403 ] ); - } - + $owner_contact_id = $this->parts['post_id']; $contact_id = intval( $params['contact_id'] ?? 0 ); $comment = $params['comment'] ?? ''; @@ -850,11 +820,7 @@ public function get_users_for_mention( WP_REST_Request $request ) { $params = $request->get_json_params(); $params = dt_recursive_sanitize_array( $params ); - $owner_contact_id = $this->get_post_id_from_magic_key( $params['parts']['meta_key'], $params['parts']['public_key'] ); - if ( ! $owner_contact_id ) { - return new WP_Error( 'invalid_key', 'Invalid magic link', [ 'status' => 403 ] ); - } - + $owner_contact_id = $this->parts['post_id']; $search = $params['search'] ?? ''; // Get the corresponding user for the magic link owner @@ -898,11 +864,7 @@ public function update_field( WP_REST_Request $request ) { $params = $request->get_json_params(); $params = dt_recursive_sanitize_array( $params ); - $owner_contact_id = $this->get_post_id_from_magic_key( $params['parts']['meta_key'], $params['parts']['public_key'] ); - if ( ! $owner_contact_id ) { - return new WP_Error( 'invalid_key', 'Invalid magic link', [ 'status' => 403 ] ); - } - + $owner_contact_id = $this->parts['post_id']; $contact_id = intval( $params['contact_id'] ?? 0 ); $field_key = $params['field_key'] ?? ''; $field_value = $params['field_value'] ?? null; @@ -949,14 +911,8 @@ public function get_field_options( WP_REST_Request $request ) { $params = $request->get_json_params(); $params = dt_recursive_sanitize_array( $params ); - $owner_contact_id = $this->get_post_id_from_magic_key( $params['parts']['meta_key'], $params['parts']['public_key'] ); - if ( ! $owner_contact_id ) { - return new WP_Error( 'invalid_key', 'Invalid magic link', [ 'status' => 403 ] ); - } - $field_key = $params['field'] ?? ''; $query = $params['query'] ?? ''; - $post_type = $params['post_type'] ?? 'contacts'; if ( empty( $field_key ) ) { return new WP_Error( 'missing_field', 'Field key is required', [ 'status' => 400 ] ); From 38303cabaaebafaf279c79ca43f4002077dd6632 Mon Sep 17 00:00:00 2001 From: corsacca Date: Fri, 5 Dec 2025 11:07:34 +0100 Subject: [PATCH 07/18] Let comments be posted by the contact's name if the user isn't signed in. --- magic-link/my-contacts/my-contacts.js | 14 +++++++++- magic-link/my-contacts/my-contacts.php | 37 +++++++++++++++++--------- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/magic-link/my-contacts/my-contacts.js b/magic-link/my-contacts/my-contacts.js index 93d3db1..9ac321e 100644 --- a/magic-link/my-contacts/my-contacts.js +++ b/magic-link/my-contacts/my-contacts.js @@ -375,8 +375,20 @@ async function submitComment() { const result = await response.json(); if (result.success || result.comment_id) { + // Insert the new comment at the top of the activity list + const activityList = document.querySelector('.activity-list'); + if (activityList) { + const newCommentHtml = ` +
+ ${escapeHtml(result.author || 'You')} + ${formatTimestamp(Math.floor(Date.now() / 1000))} +
${formatActivityContent(comment)}
+
+ `; + activityList.insertAdjacentHTML('afterbegin', newCommentHtml); + } + textarea.value = ''; - selectContact(selectedContactId); showSuccessToast('Comment added successfully!'); } else { alert('Failed to add comment: ' + (result.message || 'Unknown error')); diff --git a/magic-link/my-contacts/my-contacts.php b/magic-link/my-contacts/my-contacts.php index 7a1c851..5c3293a 100644 --- a/magic-link/my-contacts/my-contacts.php +++ b/magic-link/my-contacts/my-contacts.php @@ -17,6 +17,7 @@ class Disciple_Tools_Homescreen_Apps_My_Contacts_Magic_Link extends DT_Magic_Url public $root = 'homescreen_apps'; public $type = 'my_contacts'; public $post_type = 'contacts'; + public $show_app_tile = true; private $meta_key = ''; private static $_instance = null; @@ -236,15 +237,19 @@ public function get_my_contacts( WP_REST_Request $request ) { return new WP_Error( 'contact_not_found', 'Owner contact not found', [ 'status' => 404 ] ); } - // 1. Get subassigned contacts (contacts where this contact is in their subassigned field) - if ( ! empty( $owner_contact['subassigned'] ) ) { - foreach ( $owner_contact['subassigned'] as $subassigned ) { - $subassigned_id = $subassigned['ID'] ?? null; - if ( $subassigned_id && ! in_array( $subassigned_id, $contact_ids_added ) ) { - $contact = DT_Posts::get_post( 'contacts', $subassigned_id, true, false ); - if ( ! is_wp_error( $contact ) ) { + // 1. Get contacts where this contact is in their subassigned field + if ( ! empty( $owner_contact['subassigned_on'] ) ) { + $subassigned_contacts = DT_Posts::list_posts( 'contacts', [ + 'subassigned' => [ $owner_contact_id ], + 'sort' => '-last_modified', + 'limit' => 100, + ], false ); + + if ( ! is_wp_error( $subassigned_contacts ) && isset( $subassigned_contacts['posts'] ) ) { + foreach ( $subassigned_contacts['posts'] as $contact ) { + if ( ! in_array( $contact['ID'], $contact_ids_added ) ) { $contacts[] = $this->format_contact_for_list( $contact, 'subassigned' ); - $contact_ids_added[] = $subassigned_id; + $contact_ids_added[] = $contact['ID']; } } } @@ -348,8 +353,8 @@ public function get_contact_details( WP_REST_Request $request ) { $tile_settings = DT_Posts::get_post_tiles( 'contacts' ); // Get comments and activity - $comments = DT_Posts::get_post_comments( 'contacts', $contact_id, true, 'all', [ 'number' => 50 ] ); - $activity = DT_Posts::get_post_activity( 'contacts', $contact_id ); + $comments = DT_Posts::get_post_comments( 'contacts', $contact_id, false ); + $activity = DT_Posts::get_post_activity( 'contacts', $contact_id, [], false ); // Fields to skip (internal/system fields) $skip_fields = [ 'corresponds_to_user', 'duplicate_data', 'duplicate_of', 'post_author', 'record_picture', 'name' ]; @@ -489,8 +494,8 @@ private function verify_contact_access( $owner_contact_id, $contact_id ) { } // Check if contact is in subassigned - if ( ! empty( $owner_contact['subassigned'] ) ) { - foreach ( $owner_contact['subassigned'] as $subassigned ) { + if ( ! empty( $owner_contact['subassigned_on'] ) ) { + foreach ( $owner_contact['subassigned_on'] as $subassigned ) { if ( ( $subassigned['ID'] ?? null ) === $contact_id ) { return true; } @@ -798,8 +803,14 @@ public function add_comment( WP_REST_Request $request ) { if ( ! $this->verify_contact_access( $owner_contact_id, $contact_id ) ) { return new WP_Error( 'access_denied', 'You do not have access to this contact', [ 'status' => 403 ] ); } + $args = []; + $corresponds_to_user = Disciple_Tools_Users::get_user_for_contact( $owner_contact_id ); + if ( empty( $corresponds_to_user ) ) { + $owner_contact = DT_Posts::get_post( 'contacts', $owner_contact_id, true, false ); + $args['comment_author'] = $owner_contact['name']; + } - $result = DT_Posts::add_post_comment( 'contacts', $contact_id, $comment, 'comment', [], false, true ); + $result = DT_Posts::add_post_comment( 'contacts', $contact_id, $comment, 'comment', $args, false ); if ( is_wp_error( $result ) ) { return $result; From 0587f0fd3bb5c3d019de331244c49ecfdfdadd3f Mon Sep 17 00:00:00 2001 From: corsacca Date: Fri, 5 Dec 2025 11:59:37 +0100 Subject: [PATCH 08/18] use vue js --- magic-link/my-contacts/my-contacts.js | 1488 ++++++++++++------------ magic-link/my-contacts/my-contacts.php | 12 +- 2 files changed, 728 insertions(+), 772 deletions(-) diff --git a/magic-link/my-contacts/my-contacts.js b/magic-link/my-contacts/my-contacts.js index 9ac321e..2ffe83f 100644 --- a/magic-link/my-contacts/my-contacts.js +++ b/magic-link/my-contacts/my-contacts.js @@ -1,863 +1,809 @@ -let selectedContactId = null; -let contacts = []; -let filteredContacts = []; - -// Initialize -document.addEventListener('DOMContentLoaded', function() { - loadContacts(); -}); +const { createApp, ref, computed, onMounted, nextTick, watch } = Vue; + +const MyContactsApp = createApp({ + setup() { + // Reactive state + const contacts = ref([]); + const filteredContacts = ref([]); + const selectedContactId = ref(null); + const selectedContact = ref(null); + const searchTerm = ref(''); + const loading = ref(true); + const detailsLoading = ref(false); + const contactsCount = computed(() => filteredContacts.value.length); + + // Comment state + const commentText = ref(''); + const commentSubmitting = ref(false); + + // Mention state + const mentionUsers = ref([]); + const mentionActiveIndex = ref(0); + const mentionStartPos = ref(-1); + const showMentionDropdown = ref(false); + let mentionSearchTimeout = null; + + // Mobile state + const isMobileDetailsVisible = ref(false); + + // Field data store for DT components + const fieldDataStore = ref({}); + + // Load contacts on mount + onMounted(() => { + loadContacts(); + setupGlobalEventListeners(); + }); -// Handle dt:get-data events from DT web components (for typeahead search) -document.addEventListener('dt:get-data', async function(e) { - if (!e.detail) return; - - const { field, query, onSuccess, onError, postType } = e.detail; - - try { - const response = await fetch( - `${myContactsApp.root}${myContactsApp.parts.root}/v1/${myContactsApp.parts.type}/field-options`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': myContactsApp.nonce - }, - body: JSON.stringify({ - parts: myContactsApp.parts, - field: field, - query: query || '', - post_type: postType || 'contacts' - }) + // Watch search term to filter contacts + watch(searchTerm, (term) => { + if (!term) { + filteredContacts.value = contacts.value; + } else { + const lowerTerm = term.toLowerCase(); + filteredContacts.value = contacts.value.filter(c => + c.name.toLowerCase().includes(lowerTerm) || + (c.overall_status && c.overall_status.toLowerCase().includes(lowerTerm)) || + (c.seeker_path && c.seeker_path.toLowerCase().includes(lowerTerm)) + ); } - ); + }); - const data = await response.json(); + // API helper + async function apiRequest(endpoint, data = {}) { + const response = await fetch( + `${myContactsApp.root}${myContactsApp.parts.root}/v1/${myContactsApp.parts.type}/${endpoint}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': myContactsApp.nonce + }, + body: JSON.stringify({ + parts: myContactsApp.parts, + ...data + }) + } + ); + return response.json(); + } - if (data.success && data.options) { - if (onSuccess && typeof onSuccess === 'function') { - onSuccess(data.options); - } - } else { - if (onError && typeof onError === 'function') { - onError(new Error(data.message || 'Failed to fetch options')); + // Load contacts + async function loadContacts() { + loading.value = true; + try { + const data = await apiRequest('contacts'); + contacts.value = data.contacts || []; + filteredContacts.value = contacts.value; + } catch (error) { + console.error('Error loading contacts:', error); + contacts.value = []; + filteredContacts.value = []; + } finally { + loading.value = false; } } - } catch (err) { - console.error('Error fetching field options:', err); - if (onError && typeof onError === 'function') { - onError(err); - } - } -}); -// Load contacts -async function loadContacts() { - try { - const response = await fetch( - `${myContactsApp.root}${myContactsApp.parts.root}/v1/${myContactsApp.parts.type}/contacts`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': myContactsApp.nonce - }, - body: JSON.stringify({ - parts: myContactsApp.parts - }) - } - ); - const data = await response.json(); - contacts = data.contacts || []; - filteredContacts = contacts; - renderContacts(); - } catch (error) { - console.error('Error loading contacts:', error); - document.getElementById('contacts-list').innerHTML = - '

Error loading contacts

'; - } -} + // Select contact + async function selectContact(contactId) { + selectedContactId.value = contactId; + detailsLoading.value = true; -// Render contacts list -function renderContacts() { - const container = document.getElementById('contacts-list'); - const count = document.getElementById('contacts-count'); + if (isMobile()) { + isMobileDetailsVisible.value = true; + } - count.textContent = `(${filteredContacts.length})`; + try { + const contact = await apiRequest('contact', { contact_id: contactId }); - if (filteredContacts.length === 0) { - container.innerHTML = '

No contacts found

'; - return; - } + if (contact.code) { + selectedContact.value = { error: contact.message || 'Unknown error' }; + return; + } - container.innerHTML = filteredContacts.map(contact => { - const statusStyle = contact.overall_status_color - ? `background: ${contact.overall_status_color}20; color: ${contact.overall_status_color};` - : ''; + // Group activity items + contact.groupedActivity = groupActivityItems(contact.activity || []); + selectedContact.value = contact; + + await nextTick(); + initializeDTComponents(); + initMentionListeners(); + } catch (error) { + console.error('Error loading contact details:', error); + selectedContact.value = { error: 'Error loading details' }; + } finally { + detailsLoading.value = false; + } + } - const sourceLabel = contact.source === 'subassigned' ? 'Subassigned' : 'Assigned'; + // Group activity items + function groupActivityItems(items) { + const grouped = []; + let currentGroup = null; - return ` -
-
- ${escapeHtml(contact.name)} - ${contact.overall_status ? `${escapeHtml(contact.overall_status)}` : ''} -
-
- ${contact.seeker_path ? escapeHtml(contact.seeker_path) + ' • ' : ''} - ${contact.last_modified ? escapeHtml(contact.last_modified) : ''} - ${sourceLabel} -
-
- `; - }).join(''); -} - -// Filter contacts by search term -function filterContacts(searchTerm) { - if (!searchTerm) { - filteredContacts = contacts; - } else { - const term = searchTerm.toLowerCase(); - filteredContacts = contacts.filter(c => - c.name.toLowerCase().includes(term) || - (c.overall_status && c.overall_status.toLowerCase().includes(term)) || - (c.seeker_path && c.seeker_path.toLowerCase().includes(term)) - ); - } - renderContacts(); -} - -// Select contact and show details -async function selectContact(contactId) { - selectedContactId = contactId; - - // Highlight selected contact - document.querySelectorAll('.contact-item').forEach(el => { - el.classList.toggle('selected', parseInt(el.dataset.contactId) === contactId); - }); - - // Show details panel on mobile - if (isMobile()) { - document.getElementById('details-panel').classList.add('mobile-visible'); - document.getElementById('contacts-panel').classList.add('mobile-hidden'); - } + items.forEach(item => { + if (item.type === 'comment') { + if (currentGroup) { + grouped.push({ type: 'activity-group', items: currentGroup }); + currentGroup = null; + } + grouped.push(item); + } else { + if (!currentGroup) currentGroup = []; + currentGroup.push(item); + } + }); - // Show loading - const detailsContainer = document.getElementById('contact-details'); - detailsContainer.innerHTML = '
'; - - try { - const response = await fetch( - `${myContactsApp.root}${myContactsApp.parts.root}/v1/${myContactsApp.parts.type}/contact`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': myContactsApp.nonce - }, - body: JSON.stringify({ - contact_id: contactId, - parts: myContactsApp.parts - }) + if (currentGroup) { + grouped.push({ type: 'activity-group', items: currentGroup }); } - ); - const contact = await response.json(); - if (contact.code) { - detailsContainer.innerHTML = `

Error: ${escapeHtml(contact.message || 'Unknown error')}

`; - return; + return grouped; } - renderContactDetails(contact); - - // Update mobile header - document.getElementById('mobile-contact-name').textContent = contact.name; - } catch (error) { - console.error('Error loading contact details:', error); - detailsContainer.innerHTML = '

Error loading details

'; - } -} - -// Render contact details -function renderContactDetails(contact) { - const container = document.getElementById('contact-details'); - - // Render tiles with their fields - const tilesHtml = contact.tiles && contact.tiles.length > 0 ? - contact.tiles.map(tile => ` -
-
${escapeHtml(tile.label)}
- ${tile.fields.map(field => renderEditableField(field, contact.ID)).join('')} -
- `).join('') : - '

No contact information available

'; - - // Render record info - const recordInfoHtml = ` -
-
Record Info
- ${contact.created ? ` -
-
Created
-
${escapeHtml(contact.created)}
-
- ` : ''} - ${contact.last_modified ? ` -
-
Last Modified
-
${escapeHtml(contact.last_modified)}
-
- ` : ''} -
- `; - - // Render activity/comments timeline with grouped field updates - const groupedActivity = groupActivityItems(contact.activity || []); - - const activityHtml = groupedActivity.length > 0 ? - groupedActivity.map((item, index) => { - if (item.type === 'comment') { - // Render comment as prominent card - return ` -
- ${escapeHtml(item.author)} - ${formatTimestamp(item.timestamp)} -
${formatActivityContent(item.content)}
-
- `; - } else { - // Render activity group as collapsible - const count = item.items.length; - const groupId = `activity-group-${index}`; - return ` -
-
- - ${count} field update${count > 1 ? 's' : ''} -
-
- ${item.items.map(a => ` -
- ${formatActivityContent(a.content)} - ${escapeHtml(a.author)} - ${formatTimestamp(a.timestamp)} -
- `).join('')} -
-
- `; + // Toggle activity group + function toggleActivityGroup(index) { + const group = document.getElementById(`activity-group-${index}`); + if (group) { + group.classList.toggle('expanded'); } - }).join('') : - '

No activity yet

'; - - container.innerHTML = ` -
-
-

${escapeHtml(contact.name)}

- ${tilesHtml} - ${recordInfoHtml} -
+ } -
-

Comments & Activity

-
-
-
- -
-
- -
-
-
- ${activityHtml} -
-
-
- `; + // Submit comment + async function submitComment() { + const comment = commentText.value.trim(); + if (!comment || !selectedContactId.value) return; - // Initialize DT components with their data (value, options) - initializeDTComponents(); + commentSubmitting.value = true; - // Initialize mention listeners - initMentionListeners(); -} + try { + const result = await apiRequest('comment', { + contact_id: selectedContactId.value, + comment: comment + }); + + if (result.success || result.comment_id) { + // Add new comment to grouped activity + const newComment = { + type: 'comment', + id: result.comment_id, + content: comment, + author: result.author || 'You', + timestamp: Math.floor(Date.now() / 1000) + }; + + if (selectedContact.value && selectedContact.value.groupedActivity) { + selectedContact.value.groupedActivity.unshift(newComment); + } -// Format activity content (mentions and links) -function formatActivityContent(text) { - if (!text) return ''; + commentText.value = ''; + showSuccessToast('Comment added successfully!'); + } else { + alert('Failed to add comment: ' + (result.message || 'Unknown error')); + } + } catch (error) { + console.error('Error posting comment:', error); + alert('Error posting comment'); + } finally { + commentSubmitting.value = false; + } + } - let formatted = escapeHtml(text); + // Mention functionality + function initMentionListeners() { + const textarea = document.getElementById('comment-textarea'); + if (!textarea || textarea.hasAttribute('data-mention-init')) return; + textarea.setAttribute('data-mention-init', 'true'); - // Format @mentions: @[Name](id) -> styled span - formatted = formatted.replace( - /@\[([^\]]+)\]\((\d+)\)/g, - '@$1' - ); + textarea.addEventListener('input', handleMentionInput); + textarea.addEventListener('keydown', handleMentionKeydown); + } - // Format URLs to clickable links - const urlRegex = /(https?:\/\/[^\s<]+)/g; - formatted = formatted.replace( - urlRegex, - '$1' - ); + function handleMentionInput(e) { + const text = e.target.value; + const cursorPos = e.target.selectionStart; + const textBeforeCursor = text.substring(0, cursorPos); + const lastAtIndex = textBeforeCursor.lastIndexOf('@'); - return formatted; -} + if (lastAtIndex !== -1) { + const textAfterAt = textBeforeCursor.substring(lastAtIndex + 1); -// Group consecutive activity items while keeping comments separate -function groupActivityItems(items) { - const grouped = []; - let currentGroup = null; + if (!textAfterAt.includes(' ') && !textAfterAt.includes('\n')) { + mentionStartPos.value = lastAtIndex; - items.forEach(item => { - if (item.type === 'comment') { - // Flush any pending activity group - if (currentGroup) { - grouped.push({ type: 'activity-group', items: currentGroup }); - currentGroup = null; + clearTimeout(mentionSearchTimeout); + mentionSearchTimeout = setTimeout(() => { + searchMentionUsers(textAfterAt); + }, 200); + return; + } } - // Add comment as-is - grouped.push(item); - } else { - // Accumulate activity items - if (!currentGroup) currentGroup = []; - currentGroup.push(item); - } - }); - // Flush remaining activity group - if (currentGroup) { - grouped.push({ type: 'activity-group', items: currentGroup }); - } - - return grouped; -} + hideMentionDropdown(); + } -// Toggle activity group expand/collapse -function toggleActivityGroup(groupId) { - const group = document.getElementById(groupId); - if (group) { - group.classList.toggle('expanded'); - } -} - -// Comment submission -async function submitComment() { - const textarea = document.getElementById('comment-textarea'); - const submitBtn = document.getElementById('comment-submit-btn'); - const comment = textarea.value.trim(); - - if (!comment || !selectedContactId) return; - - submitBtn.disabled = true; - submitBtn.textContent = 'Posting...'; - - try { - const response = await fetch( - `${myContactsApp.root}${myContactsApp.parts.root}/v1/${myContactsApp.parts.type}/comment`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': myContactsApp.nonce - }, - body: JSON.stringify({ - contact_id: selectedContactId, - comment: comment, - parts: myContactsApp.parts - }) + function handleMentionKeydown(e) { + if (!showMentionDropdown.value) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + mentionActiveIndex.value = Math.min(mentionActiveIndex.value + 1, mentionUsers.value.length - 1); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + mentionActiveIndex.value = Math.max(mentionActiveIndex.value - 1, 0); + } else if (e.key === 'Enter' && mentionUsers.value.length > 0) { + e.preventDefault(); + selectMention(mentionUsers.value[mentionActiveIndex.value]); + } else if (e.key === 'Escape') { + hideMentionDropdown(); } - ); - - const result = await response.json(); - - if (result.success || result.comment_id) { - // Insert the new comment at the top of the activity list - const activityList = document.querySelector('.activity-list'); - if (activityList) { - const newCommentHtml = ` -
- ${escapeHtml(result.author || 'You')} - ${formatTimestamp(Math.floor(Date.now() / 1000))} -
${formatActivityContent(comment)}
-
- `; - activityList.insertAdjacentHTML('afterbegin', newCommentHtml); - } - - textarea.value = ''; - showSuccessToast('Comment added successfully!'); - } else { - alert('Failed to add comment: ' + (result.message || 'Unknown error')); } - } catch (error) { - console.error('Error posting comment:', error); - alert('Error posting comment'); - } finally { - submitBtn.disabled = false; - submitBtn.textContent = 'Add Comment'; - } -} - -// @mention functionality -let mentionSearchTimeout = null; -let mentionStartPos = -1; -let mentionUsers = []; -let mentionActiveIndex = 0; -function initMentionListeners() { - const commentTextarea = document.getElementById('comment-textarea'); - const mentionDropdown = document.getElementById('mention-dropdown'); + async function searchMentionUsers(search) { + if (search.length < 1) { + hideMentionDropdown(); + return; + } - if (!commentTextarea || !mentionDropdown) return; + try { + const data = await apiRequest('users-mention', { search }); + mentionUsers.value = data.users || []; + mentionActiveIndex.value = 0; + + if (mentionUsers.value.length > 0) { + showMentionDropdown.value = true; + } else { + hideMentionDropdown(); + } + } catch (error) { + console.error('Error searching users:', error); + hideMentionDropdown(); + } + } - commentTextarea.addEventListener('input', function(e) { - const text = this.value; - const cursorPos = this.selectionStart; + function selectMention(user) { + const textarea = document.getElementById('comment-textarea'); + const text = textarea.value; + const cursorPos = textarea.selectionStart; - const textBeforeCursor = text.substring(0, cursorPos); - const lastAtIndex = textBeforeCursor.lastIndexOf('@'); + const beforeMention = text.substring(0, mentionStartPos.value); + const afterCursor = text.substring(cursorPos); - if (lastAtIndex !== -1) { - const textAfterAt = textBeforeCursor.substring(lastAtIndex + 1); + const mentionTextStr = `@[${user.name}](${user.ID}) `; + commentText.value = beforeMention + mentionTextStr + afterCursor; - if (!textAfterAt.includes(' ') && !textAfterAt.includes('\n')) { - mentionStartPos = lastAtIndex; + nextTick(() => { + const newCursorPos = beforeMention.length + mentionTextStr.length; + textarea.setSelectionRange(newCursorPos, newCursorPos); + textarea.focus(); + }); - clearTimeout(mentionSearchTimeout); - mentionSearchTimeout = setTimeout(() => { - searchMentionUsers(textAfterAt); - }, 200); - return; - } + hideMentionDropdown(); } - hideMentionDropdown(); - }); - - commentTextarea.addEventListener('keydown', function(e) { - const dropdown = document.getElementById('mention-dropdown'); - if (!dropdown || !dropdown.classList.contains('show')) return; - - if (e.key === 'ArrowDown') { - e.preventDefault(); - mentionActiveIndex = Math.min(mentionActiveIndex + 1, mentionUsers.length - 1); - renderMentionDropdown(); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - mentionActiveIndex = Math.max(mentionActiveIndex - 1, 0); - renderMentionDropdown(); - } else if (e.key === 'Enter' && mentionUsers.length > 0) { - e.preventDefault(); - selectMention(mentionUsers[mentionActiveIndex]); - } else if (e.key === 'Escape') { - hideMentionDropdown(); + function hideMentionDropdown() { + showMentionDropdown.value = false; + mentionUsers.value = []; + mentionStartPos.value = -1; } - }); -} -async function searchMentionUsers(search) { - if (search.length < 1) { - hideMentionDropdown(); - return; - } + // Field editing + function toggleEditMode(fieldKey) { + const section = document.querySelector(`.detail-section[data-field-key="${fieldKey}"]`); + if (!section) return; - try { - const response = await fetch( - `${myContactsApp.root}${myContactsApp.parts.root}/v1/${myContactsApp.parts.type}/users-mention`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': myContactsApp.nonce - }, - body: JSON.stringify({ - search: search, - parts: myContactsApp.parts - }) - } - ); + const isEditing = section.classList.contains('editing'); - const data = await response.json(); - mentionUsers = data.users || []; - mentionActiveIndex = 0; + document.querySelectorAll('.detail-section.editing').forEach(el => { + if (el !== section) { + el.classList.remove('editing'); + } + }); - if (mentionUsers.length > 0) { - renderMentionDropdown(); - document.getElementById('mention-dropdown').classList.add('show'); - } else { - hideMentionDropdown(); + if (isEditing) { + section.classList.remove('editing'); + } else { + section.classList.add('editing'); + initFieldChangeListener(section); + } } - } catch (error) { - console.error('Error searching users:', error); - hideMentionDropdown(); - } -} - -function renderMentionDropdown() { - const dropdown = document.getElementById('mention-dropdown'); - if (!dropdown) return; - dropdown.innerHTML = mentionUsers.map((user, index) => ` -
- ${escapeHtml(user.name)} -
- `).join(''); -} + function initFieldChangeListener(section) { + const editMode = section.querySelector('.edit-mode'); + const component = editMode.querySelector('dt-text, dt-textarea, dt-number, dt-toggle, dt-date, dt-single-select, dt-multi-select, dt-multi-text, dt-tags, dt-connection, dt-location'); -function selectMention(user) { - const textarea = document.getElementById('comment-textarea'); - const text = textarea.value; - const cursorPos = textarea.selectionStart; + if (!component || component.hasAttribute('data-listener-added')) return; - const beforeMention = text.substring(0, mentionStartPos); - const afterCursor = text.substring(cursorPos); + component.setAttribute('data-listener-added', 'true'); - const mentionText = `@[${user.name}](${user.ID}) `; - textarea.value = beforeMention + mentionText + afterCursor; + const fieldType = section.dataset.fieldType; - const newCursorPos = beforeMention.length + mentionText.length; - textarea.setSelectionRange(newCursorPos, newCursorPos); - textarea.focus(); + component.addEventListener('change', async (e) => { + const fieldKey = section.dataset.fieldKey; + const contactId = section.dataset.contactId; + const rawValue = e.detail?.newValue ?? e.detail?.value ?? component.value; - hideMentionDropdown(); -} + let newValue = rawValue; + if (window.DtWebComponents?.ComponentService?.convertValue) { + newValue = window.DtWebComponents.ComponentService.convertValue( + component.tagName, + rawValue + ); + } -function hideMentionDropdown() { - const dropdown = document.getElementById('mention-dropdown'); - if (dropdown) { - dropdown.classList.remove('show'); - } - mentionUsers = []; - mentionStartPos = -1; -} - -// Mobile functionality -function isMobile() { - return window.innerWidth <= 768; -} - -function hideMobileDetails() { - document.getElementById('details-panel').classList.remove('mobile-visible'); - document.getElementById('contacts-panel').classList.remove('mobile-hidden'); -} - -// Store field data for programmatic initialization of components -window.fieldDataStore = window.fieldDataStore || {}; - -// Render an editable field with view and edit modes -function renderEditableField(field, contactId) { - const rawValue = field.raw_value; - - // Determine default based on field type (arrays for multi-value fields) - const isArrayField = ['multi_select', 'tags', 'communication_channel', 'connection', 'location', 'location_meta', 'link'].includes(field.type); - const defaultValue = isArrayField ? [] : ''; - const valueForJson = (rawValue !== null && rawValue !== undefined) ? rawValue : defaultValue; - - // Filter out any options with invalid IDs or labels, and ensure all values are strings - const validOptions = (field.options || []).filter(opt => - opt && - opt.id !== null && - opt.id !== undefined && - opt.id !== '' && - opt.label !== null && - opt.label !== undefined && - opt.label !== '' - ).map(opt => ({ - // Ensure id and label are strings - id: String(opt.id), - label: String(opt.label), - color: opt.color || null, - icon: opt.icon || null - })); - - // Store field data for later initialization - const fieldId = `field-${contactId}-${field.key}`; - window.fieldDataStore[fieldId] = { - value: valueForJson, - options: validOptions, - type: field.type - }; - - return ` -
-
- ${renderFieldIcon(field)}${escapeHtml(field.label)} - -
-
${field.value ? escapeHtml(field.value) : '-'}
-
- ${renderDTComponent(field, contactId)} -
-
- `; -} + await saveFieldValue(contactId, fieldKey, fieldType, newValue, section); + }); + } -// Render the appropriate DT component based on field type -// Components that need complex data (arrays/objects) get a data-field-id for programmatic init -function renderDTComponent(field, contactId) { - const fieldKey = escapeHtml(field.key); - const fieldId = `field-${contactId}-${field.key}`; + const multiValueFieldTypes = ['multi_select', 'connection', 'tags', 'location', 'location_meta', 'communication_channel']; - switch (field.type) { - case 'text': - return ``; + async function saveFieldValue(contactId, fieldKey, fieldType, value, section) { + section.classList.add('saving'); - case 'textarea': - return ``; + try { + const result = await apiRequest('update-field', { + contact_id: parseInt(contactId), + field_key: fieldKey, + field_value: value + }); - case 'number': - return ``; + if (result.success) { + const viewMode = section.querySelector('.view-mode'); + const displayValue = result.value || '-'; + viewMode.textContent = displayValue; + viewMode.classList.toggle('empty-value', !result.value); - case 'boolean': - return ``; + if (!multiValueFieldTypes.includes(fieldType)) { + section.classList.remove('editing'); + } + showSuccessToast('Field updated'); + } else { + alert(result.message || 'Failed to update field'); + } + } catch (error) { + console.error('Error saving field:', error); + alert('Error saving field'); + } finally { + section.classList.remove('saving'); + } + } - case 'date': - return ``; + // Initialize DT components + function initializeDTComponents() { + requestAnimationFrame(() => { + const components = document.querySelectorAll('[data-field-id]'); + + components.forEach(async (component) => { + const fieldId = component.dataset.fieldId; + const tagName = component.tagName.toLowerCase(); + const data = fieldDataStore.value[fieldId]; + + if (!data) return; + + try { + if (customElements.get(tagName) === undefined) { + await customElements.whenDefined(tagName); + } + + component.options = data.options && data.options.length > 0 ? data.options : []; + + if (data.value !== null && data.value !== undefined) { + if (tagName === 'dt-multi-select' && Array.isArray(data.value)) { + const cleanValue = data.value + .filter(v => v !== null && v !== undefined && v !== '') + .map(v => String(v)); + component.value = cleanValue; + } else { + component.value = data.value; + } + } + } catch (err) { + console.error('Error initializing component:', err); + } + }); + }); + } - case 'key_select': - // key_select needs options set programmatically - return ``; + // Store field data for DT component initialization + function storeFieldData(fieldId, value, options, type) { + fieldDataStore.value[fieldId] = { value, options, type }; + } - case 'multi_select': - // multi_select needs value and options set programmatically - return ``; + // Global event listeners + function setupGlobalEventListeners() { + // Handle dt:get-data events for typeahead + document.addEventListener('dt:get-data', async function(e) { + if (!e.detail) return; + + const { field, query, onSuccess, onError, postType } = e.detail; + + try { + const data = await apiRequest('field-options', { + field: field, + query: query || '', + post_type: postType || 'contacts' + }); + + if (data.success && data.options) { + if (onSuccess && typeof onSuccess === 'function') { + onSuccess(data.options); + } + } else { + if (onError && typeof onError === 'function') { + onError(new Error(data.message || 'Failed to fetch options')); + } + } + } catch (err) { + console.error('Error fetching field options:', err); + if (onError && typeof onError === 'function') { + onError(err); + } + } + }); - case 'communication_channel': - return ``; + // Close edit mode when clicking outside + document.addEventListener('click', function(e) { + const editingSection = document.querySelector('.detail-section.editing'); + if (!editingSection) return; - case 'tags': - return ``; + if (editingSection.contains(e.target)) return; + if (e.target.closest('.option-list, ul[class*="option"], li[tabindex]')) return; - case 'connection': - return ``; + editingSection.classList.remove('editing'); + }); + } - case 'location': - case 'location_meta': - return ``; + // Mobile helpers + function isMobile() { + return window.innerWidth <= 768; + } - case 'user_select': - // User select requires special permissions not available in magic link context - return `Not editable in this view`; + function hideMobileDetails() { + isMobileDetailsVisible.value = false; + } - default: - return ``; - } -} + // Utility functions + function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } -// Initialize DT components with their data after HTML is inserted -function initializeDTComponents() { - // Use requestAnimationFrame to ensure DOM is fully rendered - requestAnimationFrame(() => { - const components = document.querySelectorAll('[data-field-id]'); + function escapeAttr(text) { + if (text === null || text === undefined) return ''; + return String(text).replace(/"/g, '"').replace(/'/g, ''').replace(//g, '>'); + } - components.forEach(async (component) => { - const fieldId = component.dataset.fieldId; - const tagName = component.tagName.toLowerCase(); - const data = window.fieldDataStore[fieldId]; + function formatTimestamp(timestamp) { + if (!timestamp) return ''; + const date = new Date(timestamp * 1000); + return date.toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit' + }); + } - if (!data) { - return; - } + function formatActivityContent(text) { + if (!text) return ''; - try { - // Wait for the custom element to be defined/upgraded - if (customElements.get(tagName) === undefined) { - await customElements.whenDefined(tagName); - } + let formatted = escapeHtml(text); - // Set properties directly on the component - // IMPORTANT: Always set options first (even as empty array) before value - // This prevents _filterOptions from failing when value triggers willUpdate - component.options = data.options && data.options.length > 0 ? data.options : []; - - if (data.value !== null && data.value !== undefined) { - // For multi-select, ensure value is an array of valid strings - if (tagName === 'dt-multi-select' && Array.isArray(data.value)) { - const cleanValue = data.value - .filter(v => v !== null && v !== undefined && v !== '') - .map(v => String(v)); - component.value = cleanValue; - } else { - component.value = data.value; - } - } - } catch (err) { - console.error(`Error initializing component:`, err); - } - }); - }); -} + formatted = formatted.replace( + /@\[([^\]]+)\]\((\d+)\)/g, + '@$1' + ); -// Toggle edit mode for a field -function toggleEditMode(fieldKey) { - const section = document.querySelector(`.detail-section[data-field-key="${fieldKey}"]`); - if (!section) return; + const urlRegex = /(https?:\/\/[^\s<]+)/g; + formatted = formatted.replace( + urlRegex, + '$1' + ); - const isEditing = section.classList.contains('editing'); + return formatted; + } - // Close any other open edit modes - document.querySelectorAll('.detail-section.editing').forEach(el => { - if (el !== section) { - el.classList.remove('editing'); + function showSuccessToast(message = 'Success!') { + const toast = document.getElementById('success-toast'); + if (toast) { + toast.textContent = message; + toast.classList.add('show'); + setTimeout(() => { + toast.classList.remove('show'); + }, 3000); + } } - }); - - if (isEditing) { - section.classList.remove('editing'); - } else { - section.classList.add('editing'); - // Initialize change listener for the component - initFieldChangeListener(section); - } -} -// Close edit mode when clicking outside -document.addEventListener('click', function(e) { - // Don't close if clicking inside an editing section or its components - const editingSection = document.querySelector('.detail-section.editing'); - if (!editingSection) return; + // Field rendering helpers + function renderFieldIcon(field) { + if (field.icon && !field.icon.includes('undefined')) { + return ``; + } + if (field.font_icon && !field.font_icon.includes('undefined')) { + return ``; + } + return ''; + } - // Check if click is inside the editing section - if (editingSection.contains(e.target)) return; + function renderDTComponent(field, contactId) { + const fieldKey = escapeHtml(field.key); + const fieldId = `field-${contactId}-${field.key}`; + + // Store field data for later initialization + const isArrayField = ['multi_select', 'tags', 'communication_channel', 'connection', 'location', 'location_meta', 'link'].includes(field.type); + const defaultValue = isArrayField ? [] : ''; + const valueForStore = (field.raw_value !== null && field.raw_value !== undefined) ? field.raw_value : defaultValue; + + const validOptions = (field.options || []).filter(opt => + opt && opt.id !== null && opt.id !== undefined && opt.id !== '' && + opt.label !== null && opt.label !== undefined && opt.label !== '' + ).map(opt => ({ + id: String(opt.id), + label: String(opt.label), + color: opt.color || null, + icon: opt.icon || null + })); + + storeFieldData(fieldId, valueForStore, validOptions, field.type); + + switch (field.type) { + case 'text': + return ``; + case 'textarea': + return ``; + case 'number': + return ``; + case 'boolean': + return ``; + case 'date': + return ``; + case 'key_select': + return ``; + case 'multi_select': + return ``; + case 'communication_channel': + return ``; + case 'tags': + return ``; + case 'connection': + return ``; + case 'location': + case 'location_meta': + return ``; + case 'user_select': + return `Not editable in this view`; + default: + return ``; + } + } - // Check if click is inside a dropdown/option list (these can be outside the section) - if (e.target.closest('.option-list, ul[class*="option"], li[tabindex]')) return; + // Expose methods to template + return { + // State + contacts, + filteredContacts, + selectedContactId, + selectedContact, + searchTerm, + loading, + detailsLoading, + contactsCount, + commentText, + commentSubmitting, + mentionUsers, + mentionActiveIndex, + showMentionDropdown, + isMobileDetailsVisible, + + // Methods + loadContacts, + selectContact, + toggleActivityGroup, + submitComment, + selectMention, + hideMentionDropdown, + toggleEditMode, + hideMobileDetails, + + // Helpers + escapeHtml, + escapeAttr, + formatTimestamp, + formatActivityContent, + renderFieldIcon, + renderDTComponent + }; + }, + + template: ` +
+ +
+
+ My Contacts ({{ contactsCount }}) + +
+
+
+
+

Loading contacts...

+
+
+

No contacts found

+
+ +
+
- // Close the editing section - editingSection.classList.remove('editing'); -}); + +
+
+ + Contact Details + {{ selectedContact?.name || '' }} +
+
+ +
+
+
-// Initialize change listener for a field's DT component -function initFieldChangeListener(section) { - const editMode = section.querySelector('.edit-mode'); - const component = editMode.querySelector('dt-text, dt-textarea, dt-number, dt-toggle, dt-date, dt-single-select, dt-multi-select, dt-multi-text, dt-tags, dt-connection, dt-location'); + +
+

Error: {{ selectedContact.error }}

+
- if (!component || component.hasAttribute('data-listener-added')) return; + +
+
👤
+

Select a contact to view details

+
- component.setAttribute('data-listener-added', 'true'); + +
+
+

{{ selectedContact.name }}

+ + + +

No contact information available

+ + +
+
Record Info
+
+
Created
+
{{ selectedContact.created }}
+
+
+
Last Modified
+
{{ selectedContact.last_modified }}
+
+
+
- const fieldType = section.dataset.fieldType; +
+

Comments & Activity

+
+
+
+
+ {{ user.name }} +
+
+ +
+
+ +
+
+
+ +

No activity yet

+
+
+
+
+
+
- component.addEventListener('change', async (e) => { - const fieldKey = section.dataset.fieldKey; - const contactId = section.dataset.contactId; - const rawValue = e.detail?.newValue ?? e.detail?.value ?? component.value; + +
+ Comment added successfully! +
+ ` +}); - // Use ComponentService.convertValue to format the value for the DT API - let newValue = rawValue; - if (window.DtWebComponents?.ComponentService?.convertValue) { - newValue = window.DtWebComponents.ComponentService.convertValue( - component.tagName, - rawValue - ); +// Mount when DOM is ready +document.addEventListener('DOMContentLoaded', function() { + // Replace the static HTML with Vue mount point + const container = document.querySelector('.my-contacts-container'); + if (container) { + // Clear the container and let Vue render + const parent = container.parentNode; + const vueRoot = document.createElement('div'); + vueRoot.id = 'my-contacts-app'; + parent.replaceChild(vueRoot, container); + + // Also move the toast outside the vue container + const toast = document.getElementById('success-toast'); + if (toast) { + parent.appendChild(toast); } - await saveFieldValue(contactId, fieldKey, fieldType, newValue, section); - }); -} - -// Field types that allow multiple values - don't auto-close after saving -const multiValueFieldTypes = ['multi_select', 'connection', 'tags', 'location', 'location_meta', 'communication_channel']; - -// Save field value to the server -async function saveFieldValue(contactId, fieldKey, fieldType, value, section) { - section.classList.add('saving'); - - try { - const response = await fetch( - `${myContactsApp.root}${myContactsApp.parts.root}/v1/${myContactsApp.parts.type}/update-field`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': myContactsApp.nonce - }, - body: JSON.stringify({ - contact_id: parseInt(contactId), - field_key: fieldKey, - field_value: value, - parts: myContactsApp.parts - }) - } - ); - - const result = await response.json(); - - if (result.success) { - // Update the view mode value - const viewMode = section.querySelector('.view-mode'); - const displayValue = result.value || '-'; - viewMode.textContent = displayValue; - viewMode.classList.toggle('empty-value', !result.value); - - // Only auto-close for single-value fields, not multi-value fields - if (!multiValueFieldTypes.includes(fieldType)) { - section.classList.remove('editing'); - } - showSuccessToast('Field updated'); - } else { - const errorMsg = result.message || 'Failed to update field'; - alert(errorMsg); - } - } catch (error) { - console.error('Error saving field:', error); - alert('Error saving field'); - } finally { - section.classList.remove('saving'); - } -} - -// Escape HTML attribute -function escapeAttr(text) { - if (text === null || text === undefined) return ''; - return String(text).replace(/"/g, '"').replace(/'/g, ''').replace(//g, '>'); -} - -// Render field icon (image URL or font-icon class) -function renderFieldIcon(field) { - // Check for image icon first (URL) - if (field.icon && !field.icon.includes('undefined')) { - return ``; + MyContactsApp.mount('#my-contacts-app'); } - // Check for font icon (CSS class like mdi mdi-account) - if (field.font_icon && !field.font_icon.includes('undefined')) { - return ``; - } - return ''; -} - -// Utility: escape HTML -function escapeHtml(text) { - if (!text) return ''; - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; -} - -// Format timestamp in browser's timezone -function formatTimestamp(timestamp) { - if (!timestamp) return ''; - const date = new Date(timestamp * 1000); - return date.toLocaleString(undefined, { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit' - }); -} - -// Success toast -function showSuccessToast(message = 'Success!') { - const toast = document.getElementById('success-toast'); - toast.textContent = message; - toast.classList.add('show'); - setTimeout(() => { - toast.classList.remove('show'); - }, 3000); -} +}); diff --git a/magic-link/my-contacts/my-contacts.php b/magic-link/my-contacts/my-contacts.php index 5c3293a..2c9fe94 100644 --- a/magic-link/my-contacts/my-contacts.php +++ b/magic-link/my-contacts/my-contacts.php @@ -76,6 +76,7 @@ public function dt_settings_apps_list( $apps_list ) { public function dt_magic_url_base_allowed_js( $allowed_js ) { $allowed_js = []; + $allowed_js[] = 'vue-js'; $allowed_js[] = 'dt-web-components'; $allowed_js[] = 'my-contacts-js'; return $allowed_js; @@ -118,11 +119,20 @@ public function wp_enqueue_scripts() { ); } + // Enqueue Vue.js from CDN + wp_enqueue_script( + 'vue-js', + 'https://unpkg.com/vue@3/dist/vue.global.prod.js', + [], + '3', + true + ); + // Enqueue magic link JS wp_enqueue_script( 'my-contacts-js', trailingslashit( plugin_dir_url( __FILE__ ) ) . 'my-contacts.js', - [], + [ 'vue-js' ], filemtime( plugin_dir_path( __FILE__ ) . 'my-contacts.js' ), true ); From 2ecd35b4a40b719b1b8c70098615613fcbbaf8e2 Mon Sep 17 00:00:00 2001 From: corsacca Date: Fri, 5 Dec 2025 12:42:52 +0100 Subject: [PATCH 09/18] Use theme's render functions --- magic-link/my-contacts/my-contacts.js | 102 +-------------- magic-link/my-contacts/my-contacts.php | 172 +++++++++---------------- 2 files changed, 65 insertions(+), 209 deletions(-) diff --git a/magic-link/my-contacts/my-contacts.js b/magic-link/my-contacts/my-contacts.js index 2ffe83f..ec04691 100644 --- a/magic-link/my-contacts/my-contacts.js +++ b/magic-link/my-contacts/my-contacts.js @@ -26,9 +26,6 @@ const MyContactsApp = createApp({ // Mobile state const isMobileDetailsVisible = ref(false); - // Field data store for DT components - const fieldDataStore = ref({}); - // Load contacts on mount onMounted(() => { loadContacts(); @@ -106,7 +103,6 @@ const MyContactsApp = createApp({ selectedContact.value = contact; await nextTick(); - initializeDTComponents(); initMentionListeners(); } catch (error) { console.error('Error loading contact details:', error); @@ -368,47 +364,6 @@ const MyContactsApp = createApp({ } } - // Initialize DT components - function initializeDTComponents() { - requestAnimationFrame(() => { - const components = document.querySelectorAll('[data-field-id]'); - - components.forEach(async (component) => { - const fieldId = component.dataset.fieldId; - const tagName = component.tagName.toLowerCase(); - const data = fieldDataStore.value[fieldId]; - - if (!data) return; - - try { - if (customElements.get(tagName) === undefined) { - await customElements.whenDefined(tagName); - } - - component.options = data.options && data.options.length > 0 ? data.options : []; - - if (data.value !== null && data.value !== undefined) { - if (tagName === 'dt-multi-select' && Array.isArray(data.value)) { - const cleanValue = data.value - .filter(v => v !== null && v !== undefined && v !== '') - .map(v => String(v)); - component.value = cleanValue; - } else { - component.value = data.value; - } - } - } catch (err) { - console.error('Error initializing component:', err); - } - }); - }); - } - - // Store field data for DT component initialization - function storeFieldData(fieldId, value, options, type) { - fieldDataStore.value[fieldId] = { value, options, type }; - } - // Global event listeners function setupGlobalEventListeners() { // Handle dt:get-data events for typeahead @@ -528,58 +483,6 @@ const MyContactsApp = createApp({ return ''; } - function renderDTComponent(field, contactId) { - const fieldKey = escapeHtml(field.key); - const fieldId = `field-${contactId}-${field.key}`; - - // Store field data for later initialization - const isArrayField = ['multi_select', 'tags', 'communication_channel', 'connection', 'location', 'location_meta', 'link'].includes(field.type); - const defaultValue = isArrayField ? [] : ''; - const valueForStore = (field.raw_value !== null && field.raw_value !== undefined) ? field.raw_value : defaultValue; - - const validOptions = (field.options || []).filter(opt => - opt && opt.id !== null && opt.id !== undefined && opt.id !== '' && - opt.label !== null && opt.label !== undefined && opt.label !== '' - ).map(opt => ({ - id: String(opt.id), - label: String(opt.label), - color: opt.color || null, - icon: opt.icon || null - })); - - storeFieldData(fieldId, valueForStore, validOptions, field.type); - - switch (field.type) { - case 'text': - return ``; - case 'textarea': - return ``; - case 'number': - return ``; - case 'boolean': - return ``; - case 'date': - return ``; - case 'key_select': - return ``; - case 'multi_select': - return ``; - case 'communication_channel': - return ``; - case 'tags': - return ``; - case 'connection': - return ``; - case 'location': - case 'location_meta': - return ``; - case 'user_select': - return `Not editable in this view`; - default: - return ``; - } - } - // Expose methods to template return { // State @@ -613,8 +516,7 @@ const MyContactsApp = createApp({ escapeAttr, formatTimestamp, formatActivityContent, - renderFieldIcon, - renderDTComponent + renderFieldIcon }; }, @@ -703,7 +605,7 @@ const MyContactsApp = createApp({
{{ field.value || '-' }}
-
+
diff --git a/magic-link/my-contacts/my-contacts.php b/magic-link/my-contacts/my-contacts.php index 2c9fe94..c81c1bc 100644 --- a/magic-link/my-contacts/my-contacts.php +++ b/magic-link/my-contacts/my-contacts.php @@ -408,52 +408,20 @@ public function get_contact_details( WP_REST_Request $request ) { $field_order = $field_setting['in_create_form'] ?? 100; $field_type = $field_setting['type'] ?? 'text'; - // Prepare raw value for editing - $raw_value = $this->prepare_raw_value( $value, $field_type ); - - // Prepare options for select fields - $options = []; - if ( in_array( $field_type, [ 'key_select', 'multi_select' ] ) && isset( $field_setting['default'] ) ) { - foreach ( $field_setting['default'] as $option_key => $option_data ) { - // Skip deleted or hidden options - if ( ! empty( $option_data['deleted'] ) || ! empty( $option_data['hidden'] ) ) { - continue; - } - // Ensure we have a valid key - if ( $option_key === '' || $option_key === null ) { - continue; - } - $label = $option_data['label'] ?? (string) $option_key; - // Ensure label is never empty - if ( empty( $label ) ) { - $label = (string) $option_key; - } - $options[] = [ - 'id' => (string) $option_key, - 'label' => $label, - 'color' => $option_data['color'] ?? null, - 'icon' => $option_data['icon'] ?? null, - ]; - } - } + // Render the component HTML using DT_Components + $component_html = $this->render_field_component( $field_key, $field_settings, $contact, $field_type ); $field_data = [ - 'key' => $field_key, - 'label' => $field_setting['name'] ?? $field_key, - 'value' => $formatted_value, - 'raw_value' => $raw_value, - 'type' => $field_type, - 'options' => $options, - 'icon' => $field_setting['icon'] ?? '', - 'font_icon' => $field_setting['font-icon'] ?? '', - 'order' => is_numeric( $field_order ) ? intval( $field_order ) : 100, + 'key' => $field_key, + 'label' => $field_setting['name'] ?? $field_key, + 'value' => $formatted_value, + 'type' => $field_type, + 'icon' => $field_setting['icon'] ?? '', + 'font_icon' => $field_setting['font-icon'] ?? '', + 'order' => is_numeric( $field_order ) ? intval( $field_order ) : 100, + 'component_html' => $component_html, ]; - // Add post_type for connection fields - if ( $field_type === 'connection' && isset( $field_setting['post_type'] ) ) { - $field_data['post_type'] = $field_setting['post_type']; - } - $tiles_with_fields[ $tile_key ]['fields'][] = $field_data; } @@ -708,90 +676,76 @@ private function format_field_value( $value, $field_setting ) { } /** - * Prepare raw value for editing (JSON-safe format for DT components) + * Render field component HTML using DT_Components */ - private function prepare_raw_value( $value, $field_type ) { - if ( $value === null || $value === '' ) { - return null; - } + private function render_field_component( $field_key, $fields, $post, $field_type ) { + ob_start(); switch ( $field_type ) { case 'text': + DT_Components::render_text( $field_key, $fields, $post ); + break; case 'textarea': + DT_Components::render_textarea( $field_key, $fields, $post ); + break; case 'number': - return is_string( $value ) || is_numeric( $value ) ? $value : ''; - - case 'boolean': - return (bool) $value; - + DT_Components::render_number( $field_key, $fields, $post ); + break; + case 'date': + DT_Components::render_date( $field_key, $fields, $post ); + break; + case 'datetime': + DT_Components::render_datetime( $field_key, $fields, $post ); + break; case 'key_select': - return $value['key'] ?? ''; - + DT_Components::render_key_select( $field_key, $fields, $post ); + break; case 'multi_select': + DT_Components::render_multi_select( $field_key, $fields, $post ); + break; case 'tags': - // Value should be an array of string IDs for the component - if ( is_array( $value ) ) { - $result = []; - foreach ( $value as $item ) { - if ( is_string( $item ) ) { - $result[] = $item; - } elseif ( is_array( $item ) && isset( $item['value'] ) ) { - $result[] = $item['value']; - } elseif ( is_array( $item ) && isset( $item['key'] ) ) { - $result[] = $item['key']; - } - } - return $result; - } - return []; - - case 'communication_channel': - if ( is_array( $value ) ) { - return array_values( $value ); - } - return []; - + DT_Components::render_tags( $field_key, $fields, $post ); + break; case 'connection': - if ( is_array( $value ) ) { - return array_map( function( $item ) { + DT_Components::render_connection( $field_key, $fields, $post ); + break; + case 'location': + // Ensure location values have required id/label format + if ( isset( $post[ $field_key ] ) && is_array( $post[ $field_key ] ) ) { + $post[ $field_key ] = array_filter( array_map( function( $loc ) { + if ( ! is_array( $loc ) ) { + return null; + } return [ - 'id' => (int) ( $item['ID'] ?? 0 ), - 'label' => $item['post_title'] ?? '', - 'link' => $item['permalink'] ?? '', - 'status' => $item['status'] ?? null, + 'id' => $loc['grid_id'] ?? $loc['id'] ?? $loc['ID'] ?? '', + 'label' => $loc['label'] ?? $loc['name'] ?? '', ]; - }, $value ); + }, $post[ $field_key ] ), function( $loc ) { + return $loc !== null && ! empty( $loc['id'] ); + }); } - return []; - - case 'location': + DT_Components::render_location( $field_key, $fields, $post ); + break; case 'location_meta': - if ( is_array( $value ) ) { - return array_values( $value ); - } - return []; - + DT_Components::render_location_meta( $field_key, $fields, $post ); + break; + case 'communication_channel': + DT_Components::render_communication_channel( $field_key, $fields, $post ); + break; + case 'boolean': + ?> + > + Not editable in this view'; + break; default: - return $value; + DT_Components::render_text( $field_key, $fields, $post ); + break; } + + return ob_get_clean(); } /** From 29ee9ec2c59cf3e280c568ad0a27751abdc3df59 Mon Sep 17 00:00:00 2001 From: corsacca Date: Mon, 8 Dec 2025 14:52:39 +0100 Subject: [PATCH 10/18] Update edit icon and record name --- magic-link/my-contacts/my-contacts.css | 15 ++++++++++----- magic-link/my-contacts/my-contacts.js | 8 +++----- magic-link/my-contacts/my-contacts.php | 2 +- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/magic-link/my-contacts/my-contacts.css b/magic-link/my-contacts/my-contacts.css index 8df804c..6ea192e 100644 --- a/magic-link/my-contacts/my-contacts.css +++ b/magic-link/my-contacts/my-contacts.css @@ -53,6 +53,12 @@ body { font-size: 14px; } +.panel-header .header-contact-name { + color: var(--primary-color); + font-weight: 600; + font-size: 16px; +} + .panel-content { flex: 1; overflow-y: auto; @@ -191,7 +197,6 @@ body { .field-icon { color: #aaa; - vertical-align: middle; opacity: 0.7; } @@ -206,6 +211,7 @@ i.field-icon { font-size: 12px; width: 12px; text-align: center; + color: black; } .detail-value { @@ -225,14 +231,13 @@ i.field-icon { /* Edit mode */ .edit-icon { cursor: pointer; - opacity: 0.4; margin-left: 6px; - font-size: 12px; - transition: opacity 0.2s ease; + color: #666; + font-size: 14px; + transition: color 0.2s ease; } .edit-icon:hover { - opacity: 1; color: var(--primary-color); } diff --git a/magic-link/my-contacts/my-contacts.js b/magic-link/my-contacts/my-contacts.js index ec04691..0ee9f50 100644 --- a/magic-link/my-contacts/my-contacts.js +++ b/magic-link/my-contacts/my-contacts.js @@ -564,7 +564,7 @@ const MyContactsApp = createApp({
- Contact Details + {{ selectedContact?.name || 'Contact Details' }} {{ selectedContact?.name || '' }}
@@ -587,8 +587,6 @@ const MyContactsApp = createApp({
-

{{ selectedContact.name }}

-