} */ function addon_boss_normalize_when( $when ): array { $when = is_array( $when ) ? $when : array(); // Direction. Everything ever stored is a SHOW rule and carries no act at // all, so absent must keep meaning show or every configured product on // every install flips over on update. $act = ( 'hide' === (string) ( $when['act'] ?? '' ) ) ? 'hide' : 'show'; // One condition, cleaned or dropped. The vocabulary is words a merchant // says out loud, and each trigger kind offers only the words that fit it: // a choice is / is not / is any of / is answered; a field adds filled and // empty, and a numeric compare for the fields that hold numbers. $bb_clean_cond = static function ( $c ) { if ( ! is_array( $c ) ) { return null; } // A condition names EITHER a set or a field, never both. A field // trigger is the newer half; a set trigger is unchanged. $bb_set = (string) ( $c['set'] ?? '' ); $bb_field = '' === $bb_set ? (string) ( $c['field'] ?? '' ) : ''; if ( '' === $bb_set && '' === $bb_field ) { return null; } $bb_ops = '' !== $bb_field ? array( 'is', 'is_not', 'is_any', 'filled', 'empty', 'more', 'less', 'exact' ) : array( 'is', 'is_not', 'is_any', 'filled', 'empty' ); $op = in_array( $c['op'] ?? 'is', $bb_ops, true ) ? (string) $c['op'] : 'is'; // is_any carries SEVERAL answers ("Birthday or Anniversary") and // absorbs most OR demand inside one sentence; it stores a clean list. if ( 'is_any' === $op ) { $bb_vals = array(); foreach ( (array) ( $c['value'] ?? array() ) as $bb_v ) { $bb_v = (string) $bb_v; if ( '' !== $bb_v && ! in_array( $bb_v, $bb_vals, true ) ) { $bb_vals[] = $bb_v; } } if ( ! $bb_vals ) { return null; // "is any of nothing" is not a rule. } // One value is just "is": store the simpler sentence it really is. if ( 1 === count( $bb_vals ) ) { return array( 'set' => $bb_set, 'field' => $bb_field, 'op' => 'is', 'value' => $bb_vals[0] ); } return array( 'set' => $bb_set, 'field' => $bb_field, 'op' => 'is_any', 'value' => $bb_vals ); } // A numeric compare needs a number to compare with, or it is nothing. if ( in_array( $op, array( 'more', 'less', 'exact' ), true ) ) { $bb_num = trim( (string) ( $c['value'] ?? '' ) ); if ( '' === $bb_num || ! is_numeric( $bb_num ) ) { return null; } return array( 'set' => '', 'field' => $bb_field, 'op' => $op, 'value' => (string) (float) $bb_num ); } return array( 'set' => $bb_set, 'field' => $bb_field, 'op' => $op, // filled / empty carry no value; keeping one would let a stale // string decide something nobody can see on the screen. 'value' => in_array( $op, array( 'filled', 'empty' ), true ) ? '' : (string) ( $c['value'] ?? '' ), ); }; // The normal form is act + GROUPS: conditions inside a group must all // hold (the printed word "and"), and any one group holding fires the rule // ("Or when..."). Every older shape maps in without touching stored data: // a flat all-list is one group; a flat any-list is one group per // condition, because OR of singles is exactly what "any" always meant. $bb_groups = array(); if ( isset( $when['groups'] ) && is_array( $when['groups'] ) ) { foreach ( $when['groups'] as $bb_g ) { $bb_conds = array(); foreach ( (array) ( is_array( $bb_g ) && isset( $bb_g['conditions'] ) ? $bb_g['conditions'] : $bb_g ) as $c ) { $bb_c = $bb_clean_cond( $c ); if ( null !== $bb_c ) { $bb_conds[] = $bb_c; } } if ( $bb_conds ) { $bb_groups[] = $bb_conds; } } } elseif ( isset( $when['conditions'] ) && is_array( $when['conditions'] ) ) { $bb_conds = array(); foreach ( $when['conditions'] as $c ) { $bb_c = $bb_clean_cond( $c ); if ( null !== $bb_c ) { $bb_conds[] = $bb_c; } } if ( $bb_conds ) { if ( 'any' === ( $when['match'] ?? 'all' ) ) { foreach ( $bb_conds as $bb_c ) { $bb_groups[] = array( $bb_c ); } } else { $bb_groups[] = $bb_conds; } } } elseif ( isset( $when['set'] ) && array_key_exists( 'is', $when ) && '' !== (string) $when['set'] ) { $bb_groups[] = array( array( 'set' => (string) $when['set'], 'field' => '', 'op' => 'is', 'value' => (string) $when['is'] ) ); } return array( 'act' => $act, 'groups' => $bb_groups ); } /** * Every condition of a normalised rule, group boundaries flattened away, for * the readers that ask about triggers rather than logic (which triggers are * referenced, are they all fields, do any name this set). * * @return array */ function addon_boss_when_all_conditions( array $normal ): array { $out = array(); foreach ( (array) ( $normal['groups'] ?? array() ) as $bb_g ) { foreach ( (array) $bb_g as $bb_c ) { $out[] = $bb_c; } } return $out; } /** * Does a rule use the numeric compares, the half of the vocabulary that asks * the 'reveal' capability? * * @param mixed $when The stored rule, any shape. */ function addon_boss_when_is_deep( $when ): bool { foreach ( addon_boss_when_all_conditions( addon_boss_normalize_when( $when ) ) as $bb_c ) { if ( in_array( $bb_c['op'], array( 'more', 'less', 'exact' ), true ) ) { return true; } } return false; } /** * Does this build EVALUATE a given rule? The basic vocabulary always runs; * a rule carrying a numeric compare runs only with the capability. A rule * this build does not evaluate is ignored whole and its row shows: judging * it with only the conditions this build understands could hide a row for * a reason the merchant never wrote. * * @param mixed $when The stored rule, any shape. */ function addon_boss_when_runs_here( $when ): bool { return addon_boss_engine_can( 'reveal' ) || ! addon_boss_when_is_deep( $when ); } /** * Is ONE condition satisfied by the current answers? * * A trigger that is itself hidden answers nothing, whether it is a set or a * field. That is the discipline that stops a chain being satisfied through a * middle nobody can see, and it applies to both kinds by the same line of code. * * @param array $cond One normalised condition. * @param array $picks Set id => picked keys, plus field: => answer. * @param array $active The same keys => whether that trigger is on the page. * @return bool */ function addon_boss_condition_met( array $cond, array $picks, array $active ): bool { $bb_id = '' !== $cond['set'] ? $cond['set'] : ADDON_BOSS_FIELD_RULE_KEY . $cond['field']; // A trigger that is not on the page answers NOTHING, rather than answering // false. The difference matters: "is not red" is satisfied by a colour // nobody was asked for, and so is "is empty". Short-circuiting the whole // condition to false instead would quietly invert both. $bb_answers = empty( $active[ $bb_id ] ) ? array() // A several-of trigger's entry is its ARRAY of picked keys; a pick-one // set and a field both hand over a string. "Is" means "is among the // answers", which for one answer is exactly the old equality. : array_map( 'strval', (array) ( $picks[ $bb_id ] ?? '' ) ); $bb_filled = (bool) array_filter( $bb_answers, 'strlen' ); switch ( $cond['op'] ) { case 'filled': return $bb_filled; case 'empty': return ! $bb_filled; case 'is_not': return ! in_array( $cond['value'], $bb_answers, true ); case 'is_any': // Several answers in one sentence: met when ANY of them is among // what was picked. The value is a list by construction. return (bool) array_intersect( array_map( 'strval', (array) $cond['value'] ), $bb_answers ); case 'more': case 'less': case 'exact': // A numeric compare of the first real answer. An unanswered field // compares as nothing at all, the same way "is" of nothing fails: // no answer can never be "more than 5". if ( ! $bb_filled ) { return false; } $bb_first = null; foreach ( $bb_answers as $bb_a ) { if ( '' !== $bb_a ) { $bb_first = $bb_a; break; } } if ( null === $bb_first || ! is_numeric( $bb_first ) ) { return false; } $bb_n = (float) $bb_first; $bb_t = (float) $cond['value']; return 'more' === $cond['op'] ? $bb_n > $bb_t : ( 'less' === $cond['op'] ? $bb_n < $bb_t : $bb_n === $bb_t ); default: return in_array( $cond['value'], $bb_answers, true ); } } /** * Is a reveal rule satisfied for one pick state? * * An unconditional rule is always TRUE in either match mode, so an empty rule * can never flip something to hidden. A condition tests a referenced set that * must itself be ACTIVE (an inert set's pick never counts) and picked with the * value; 'is_not' inverts. 'all' is AND, 'any' is OR. * * Those two are the classic client/server divergence footguns in this category, * so both are pinned by tests rather than left to reading. */ function addon_boss_when_met( $when, array $picks, array $active ): bool { $n = addon_boss_normalize_when( $when ); if ( empty( $n['groups'] ) ) { return true; } $fired = addon_boss_when_groups_fired( $n, $picks, $active ); if ( null === $fired ) { // PAUSED: the rule points at a question that is not on this product any // more. A paused rule rules nothing out, in either direction — the row // SHOWS, because visible breakage gets noticed and quietly-hidden // breakage never does. The builder says the same thing in words. return true; } // A HIDE rule says the opposite of what its conditions decide. The empty // case is handled above, deliberately: "hide when nothing" must never mean // "hide always", in either direction. return ( 'hide' === $n['act'] ) ? ! $fired : $fired; } /** * Did a normalised rule's conditions FIRE — before the act is applied? * * A group fires when EVERY condition in it holds (the printed word "and"); * the rule fires when ANY group does ("Or when..."). One flat all-list is one * group; one flat any-list normalised to single-condition groups, so both of * yesterday's shapes keep exactly yesterday's answers. * * Returns NULL when the rule cannot be judged at all: some condition names a * trigger that is not in the state — a set taken off the product, a field row * deleted — as opposed to one that is attached but currently hidden, which * answers nothing and judges normally. Callers translate null to "paused". * * @param array $normal The {act,groups} shape from addon_boss_normalize_when(). * @param array $picks Trigger id => current answers. * @param array $active Trigger id => whether it is on the page; the KEY LIST * is the roster of what exists at all. * @return bool|null */ function addon_boss_when_groups_fired( array $normal, array $picks, array $active ) { foreach ( (array) ( $normal['groups'] ?? array() ) as $bb_g ) { foreach ( (array) $bb_g as $c ) { $bb_id = '' !== (string) $c['set'] ? (string) $c['set'] : ADDON_BOSS_FIELD_RULE_KEY . $c['field']; if ( ! array_key_exists( $bb_id, $active ) ) { return null; } } } foreach ( (array) ( $normal['groups'] ?? array() ) as $bb_g ) { $bb_all = true; foreach ( (array) $bb_g as $c ) { if ( ! addon_boss_condition_met( $c, $picks, $active ) ) { $bb_all = false; break; } } if ( $bb_all ) { return true; } } return false; } /** * Answer the engine's when-met seam with the real evaluator. * * @param bool $shown The default (visible). * @param mixed $when The stored rule. * @param array $picks Set id => picked option key. * @param array $active Set id => active. * @return bool */ function addon_boss_reveal_when_met( $shown, $when, array $picks, array $active ): bool { if ( ! addon_boss_when_runs_here( $when ) ) { return true; // a rule this build does not evaluate rules nothing out. } return addon_boss_when_met( $when, $picks, $active ); } add_filter( 'addon_boss_when_met', 'addon_boss_reveal_when_met', 10, 4 ); /** * The reveal-rule second pass on a product's rows, applied to every row * including group children: a rule may only point at another set attached to * the product, with a real option of that set. Anything half-filled or * dangling is blanked. Mutates in place (groups recurse into their children). */ function addon_boss_validate_option_when( array &$rows, array $attached, array &$seen_fields = array() ): void { foreach ( $rows as $i => $row ) { if ( ( $row['row_type'] ?? '' ) === 'group' ) { $children = is_array( $row['children'] ?? null ) ? $row['children'] : array(); addon_boss_validate_option_when( $children, $attached, $seen_fields ); $rows[ $i ]['children'] = $children; continue; } // Validate through the canonical shape so a multi-condition rule survives // this pass. Reading only { set, is } silently blanked every rule with // more than one condition, which looked exactly like the save failing. $rule = addon_boss_normalize_when( $row['when'] ?? array() ); // A SET row's rule may only speak the positive words. Its show rules // join the activation fixpoint, which converges because it only ever // turns things on; a negative there could leave the server counting a // required set active that the settled page hides — a checkout nobody // can complete. The builder never offers the words; this line keeps // hand-crafted data to the same grammar. Field rows, which nothing // else depends on, keep the full vocabulary. $bb_set_row = ( $row['row_type'] ?? '' ) !== 'field'; // One condition, kept, repaired (is_any with a dead value keeps its live // ones) or dropped. Shared by every group so no group validates by a // different rulebook. $bb_judge = static function ( array $cond ) use ( $row, $attached, $seen_fields, $bb_set_row ) { $ref = (string) $cond['set']; if ( $bb_set_row && in_array( $cond['op'], array( 'is_not', 'empty' ), true ) ) { return null; } if ( '' !== (string) $cond['field'] ) { // A FIELD trigger must be a field already ASKED, which is to say // one of the rows above this one. That is what removes the need // for a fixpoint over field answers, and it makes a cycle // impossible rather than merely detected. (It also keeps a set // row from ruling on a field: the set fixpoint runs before the // field state exists, so such a rule could only ever pause.) $bb_f = (string) $cond['field']; $bb_ok = isset( $seen_fields[ $bb_f ] ) && $bb_f !== (string) ( $row['field']['key'] ?? '' ); if ( ! $bb_ok ) { return null; } if ( in_array( $cond['op'], array( 'is', 'is_not' ), true ) && '' === (string) $cond['value'] ) { return null; // "is" with nothing to compare against decides nothing } if ( 'is_any' === $cond['op'] && ! (array) $cond['value'] ) { return null; } return $cond; } if ( '' === $ref || $ref === ( $row['set'] ?? '' ) ) { return null; } $ref_set = in_array( $ref, $attached, true ) ? addon_boss_option_set( $ref ) : null; if ( ! $ref_set ) { // The set is off the product (or gone from the library). The // rule is KEPT, shape-checked only, and runs PAUSED — the row // shows and the builder says why. Blanking it here is how a // rival made re-attaching a set silently lose every rule that // pointed at it; visible breakage gets noticed, invisible // breakage never does. if ( in_array( $cond['op'], array( 'filled', 'empty' ), true ) ) { return $cond; } if ( 'is_any' === $cond['op'] ) { $bb_kept = array_values( array_filter( array_map( 'strval', (array) $cond['value'] ), 'addon_boss_option_key_valid' ) ); if ( ! $bb_kept ) { return null; } $cond['value'] = $bb_kept; return $cond; } return addon_boss_option_key_valid( (string) $cond['value'] ) ? $cond : null; } $bb_keys = array_column( $ref_set['options'], 'key' ); if ( 'is_any' === $cond['op'] ) { // Keep the values that are still real choices; one left is an // ordinary "is", none left is no condition. $bb_live = array_values( array_intersect( array_map( 'strval', (array) $cond['value'] ), $bb_keys ) ); if ( ! $bb_live ) { return null; } if ( 1 === count( $bb_live ) ) { return array( 'set' => $ref, 'field' => '', 'op' => 'is', 'value' => $bb_live[0] ); } $cond['value'] = $bb_live; return $cond; } $is = (string) $cond['value']; if ( in_array( $cond['op'], array( 'filled', 'empty' ), true ) ) { return $cond; // asks whether anything is picked; no value to check. } if ( '' === $is || ! addon_boss_option_key_valid( $is ) || ! in_array( $is, $bb_keys, true ) ) { return null; } return $cond; }; $bb_groups = array(); foreach ( (array) $rule['groups'] as $bb_g ) { $bb_kept = array(); foreach ( $bb_g as $cond ) { $bb_c = $bb_judge( $cond ); if ( null !== $bb_c ) { $bb_kept[] = $bb_c; } } if ( $bb_kept ) { $bb_groups[] = $bb_kept; } } // Recorded AFTER this row's own rule is judged, so a field can never // point at itself and the "above only" reading is exact. A field that // may not BE a trigger — display-only, hidden, an amount they choose — // is never recorded, so a rule pointing at one is judged dead here by // the same fence the builder's list applies. $bb_own_key = (string) ( $row['field']['key'] ?? '' ); if ( '' !== $bb_own_key ) { $bb_own_type = (string) ( $row['field']['type'] ?? '' ); $bb_types = addon_boss_personalisation_field_types(); if ( empty( $bb_types[ $bb_own_type ]['display_only'] ) && empty( $bb_types[ $bb_own_type ]['admin_only'] ) && empty( $bb_types[ $bb_own_type ]['own_amount'] ) ) { $seen_fields[ $bb_own_key ] = $bb_own_type; } } // Stored in the SMALLEST shape that says it (legacy single, flat, or // groups), so an ordinary rule is unchanged on disk and older readers // keep reading it. $rows[ $i ]['when'] = addon_boss_when_store_shape( array( 'act' => $rule['act'], 'groups' => $bb_groups, ) ); } } /** * Hook the validation into the engine's row sanitiser. * * @param array $rows The sanitised rows. * @param array $attached The attached set ids. * @return array */ function addon_boss_reveal_sanitize_rows( array $rows, array $attached ): array { addon_boss_validate_option_when( $rows, $attached ); return $rows; } add_filter( 'addon_boss_sanitize_option_rows', 'addon_boss_reveal_sanitize_rows', 10, 2 ); /** * Which resolved sets are ACTIVE for one pick state: the fixpoint over the * rules. A conditional set is active only when its rule fires against the * settled state; chains resolve and cycles stay inactive. * * Reveal rules activate monotonically (a set only ever turns ON as the * fixpoint runs), which is what keeps this loop sound — and it stays sound * because a SET row's show rule is positive by construction: the builder * offers is / is any of / is answered there and spells every negative as a * "Hidden when" rule instead, judged once after the settle. A negative that * could turn a set back off mid-loop would let two sets flip each other * forever, and — worse — could leave the server counting a required set * active that the browser's settled page hides, which is a checkout nobody * can complete. Fields, which nothing else depends on, take the full * evaluator with the full vocabulary. * * @param array $active Set id => active, seeded ruleless-true by the engine. * @param array $sets Resolved sets carrying 'when'. * @param array $picks Set id => currently picked option key. * @return array */ function addon_boss_reveal_active_sets( array $active, array $sets, array $picks ): array { // A rule this build does not evaluate is ignored whole, in BOTH directions: // its set is active as if it had no rule, before the passes below ever // judge anything. Settled here so a deep show rule cannot leave a set // seeded-off and a deep hide rule cannot turn one off. foreach ( $sets as $set ) { if ( ! empty( $set['when'] ) && ! addon_boss_when_runs_here( $set['when'] ) ) { $active[ $set['id'] ] = true; } } $rounds = count( $sets ); for ( $i = 0; $i < $rounds; $i++ ) { $changed = false; // Every rule this pass is judged against the SAME state — the one the // previous pass settled on — so the answer never depends on which row // happened to be walked first. $frozen = $active; foreach ( $sets as $set ) { if ( ! empty( $active[ $set['id'] ] ) || empty( $set['when'] ) ) { continue; } $rule = addon_boss_normalize_when( $set['when'] ); if ( 'hide' === $rule['act'] || empty( $rule['groups'] ) ) { continue; // seeded active by the engine; the pass below turns it off. } // The builder only offers a SET row's show rule the positive words // (is, is any of, is answered) and puts every negative behind the // "Hidden when" act instead — see the note on the save validation. // Positives only ever go from unmet to met as sets turn on, which // is what lets this loop converge and is why the browser's own // evaluation of the same rules lands on the same state. A PAUSED // rule (null: its trigger is off the product) shows, the same // fail-safe as everywhere else. $fired = addon_boss_when_groups_fired( $rule, $picks, $frozen ); if ( false !== $fired ) { $active[ $set['id'] ] = true; $changed = true; } } if ( ! $changed ) { break; } } // HIDE rules, once, against the settled state. The pass above only ever // turns things ON, which is what makes a chain of show rules converge; a // hide rule is the other direction and cannot ride it. Judged after the // settle, its conditions read exactly the state the shopper's page shows, // so the server's last word and the browser's agree. A paused hide rule // (trigger gone) hides nothing. foreach ( $sets as $set ) { if ( empty( $set['when'] ) || ! addon_boss_when_runs_here( $set['when'] ) ) { continue; } $rule = addon_boss_normalize_when( $set['when'] ); if ( 'hide' !== $rule['act'] || empty( $rule['groups'] ) ) { continue; } if ( true === addon_boss_when_groups_fired( $rule, $picks, $active ) ) { $active[ $set['id'] ] = false; } } return $active; } add_filter( 'addon_boss_active_option_sets', 'addon_boss_reveal_active_sets', 10, 3 ); /** * The availability line for a conditional group, in the customer's words * and the configurator convention: "Available when is ' ).text( bbL.always ) ); var setHome = sel; if ( sets.length && fields.length ) { setHome = $( '' ).attr( 'label', bbL.setsGrp ).appendTo( sel ); } sets.forEach( function ( t ) { setHome.append( $( '' ).val( t.id ).text( t.name ) ); } ); if ( fields.length ) { var g = sets.length ? $( '' ).attr( 'label', bbL.fieldsGrp ).appendTo( sel ) : sel; fields.forEach( function ( t ) { g.append( $( '' ).val( t.id ).text( t.name ) ); } ); } // A rule already pointing somewhere keeps pointing there, even at // a trigger since removed: silently repointing a saved rule would // change a live product with nobody asking. It reads as paused. if ( current && ! sel.find( 'option' ).filter( function () { return this.value === current; } ).length ) { sel.append( $( '' ).val( current ).text( current ) ); } sel.val( current || '' ); return sel; } function bbValueControl( shell, trig, op, value ) { if ( ! trig || op === 'filled' || op === 'empty' ) { return $(); } if ( op === 'more' || op === 'less' || op === 'exact' ) { return $( '' ).val( value || '' ); } var list = bbValueList( shell, trig ); if ( ! list ) { return $( '' ).attr( 'placeholder', bbL.valuePh ) .val( op === 'is_any' && Array.isArray( value ) ? value.join( ', ' ) : ( value || '' ) ); } if ( op === 'is_any' ) { var box = $( '' ); var have = Array.isArray( value ) ? value.map( String ) : [ String( value || '' ) ]; list.forEach( function ( pair ) { var lab = $( '' ); $( '' ).val( pair[ 0 ] ) .prop( 'checked', have.indexOf( String( pair[ 0 ] ) ) !== -1 ).appendTo( lab ); lab.append( $( '' ).text( pair[ 1 ] ) ); box.append( lab ); } ); return box; } var sel = $( '' ); list.forEach( function ( pair ) { sel.append( $( '' ).val( pair[ 0 ] ).text( pair[ 1 ] ) ); } ); if ( value && ! sel.find( 'option' ).filter( function () { return this.value === value; } ).length ) { sel.append( $( '' ).val( value ).text( value ) ); } sel.val( value || ( list[ 0 ] ? list[ 0 ][ 0 ] : '' ) ); return sel; } function bbCondRow( shell, gi, ci, cond ) { var row = $( '
' ).attr( { 'data-g': gi, 'data-c': ci } ); var id = cond ? ( cond.set || ( cond.field ? bbFieldPrefix + cond.field : '' ) ) : ''; var trig = id ? bbFindTrigger( shell, id ) : null; if ( ci > 0 ) { row.append( $( '' ).text( bbL.andWord ) ); } row.append( bbTriggerSelect( shell, id ) ); if ( id ) { var ops = bbOpsFor( shell, trig ); var opSel = $( '' ); $.each( ops, function ( k, word ) { opSel.append( $( '' ).val( k ).text( word ) ); } ); var op = cond && ops[ cond.op ] ? cond.op : opSel.find( 'option' ).first().val(); opSel.val( op ); row.append( opSel ); row.append( bbValueControl( shell, trig, op, cond ? cond.value : '' ) ); } row.append( $( '' ).attr( 'aria-label', bbL.dropCond ) ); return row; } function bbRender( shell ) { var rule = bbRead( shell ); var body = shell.find( '.bb-when-body' ).empty(); var lead = $( '' ) .append( $( '' ).text( bbL.showLead ) ) .append( $( '' ).text( bbL.hideLead ) ) .val( rule.act ); body.append( $( '
' ).append( lead ) ); var groups = rule.groups.length ? rule.groups : [ [ null ] ]; groups.forEach( function ( g, gi ) { var gbox = $( '
' ).attr( 'data-g', gi ); if ( gi > 0 ) { gbox.append( $( '
' ) .append( $( '' ).text( bbL.orLink.replace( '…', '' ) ) ) .append( $( '' ).attr( 'aria-label', bbL.dropGroup ) ) ); } var rows = $( '
' ); ( g.length ? g : [ null ] ).forEach( function ( c, ci ) { rows.append( bbCondRow( shell, gi, ci, c ) ); } ); gbox.append( rows ); if ( g.length && g[ 0 ] ) { gbox.append( $( '' ).attr( 'data-g', gi ).text( bbL.addCond ) ); } body.append( gbox ); } ); if ( rule.groups.length && rule.groups[ rule.groups.length - 1 ].length ) { body.append( $( '' ).text( bbL.orLink ) ); } // Which fields are OUT of reach from here, and what to do about it. var below = bbMineFields( shell ).below; if ( below.length ) { body.append( $( '

' ).text( bbL.belowMsg.replace( '%s', below.map( function ( f ) { return f.name; } ).join( ', ' ) ) ) ); } bbSay( shell ); } // ---- reading an edit back into the rule -------------------------- function bbCondFromRow( shell, row ) { var id = row.find( '.bb-when-set' ).val() || ''; if ( ! id ) { return null; } var isField = id.indexOf( bbFieldPrefix ) === 0; var cond = { set: isField ? '' : id, field: isField ? id.slice( bbFieldPrefix.length ) : '', op: row.find( '.bb-when-op' ).val() || 'is', value: '' }; if ( cond.op === 'is_any' ) { var vals = []; row.find( '.bb-when-multival:checked' ).each( function () { vals.push( this.value ); } ); if ( ! vals.length ) { var typed = ( row.find( '.bb-when-txt' ).val() || '' ).split( ',' ); typed.forEach( function ( v ) { v = v.trim(); if ( v ) { vals.push( v ); } } ); } cond.value = vals; } else if ( cond.op !== 'filled' && cond.op !== 'empty' ) { cond.value = row.find( '.bb-when-is, .bb-when-num, .bb-when-txt' ).first().val() || ''; } return cond; } function bbHarvest( shell ) { var rule = bbRead( shell ); rule.act = shell.find( '.bb-when-actsel' ).val() === 'hide' ? 'hide' : 'show'; var groups = []; shell.find( '.bb-when-group' ).each( function () { var g = []; $( this ).find( '.bb-when-row' ).each( function () { var c = bbCondFromRow( shell, $( this ) ); if ( c ) { g.push( c ); } } ); groups.push( g ); } ); rule.groups = groups.filter( function ( g ) { return g.length; } ); shell.data( 'bbRule', rule ); bbWrite( shell ); } // ---- wiring ------------------------------------------------------ panel.on( 'click', '.bb-when-toggle', function ( e ) { e.preventDefault(); var shell = $( this ).closest( '.bb-when-builder' ); var body = shell.find( '.bb-when-body' ); var open = body.prop( 'hidden' ); if ( open ) { bbRender( shell ); } body.prop( 'hidden', ! open ); $( this ).attr( 'aria-expanded', open ? 'true' : 'false' ).text( open ? bbL.doneRule : bbL.editRule ); } ); // Trigger or operator changed: the row's other controls follow, so the // sentence can never hold words that do not fit its trigger. panel.on( 'change', '.bb-when-builder .bb-when-set, .bb-when-builder .bb-when-op', function () { var shell = $( this ).closest( '.bb-when-builder' ); var row = $( this ).closest( '.bb-when-row' ); if ( $( this ).hasClass( 'bb-when-set' ) ) { // Rebuild the whole row for the new trigger's vocabulary. var fresh = bbCondRow( shell, +row.attr( 'data-g' ), +row.attr( 'data-c' ), bbCondFromRow( shell, row ) ); row.replaceWith( fresh ); } else { var id = row.find( '.bb-when-set' ).val() || ''; var trig = bbFindTrigger( shell, id ); row.find( '.bb-when-is, .bb-when-num, .bb-when-txt, .bb-when-multi' ).remove(); bbValueControl( shell, trig, $( this ).val(), '' ).insertAfter( row.find( '.bb-when-op' ) ); } bbHarvest( shell ); } ); panel.on( 'change input', '.bb-when-builder .bb-when-is, .bb-when-builder .bb-when-num, .bb-when-builder .bb-when-txt, .bb-when-builder .bb-when-multival, .bb-when-builder .bb-when-actsel', function () { bbHarvest( $( this ).closest( '.bb-when-builder' ) ); } ); panel.on( 'click', '.bb-when-builder .bb-when-add', function ( e ) { e.preventDefault(); var shell = $( this ).closest( '.bb-when-builder' ); bbHarvest( shell ); var rule = bbRead( shell ); var gi = +$( this ).attr( 'data-g' ) || 0; if ( rule.groups[ gi ] ) { rule.groups[ gi ].push( { set: '', field: '', op: 'is', value: '' } ); } bbRender( shell ); } ); panel.on( 'click', '.bb-when-builder .bb-when-or', function ( e ) { e.preventDefault(); var shell = $( this ).closest( '.bb-when-builder' ); bbHarvest( shell ); var rule = bbRead( shell ); rule.groups.push( [] ); bbRender( shell ); } ); panel.on( 'click', '.bb-when-builder .bb-when-drop', function ( e ) { e.preventDefault(); var shell = $( this ).closest( '.bb-when-builder' ); $( this ).closest( '.bb-when-row' ).remove(); bbHarvest( shell ); bbRender( shell ); } ); panel.on( 'click', '.bb-when-builder .bb-when-gdrop', function ( e ) { e.preventDefault(); var shell = $( this ).closest( '.bb-when-builder' ); $( this ).closest( '.bb-when-group' ).remove(); bbHarvest( shell ); bbRender( shell ); } ); // The closed row's line of facts owns up to a rule, and follows edits // live: bbSay pokes the row, this reads the carrier the save will read. window.addonBossSummarise = window.addonBossSummarise || []; window.addonBossSummarise.push( function ( bits, ftype, row ) { var shell = row.find( '.bb-when-builder' ).first(); if ( ! shell.length ) { return bits; } var rule = null; try { rule = JSON.parse( shell.find( '.bb-when-json' ).val() || '' ); } catch ( e ) { rule = null; } if ( rule && Array.isArray( rule.groups ) && rule.groups.length ) { bits.push( rule.act === 'hide' ? bbL.bitHidden : bbL.bitShown ); } return bits; } ); // Every saved rule reads itself out loud once the panel is up. panel.find( '.bb-when-builder' ).each( function () { bbSay( $( this ) ); } ); } );