diff --git a/.distignore b/.distignore index ce04715..fbfdb1b 100644 --- a/.distignore +++ b/.distignore @@ -5,7 +5,9 @@ /docs /tests /node_modules -/vendor/bin +# Every dependency is dev-only — the plugin autoloads its own classes and +# requires nothing at runtime, so none of this belongs in a release. +/vendor /build # Generated by the CI checks that run immediately before the build is staged. .phpunit.result.cache diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ffa9cb..0ac10db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ All notable changes to Procore Connect are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.0.6] — 2026-08-09 + +### Fixed + +- **A column named in `columns=` is no longer dropped.** 2.0.5 applied its + empty-column filter to every table, including one where the page author had listed + the columns by hand. `[procore_team columns="name,email_address"]` rendered only + Name — no header, no cell, no explanation. The filter now applies only to the + default column set, which is the set nobody chose; an explicit list is rendered + verbatim, empty or not. + +- **A column no record populates is now actually dropped.** 2.0.5 claimed to do this + but could not: a missing value renders as a placeholder dash, not as an empty + string, so the length test it used only ever matched a suppressed email address. + Every other empty column survived as a full column of dashes. Emptiness is now + judged by meaning rather than by length, so `[procore_rfis]` against a project + where no RFI has a due date drops the Due Date column as documented. + +- **A column's visibility no longer depends on its neighbours.** Because the + never-render-an-empty-table fallback restored the whole list, `columns="email_address"` + kept the Email column while `columns="name,email_address"` dropped it — identical + data, opposite outcome. Adding a column to a working shortcode could silently remove + a different one. + +Found by reviewing the 2.0.5 diff and checking each claim in its own commit message +against what the code does. + ## [2.0.5] — 2026-08-08 ### Fixed @@ -12,8 +39,9 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **`[procore_team]` rendered an empty Email column.** Email suppression is on by default, so the Email header appeared above a blank cell for every team member — visible dead weight on the most commonly used shortcode, on every default install. - Columns that render nothing for any row are now dropped. This also covers a column - no record happens to populate, and a table is never reduced to no columns at all. + The Email column is now dropped when it is blank for every row, and a table is never + reduced to no columns at all. (This entry originally also claimed to drop any column + no record populates. It did not — see 2.0.6, which makes that true.) Found by standing up a real WordPress site, serving it over HTTP and reading what a visitor actually gets, rather than by asserting on strings. @@ -244,6 +272,7 @@ every install was non-functional regardless of configuration. - Initial release. +[2.0.6]: https://github.com/ibuilder/ProcoreWP/releases/tag/v2.0.6 [2.0.5]: https://github.com/ibuilder/ProcoreWP/releases/tag/v2.0.5 [2.0.4]: https://github.com/ibuilder/ProcoreWP/releases/tag/v2.0.4 [2.0.3]: https://github.com/ibuilder/ProcoreWP/releases/tag/v2.0.3 diff --git a/docs/shortcodes.md b/docs/shortcodes.md index c451715..356608c 100644 --- a/docs/shortcodes.md +++ b/docs/shortcodes.md @@ -26,7 +26,7 @@ Accepted by every shortcode. | `page` | `1` | Which page of results to request | | `orderby` | — | Any field path, e.g. `name` or `vendor.name` | | `order` | `asc` | `asc` or `desc` | -| `columns` | Per shortcode | Comma-separated field paths | +| `columns` | Per shortcode | Comma-separated field paths; see [Empty columns](#empty-columns) | | `template` | Per shortcode | Template name, overridable in your theme | | `class` | — | Extra CSS classes, sanitized | | `title` | Per shortcode | Heading text; pass `-` to omit the heading | @@ -37,6 +37,24 @@ Accepted by every shortcode. Records missing the `orderby` field always sort last, in both directions. +### Empty columns + +A default column that no record fills in is not rendered. Procore projects vary in +which tools they use and which fields they populate, so a default column set that +suits one project can produce a column of dashes on another. Rather than show a +header above nothing, the column is left out. + +This applies only to the default columns — the ones you did not choose. If you pass +`columns=` the list is rendered exactly as written, empty or not, because you asked +for it: + +``` +[procore_rfis id="123"] Due Date drops if no RFI has one +[procore_rfis id="123" columns="number,due_date"] Due Date always renders +``` + +A table is never reduced to no columns at all. + --- ## Projects diff --git a/procore-connect.php b/procore-connect.php index 8b8f6cd..50773ee 100644 --- a/procore-connect.php +++ b/procore-connect.php @@ -3,7 +3,7 @@ * Plugin Name: Procore Connect * Plugin URI: https://github.com/ibuilder/ProcoreWP * Description: Connect WordPress to the Procore construction management platform. Display projects, teams, drawings, RFIs and more with shortcodes, blocks and a cached REST proxy. - * Version: 2.0.5 + * Version: 2.0.6 * Requires at least: 6.5 * Requires PHP: 7.4 * Author: ibuilder @@ -22,7 +22,7 @@ defined( 'ABSPATH' ) || exit; -const VERSION = '2.0.5'; +const VERSION = '2.0.6'; define( 'PROCORE_CONNECT_VERSION', VERSION ); define( 'PROCORE_CONNECT_FILE', __FILE__ ); diff --git a/readme.txt b/readme.txt index 6268a23..9d8960f 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: procore, construction, project management, shortcode, api Requires at least: 6.5 Tested up to: 7.0 Requires PHP: 7.4 -Stable tag: 2.0.5 +Stable tag: 2.0.6 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -127,9 +127,15 @@ Yes. Caching goes through the transient API, so a persistent object cache such a == Changelog == += 2.0.6 = + +* Fixed: a column you named in `columns=` could be dropped. 2.0.5 applied its empty-column filter to hand-written column lists too, so `[procore_team columns="name,email_address"]` rendered only Name. An explicit list is now rendered exactly as written; only the default column set is trimmed. +* Fixed: a column no record fills in is now genuinely hidden. 2.0.5 said it did this but could not — a missing value renders as a dash rather than as nothing, so only a suppressed email address was ever hidden and every other empty column showed as a full column of dashes. +* Fixed: whether a column appeared no longer depends on the other columns. `columns="email_address"` kept the Email column while `columns="name,email_address"` dropped it. + = 2.0.5 = -* Fixed: `[procore_team]` showed an empty Email column. Email output is suppressed by default, so the column appeared with a header and blank cells on every default install. Columns with no value in any row are now hidden. +* Fixed: `[procore_team]` showed an empty Email column. Email output is suppressed by default, so the column appeared with a header and blank cells on every default install. The Email column is now hidden when it is blank for every row. (This entry also claimed to hide any column with no value in any row; that did not work until 2.0.6.) = 2.0.4 = @@ -187,6 +193,9 @@ A complete rewrite. See the upgrade notice below before updating. == Upgrade Notice == += 2.0.6 = +Corrects the 2.0.5 column-hiding fix, which dropped columns you had asked for by name and did not drop the empty ones it claimed to. Recommended for anyone on 2.0.5 who sets `columns=` on a shortcode. + = 2.0.5 = Cosmetic fix: the team table no longer shows an empty Email column when email output is suppressed, which is the default. diff --git a/src/Frontend/Shortcodes/CollectionShortcode.php b/src/Frontend/Shortcodes/CollectionShortcode.php index c2b8135..d0a1c92 100644 --- a/src/Frontend/Shortcodes/CollectionShortcode.php +++ b/src/Frontend/Shortcodes/CollectionShortcode.php @@ -54,7 +54,7 @@ protected function output( $data, array $atts ): string { $this->template( $atts ), array( 'rows' => $rows, - 'columns' => $this->drop_empty_columns( $this->resolve_columns( $atts ), $rows, ! empty( $atts['show_email'] ) ), + 'columns' => $this->visible_columns( $atts, $rows ), 'title' => $this->title( $atts ), 'class' => Format::classes( 'procore-connect procore-connect-collection procore-connect-' . str_replace( '_', '-', $this->endpoint ), (string) $atts['class'] ), 'show_email' => ! empty( $atts['show_email'] ), @@ -65,15 +65,38 @@ protected function output( $data, array $atts ): string { } /** - * Remove columns that render nothing for every row. + * The columns this render should actually show. + * + * When the author named the columns, that list is used verbatim: they asked + * for a column, they get it, even if every row is blank. Only the default + * column set — which the author never saw and did not choose — is trimmed + * to what the data supports. + * + * @param array $atts Sanitized attributes. + * @param array $rows Records being rendered. + * @return array> Column definitions to render. + */ + protected function visible_columns( array $atts, array $rows ): array { + $columns = $this->resolve_columns( $atts ); + + if ( '' !== (string) $atts['columns'] || '' !== (string) $atts['fields'] ) { + return $columns; + } + + return $this->drop_empty_columns( $columns, $rows, ! empty( $atts['show_email'] ) ); + } + + /** + * Remove columns that carry no information for any row. * * The visible case is `[procore_team]`: email suppression is on by default, * so the Email column produced a header and a blank cell for every member — - * dead weight on the most commonly used shortcode. This also covers a - * column no record happens to populate. - * - * A column an author asked for explicitly is always kept, so `columns=""` - * still means what it says. + * dead weight on the most commonly used shortcode. It also covers a column + * no record populates, such as a due date nobody has set. Those two look + * different in the markup — a suppressed email renders as nothing, an + * absent date renders as a placeholder dash — so emptiness is judged by + * {@see Format::is_blank()} rather than by string length, which would only + * ever catch the first. * * @param array> $columns Column definitions. * @param array $rows Records being rendered. @@ -87,7 +110,7 @@ protected function drop_empty_columns( array $columns, array $rows, bool $show_e $has_value = false; foreach ( $rows as $row ) { - if ( '' !== trim( Format::cell( $row, $column, $show_email ) ) ) { + if ( ! Format::is_blank( Format::cell( $row, $column, $show_email ) ) ) { $has_value = true; break; } diff --git a/src/Support/Format.php b/src/Support/Format.php index 7fd0519..0051241 100644 --- a/src/Support/Format.php +++ b/src/Support/Format.php @@ -18,6 +18,34 @@ */ final class Format { + /** + * Stands in for a value Procore did not supply. + * + * An em dash reads as "no value here" in a table without leaving the cell + * looking broken. Because it is what an absent value renders as, anything + * deciding whether a cell carries information has to recognise it — see + * `is_blank()`. + * + * @var string + */ + public const PLACEHOLDER = '—'; + + /** + * Whether a rendered cell carries no information. + * + * A cell is blank when it is empty or when it is nothing but the + * placeholder. Both mean Procore returned no value; the difference is only + * whether the column's format prints a dash for it. + * + * @param string $cell Output of `cell()`. + * @return bool True when the cell conveys nothing. + */ + public static function is_blank( string $cell ): bool { + $trimmed = trim( $cell ); + + return '' === $trimmed || self::PLACEHOLDER === $trimmed; + } + /** * Format an ISO-8601 or `Y-m-d` date using the configured format. * @@ -139,21 +167,21 @@ public static function cell( $record, array $column, bool $show_email = false ): switch ( $format ) { case 'date': - return esc_html( self::date( $raw, '—' ) ); + return esc_html( self::date( $raw, self::PLACEHOLDER ) ); case 'currency': - return esc_html( self::currency( $raw, '—' ) ); + return esc_html( self::currency( $raw, self::PLACEHOLDER ) ); case 'percent': return is_numeric( $raw ) ? esc_html( number_format_i18n( (float) $raw, 0 ) . '%' ) - : '—'; + : self::PLACEHOLDER; case 'status': $active = null === $raw ? null : (bool) $raw; if ( null === $active ) { - return '—'; + return self::PLACEHOLDER; } return sprintf( @@ -179,7 +207,7 @@ public static function cell( $record, array $column, bool $show_email = false ): $url = esc_url_raw( Arr::stringify( $raw ), array( 'http', 'https' ) ); if ( '' === $url ) { - return '—'; + return self::PLACEHOLDER; } $host = wp_parse_url( $url, PHP_URL_HOST ); @@ -193,7 +221,7 @@ public static function cell( $record, array $column, bool $show_email = false ): default: $value = Arr::stringify( $raw ); - return '' === $value ? '—' : esc_html( $value ); + return '' === $value ? self::PLACEHOLDER : esc_html( $value ); }//end switch } diff --git a/tests/unit/ShortcodeTest.php b/tests/unit/ShortcodeTest.php index 72b0d73..862320d 100644 --- a/tests/unit/ShortcodeTest.php +++ b/tests/unit/ShortcodeTest.php @@ -11,6 +11,7 @@ use ProcoreConnect\Admin\Settings; use ProcoreConnect\Frontend\Shortcodes\Registrar; +use ProcoreConnect\Support\Format; /** * Renders each shortcode against canned API payloads to confirm attribute @@ -363,6 +364,122 @@ public function test_keeps_columns_that_carry_a_value(): void { $this->assertStringContainsString( 'dana@example.com', $html ); } + /** + * A column the author named must be rendered even when it is empty. + * + * The empty-column filter exists to tidy a default column set nobody chose. + * An explicit `columns=` is a request, and silently dropping part of it + * leaves the author with no header, no cell and no explanation. + * + * @return void + */ + public function test_keeps_an_empty_column_the_author_asked_for(): void { + $this->client_returning( + array( + $this->response( + array( + array( + 'id' => 3, + 'name' => 'Dana Reed', + 'job_title' => 'PM', + 'email_address' => 'dana@example.com', + ), + ) + ), + ) + ); + + // Email suppression is on, so this column renders blank for every row. + $html = $this->shortcode( 'procore_team' )->render( + array( + 'project_id' => '5', + 'company_id' => '9', + 'columns' => 'name,email_address', + ) + ); + + $this->assertStringContainsString( '>Email<', $html ); + $this->assertStringNotContainsString( 'dana@example.com', $html ); + } + + /** + * A column no record populates must drop, not render a wall of dashes. + * + * A missing value renders as a placeholder dash rather than as nothing, so + * a filter that tested for an empty string would keep the column and only + * ever be able to drop a suppressed email address. + * + * @return void + */ + public function test_drops_a_default_column_no_record_populates(): void { + $this->client_returning( + array( + $this->response( + array( + array( + 'id' => 1, + 'number' => 'RFI-001', + 'subject' => 'Slab depth', + 'status' => 'open', + ), + ) + ), + ) + ); + + $html = $this->shortcode( 'procore_rfis' )->render( + array( + 'project_id' => '5', + 'company_id' => '9', + ) + ); + + $this->assertStringContainsString( 'RFI-001', $html ); + $this->assertStringNotContainsString( '>Due<', $html ); + $this->assertStringNotContainsString( Format::PLACEHOLDER, $html ); + } + + /** + * The same column must survive or drop regardless of its neighbours. + * + * Requesting one empty column used to keep it, because the never-empty + * fallback restored the whole list; adding a populated column alongside it + * dropped it again. Identical data, opposite outcome. + * + * @return void + */ + public function test_column_visibility_does_not_depend_on_other_columns(): void { + $member = array( + 'id' => 3, + 'name' => 'Dana Reed', + 'job_title' => 'PM', + 'email_address' => 'dana@example.com', + ); + + $this->client_returning( array( $this->response( array( $member ) ) ) ); + + $alone = $this->shortcode( 'procore_team' )->render( + array( + 'project_id' => '5', + 'company_id' => '9', + 'columns' => 'email_address', + ) + ); + + $this->client_returning( array( $this->response( array( $member ) ) ) ); + + $paired = $this->shortcode( 'procore_team' )->render( + array( + 'project_id' => '5', + 'company_id' => '9', + 'columns' => 'name,email_address', + ) + ); + + $this->assertStringContainsString( '>Email<', $alone ); + $this->assertStringContainsString( '>Email<', $paired ); + } + /** * A field outside the allow-list must be refused. *