> */ public function data() { return array_merge( parent::data(), array( Cleanup::record( 'stock', __( 'Stock changes', 'admin-powerups-for-woocommerce' ), __( 'Quantities and statuses you change are saved on the products in WooCommerce. They stay when the power-up is off or removed.', 'admin-powerups-for-woocommerce' ) ), array( 'id' => 'edit-locks', 'kind' => 'cache', 'label' => __( 'Edit locks', 'admin-powerups-for-woocommerce' ), 'description' => __( 'Short-lived rows that stop two saves of the same product overlapping. Only left behind if a request stopped half way.', 'admin-powerups-for-woocommerce' ), 'count' => array( StockEditor::class, 'count_locks' ), 'delete' => array( StockEditor::class, 'delete_locks' ), ), ) ); } /** * {@inheritDoc} */ public function boot() { add_action( 'rest_api_init', array( $this, 'register_routes' ) ); } /** * {@inheritDoc} */ public function views() { return array( 'stock' => __( 'Stock', 'admin-powerups-for-woocommerce' ) ); } /** * {@inheritDoc} */ public function shortcut() { return 'stock'; } /** * The list query. * * @since 1.0.0 * @return StockQuery */ public function query() { if ( null === $this->query ) { $this->query = new StockQuery(); } return $this->query; } /** * Stock tab URL. * * @since 1.0.0 * @param array $args Query args (s, stock, type, cat, parent, orderby, order, paged, per_page). * @return string */ public static function url( array $args = array() ) { return add_query_arg( array_filter( $args, static fn( $v ) => '' !== $v && null !== $v && 0 !== $v ), AdminPage::url( 'quick-stock', 'stock' ) ); } /** * Columns of the table. * * @since 1.0.0 * @return array Key => label. */ public static function columns() { $columns = array( 'product' => __( 'Product', 'admin-powerups-for-woocommerce' ), 'sku' => __( 'SKU', 'admin-powerups-for-woocommerce' ), 'stock' => __( 'Stock', 'admin-powerups-for-woocommerce' ), 'status' => __( 'Status', 'admin-powerups-for-woocommerce' ), ); /** * Filter the Quick stock columns. Draw an added column's cells with the * `apwc_quick_stock_column` action. * * @since 1.0.0 * @param array $columns Key => label. */ $filtered = (array) apply_filters( 'apwc_quick_stock_columns', $columns ); $out = array(); foreach ( $filtered as $key => $label ) { if ( is_string( $key ) && is_string( $label ) ) { $out[ sanitize_key( $key ) ] = $label; } } return $out ? $out : $columns; } /** * {@inheritDoc} * * @since 1.0.0 * @param string $view View ID. */ public function render_view( $view ) { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only screen filters. $get = static fn( $key ) => isset( $_GET[ $key ] ) && is_string( $_GET[ $key ] ) ? sanitize_text_field( wp_unslash( $_GET[ $key ] ) ) : ''; $num = static fn( $key ) => max( 0, (int) $get( $key ) ); // Not absint(): "-3" must not mean 3. $types = $this->type_choices(); $per = in_array( $num( 'per_page' ), self::PER_PAGE, true ) ? $num( 'per_page' ) : 50; $filter = array( 's' => $get( 's' ), 'stock' => in_array( $get( 'stock' ), self::STOCK_FILTERS, true ) ? $get( 'stock' ) : '', 'type' => isset( $types[ sanitize_key( $get( 'type' ) ) ] ) ? sanitize_key( $get( 'type' ) ) : '', 'cat' => $num( 'cat' ), 'parent' => $num( 'parent' ), 'orderby' => in_array( $get( 'orderby' ), self::SORTS, true ) ? $get( 'orderby' ) : 'name', 'order' => 'desc' === $get( 'order' ) ? 'desc' : 'asc', 'paged' => Paging::page( $get( 'paged' ), $per ), 'per_page' => $per, ); // phpcs:enable $query = new StockQuery(); // Fresh per screen (type terms are memoized per instance). $this->query = $query; $total = $query->count( $filter ); $last = max( 1, (int) ceil( $total / $filter['per_page'] ) ); if ( $total > 0 && $filter['paged'] > $last ) { $filter['paged'] = $last; // Past the end (old link, fewer products): show the last page. } $rows = $total > 0 ? $query->rows( $query->ids( $filter ) ) : array(); $columns = self::columns(); $categories = $this->categories(); $rebuilding = function_exists( 'wc_update_product_lookup_tables_is_running' ) && wc_update_product_lookup_tables_is_running(); $parent = $filter['parent'] ? get_post( $filter['parent'] ) : null; include __DIR__ . '/views/stock.php'; } /** * Product types offered in the filter. * * @since 1.0.0 * @return array */ public function type_choices() { $types = Lookup::product_types(); foreach ( array_merge( array( 'virtual', 'downloadable' ), StockQuery::excluded_types() ) as $drop ) { unset( $types[ $drop ] ); } return array_map( 'strval', $types ); } /** * Product categories for the filter, parents first with children indented. * * @since 1.0.0 * @return array */ public function categories() { $terms = array_slice( $this->query()->categories(), 0, 1000 ); // A longer list isn't usable as a dropdown. $ids = array_flip( array_column( $terms, 'id' ) ); $under = array(); foreach ( $terms as $term ) { // A parent beyond the first 1,000: show the category at the top level. $under[ isset( $ids[ $term['parent'] ] ) ? $term['parent'] : 0 ][] = $term; } $out = array(); $seen = array(); $walk = static function ( $parent_id, $depth ) use ( &$walk, &$out, &$seen, $under ) { foreach ( $under[ $parent_id ] ?? array() as $term ) { if ( isset( $seen[ $term['id'] ] ) ) { continue; } $seen[ $term['id'] ] = true; $out[] = array( 'id' => $term['id'], 'name' => $term['name'], 'depth' => $depth, ); if ( $depth < 10 ) { $walk( $term['id'], $depth + 1 ); } } }; $walk( 0, 0 ); return $out; } /** * One row's HTML. * * @since 1.0.0 * @param array $row Row from StockQuery::rows(). * @param array|null $columns Columns (null = read them; pass them when drawing many rows). * @return string Markup, for REST responses. */ public function row_html( array $row, $columns = null ) { ob_start(); $this->render_row( $row, $columns ); return (string) ob_get_clean(); } /** * Print one row's HTML. * * @since 1.0.0 * @param array $row Row from StockQuery::rows(). * @param array|null $columns Columns (null = read them; pass them when drawing many rows). * @return void */ public function render_row( array $row, $columns = null ) { $columns = is_array( $columns ) ? $columns : self::columns(); include __DIR__ . '/views/row.php'; } /** * A product's row HTML, read fresh ('' when it isn't listed any more). * * @since 1.0.0 * @param int $id Product or variation ID. * @return string */ public function row_html_for( $id ) { // A fresh query: types and categories are memoized per instance, and a save can add a term. $rows = ( new StockQuery() )->rows( array( (int) $id ) ); return $rows ? $this->row_html( $rows[0] ) : ''; } /** * {@inheritDoc} * * @since 1.0.0 * @param string $view View ID. */ public function enqueue_view_assets( $view ) { wp_enqueue_script( 'apwc-quick-stock', Assets::url( 'assets/js/quick-stock.js' ), array( 'wp-api-fetch', 'wp-i18n' ), Assets::ver( 'assets/js/quick-stock.js' ), true ); wp_set_script_translations( 'apwc-quick-stock', 'admin-powerups-for-woocommerce' ); } /* ------------------------------------------------------------------ REST */ /** * REST routes. * * @since 1.0.0 * @return void */ public function register_routes() { $routes = array( 'stock' => array( 'callback' => array( $this, 'rest_stock' ), 'args' => array( 'quantity' => array( 'required' => true ), 'expected' => array( 'required' => true ), ), ), 'status' => array( 'callback' => array( $this, 'rest_status' ), 'args' => array( 'status' => array( 'required' => true, 'type' => 'string', ), 'expected' => array( 'type' => 'string', 'default' => '', ), ), ), 'manage' => array( 'callback' => array( $this, 'rest_manage' ), 'args' => array( 'quantity' => array( 'required' => true ), ), ), ); foreach ( $routes as $path => $route ) { register_rest_route( 'apwc/v1', '/quick-stock/(?P\d+)/' . $path, array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => $route['callback'], 'permission_callback' => array( $this, 'can_use' ), 'args' => $route['args'], ) ); } } /** * Who may use the routes at all (each product is checked again in StockEditor). * * @since 1.0.0 * @return bool */ public function can_use() { return current_user_can( 'manage_woocommerce' ) && current_user_can( 'edit_products' ); } /** * The editor. * * @since 1.0.0 * @return StockEditor */ private function editor() { return new StockEditor( fn( $id ) => $this->row_html_for( (int) $id ) ); } /** * POST /quick-stock/{id}/stock. * * @since 1.0.0 * @param WP_REST_Request $request Request. * @return \WP_REST_Response|WP_Error */ public function rest_stock( WP_REST_Request $request ) { $editor = $this->editor(); $result = $editor->set_quantity( (int) $request['id'], $request['quantity'], $request['expected'] ); if ( is_wp_error( $result ) ) { return $result; } $name = $this->name( $result['product'] ); if ( ! $result['changed'] ) { /* translators: %s: product name. */ $message = sprintf( __( '%s: no change', 'admin-powerups-for-woocommerce' ), $name ); } elseif ( abs( (float) $result['new'] - (float) $request['quantity'] ) > 1e-9 ) { /* translators: 1: product name, 2: stock before, 3: stock now. */ $message = sprintf( __( '%1$s: %2$s → %3$s (another change came in while saving)', 'admin-powerups-for-woocommerce' ), $name, $editor->number( $result['old'] ), $editor->number( $result['new'] ) ); } else { /* translators: 1: product name, 2: stock before, 3: stock now. */ $message = sprintf( __( '%1$s: %2$s → %3$s', 'admin-powerups-for-woocommerce' ), $name, $editor->number( $result['old'] ), $editor->number( $result['new'] ) ); } return $this->answer( $result, $message ); } /** * POST /quick-stock/{id}/status. * * @since 1.0.0 * @param WP_REST_Request $request Request. * @return \WP_REST_Response|WP_Error */ public function rest_status( WP_REST_Request $request ) { $result = $this->editor()->set_status( (int) $request['id'], $request['status'], (string) $request['expected'] ); if ( is_wp_error( $result ) ) { return $result; } $options = (array) wc_get_product_stock_status_options(); $label = static fn( $key ) => (string) ( $options[ $key ] ?? $key ); $message = $result['changed'] /* translators: 1: product name, 2: status before, 3: status now. */ ? sprintf( __( '%1$s: %2$s → %3$s', 'admin-powerups-for-woocommerce' ), $this->name( $result['product'] ), $label( $result['old'] ), $label( $result['new'] ) ) /* translators: %s: product name. */ : sprintf( __( '%s: no change', 'admin-powerups-for-woocommerce' ), $this->name( $result['product'] ) ); return $this->answer( $result, $message ); } /** * POST /quick-stock/{id}/manage. * * @since 1.0.0 * @param WP_REST_Request $request Request. * @return \WP_REST_Response|WP_Error */ public function rest_manage( WP_REST_Request $request ) { $editor = $this->editor(); $result = $editor->start_tracking( (int) $request['id'], $request['quantity'] ); if ( is_wp_error( $result ) ) { return $result; } /* translators: 1: product name, 2: stock amount. */ $message = sprintf( __( '%1$s: tracking stock, %2$s in stock', 'admin-powerups-for-woocommerce' ), $this->name( $result['product'] ), $editor->number( $result['new'] ) ); return $this->answer( $result, $message ); } /** * Success answer: the redrawn row (and its parent's, for a variation). * * @since 1.0.0 * @param array $result Editor result. * @param string $message Plain text. * @return \WP_REST_Response */ private function answer( array $result, $message ) { $product = $result['product']; $parent = $product instanceof WC_Product && $product->is_type( 'variation' ) ? $product->get_parent_id() : 0; $html = $product instanceof WC_Product ? $this->row_html_for( $product->get_id() ) : ''; return rest_ensure_response( array( 'row_html' => $html, 'parent_html' => $parent ? $this->row_html_for( $parent ) : null, 'message' => $message, 'old' => $result['old'], 'new' => $result['new'], 'changed' => (bool) $result['changed'], ) ); } /** * Product name for messages (plain text; the page shows it with textContent). * * @since 1.0.0 * @param WC_Product|null $product Product. * @return string */ private function name( $product ) { return $product instanceof WC_Product ? wp_specialchars_decode( (string) $product->get_name(), ENT_QUOTES ) : ''; } }