Skip to content

Commit

Permalink
Visualize hierarchical data in DataViews (WordPress#66479)
Browse files Browse the repository at this point in the history
Co-authored-by: oandregal <oandregal@git.wordpress.org>
Co-authored-by: mcsf <mcsf@git.wordpress.org>
Co-authored-by: ntsekouras <ntsekouras@git.wordpress.org>
Co-authored-by: jameskoster <jameskoster@git.wordpress.org>
Co-authored-by: youknowriad <youknowriad@git.wordpress.org>
Co-authored-by: jasmussen <joen@git.wordpress.org>
Co-authored-by: jarekmorawski <jarekmorawski@git.wordpress.org>
Co-authored-by: ciampo <mciampini@git.wordpress.org>
  • Loading branch information
9 people authored Dec 18, 2024
1 parent 3159fa2 commit 2e3e6e4
Show file tree
Hide file tree
Showing 17 changed files with 478 additions and 6 deletions.
3 changes: 3 additions & 0 deletions backport-changelog/6.8/8014.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
https://github.com/WordPress/wordpress-develop/pull/8014

* https://github.com/WordPress/gutenberg/pull/66479
205 changes: 205 additions & 0 deletions lib/compat/wordpress-6.8/class-gutenberg-hierarchical-sort.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
<?php

/**
* Modifies the Post controller endpoint to support orderby_hierarchy.
*
* @package gutenberg
* @since 6.8.0
*/

class Gutenberg_Hierarchical_Sort {
private static $post_ids = array();
private static $levels = array();
private static $instance;

public static function get_instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}

return self::$instance;
}

public function run( $args ) {
$new_args = array_merge(
$args,
array(
'fields' => 'id=>parent',
'posts_per_page' => -1,
)
);
$query = new WP_Query( $new_args );
$posts = $query->posts;
$result = self::sort( $posts );

self::$post_ids = $result['post_ids'];
self::$levels = $result['levels'];
}

/**
* Check if the request is eligible for hierarchical sorting.
*
* @param array $request The request data.
*
* @return bool Return true if the request is eligible for hierarchical sorting.
*/
public static function is_eligible( $request ) {
if ( ! isset( $request['orderby_hierarchy'] ) || true !== $request['orderby_hierarchy'] ) {
return false;
}

return true;
}

public static function get_ancestor( $post_id ) {
return get_post( $post_id )->post_parent ?? 0;
}

/**
* Sort posts by hierarchy.
*
* Takes an array of posts and sorts them based on their parent-child relationships.
* It also tracks the level depth of each post in the hierarchy.
*
* Example input:
* ```
* [
* ['ID' => 4, 'post_parent' => 2],
* ['ID' => 2, 'post_parent' => 0],
* ['ID' => 3, 'post_parent' => 2],
* ]
* ```
*
* Example output:
* ```
* [
* 'post_ids' => [2, 4, 3],
* 'levels' => [0, 1, 1]
* ]
* ```
*
* @param array $posts Array of post objects containing ID and post_parent properties.
*
* @return array {
* Sorted post IDs and their hierarchical levels
*
* @type array $post_ids Array of post IDs
* @type array $levels Array of levels for the corresponding post ID in the same index
* }
*/
public static function sort( $posts ) {
/*
* Arrange pages in two arrays:
*
* - $top_level: posts whose parent is 0
* - $children: post ID as the key and an array of children post IDs as the value.
* Example: $children[10][] contains all sub-pages whose parent is 10.
*
* Additionally, keep track of the levels of each post in $levels.
* Example: $levels[10] = 0 means the post ID is a top-level page.
*
*/
$top_level = array();
$children = array();
foreach ( $posts as $post ) {
if ( empty( $post->post_parent ) ) {
$top_level[] = $post->ID;
} else {
$children[ $post->post_parent ][] = $post->ID;
}
}

$ids = array();
$levels = array();
self::add_hierarchical_ids( $ids, $levels, 0, $top_level, $children );

// Process remaining children.
if ( ! empty( $children ) ) {
foreach ( $children as $parent_id => $child_ids ) {
$level = 0;
$ancestor = $parent_id;
while ( 0 !== $ancestor ) {
++$level;
$ancestor = self::get_ancestor( $ancestor );
}
self::add_hierarchical_ids( $ids, $levels, $level, $child_ids, $children );
}
}

return array(
'post_ids' => $ids,
'levels' => $levels,
);
}

private static function add_hierarchical_ids( &$ids, &$levels, $level, $to_process, $children ) {
foreach ( $to_process as $id ) {
if ( in_array( $id, $ids, true ) ) {
continue;
}
$ids[] = $id;
$levels[ $id ] = $level;

if ( isset( $children[ $id ] ) ) {
self::add_hierarchical_ids( $ids, $levels, $level + 1, $children[ $id ], $children );
unset( $children[ $id ] );
}
}
}

public static function get_post_ids() {
return self::$post_ids;
}

public static function get_levels() {
return self::$levels;
}
}

add_filter(
'rest_page_collection_params',
function ( $params ) {
$params['orderby_hierarchy'] = array(
'description' => 'Sort pages by hierarchy.',
'type' => 'boolean',
'default' => false,
);
return $params;
}
);

add_filter(
'rest_page_query',
function ( $args, $request ) {
if ( ! Gutenberg_Hierarchical_Sort::is_eligible( $request ) ) {
return $args;
}

$hs = Gutenberg_Hierarchical_Sort::get_instance();
$hs->run( $args );

// Reconfigure the args to display only the ids in the list.
$args['post__in'] = $hs->get_post_ids();
$args['orderby'] = 'post__in';

return $args;
},
10,
2
);

add_filter(
'rest_prepare_page',
function ( $response, $post, $request ) {
if ( ! Gutenberg_Hierarchical_Sort::is_eligible( $request ) ) {
return $response;
}

$hs = Gutenberg_Hierarchical_Sort::get_instance();
$response->data['level'] = $hs->get_levels()[ $post->ID ];

return $response;
},
10,
3
);
1 change: 1 addition & 0 deletions lib/load.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ function gutenberg_is_experiment_enabled( $name ) {
require __DIR__ . '/compat/wordpress-6.8/block-comments.php';
require __DIR__ . '/compat/wordpress-6.8/class-gutenberg-rest-comment-controller-6-8.php';
require __DIR__ . '/compat/wordpress-6.8/class-gutenberg-rest-post-types-controller-6-8.php';
require __DIR__ . '/compat/wordpress-6.8/class-gutenberg-hierarchical-sort.php';
require __DIR__ . '/compat/wordpress-6.8/rest-api.php';

// Plugin specific code.
Expand Down
8 changes: 6 additions & 2 deletions packages/dataviews/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,13 @@

- Fixed commonjs export ([#67962](https://github.com/WordPress/gutenberg/pull/67962))

### Features

- Add support for hierarchical visualization of data. `DataViews` gets a new prop `getItemLevel` that should return the hierarchical level of the item. The view can use `view.showLevels` to display the levels. It's up to the consumer data source to prepare this information.

## 4.10.0 (2024-12-11)

## Breaking Changes
### Breaking Changes

- Support showing or hiding title, media and description fields ([#67477](https://github.com/WordPress/gutenberg/pull/67477)).
- Unify the `title`, `media` and `description` fields for the different layouts. So instead of the previous `view.layout.mediaField`, `view.layout.primaryField` and `view.layout.columnFields`, all the layouts now support these three fields with the following config ([#67477](https://github.com/WordPress/gutenberg/pull/67477)):
Expand All @@ -23,7 +27,7 @@ const view = {
};
```

## Internal
### Internal

- Upgraded `@ariakit/react` (v0.4.13) and `@ariakit/test` (v0.4.5) ([#65907](https://github.com/WordPress/gutenberg/pull/65907)).
- Upgraded `@ariakit/react` (v0.4.15) and `@ariakit/test` (v0.4.7) ([#67404](https://github.com/WordPress/gutenberg/pull/67404)).
Expand Down
14 changes: 14 additions & 0 deletions packages/dataviews/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,19 @@ Example:
}
```
#### `getItemLevel`: `function`

A function that receives an item and returns its hierarchical level. It's optional, but this property must be passed for DataViews to display the hierarchical levels of the data if `view.showLevels` is true.

Example:

```js
// Example implementation
{
getItemLevel={ ( item ) => item.level }
}
```
#### `fields`: `Object[]`
The fields describe the visible items for each record in the dataset and how they behave (how to sort them, display them, etc.). See "Fields API" for a description of every property.
Expand Down Expand Up @@ -193,6 +206,7 @@ Properties:
- `showTitle`: Whether the title should be shown in the UI. `true` by default.
- `showMedia`: Whether the media should be shown in the UI. `true` by default.
- `showDescription`: Whether the description should be shown in the UI. `true` by default.
- `showLevels`: Whether to display the hierarchical levels for the data. `false` by default. See related `getItemLevel` DataView prop.
- `fields`: a list of remaining field `id` that are visible in the UI and the specific order in which they are displayed.
- `layout`: config that is specific to a particular layout type.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type DataViewsContextType< Item > = {
openedFilter: string | null;
setOpenedFilter: ( openedFilter: string | null ) => void;
getItemId: ( item: Item ) => string;
getItemLevel?: ( item: Item ) => number;
onClickItem?: ( item: Item ) => void;
isItemClickable: ( item: Item ) => boolean;
};
Expand Down
2 changes: 2 additions & 0 deletions packages/dataviews/src/components/dataviews-layout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export default function DataViewsLayout() {
data,
fields,
getItemId,
getItemLevel,
isLoading,
view,
onChangeView,
Expand All @@ -40,6 +41,7 @@ export default function DataViewsLayout() {
data={ data }
fields={ fields }
getItemId={ getItemId }
getItemLevel={ getItemLevel }
isLoading={ isLoading }
onChangeView={ onChangeView }
onChangeSelection={ onChangeSelection }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ function SortFieldControl() {
direction: view?.sort?.direction || 'desc',
field: value,
},
showLevels: false,
} );
} }
/>
Expand Down Expand Up @@ -194,6 +195,7 @@ function SortDirectionControl() {
)?.id ||
'',
},
showLevels: false,
} );
return;
}
Expand Down
3 changes: 3 additions & 0 deletions packages/dataviews/src/components/dataviews/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ type DataViewsProps< Item > = {
onClickItem?: ( item: Item ) => void;
isItemClickable?: ( item: Item ) => boolean;
header?: ReactNode;
getItemLevel?: ( item: Item ) => number;
} & ( Item extends ItemWithId
? { getItemId?: ( item: Item ) => string }
: { getItemId: ( item: Item ) => string } );
Expand All @@ -64,6 +65,7 @@ export default function DataViews< Item >( {
actions = EMPTY_ARRAY,
data,
getItemId = defaultGetItemId,
getItemLevel,
isLoading = false,
paginationInfo,
defaultLayouts,
Expand Down Expand Up @@ -115,6 +117,7 @@ export default function DataViews< Item >( {
openedFilter,
setOpenedFilter,
getItemId,
getItemLevel,
isItemClickable,
onClickItem,
} }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ const _HeaderMenu = forwardRef( function HeaderMenu< Item >(
field: fieldId,
direction,
},
showLevels: false,
} );
} }
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ import getClickableItemProps from '../utils/get-clickable-item-props';

function ColumnPrimary< Item >( {
item,
level,
titleField,
mediaField,
descriptionField,
onClickItem,
isItemClickable,
}: {
item: Item;
level?: number;
titleField?: NormalizedField< Item >;
mediaField?: NormalizedField< Item >;
descriptionField?: NormalizedField< Item >;
Expand All @@ -44,6 +46,11 @@ function ColumnPrimary< Item >( {
<VStack spacing={ 0 }>
{ titleField && (
<div { ...clickableProps }>
{ level !== undefined && (
<span className="dataviews-view-table__level">
{ '—'.repeat( level ) }&nbsp;
</span>
) }
<titleField.render item={ item } />
</div>
) }
Expand Down
Loading

0 comments on commit 2e3e6e4

Please sign in to comment.