-
Notifications
You must be signed in to change notification settings - Fork 4.2k
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
Changes from all commits
d3d28ec
9a2d8b9
3a4cd08
5193146
cf7c321
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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' ); |
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; |
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 } ) { | ||
const { | ||
params: { path }, | ||
} = useLocation(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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>; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -14,6 +14,9 @@ export default function TextFilter( { filter, view, onChangeView } ) { | |
const [ search, setSearch, debouncedSearch ] = useDebouncedInput( | ||
view.filters[ filter.id ] | ||
); | ||
useEffect( () => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why this was needed? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is how to reproduce the bug this effect fixed:
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
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; | ||
|
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@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.
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.