'inbox', 'no-results' => 'list-filter', 'all-done' => 'circle-check', 'not-set-up' => 'plug', 'not-run' => 'clock', 'off' => 'zap', 'failed' => 'circle-alert', 'pro' => 'sparkles', 'loading' => 'refresh-cw', ); /** * The head badges (FR13: a card's state from one list) and their tones. */ public const STATUSES = array( 'not-set' => 'mono', 'active' => 'success', 'connected' => 'success', 'on' => 'success', 'off' => 'mono', 'attention' => 'warning', 'scheduled' => 'info', 'not-run' => 'mono', ); /** * The confirmation levels (FR12), from the lightest: none (an Undo toast where useful), a reset, a bulk action that * creates or changes many records without destroying any (0.63.1: "Create 24 accounts?"), switching off a duty or a * status in use, replacing data, an irreversible loss of shared or remote data. */ public const LEVELS = array( 'reset', 'apply', 'off', 'delete', 'replace', 'loss' ); /** * A job block's states (FR11, B12). */ public const JOBS = array( 'not-run', 'idle', 'scheduled', 'running', 'done', 'partly', 'failed' ); /** * Loads the behaviour (assets/states.js, after the chrome's script, whose saving, toasts and live region it uses) * on the screen that prints a builder needing it; its words come with it. Registered on first use, so a screen * without states pays nothing. */ public static function enqueue(): void { if ( ! function_exists( 'wp_enqueue_script' ) ) { return; } if ( ! wp_script_is( self::HANDLE, 'registered' ) ) { wp_register_script( self::HANDLE, Library::asset( 'assets/states' . Library::suffix() . '.js' ), array( Library::HANDLE ), Library::VERSION, array( 'in_footer' => true, 'strategy' => 'defer', ) ); wp_add_inline_script( self::HANDLE, 'window.roosterAdminStatesL10n = ' . wp_json_encode( self::words(), JSON_HEX_TAG | JSON_HEX_AMP ) . ';', 'before' ); } wp_enqueue_script( self::HANDLE ); } /** * The script's words, in the site's language. * * @return array */ public static function words(): array { return array( 'saved' => __( 'Saved', 'ai-eshop-optimizer' ), 'notSaved' => __( 'Not saved', 'ai-eshop-optimizer' ), /* translators: %s: why the save failed. */ 'failed' => __( 'Not saved: %s', 'ai-eshop-optimizer' ), 'network' => __( 'The site did not answer. Check the connection and try again.', 'ai-eshop-optimizer' ), 'unsaved' => __( 'Unsaved', 'ai-eshop-optimizer' ), /* translators: %s: the card's title. */ 'unsavedIn' => __( 'Unsaved changes in %s', 'ai-eshop-optimizer' ), 'undo' => __( 'Undo', 'ai-eshop-optimizer' ), 'undone' => __( 'Undone', 'ai-eshop-optimizer' ), /* translators: %s: the card's title (the feature the switch turns on). */ 'offTitle' => __( 'Switch off %s?', 'ai-eshop-optimizer' ), 'switchOff' => __( 'Switch off', 'ai-eshop-optimizer' ), 'keepOn' => __( 'Keep it on', 'ai-eshop-optimizer' ), /* translators: %s: how many fields (one). */ 'checkOne' => __( 'Check %s field', 'ai-eshop-optimizer' ), /* translators: %s: how many fields (several). */ 'checkMany' => __( 'Check %s fields', 'ai-eshop-optimizer' ), 'fixToSave' => __( 'Fix these to save the card.', 'ai-eshop-optimizer' ), 'jobFailed' => __( 'Could not finish', 'ai-eshop-optimizer' ), 'notRun' => __( 'Not run yet', 'ai-eshop-optimizer' ), // 0.63.1: why a confirmation's number cannot be confirmed yet (its field's error, FR7). 'numNone' => __( 'Enter a number.', 'ai-eshop-optimizer' ), /* translators: 1: the smallest number allowed, 2: the largest. */ 'numRange' => __( 'Enter a number from %1$s to %2$s.', 'ai-eshop-optimizer' ), /* translators: %s: the smallest number allowed. */ 'numMin' => __( 'Enter %s or more.', 'ai-eshop-optimizer' ), /* translators: %s: the largest number allowed. */ 'numMax' => __( 'Enter %s or less.', 'ai-eshop-optimizer' ), ); } /** * Words for the head badges (FR13's list). * * @return array */ public static function status_words(): array { return array( 'not-set' => __( 'Not set', 'ai-eshop-optimizer' ), 'active' => __( 'Active', 'ai-eshop-optimizer' ), 'connected' => __( 'Connected', 'ai-eshop-optimizer' ), 'on' => __( 'On', 'ai-eshop-optimizer' ), 'off' => __( 'Off', 'ai-eshop-optimizer' ), 'attention' => __( 'Needs attention', 'ai-eshop-optimizer' ), 'scheduled' => __( 'Scheduled', 'ai-eshop-optimizer' ), 'not-run' => __( 'Not run yet', 'ai-eshop-optimizer' ), ); } /** * An action's outcome (0.84.2, the Rooster One cleanup): the short line beside a button that a script writes the * result into ("Testing…", "Connected: 3 stores answer", "The courier refused the key: 401"); a polite status, so a * script's words are heard. Its tone is `data-tone`: success, warning or destructive; none for what is pending or * neutral. A script sets the text and the attribute; printed empty, it takes no room. * * @param string $text The words (raw; '' for a line a script fills). * @param string $tone success, warning, destructive or ''. * @param array $opts id, attrs. */ public static function outcome( string $text = '', string $tone = '', array $opts = array() ): string { $attrs = array( 'class' => 'rooster-admin-outcome', 'id' => '' !== (string) ( $opts['id'] ?? '' ) ? (string) $opts['id'] : null, 'role' => 'status', 'aria-live' => 'polite', 'data-tone' => in_array( $tone, array( 'success', 'warning', 'destructive' ), true ) ? $tone : null, ); $attrs = Ui::merge_attrs( $attrs, $opts['attrs'] ?? null ); return '' . esc_html( $text ) . ''; } /** * A card's state badge from FR13's one list ('' for an unknown key): for the card's `meta`. * * @param string $key not-set, active, connected, on, off, attention, scheduled, not-run. * @param array $opts for (the id of the switch the card's feature hangs on: the badge shows while * it is off, the script hides it while on; for `off`), on (with `for`: the switch * is on now, the badge printed hidden), label (raw: the words instead of the * list's, the key keeping the tone), attrs. */ public static function status( string $key, array $opts = array() ): string { if ( ! isset( self::STATUSES[ $key ] ) ) { return ''; } $attrs = array( 'data-ra-status' => $key ) + ( is_array( $opts['attrs'] ?? null ) ? $opts['attrs'] : array() ); if ( '' !== (string) ( $opts['for'] ?? '' ) ) { $attrs['data-ra-off-for'] = (string) $opts['for']; $attrs['hidden'] = ! empty( $opts['on'] ) ? true : null; self::enqueue(); } $label = (string) ( $opts['label'] ?? self::status_words()[ $key ] ); return Ui::badge( $label, self::STATUSES[ $key ], array( 'dot' => true, 'sm' => true, 'attrs' => $attrs, ) ); } /** * The result head (UI-Design-Patterns §4.1): what a result is, before it is shown, in one line above the table, * the list or the job's output: "128 products match · brand: Nivea · in stock · [Clear filters]"; * "12 results for “lipstick”"; "Imported 240 rows, 3 skipped · 2 minutes ago · * [See the log]". The line is a polite live region, so a script that rewrites it (roosterAdmin.states.result()) * is heard. Counts, money and dates in the site's locale (number_format_i18n(), wp_date()), or as the product formats * them (FR13). * * @param string $count The count with its noun (raw): "128 products" (the product's * sprintf( _n( … ), number_format_i18n( $n ) )), or a job's "Imported 240 rows". * @param array $opts after (raw: the words right after the count, "match", "for “lipstick”", * ", 3 skipped"), facts (raw strings after it, one separator each: the filters, * the period, when it ran; written in lower case, and a "Label: value" fact's * label is lowered: "brand: Nivea", FR13), actions (escaped HTML at its end: * Clear filters, See the log), id, class, attrs. */ public static function result_head( string $count, array $opts = array() ): string { $attrs = array( 'class' => trim( 'rooster-admin-result ' . (string) ( $opts['class'] ?? '' ) ), 'id' => '' !== (string) ( $opts['id'] ?? '' ) ? (string) $opts['id'] : null, 'data-ra-result' => '', ); $attrs = Ui::merge_attrs( $attrs, $opts['attrs'] ?? null ); $actions = (string) ( $opts['actions'] ?? '' ); return '

' . self::result_words( $count, (string) ( $opts['after'] ?? '' ), (array) ( $opts['facts'] ?? array() ) ) . '

' . ( '' !== $actions ? '
' . $actions . '
' : '' ) . ''; } /** * The words of a result head (escaped): the count, what follows it, the facts. * * @param string $count The count with its noun (raw). * @param string $after The words after it (raw). * @param array $facts The facts (raw). */ private static function result_words( string $count, string $after, array $facts ): string { $html = '' . esc_html( $count ) . ''; $sep = '' !== $count || '' !== $after; // a line of facts alone (a failed job's "10 minutes ago") starts without one if ( '' !== $after ) { $html .= ( 1 === preg_match( '/^[,.;:)]/', $after ) ? '' : ' ' ) . esc_html( $after ); } foreach ( $facts as $fact ) { $fact = trim( (string) $fact ); if ( '' === $fact ) { continue; } // A filter's "Label: value" reads as one sentence after the count: its label in lower case (FR13). if ( 1 === preg_match( '/^(\p{Lu})(\p{Ll}[^:]{0,40}:\s)/u', $fact, $m ) ) { $fact = mb_strtolower( $m[1] ) . mb_substr( $fact, 1 ); } $html .= ( $sep ? ', ' : '' ) . '' . esc_html( $fact ) . ''; $sep = true; } return $html; } /** * The default title of a state kind, from the item or the feature it is about (raw). * * @param string $kind A kind (KINDS). * @param array $opts items, feature, verb, pro. */ private static function title( string $kind, array $opts ): string { $items = (string) ( $opts['items'] ?? '' ); $feature = (string) ( $opts['feature'] ?? '' ); switch ( $kind ) { case 'first-use': /* translators: %s: what the list holds, in the plural ("declarations"). */ return '' !== $items ? sprintf( __( 'No %s yet', 'ai-eshop-optimizer' ), $items ) : __( 'Nothing here yet', 'ai-eshop-optimizer' ); case 'no-results': /* translators: %s: what the list holds, in the plural ("products"). */ return '' !== $items ? sprintf( __( 'No %s match these filters', 'ai-eshop-optimizer' ), $items ) : __( 'Nothing matches these filters', 'ai-eshop-optimizer' ); case 'all-done': return __( 'All caught up', 'ai-eshop-optimizer' ); case 'not-set-up': /* translators: %s: the feature ("Search Console"). */ return '' !== $feature ? sprintf( __( '%s is not set up', 'ai-eshop-optimizer' ), $feature ) : __( 'Not set up yet', 'ai-eshop-optimizer' ); case 'not-run': /* translators: %s: the job or the prediction ("The daily sweep"). */ return '' !== $feature ? sprintf( __( '%s has not run yet', 'ai-eshop-optimizer' ), $feature ) : __( 'Not run yet', 'ai-eshop-optimizer' ); case 'off': /* translators: %s: the feature ("Price recording"). */ return '' !== $feature ? sprintf( __( '%s is off', 'ai-eshop-optimizer' ), $feature ) : __( 'Switched off', 'ai-eshop-optimizer' ); case 'failed': /* translators: %s: what could not be done ("load the report"). */ return '' !== (string) ( $opts['verb'] ?? '' ) ? sprintf( __( 'Could not %s', 'ai-eshop-optimizer' ), (string) $opts['verb'] ) : __( 'Something went wrong', 'ai-eshop-optimizer' ); case 'pro': /* translators: 1: the feature, 2: the Pro edition ("Rooster One Pro"). */ return sprintf( __( '%1$s is part of %2$s', 'ai-eshop-optimizer' ), '' !== $feature ? $feature : __( 'This', 'ai-eshop-optimizer' ), (string) ( $opts['pro'] ?? __( 'Pro', 'ai-eshop-optimizer' ) ) ); case 'loading': /* translators: %s: what is loading, in the plural ("orders"). */ return '' !== $items ? sprintf( __( 'Loading %s…', 'ai-eshop-optimizer' ), $items ) : __( 'Loading…', 'ai-eshop-optimizer' ); } return ''; } /** * One state (UI-Design-Patterns §4.2, FR11): a Lucide icon, a title, one sentence, at most one action, in the * kind's look. Inside a table it takes the body's place (`compact`), inside a card the card body's. Which kind is * the data's to say (kind(), from()): all done only when the source has data, first use when it has none yet, not * run yet for a job or a prediction that never ran. * * @param string $kind first-use, no-results, all-done, not-set-up, not-run, off, failed, pro, loading. * @param array $opts title (raw; by default the kind's wording from `items`, `feature`, `verb` or * `pro`), text (raw: one sentence: what fills it and when, what is missing, the * cause and what to do), action (escaped HTML: one button), href and label (the * action as a link button), clear (no results: the URL without the filters, a * "Clear filters" link), switch (off: the head switch's id, a "Turn on" that flips * it), compact, id, attrs. */ public static function state( string $kind, array $opts = array() ): string { $kind = isset( self::KINDS[ $kind ] ) ? $kind : 'first-use'; $title = (string) ( $opts['title'] ?? '' ); $title = '' !== $title ? $title : self::title( $kind, $opts ); $action = (string) ( $opts['action'] ?? '' ); if ( '' === $action && '' !== (string) ( $opts['href'] ?? '' ) && '' !== (string) ( $opts['label'] ?? '' ) ) { $action = Ui::button( (string) $opts['label'], array( 'href' => (string) $opts['href'] ) ); } if ( '' === $action && 'no-results' === $kind && '' !== (string) ( $opts['clear'] ?? '' ) ) { $action = Ui::button( __( 'Clear filters', 'ai-eshop-optimizer' ), array( 'href' => (string) $opts['clear'], 'icon' => 'x' ) ); } if ( '' === $action && 'off' === $kind && '' !== (string) ( $opts['switch'] ?? '' ) ) { $action = self::turn_on( (string) $opts['switch'] ); } $text = (string) ( $opts['text'] ?? ( 'no-results' === $kind ? __( 'Try fewer filters or a wider period.', 'ai-eshop-optimizer' ) : '' ) ); $attrs = array( 'class' => 'rooster-admin-state is-' . $kind, 'id' => '' !== (string) ( $opts['id'] ?? '' ) ? (string) $opts['id'] : null, 'data-ra-state' => $kind, ) + ( 'loading' === $kind ? array( 'role' => 'status', 'aria-busy' => 'true', ) : array() ); $attrs = Ui::merge_attrs( $attrs, $opts['attrs'] ?? null ); return '' . Ui::empty_state( $title, $text, array( 'icon' => self::KINDS[ $kind ], 'action' => $action, 'compact' => ! empty( $opts['compact'] ), ) ) . ''; } /** * The kind a view's data calls for ('' when there is something to show): the reasons nothing shows, the strongest * first. * * @param array $data loading (true: on its way), pro (true: the feature is Pro and this site has not * got it), ready (false: something it needs is missing), on (false: switched off), * error (raw: the load or the job failed), ran (false: the job or the prediction * never ran), total (int: everything the source ever recorded), shown (int: what * the view shows), filtered (true: a search or filters narrow the view). */ public static function kind( array $data ): string { if ( ! empty( $data['loading'] ) ) { return 'loading'; } if ( ! empty( $data['pro'] ) ) { return 'pro'; } if ( array_key_exists( 'ready', $data ) && false === (bool) $data['ready'] ) { return 'not-set-up'; } if ( array_key_exists( 'on', $data ) && false === (bool) $data['on'] ) { return 'off'; } if ( '' !== (string) ( $data['error'] ?? '' ) ) { return 'failed'; } if ( array_key_exists( 'ran', $data ) && false === (bool) $data['ran'] ) { return 'not-run'; } $total = isset( $data['total'] ) ? (int) $data['total'] : null; $shown = isset( $data['shown'] ) ? (int) $data['shown'] : $total; if ( 0 === $total ) { return 'first-use'; // nothing ever recorded: never "all done" (FR11) } if ( 0 === $shown ) { return ! empty( $data['filtered'] ) ? 'no-results' : 'all-done'; } return ''; } /** * The state a view's data calls for, printed ('' when there is something to show): kind() and state() in one. * * @param array $data See kind(); `error` also becomes the failed state's sentence. * @param array $opts state()'s options for every kind (items, feature, pro, compact, clear, * switch), and per kind an array of its own under the kind's key * ('all-done' => array( 'text' => 'New declarations appear here …' )). */ public static function from( array $data, array $opts = array() ): string { $kind = self::kind( $data ); if ( '' === $kind ) { return ''; } $own = is_array( $opts[ $kind ] ?? null ) ? $opts[ $kind ] : array(); foreach ( array_keys( self::KINDS ) as $key ) { unset( $opts[ $key ] ); } if ( 'failed' === $kind && ! isset( $own['text'] ) ) { $own['text'] = (string) $data['error']; } return self::state( $kind, $own + $opts ); } /** * The "Turn on" of an off feature: flips the head switch it names (assets/states.js), which saves as it does when * switched by hand. * * @param string $target The head switch's id. */ private static function turn_on( string $target ): string { self::enqueue(); return Ui::button( __( 'Turn on', 'ai-eshop-optimizer' ), array( 'variant' => 'primary', 'size' => 'sm', 'attrs' => array( 'data-ra-flips' => $target ), ) ); } /** * The attributes of a control saved in place (FR1, B03), or of a container whose controls each save in place (a * switchboard, a row of settings): each change is sent at once to the product's URL (CONTRACT §8.4), "Saved" is * said beside it, and a refusal puts the control back and says why. Put them on the control (`attrs` of * Ui::toggle(), Ui::select(), Ui::input()) or on its container. * * @param string $url The product's endpoint (a REST route; admin-ajax with `data`). * @param array $opts data (fields sent with every save: an admin-ajax `action` and its nonce), key * (the setting's key in the product's words, sent as `_ra_key`), undo (true: an * Undo in the toast after each save: the reversible level of FR12). * @return array */ public static function save_attrs( string $url, array $opts = array() ): array { self::enqueue(); $attrs = array( 'data-ra-save' => $url ); if ( is_array( $opts['data'] ?? null ) && array() !== $opts['data'] ) { $attrs['data-ra-save-data'] = (string) wp_json_encode( $opts['data'] ); } if ( '' !== (string) ( $opts['key'] ?? '' ) ) { $attrs['data-ra-save-key'] = (string) $opts['key']; } if ( ! empty( $opts['undo'] ) ) { $attrs['data-ra-undo'] = ''; } return $attrs; } /** * A card's form that saves with the Save in the card's footer, without a reload (FR1): printed in the card's body * around its fields, its Save printed by save_button() in the footer (tied by `form`). The script sends the form's * fields to the product's URL, says "Saved", marks the card "Unsaved" in its head while it differs from what was * saved, asks before the page is left with unsaved changes, and shows a refusal's field errors (the invalid state). * Without the script the form posts to its `action` as any form does. * * @param string $id The form's id (unique on the screen: the card's id with `-form`). * @param string $html The fields (escaped HTML). * @param array $opts url (where the script sends: the product's endpoint; '' sends to `action`), * action (where the form posts without the script; by default `url`), method * (post), hidden (name => value: fields sent along, a nonce), data (fields sent * by the script only), class, attrs. */ public static function form( string $id, string $html, array $opts = array() ): string { self::enqueue(); $url = (string) ( $opts['url'] ?? '' ); $attrs = array( 'class' => trim( 'rooster-admin-form ' . (string) ( $opts['class'] ?? '' ) ), 'id' => $id, 'method' => 'get' === strtolower( (string) ( $opts['method'] ?? 'post' ) ) ? 'get' : 'post', 'action' => (string) ( $opts['action'] ?? $url ), 'data-ra-submit' => $url, ) + ( is_array( $opts['data'] ?? null ) && array() !== $opts['data'] ? array( 'data-ra-save-data' => (string) wp_json_encode( $opts['data'] ) ) : array() ); $attrs = Ui::merge_attrs( $attrs, $opts['attrs'] ?? null ); if ( '' === $attrs['action'] ) { unset( $attrs['action'] ); } else { $attrs['action'] = esc_url( $attrs['action'] ); } $hidden = ''; foreach ( is_array( $opts['hidden'] ?? null ) ? $opts['hidden'] : array() as $name => $value ) { $hidden .= ''; } return '' . $hidden . $html . ''; } /** * The Save of a card that saves with its footer (FR1): the primary button at the footer's start, at its natural * width, named with its object ("Save" on a settings card, "Save discount" on a record: FR13), tied to the card's * form; then the polite "Saved" the script fills. * * @param string $form The form's id (form()). * @param array $opts label (raw; default "Save"), attrs. */ public static function save_button( string $form, array $opts = array() ): string { self::enqueue(); return Ui::button( (string) ( $opts['label'] ?? __( 'Save', 'ai-eshop-optimizer' ) ), array( 'variant' => 'primary', 'type' => 'submit', 'attrs' => array( 'form' => $form, 'data-ra-save-submit' => '', ) + ( is_array( $opts['attrs'] ?? null ) ? $opts['attrs'] : array() ), ) ) . self::saved(); } /** * The polite status a save fills ("Saved", "Not saved"), beside what it saves. */ private static function saved(): string { return ''; } /** * The invalid state's summary (FR11): at the top of the card's body after a refused Save, a link to each field in * error (a rule across two fields names the second); each field shows its own error under it (Ui::field_row()'s * `error`, the control's `invalid`), and the focus goes to the first. The script draws the same after a refusal * that names the fields. * * @param array> $errors {id (the control's id), label (raw), message (raw)}. * @param array $opts text (raw: the sentence under the title), id. */ public static function invalid( array $errors, array $opts = array() ): string { $items = ''; $count = 0; foreach ( $errors as $error ) { if ( ! is_array( $error ) || '' === (string) ( $error['message'] ?? '' ) ) { continue; } ++$count; $text = ( '' !== (string) ( $error['label'] ?? '' ) ? (string) $error['label'] . ': ' : '' ) . (string) $error['message']; $items .= '
  • ' . ( '' !== (string) ( $error['id'] ?? '' ) ? '' . esc_html( $text ) . '' : esc_html( $text ) ) . '
  • '; } if ( 0 === $count ) { return ''; } self::enqueue(); return Ui::notice( esc_html( (string) ( $opts['text'] ?? __( 'Fix these to save the card.', 'ai-eshop-optimizer' ) ) ), 'destructive', array( 'inline' => true, /* translators: %s: how many fields are in error. */ 'title' => sprintf( _n( 'Check %s field', 'Check %s fields', $count, 'ai-eshop-optimizer' ), number_format_i18n( $count ) ), 'detail' => '
      ' . $items . '
    ', 'id' => (string) ( $opts['id'] ?? '' ), 'attrs' => array( 'data-ra-invalid' => '', 'tabindex' => '-1', ), ) ); } /** * The head switch (FR2): the one switch that turns a card's feature on, in the card's head (the card's `tools`): * a visible "Enable", named with its card ("Enable Payment icons"), its tip "Saves as you switch it"; it saves as it * is switched and says "Saved", and goes back and says why when the save is refused (FR1). Switching off a legal * duty or a status in use asks first and states the consequence (`confirm_off`, FR12). A record's own status (a * discount rule's Active) is a field of the record, never this switch. * * @param string $card_title The card's title (raw). * @param bool $on Whether the feature is on. * @param array $opts id (the input's; default from the title), name and value (what the save * sends: `name=value` on, `name=0` off), save (the product's URL: saved in * place; without it the product's script saves on `change`), data, key * (save_attrs()), confirm_off (raw: what switching off does: "Customers can * no longer cancel within 14 days"), disabled, reason (raw: why it cannot be * switched, shown after it and tied to it: FR7). */ public static function head_switch( string $card_title, bool $on, array $opts = array() ): string { self::enqueue(); $id = (string) ( $opts['id'] ?? '' ); $id = '' !== $id ? $id : 'rooster-admin-switch-' . sanitize_title( $card_title ); $reason = (string) ( $opts['reason'] ?? '' ); $disabled = ! empty( $opts['disabled'] ); $attrs = array( 'data-ra-headswitch' => '' ); if ( '' !== (string) ( $opts['confirm_off'] ?? '' ) ) { $attrs['data-ra-confirm-off'] = (string) $opts['confirm_off']; } if ( $disabled && '' !== $reason ) { $attrs['aria-describedby'] = $id . '-reason'; } $toggle = Ui::toggle( (string) ( $opts['name'] ?? '' ), $on, array( 'id' => $id, 'value' => (string) ( $opts['value'] ?? '1' ), 'hidden' => false, 'label' => __( 'Enable', 'ai-eshop-optimizer' ), /* translators: %s: the card's title. */ 'aria-label' => sprintf( __( 'Enable %s', 'ai-eshop-optimizer' ), $card_title ), 'tip' => $disabled ? '' : __( 'Saves as you switch it', 'ai-eshop-optimizer' ), 'disabled' => $disabled, 'attrs' => $attrs, ) ); $wrap = array( 'class' => 'rooster-admin-headswitch' ); if ( '' !== (string) ( $opts['save'] ?? '' ) ) { $wrap += self::save_attrs( (string) $opts['save'], $opts ); } return '' . $toggle . self::saved() . ( $disabled && '' !== $reason ? '' . esc_html( $reason ) . '' : '' ) . ''; } /** * The page's main feature off (FR3): one page notice under the head and the tabs, never inside a card (a card's own * sub-feature off is the "Off" badge in its head: status( 'off' )). A warning for a legal duty, else information; * its "Turn on" flips the head switch it names, which saves as it does by hand, and the notice goes once the switch * is on (it comes back while it is off). * * @param string $feature The feature (raw): "Withdrawal right". * @param string $text What it would do, or what being off means (raw). * @param array $opts switch (the head switch's id), legal (true: a duty; the warning tone), title * (raw; default " is off"), on (true: the switch is on now: printed * hidden, shown when it is switched off), action (escaped HTML instead of Turn * on: a link to the screen that holds the switch), id. */ public static function off_notice( string $feature, string $text, array $opts = array() ): string { $switch = (string) ( $opts['switch'] ?? '' ); $action = (string) ( $opts['action'] ?? ( '' !== $switch ? self::turn_on( $switch ) : '' ) ); $attrs = array( 'data-ra-off-notice' => '' ); if ( '' !== $switch ) { self::enqueue(); $attrs['data-ra-off-for'] = $switch; } if ( ! empty( $opts['on'] ) ) { $attrs['hidden'] = true; } return Ui::notice( esc_html( $text ), ! empty( $opts['legal'] ) ? 'warning' : 'info', array( /* translators: %s: the feature ("Withdrawal right"). */ 'title' => (string) ( $opts['title'] ?? sprintf( __( '%s is off', 'ai-eshop-optimizer' ), $feature ) ), 'actions' => $action, 'id' => (string) ( $opts['id'] ?? '' ), 'icon' => 'zap', 'attrs' => $attrs, ) ); } /** * Rows that only matter while a switch is on (FR4): hidden while it is off, shown when it is switched on; never * dimmed, still editable. A feature's main settings stay visible while it is off (so it can be prepared): only the * options that depend on a switch of their own go here. A select or a radio group may be the master too: the * rows show for one of its values (a mode that reveals its group of fields). * * @param string $html The rows (escaped HTML: field rows, a section's blocks). * @param string $master The master control's id (a checkbox or switch; a select; a radio of the group). * @param bool $on Whether they show now (the master is on, or has the value). * @param array $opts value (the master's value that shows them: a select or a radio group; since * 0.84.3 a list, for rows that show for any of several values, * `data-ra-master-values`), id. */ public static function depends( string $html, string $master, bool $on, array $opts = array() ): string { return ' 'rooster-admin-depends' ) + self::depends_attrs( $master, $on, $opts ) + ( '' !== (string) ( $opts['id'] ?? '' ) ? array( 'id' => (string) $opts['id'] ) : array() ) ) . '>' . $html . ''; } /** * The attributes of one dependent field row (depends() without its wrapper): its attributes (Ui::field_row()'s `attrs`), so a single dependent row needs no * wrapper. * * @param string $master The master control's id. * @param bool $on Whether the row shows now. * @param array $opts value (see depends()). * @return array */ public static function depends_attrs( string $master, bool $on, array $opts = array() ): array { self::enqueue(); $attrs = array( 'data-ra-master' => $master ); if ( is_array( $opts['value'] ?? null ) ) { $attrs['data-ra-master-values'] = (string) wp_json_encode( array_values( array_map( 'strval', $opts['value'] ) ) ); // any of them (0.84.3) } elseif ( isset( $opts['value'] ) ) { $attrs['data-ra-master-value'] = (string) $opts['value']; } if ( ! $on ) { $attrs['hidden'] = true; } return $attrs; } /** * A confirmation by consequence (FR12), printed after the page as any dialog: `reset` (settings or an order back * to the defaults: a plain dialog; or an Undo instead), `apply` (0.63.1: a bulk action that creates or changes many * records and destroys none, "Create 24 accounts?": the counts in the body, a primary "Apply"), `off` (switching off * a legal duty or a status in use: the consequence stated, "Keep it on" or "Switch off"), `delete` (0.65.9: one record * the merchant owns, from its row: "Delete the rule "Express, over 2 kg"?", the record named in the title, a * destructive "Delete", no typed word), `replace` (replacing data: the counts in the body), `loss` (an irreversible * loss of shared or remote data: a typed word). A reversible action * asks nothing (save_attrs()'s `undo`). The control that asks carries confirm_attrs(): the click opens the dialog, * and the action goes on when it is confirmed (a link is followed, a form submitted, a script's click handler run; * first `rooster-admin:confirm` is sent on the control, its detail the field's value). * * A confirmation may ask for one number before it acts (0.63.1, `field`: "How many arrived?"): its field in the * dialog's body, the confirming button disabled while the value is not valid (the field says why). Confirmed, the * value goes with the action: a link gets it as a query argument (the field's `name`), a form a hidden input of that * name, a script the `rooster-admin:confirm` event's detail. A control may bring its own value (confirm_attrs()). * * @param string $id The dialog's id. * @param string $level reset, apply, off, delete, replace, loss. * @param string $title The question (raw): "Replace 240 prices?". * @param string $body What happens (escaped HTML): the consequence, the counts. * @param array $opts confirm (the confirming button's label: the action's verb), cancel, word (loss: * the word to type; default DELETE), field (0.63.1: {name (the query argument or * the hidden input's name; default `value`), label (raw; default "Quantity"), * value, min, max, step (1 by default; `any`), unit (raw: "pcs"), required * (default true), help (raw), short (10rem instead of 6rem)}), say (0.84.1, true: * a place after the body for the asking control's own consequence, its * confirm_attrs() `say`: one dialog for every row of a list, each row's counts; * a product may print the place itself inside its sentence, any element with * `data-ra-confirm-say`), and Ui::dialog()'s options. */ public static function confirm( string $id, string $level, string $title, string $body, array $opts = array() ): string { $level = in_array( $level, self::LEVELS, true ) ? $level : 'reset'; $labels = array( 'reset' => __( 'Reset', 'ai-eshop-optimizer' ), 'apply' => __( 'Apply', 'ai-eshop-optimizer' ), 'off' => __( 'Switch off', 'ai-eshop-optimizer' ), 'delete' => __( 'Delete', 'ai-eshop-optimizer' ), 'replace' => __( 'Replace', 'ai-eshop-optimizer' ), 'loss' => __( 'Delete', 'ai-eshop-optimizer' ), ); $dialog = array( 'confirm' => (string) ( $opts['confirm'] ?? $labels[ $level ] ), 'variant' => in_array( $level, array( 'reset', 'apply' ), true ) ? '' : 'destructive', // nothing is lost: a primary button 'dialog_attrs' => array( 'data-ra-level' => $level ) + ( is_array( $opts['dialog_attrs'] ?? null ) ? $opts['dialog_attrs'] : array() ), ); if ( 'off' === $level ) { $dialog['cancel'] = __( 'Keep it on', 'ai-eshop-optimizer' ); } if ( 'loss' === $level ) { $dialog['confirm_word'] = (string) ( $opts['word'] ?? __( 'DELETE', 'ai-eshop-optimizer' ) ); } $field = is_array( $opts['field'] ?? null ) ? self::confirm_field( $id, $opts['field'] ) : ''; $say = ! empty( $opts['say'] ) ? '' : ''; // states.js says the control's own there unset( $opts['word'], $opts['dialog_attrs'], $opts['field'], $opts['say'] ); self::enqueue(); return Ui::dialog( $id, $title, $body . $say . $field, $dialog + $opts ); } /** * The number a confirmation asks for (0.63.1): one field row, stacked, in the dialog's body after what happens. The * input has no name (the dialog posts nothing): `data-ra-confirm-field` names what the value travels as. * * @param string $dialog The dialog's id (the field's is `-field`). * @param array $field See confirm()'s `field`. */ private static function confirm_field( string $dialog, array $field ): string { $id = $dialog . '-field'; $required = false !== ( $field['required'] ?? true ); $step = (string) ( $field['step'] ?? '1' ); $name = (string) ( $field['name'] ?? '' ); $number = static fn( string $key ): ?string => isset( $field[ $key ] ) && is_scalar( $field[ $key ] ) && '' !== (string) $field[ $key ] ? (string) $field[ $key ] : null; $input = Ui::input( '', (string) ( $number( 'value' ) ?? '' ), array( 'id' => $id, 'type' => 'number', 'xs' => empty( $field['short'] ), 'short' => ! empty( $field['short'] ), 'min' => $number( 'min' ), 'max' => $number( 'max' ), 'step' => $step, 'inputmode' => 1 === preg_match( '/^\d+$/', $step ) ? 'numeric' : 'decimal', 'required' => $required, 'unit' => (string) ( $field['unit'] ?? '' ), 'autocomplete' => 'off', 'attrs' => array( 'data-ra-confirm-field' => '' !== $name ? $name : 'value' ), ) ); return '
    ' . Ui::field_row( array( 'id' => $id, 'label' => (string) ( $field['label'] ?? __( 'Quantity', 'ai-eshop-optimizer' ) ), 'control' => $input, 'help' => (string) ( $field['help'] ?? '' ), 'required' => $required, ) ) . '
    '; } /** * The attributes of a control that asks first (FR12): its click opens the confirmation dialog, and only a * confirmation lets it act. A control of a list (a table's row) may bring its own value for the dialog's field: the * row's requested quantity as the received one's default, and its own bounds. Delegated: a row a table draws later * asks as the first one does. * * @param string $dialog The dialog's id (confirm()). * @param array $opts 0.63.1: value, min, max (this control's own for the dialog's field: * `data-ra-confirm-value`, `-min`, `-max`); 0.84.1: say (raw: this control's * own consequence, with its counts, said where the dialog keeps a place for it: * "It replaces its 14 lines and the 3 you confirmed."). * @return array */ public static function confirm_attrs( string $dialog, array $opts = array() ): array { self::enqueue(); $attrs = array( 'data-ra-confirm' => $dialog, 'aria-haspopup' => 'dialog', ); foreach ( array( 'value', 'min', 'max', 'say' ) as $key ) { if ( isset( $opts[ $key ] ) && is_scalar( $opts[ $key ] ) && '' !== (string) $opts[ $key ] ) { $attrs[ 'data-ra-confirm-' . $key ] = (string) $opts[ $key ]; } } return $attrs; } /** * The job block (FR11, B12): an action that takes time (rebuild, sync, import, export), in a card whose * description says what it does and whose body holds its few options: the Run button (its verb, FR13), the * progress while it runs, then the result line ("Imported 240 rows, 3 skipped · 2 minutes ago · See the log"), the * rows it skipped and why, or the failure with its cause; or "Not run yet" with when it will. The product runs the * job (its button's hooks, a link to its handler) and tells the block where it is with roosterAdmin.states.job(). * * @param array $job id (the block's id), state (not-run, idle, scheduled, running, done, partly, * failed), run (the Run button's label: the job's verb, "Import"), run_href (a link * to the product's handler instead of a button), run_attrs (the button's hooks), * primary (false: the Run in outline, in a card whose primary is its own Save), * confirm (a confirmation dialog's id: a destructive job asks first), disabled and * reason (the Run cannot work now, and why: FR7), progress (0 to 100; 0.63.1: none, * or null, is a progress nobody knows: the bar without a value, which moves while it * runs and stands still under reduced motion), progress_label (raw: "236 of 332 * rows"), stop (0.63.1, raw: the Stop button's label, "Stop the import": shown while * the job runs) with stop_attrs (its hooks: the product stops the job and tells the * block), result (raw: "Imported 240 rows"), after (raw: ", 3 skipped"), facts * (raw: "2 minutes ago"), last (a Unix time: when it last ran, said as "2 minutes * ago"), next (raw: "Runs every night at 03:00"), log (the log's URL), detail * (escaped HTML: the skipped rows and why), error (raw: the cause of a failure and * what to do), error_title (raw: "Could not import the file"), actions (escaped HTML * after the result: See the skipped rows). */ public static function job( array $job ): string { self::enqueue(); $state = in_array( $job['state'] ?? '', self::JOBS, true ) ? (string) $job['state'] : 'idle'; $id = (string) ( $job['id'] ?? '' ); $running = 'running' === $state; $run = (string) ( $job['run'] ?? __( 'Run now', 'ai-eshop-optimizer' ) ); $stop = (string) ( $job['stop'] ?? '' ); $attrs = ( is_array( $job['run_attrs'] ?? null ) ? $job['run_attrs'] : array() ) + array( 'data-ra-job-run' => '' ); if ( '' !== (string) ( $job['confirm'] ?? '' ) ) { $attrs += self::confirm_attrs( (string) $job['confirm'] ); } if ( ! empty( $job['disabled'] ) ) { $attrs['data-ra-job-off'] = ''; // stays disabled when the script tells the block the job stopped } $button = array( 'variant' => false === ( $job['primary'] ?? true ) ? 'outline' : 'primary', // a card whose own Save is its primary (H11) 'icon' => 'play', 'disabled' => $running || ! empty( $job['disabled'] ), 'attrs' => $attrs, ); if ( '' !== (string) ( $job['reason'] ?? '' ) ) { $button['reason'] = (string) $job['reason']; // FR7, Ui::button()'s `reason` (R2): the disabled Run says why } if ( '' !== (string) ( $job['run_href'] ?? '' ) ) { $button['href'] = (string) $job['run_href']; } $html = ' 'rooster-admin-job is-' . $state, 'id' => '' !== $id ? $id : null, 'data-ra-job' => $state, 'aria-busy' => $running ? 'true' : null, ) ) . '>'; $halt = '' === $stop ? '' : Ui::button( $stop, array( 'icon' => 'square', 'attrs' => ( is_array( $job['stop_attrs'] ?? null ) ? $job['stop_attrs'] : array() ) + array( 'data-ra-job-stop' => '', 'hidden' => ! $running, // the script shows it while the job runs ), ) ); $html .= '
    ' . Ui::button( $run, $button ) . $halt . '
    '; $label = (string) ( $job['progress_label'] ?? '' ); $label = '' !== $label ? $label : $run; $pid = '' !== $id ? $id . '-progress' : ''; $bar = isset( $job['progress'] ) && is_numeric( $job['progress'] ) ? Ui::progress( (float) $job['progress'], 100, array( 'label' => $label, 'show_value' => true, 'id' => $pid, ) ) : self::indeterminate( $label, $pid ); $html .= '
    ' . $bar . '
    '; $facts = array_values( array_filter( array_map( 'strval', (array) ( $job['facts'] ?? array() ) ) ) ); if ( ! empty( $job['last'] ) && is_numeric( $job['last'] ) ) { /* translators: %s: how long ago ("2 minutes"). */ $facts[] = sprintf( __( '%s ago', 'ai-eshop-optimizer' ), human_time_diff( (int) $job['last'] ) ); } if ( '' !== (string) ( $job['next'] ?? '' ) ) { $facts[] = (string) $job['next']; } $actions = (string) ( $job['actions'] ?? '' ); if ( '' !== (string) ( $job['log'] ?? '' ) ) { $actions .= Ui::button( __( 'See the log', 'ai-eshop-optimizer' ), array( 'variant' => 'ghost', 'size' => 'sm', 'icon' => 'scroll-text', 'href' => (string) $job['log'], ) ); } $result = (string) ( $job['result'] ?? '' ); if ( '' === $result && in_array( $state, array( 'not-run', 'scheduled' ), true ) ) { $result = 'scheduled' === $state ? self::status_words()['scheduled'] : __( 'Not run yet', 'ai-eshop-optimizer' ); } $html .= self::result_head( $result, array( 'after' => (string) ( $job['after'] ?? '' ), 'facts' => $facts, 'actions' => $actions, 'class' => 'rooster-admin-job__result', 'attrs' => '' === $result && '' === $actions && array() === $facts ? array( 'hidden' => true ) : array(), ) ); if ( '' !== (string) ( $job['detail'] ?? '' ) ) { $html .= '
    ' . (string) $job['detail'] . '
    '; } if ( 'failed' === $state ) { $html .= Ui::notice( esc_html( (string) ( $job['error'] ?? '' ) ), 'destructive', array( 'inline' => true, 'title' => (string) ( $job['error_title'] ?? __( 'Could not finish', 'ai-eshop-optimizer' ) ), 'attrs' => array( 'data-ra-job-error' => '' ), ) ); } return $html . ''; } /** * A progress nobody knows yet (0.63.1): Ui::progress()'s markup with a `` without a value, which the * browser and the stylesheet draw as work under way; its figure printed hidden, for the script to fill once a * value comes (roosterAdmin.states.job( block, { value } )). * * @param string $label The label (raw). * @param string $id The bar's id ('' for none). */ private static function indeterminate( string $label, string $id ): string { return '' . esc_html( $label ) . '' . '' . ''; } }