77 */
88
99/**
10- * An object-declared bulk action must reach the ActionRunner as a real action
10+ * A view's `bulkActions` name must reach the ActionRunner as a real action
1111 * DEF, applied to every selected record (objectui#3002).
1212 *
1313 * Before this, `bulkActions: ['push_down']` dispatched the action NAME in the
1414 * runner's `type` slot — `{ type: 'push_down', params: { records } }` — which
1515 * matches no built-in type and no handler, so it fell through the runner's
1616 * schema fallback (green success toast pre-#2996, a loud failure after it).
17- * Either way the action never ran. And an object could not declare a bulk
18- * action at all: `bulkActionDefs` was passed through from view JSON verbatim,
19- * never derived from `objectDef.actions`.
17+ * Either way the action never ran, and the button carried none of the def's
18+ * label / icon / params: `bulkActionDefs` was passed through from view JSON
19+ * verbatim, never resolved against `objectDef.actions`.
20+ *
21+ * Naming the action in the view is the ONLY way to declare a bulk action —
22+ * spec 17 retired `action.bulkEnabled` as a tombstone (framework#4054) and
23+ * prescribes exactly this. See `resolveBulkActions`.
2024 *
2125 * These drive the REAL ObjectGrid through a real ActionProvider and assert the
2226 * user-visible outcome: selecting rows and clicking the button issues one
@@ -51,17 +55,17 @@ const ROWS = [
5155] ;
5256
5357/**
54- * The object's own bulk action. The label is deliberately NOT the humanization
58+ * The object's declared action. The label is deliberately NOT the humanization
5559 * of the name ("Push Down"), so an assertion on it distinguishes the resolved
56- * def from the legacy path — which could only ever render `formatActionLabel`.
60+ * def from the by-name path — which could only ever render
61+ * `formatActionLabel`.
5762 */
5863const PUSH_DOWN = {
5964 name : 'push_down' ,
6065 label : '下推' ,
6166 type : 'api' ,
6267 target : '/api/v1/plans/push' ,
6368 recordIdParam : 'planId' ,
64- bulkEnabled : true ,
6569} ;
6670
6771let fetchMock : ReturnType < typeof vi . fn > ;
@@ -121,17 +125,32 @@ afterEach(() => {
121125 vi . unstubAllGlobals ( ) ;
122126} ) ;
123127
124- describe ( 'object-declared bulk actions (objectui#3002)' , ( ) => {
125- it ( 'surfaces a bulkEnabled object action with no view declaration at all' , async ( ) => {
126- await renderAndSelectAll ( { objectActions : [ PUSH_DOWN ] } ) ;
128+ describe ( 'view-declared bulk actions (objectui#3002)' , ( ) => {
129+ it ( 'renders the declared action label, not the humanized name' , async ( ) => {
130+ await renderAndSelectAll ( {
131+ objectActions : [ PUSH_DOWN ] ,
132+ schema : { bulkActions : [ 'push_down' ] } ,
133+ } ) ;
127134
128135 const button = await screen . findByTestId ( 'bulk-action-push_down' ) ;
129136 // The object action's own label, not `formatActionLabel('push_down')`.
130137 expect ( button ) . toHaveTextContent ( '下推' ) ;
131138 } ) ;
132139
140+ it ( 'surfaces nothing for an object action the view never names' , async ( ) => {
141+ // `action.bulkEnabled` is a spec-17 tombstone, so there is no object-level
142+ // opt-in: an unnamed action must not reach the selection bar.
143+ renderGrid ( { objectActions : [ { ...PUSH_DOWN , bulkEnabled : true } ] } ) ;
144+ await waitFor ( ( ) => expect ( screen . getByText ( 'Plan A' ) ) . toBeInTheDocument ( ) ) ;
145+
146+ expect ( screen . queryByTestId ( 'bulk-action-push_down' ) ) . not . toBeInTheDocument ( ) ;
147+ } ) ;
148+
133149 it ( 'issues one request per selected record instead of no-opping' , async ( ) => {
134- await renderAndSelectAll ( { objectActions : [ PUSH_DOWN ] } ) ;
150+ await renderAndSelectAll ( {
151+ objectActions : [ PUSH_DOWN ] ,
152+ schema : { bulkActions : [ 'push_down' ] } ,
153+ } ) ;
135154
136155 fireEvent . click ( await screen . findByTestId ( 'bulk-action-push_down' ) ) ;
137156 // No params on this action → the dialog opens straight on confirm.
@@ -148,22 +167,6 @@ describe('object-declared bulk actions (objectui#3002)', () => {
148167 expect ( sentIds ) . toEqual ( [ 'r1' , 'r2' ] ) ;
149168 } ) ;
150169
151- it ( 'resolves a legacy bulkActions name to the declared action' , async ( ) => {
152- // The reported shape: the object never flagged the action, the view names it.
153- const unflagged = { ...PUSH_DOWN , bulkEnabled : undefined } ;
154- await renderAndSelectAll ( {
155- objectActions : [ unflagged ] ,
156- schema : { bulkActions : [ 'push_down' ] } ,
157- } ) ;
158-
159- const button = await screen . findByTestId ( 'bulk-action-push_down' ) ;
160- expect ( button ) . toHaveTextContent ( '下推' ) ;
161-
162- fireEvent . click ( button ) ;
163- fireEvent . click ( await screen . findByRole ( 'button' , { name : 'Run' } ) ) ;
164- await waitFor ( ( ) => expect ( fetchMock ) . toHaveBeenCalledTimes ( 2 ) ) ;
165- } ) ;
166-
167170 it ( 'reports per-record failures instead of counting them as successes' , async ( ) => {
168171 fetchMock . mockResolvedValue ( {
169172 ok : false ,
@@ -172,7 +175,10 @@ describe('object-declared bulk actions (objectui#3002)', () => {
172175 headers : { get : ( ) => 'application/json' } ,
173176 json : async ( ) => ( { error : 'precondition not met' } ) ,
174177 } ) ;
175- await renderAndSelectAll ( { objectActions : [ PUSH_DOWN ] } ) ;
178+ await renderAndSelectAll ( {
179+ objectActions : [ PUSH_DOWN ] ,
180+ schema : { bulkActions : [ 'push_down' ] } ,
181+ } ) ;
176182
177183 fireEvent . click ( await screen . findByTestId ( 'bulk-action-push_down' ) ) ;
178184 fireEvent . click ( await screen . findByRole ( 'button' , { name : 'Run' } ) ) ;
@@ -184,27 +190,27 @@ describe('object-declared bulk actions (objectui#3002)', () => {
184190 await waitFor ( ( ) => expect ( screen . getByText ( / S u c c e e d e d 0 \/ 2 / ) ) . toBeInTheDocument ( ) ) ;
185191 expect ( screen . getByTestId ( 'bulk-error-row-r1' ) ) . toHaveTextContent ( 'HTTP 422' ) ;
186192 expect ( screen . getByTestId ( 'bulk-error-row-r2' ) ) . toBeInTheDocument ( ) ;
187- // A derived def re-dispatches for one record, so the row keeps its Retry.
193+ // A promoted def re-dispatches for one record, so the row keeps its Retry.
188194 expect ( screen . getByTestId ( 'bulk-error-retry-r1' ) ) . toBeInTheDocument ( ) ;
189195 } ) ;
190196
191- it ( 'renders one button when a legacy name repeats a derived action ' , async ( ) => {
197+ it ( 'renders one button for a repeated name' , async ( ) => {
192198 await renderAndSelectAll ( {
193199 objectActions : [ PUSH_DOWN ] ,
194- schema : { bulkActions : [ 'push_down' ] } ,
200+ schema : { bulkActions : [ 'push_down' , 'push_down' ] } ,
195201 } ) ;
196202
197203 await waitFor ( ( ) => expect ( screen . getAllByTestId ( 'bulk-action-push_down' ) ) . toHaveLength ( 1 ) ) ;
198204 } ) ;
199205
200- it ( 'keeps an unresolvable legacy name alongside the derived defs' , async ( ) => {
206+ it ( 'keeps an unresolvable name alongside the promoted defs' , async ( ) => {
201207 // A name the object never declared may still have a runner handler
202208 // registered under it; hiding it because some OTHER def exists dropped
203209 // half the bar's buttons.
204210 const handler = vi . fn ( async ( ) => ( { success : true } ) ) ;
205211 await renderAndSelectAll ( {
206212 objectActions : [ PUSH_DOWN ] ,
207- schema : { bulkActions : [ 'crm_only_handler' ] } ,
213+ schema : { bulkActions : [ 'push_down' , ' crm_only_handler'] } ,
208214 handlers : { crm_only_handler : handler } ,
209215 } ) ;
210216
0 commit comments