Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add: Ability to persist dataviews on the database. #55465

Merged
merged 5 commits into from
Oct 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions lib/experimental/data-views.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php
/**
* Dataviews custom post type and taxonomy.
*
* @package gutenberg
*/

/**
* Registers the `wp_dataviews` post type and the `wp_dataviews_type` taxonomy.
*/
function _gutenberg_register_data_views_post_type() {
register_post_type(
'wp_dataviews',
array(
'label' => _x( 'Dataviews', 'post type general name', 'gutenberg' ),
'description' => __( 'Post which stores the different data views configurations', 'gutenberg' ),
'public' => false,
'show_ui' => false,
'show_in_rest' => true,
'rewrite' => false,
'capabilities' => array(
'read' => 'edit_published_posts',
// 'create_posts' => 'edit_published_posts',
// 'edit_posts' => 'edit_published_posts',
// 'edit_published_posts' => 'edit_published_posts',
// 'delete_published_posts' => 'delete_published_posts',
// 'edit_others_posts' => 'edit_others_posts',
// 'delete_others_posts' => 'edit_theme_options',
),
'map_meta_cap' => true,
'supports' => array( 'title', 'slug', 'editor' ),
)
);

register_taxonomy(
'wp_dataviews_type',
array( 'wp_dataviews' ),
array(
'public' => true,
'hierarchical' => false,
'labels' => array(
'name' => __( 'Dataview types', 'gutenberg' ),
'singular_name' => __( 'Dataview type', 'gutenberg' ),
),
'rewrite' => false,
'show_ui' => false,
'show_in_nav_menus' => false,
'show_in_rest' => true,
)
);
}

add_action( 'init', '_gutenberg_register_data_views_post_type' );
3 changes: 3 additions & 0 deletions lib/load.php
Original file line number Diff line number Diff line change
Expand Up @@ -256,3 +256,6 @@ function () {
require __DIR__ . '/block-supports/shadow.php';
require __DIR__ . '/block-supports/background.php';
require __DIR__ . '/block-supports/behaviors.php';

// Data views.
require_once __DIR__ . '/experimental/data-views.php';
7 changes: 5 additions & 2 deletions packages/edit-site/src/components/app/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { privateApis as routerPrivateApis } from '@wordpress/router';
*/
import Layout from '../layout';
import { GlobalStylesProvider } from '../global-styles/global-styles-provider';
import DataviewsProvider from '../dataviews/provider';
import { unlock } from '../../lock-unlock';

const { RouterProvider } = unlock( routerPrivateApis );
Expand All @@ -38,8 +39,10 @@ export default function App() {
<GlobalStylesProvider>
<UnsavedChangesWarning />
<RouterProvider>
<Layout />
<PluginArea onError={ onPluginAreaError } />
<DataviewsProvider>
<Layout />
<PluginArea onError={ onPluginAreaError } />
</DataviewsProvider>
</RouterProvider>
</GlobalStylesProvider>
</SlotFillProvider>
Expand Down
7 changes: 7 additions & 0 deletions packages/edit-site/src/components/dataviews/context.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* WordPress dependencies
*/
import { createContext } from '@wordpress/element';

const DataviewsContext = createContext( {} );
export default DataviewsContext;
253 changes: 253 additions & 0 deletions packages/edit-site/src/components/dataviews/provider.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
/**
* WordPress dependencies
*/
import { privateApis as routerPrivateApis } from '@wordpress/router';
import { useEffect, useState, useRef, useMemo } from '@wordpress/element';
import { useDispatch, useSelect } from '@wordpress/data';
import { store as coreStore } from '@wordpress/core-data';

/**
* Internal dependencies
*/
import { unlock } from '../../lock-unlock';
import DataviewsContext from './context';

const { useLocation } = unlock( routerPrivateApis );

// DEFAULT_STATUSES is intentionally sorted. Items do not have spaces in between them.
// The reason for that is to match the default statuses coming from the endpoint
// (entity request and useEffect to update the view).
export const DEFAULT_STATUSES = 'draft,future,pending,private,publish'; // All statuses but 'trash'.

const DEFAULT_VIEWS = {
page: {
type: 'list',
filters: {
search: '',
status: DEFAULT_STATUSES,
},
page: 1,
perPage: 5,
sort: {
field: 'date',
direction: 'desc',
},
visibleFilters: [ 'search', 'author', 'status' ],
// All fields are visible by default, so it's
// better to keep track of the hidden ones.
hiddenFields: [ 'date', 'featured-image' ],
layout: {},
},
};

const PATH_TO_DATAVIEW_TYPE = {
'/pages': 'page',
};

function useDataviewTypeTaxonomyId( type ) {
const isCreatingATerm = useRef( false );
const {
dataViewTypeRecords,
dataViewTypeIsResolving,
dataViewTypeIsSaving,
} = useSelect(
( select ) => {
const { getEntityRecords, isResolving, isSavingEntityRecord } =
select( coreStore );
const dataViewTypeQuery = { slug: type };
return {
dataViewTypeRecords: getEntityRecords(
'taxonomy',
'wp_dataviews_type',
dataViewTypeQuery
),
dataViewTypeIsResolving: isResolving( 'getEntityRecords', [
'taxonomy',
'wp_dataviews_type',
dataViewTypeQuery,
] ),
dataViewTypeIsSaving: isSavingEntityRecord(
'taxonomy',
'wp_dataviews_type'
),
};
},
[ type ]
);
const { saveEntityRecord } = useDispatch( coreStore );
useEffect( () => {
if (
dataViewTypeRecords?.length === 0 &&
! dataViewTypeIsResolving &&
! dataViewTypeIsSaving &&
! isCreatingATerm.current
) {
isCreatingATerm.current = true;
saveEntityRecord( 'taxonomy', 'wp_dataviews_type', { name: type } );
}
}, [
type,
dataViewTypeRecords,
dataViewTypeIsResolving,
dataViewTypeIsSaving,
saveEntityRecord,
] );
useEffect( () => {
if ( dataViewTypeRecords?.length > 0 ) {
isCreatingATerm.current = false;
}
}, [ dataViewTypeRecords ] );
useEffect( () => {
isCreatingATerm.current = false;
}, [ type ] );
if ( dataViewTypeRecords?.length > 0 ) {
return dataViewTypeRecords[ 0 ].id;
}
return null;
}

function useDataviews( type, typeTaxonomyId ) {
const isCreatingADefaultView = useRef( false );
const { dataViewRecords, dataViewIsLoading, dataViewIsSaving } = useSelect(
( select ) => {
const { getEntityRecords, isResolving, isSavingEntityRecord } =
select( coreStore );
if ( ! typeTaxonomyId ) {
return {};
}
const dataViewQuery = {
wp_dataviews_type: typeTaxonomyId,
orderby: 'date',
order: 'asc',
};
return {
dataViewRecords: getEntityRecords(
'postType',
'wp_dataviews',
dataViewQuery
),
dataViewIsLoading: isResolving( 'getEntityRecords', [
'postType',
'wp_dataviews',
dataViewQuery,
] ),
dataViewIsSaving: isSavingEntityRecord(
'postType',
'wp_dataviews'
),
};
},
[ typeTaxonomyId ]
);
const { saveEntityRecord } = useDispatch( coreStore );
useEffect( () => {
if (
dataViewRecords?.length === 0 &&
! dataViewIsLoading &&
! dataViewIsSaving &&
! isCreatingADefaultView.current
) {
isCreatingADefaultView.current = true;
saveEntityRecord( 'postType', 'wp_dataviews', {
title: 'All',
status: 'publish',
wp_dataviews_type: typeTaxonomyId,
content: JSON.stringify( DEFAULT_VIEWS[ type ] ),
} );
}
}, [
type,
dataViewIsLoading,
dataViewRecords,
dataViewIsSaving,
typeTaxonomyId,
saveEntityRecord,
] );
useEffect( () => {
if ( dataViewRecords?.length > 0 ) {
isCreatingADefaultView.current = false;
}
}, [ dataViewRecords ] );
useEffect( () => {
isCreatingADefaultView.current = false;
}, [ typeTaxonomyId ] );
if ( dataViewRecords?.length > 0 ) {
return dataViewRecords;
}
return null;
}

function DataviewsProviderInner( { type, children } ) {
const [ currentViewId, setCurrentViewId ] = useState( null );
const dataviewTypeTaxonomyId = useDataviewTypeTaxonomyId( type );
const dataviews = useDataviews( type, dataviewTypeTaxonomyId );
const { editEntityRecord } = useDispatch( coreStore );
useEffect( () => {
if ( ! currentViewId && dataviews?.length > 0 ) {
setCurrentViewId( dataviews[ 0 ].id );
}
}, [ currentViewId, dataviews ] );
const editedViewRecord = useSelect(
( select ) => {
if ( ! currentViewId ) {
return;
}
const { getEditedEntityRecord } = select( coreStore );
const dataviewRecord = getEditedEntityRecord(
'postType',
'wp_dataviews',
currentViewId
);
return dataviewRecord;
},
[ currentViewId ]
);

const value = useMemo( () => {
return {
taxonomyId: dataviewTypeTaxonomyId,
dataviews,
currentViewId,
setCurrentViewId,
view: editedViewRecord?.content
? JSON.parse( editedViewRecord?.content )
: DEFAULT_VIEWS[ type ],
setView( view ) {
if ( ! currentViewId ) {
return;
}
editEntityRecord( 'postType', 'wp_dataviews', currentViewId, {
content: JSON.stringify( view ),
} );
},
};
}, [
type,
dataviewTypeTaxonomyId,
dataviews,
currentViewId,
editedViewRecord?.content,
editEntityRecord,
] );

return (
<DataviewsContext.Provider value={ value }>
{ children }
</DataviewsContext.Provider>
);
}
export default function DataviewsProvider( { children } ) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Am I correct in assuming that the decision to use a provider is that the context will be managed separately by the main view and the sidebar?

My first impressions: there is a lot of code in this file and I wonder if all the complexity is absolutely necessary.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Am I correct in assuming that the decision to use a provider is that the context will be managed separately by the main view and the sidebar?

@mcsf Exactly both the sidebar and the main view need to change the active view access the views etc so I needed a provider to share this information/state across different parts of the UI.

there is a lot of code in this file and I wonder if all the complexity is absolutely necessary.

The code contains a lot of complexity, but it is necessary. For example, we need to create a taxonomy that can identify a view for a type such as a page. If the taxonomy does not exist, we need to create it. The logic behind this is that we need to wait for the request to determine if the taxonomy already exists. If it doesn't, we create it, and the same for a post with a default view. Although I considered using actions and resolvers to implement this, it did not seem simple, and I was not sure how to follow that approach without previous history. To make the logic easier to understand, I will add better comments, and I will attempt to reuse more logic between creating a taxonomy and creating a post.

const {
params: { path },
} = useLocation();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't believe we should couple data views component with the url path. The connection should be made with a param passed to it. How it would work if we have a view with more than one data view components? It's not the current use case, but the component is meant to be reused by consumers to just render data views, so they could have more than one instances in a page and also nothing to do with the url.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point it was addressed at #55695.

const viewType = PATH_TO_DATAVIEW_TYPE[ path ];

if ( window?.__experimentalAdminViews && viewType ) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need the plugin gate here in addition to the experiment gate, otherwise this can't be tree-shaken.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, we should move the gating out to the module level:

let BlaBla = () => null;

// This condition must stand on its own for correct tree-shaking
if ( process.env.IS_GUTENBERG_PLUGIN ) {
	if ( window?.__experimentalAdminViews ) {
		BlaBla = DataviewsProvider;
	}
}

export default BlaBla;

return (
<DataviewsProviderInner type={ viewType }>
{ children }
</DataviewsProviderInner>
);
}
return <> { children }</>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function DataViewsSidebarContent() {
return <p>Add views ui here</p>;
}
3 changes: 3 additions & 0 deletions packages/edit-site/src/components/dataviews/text-filter.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ export default function TextFilter( { filter, view, onChangeView } ) {
const [ search, setSearch, debouncedSearch ] = useDebouncedInput(
view.filters[ filter.id ]
);
useEffect( () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this was needed?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not inside how text filters work, maybe @oandregal can clarify why this part is needed?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is how to reproduce the bug this effect fixed:

  • having a page with title "My page title"
  • filter the list of pages by the search component (e.g.: type "title")
  • save the view
  • reload the page

At this point, we want the search component to show the searched string "title", otherwise the user will see a filtered list but would not know how (or whether) is filtered.

This happens because once Search is mounted, the useDebouncedInput hook is only updated upon changes on local state, ignoring new incoming values for view.search. I've noticed there are two network requests to the pages endpoint:

  • the 1st with the default parameters (view.search is empty, which is the default state for useDebouncedInput),
  • the 2nd with the parameters from the persisted view (view.search is "title", the local states needs telling that ).

It seems this code is being refactored. It may be worth taking a look at this after to minimize the network requests, etc.

setSearch( view.filters[ filter.id ] );
}, [ view ] );
const onChangeViewRef = useRef( onChangeView );
useEffect( () => {
onChangeViewRef.current = onChangeView;
Expand Down
Loading
Loading