Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/.vitepress/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ export default defineConfig({
nav: [
{ text: "Guide", link: "/get_started/about", activeMatch: "^/(get_started|configuration|advanced)/" },
{ text: "Tutorial", link: "/tutorials/", activeMatch: "^/tutorials/" },
{ text: "Everyday Work", link: "/everyday/", activeMatch: "^/everyday/" },
{ text: "Cookbook", link: "/cookbook/", activeMatch: "^/cookbook/" },
{ text: "API", link: "/resources/api", activeMatch: "^/resources/" },
{
Expand Down Expand Up @@ -227,6 +228,23 @@ export default defineConfig({
{ text: "12. Unit Tests", link: "/tutorials/step-12" },
],
},
{
// Between the Tutorial and the Cookbook: a tutorial teaches one
// concept per step, a cookbook chapter answers one question - a guide
// here is a whole job from a real project, built from several
// chapters and backed by an installable sample. The row opens the
// index, which says what the section is and lists the guides.
text: "Everyday Work",
link: "/everyday/",
collapsed: true,
items: [
{ text: "Excel Import into RAP", link: "/everyday/excel_import_rap" },
{ text: "Mass Change", link: "/everyday/mass_change" },
{ text: "Approval Worklist", link: "/everyday/approval_worklist" },
{ text: "Report and Export", link: "/everyday/report_export" },
{ text: "Concurrent Editing", link: "/everyday/concurrent_editing" },
],
},
{
// An index page again, for DISCOVERY rather than lookup. It was
// taken out once, with reason: a reader looking a recipe up has this
Expand Down
4 changes: 4 additions & 0 deletions docs/cookbook/device_capabilities/spreadsheet.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,10 @@ ENDCLASS.
```
:::

::: tip **Into a business object**
To create the uploaded rows in a RAP business object - with a preview, one `MODIFY ENTITIES` for all rows and every refusal shown on its row - see the guide [Excel Import into RAP](/everyday/excel_import_rap).
:::

::: tip **ABAP Cloud**
The code snippets above aren't ABAP Cloud compatible. To make them cloud-compatible, replace the code in the `lcl_help` class with functions from the `XCO_CP_XLSX` APIs.
:::
Expand Down
138 changes: 138 additions & 0 deletions docs/everyday/approval_worklist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
---
outline: [2, 4]
samples:
- z2ui5_cl_smps_app_030
description: An approval worklist on a RAP business object - the open items, several selected and accepted or rejected with one EXECUTE of the business object's action, and the list shrinking as decisions are made.
---
# Approval Worklist

Somebody has to decide: accept or reject the travel requests, release the
purchase orders, confirm the time sheets. The screen for it is not a
maintenance app. It shows only what is waiting, lets the user decide one or
many at once, and gets shorter with every decision. When it is empty, the
work is done.

The worked example is `Z2UI5_CL_SMPS_APP_030` in
[abap2UI5/samples-stack](https://github.com/abap2UI5/samples-stack) (package
`03`). The travel business object `Z2UI5_R_SMPS_TRV` has two actions,
`acceptTravel` and `rejectTravel`, which set the status.

## The Shape of the App

| Part | What it does |
|---|---|
| **The list** | The open travels only, oldest first. A `SegmentedButton` switches to the decided ones, so a decision can be looked up |
| **The decision** | Accept and Reject in the table's toolbar, for every selected row at once |
| **The action** | One `EXECUTE` of the business object's action for all selected keys, then `COMMIT ENTITIES` |
| **After** | A fresh read; the decided rows are gone from the list |

## Step 1: Show What Is Waiting

The filter belongs in the `SELECT`. A worklist that reads everything and hides
the decided rows on the screen grows with every decision ever made; one that
selects only the open ones stays as short as the work is:

```abap
DATA(t_status) = VALUE ty_r_status( ( sign = `I`
option = COND #( WHEN filter = `OPEN` THEN `EQ` ELSE `NE` )
low = z2ui5_cl_smps_context=>cs_status-open ) ).

SELECT FROM z2ui5_r_smps_trv
FIELDS TravelId, CustomerId, BeginDate, EndDate, TotalPrice, CurrencyCode, Description, OverallStatus
WHERE OverallStatus IN @t_status
ORDER BY BeginDate, TravelId
INTO TABLE @DATA(t_result) UP TO 100 ROWS.
```

Sort by what makes an item urgent - here the begin date of the trip. The
oldest request at the top is the one the user should look at first.

## Step 2: Offer the Decision Where It Applies

The table runs in `MultiSelect` mode only on the worklist, and the buttons are
visible only there. On the list of decided travels there is nothing to decide:

```abap
DATA(table) = page->ele( `Table`
)->a( n = `items` v = client->_bind( t_travels )
)->a( n = `mode` v = COND #( WHEN filter = `OPEN` THEN `MultiSelect` ELSE `None` ) ).
```

The button types `Accept` and `Reject` give the two buttons their green and
red, which every Fiori user reads without a label.

## Step 3: Call the Action

The action is called like any other operation of the business object: by
key, for many instances at once. The app never writes `OverallStatus` itself;
the behavior definition declares it `readonly`, and the action is the only way
to change it:

```abap
MODIFY ENTITIES OF z2ui5_r_smps_trv
ENTITY travel
EXECUTE acceptTravel FROM VALUE #( FOR k IN t_keys ( travelid = k ) )
RESULT DATA(t_result)
FAILED DATA(s_failed)
REPORTED DATA(s_reported).
```

Write two statements, one per action, rather than a dynamic action name. The
action is part of the statement, so the compiler checks that it exists and
that its parameter fits.

The action is declared with `result [1] $self`, so `RESULT` hands back the
changed instances. The confirmation can then say what the business object
did - the new status comes from `%param` - instead of what the app expected
it to do.

Then `COMMIT ENTITIES RESPONSE OF`, and on any `FAILED` a
`ROLLBACK ENTITIES` with the messages of `REPORTED` in a message box. One
decision for twenty travels is one transaction: if the business object
refuses one of them, none of them is decided, and the user sees why.

## Step 4: Read Again

After the save, read the list again. The decided travels leave it because
the `SELECT` no longer finds them, not because the app removed rows from its
table. That way the list shows what the database says, including decisions
someone else made in the meantime.

## Variants

- **A reason for a rejection.** An action can take a parameter. The business
object declares it with an abstract entity, the app asks for it in a popup
and passes it as `%param`. `Z2UI5_CL_SMPS_APP_018` in package `12` of
samples-stack does exactly that for a reject.
- **Only what the user may decide.** With instance feature control, the
business object says per item whether the action is enabled.
`GET PERMISSIONS` asks it before the buttons are offered;
`Z2UI5_CL_SMPS_APP_017` in package `12` shows it.
- **One item at a time.** For decisions that need a closer look, put the
buttons on a detail page instead, and navigate back to the shortened list
after each decision - see [Inner App](/cookbook/event_navigation/navigation/inner_app)
navigation.
- **In the launchpad.** A worklist is a typical tile. The number of open items
can be the tile's KPI - see [Launchpad](/configuration/launchpad).

## Related

- [Tables](/cookbook/model/tables) - selection and toolbars.
- [EML](/cookbook/eml_cds_sql/eml) - `EXECUTE` and the responses.
- [Message](/cookbook/translation_messages/message) - toasts and message
boxes.

<!-- samples:start (generated by scripts/link-samples.mjs — do not edit) -->

## Working Samples

Complete apps from the [sample catalog](https://abap2ui5.github.io/samples-site/)
that use what this page describes. Each is a single class in [abap2UI5/samples](https://github.com/abap2UI5/samples)
unless its row names another of the three sample repositories — pull that repository with
[abapGit](https://abapgit.org) and start the class with `?app_start=<class>`.

| Sample | Class |
|---|---|
| Approval Worklist — An approval worklist - the open travels, several selected and accepted or rejected with one EXECUTE, the list shrinking as they are decided | [`Z2UI5_CL_SMPS_APP_030`](https://github.com/abap2UI5/samples-stack/blob/main/src/03/z2ui5_cl_smps_app_030.clas.abap) · [samples-stack](https://github.com/abap2UI5/samples-stack), branch `03-rap` |

<!-- samples:end -->
161 changes: 161 additions & 0 deletions docs/everyday/concurrent_editing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
---
outline: [2, 4]
samples:
- z2ui5_cl_smps_app_032
description: Two users edit the same RAP instance - optimistic locking in a stateless abap2UI5 app, with LastChangedAt remembered on read and compared after the UPDATE has taken the business object's lock.
---
# Concurrent Editing

Two users open the same travel. The first changes the fee and saves; the
second changes the description a minute later and saves too. Without a check,
the second save writes the fee the second user saw when the popup opened, and
the first change is gone without anyone noticing.

In SAP GUI the answer was an enqueue lock held while the screen was open. An
abap2UI5 app holds no session between two roundtrips, so a lock set when the
popup opens is released before the user types the first character. The
answer here is an optimistic check: remember the version of the data when
reading, and refuse the save if it changed.

The worked example is `Z2UI5_CL_SMPS_APP_032` in
[abap2UI5/samples-stack](https://github.com/abap2UI5/samples-stack) (package
`03`), on the travel business object `Z2UI5_R_SMPS_TRV`. Its edit popup has a
*Change by Someone Else* button that plays the second user.

## The Version of an Instance

A RAP business object usually has it already. The behavior definition of the
travel names its ETag:

```abap
define behavior for z2ui5_r_smps_trv alias Travel
...
etag master LastChangedAt
```

`LastChangedAt` carries the annotation
`@Semantics.systemDateTime.localInstanceLastChangedAt`, so the managed
runtime sets it on every save, whoever saves - this app,
another app, a Fiori elements UI, a background job going through the business
object. A Fiori elements app compares it automatically through the OData
ETag. An app that consumes the business object with EML compares it itself,
in two places.

## Step 1: Remember the Version When Reading

Read the instance fresh when the user opens it, not from the list on the
screen, and keep `LastChangedAt` with the values the user is about to edit:

```abap
READ ENTITIES OF z2ui5_r_smps_trv
ENTITY travel
FIELDS ( bookingfee currencycode description lastchangedat )
WITH VALUE #( ( travelid = travel_id ) )
RESULT DATA(t_result)
FAILED DATA(s_failed).

s_edit = VALUE #( ...
last_changed_at = t_result[ 1 ]-lastchangedat ).
```

The attribute does not have to be bound to anything. abap2UI5 keeps the app
instance between roundtrips, so it is still there when the user presses Save.

## Step 2: Lock, Then Check, Then Save

The order of the three statements is the whole technique:

```abap
" 1. the UPDATE - the managed runtime takes the lock of the instance here
MODIFY ENTITIES OF z2ui5_r_smps_trv
ENTITY travel
UPDATE FIELDS ( bookingfee currencycode description )
WITH VALUE #( ( travelid = travel_id ... ) )
FAILED DATA(s_failed)
REPORTED DATA(s_reported).

" 2. the check - against the database, where this change is not written yet
SELECT SINGLE FROM z2ui5_r_smps_trv
FIELDS LastChangedAt
WHERE TravelId = @travel_id
INTO @DATA(current).

IF current <> s_edit-last_changed_at.
ROLLBACK ENTITIES. " drops the change and releases the lock
" ... tell the user, read the current state
RETURN.
ENDIF.

" 3. the save
COMMIT ENTITIES RESPONSE OF z2ui5_r_smps_trv
FAILED DATA(s_failed_commit)
REPORTED DATA(s_reported_commit).
```

Why this order and not check first, then update:

- **The UPDATE takes the lock.** The travel is its own lock master, so the
runtime locks the instance when it processes the UPDATE, and holds the lock
until the COMMIT or the ROLLBACK. If somebody else is saving the same travel
at this moment, the UPDATE fails right here, with a message in `REPORTED`.
- **The check reads the database.** The UPDATE lives in the transactional
buffer until the COMMIT, so a `SELECT` still sees the last saved version.
A `READ ENTITIES` would read the buffer, where the change of this roundtrip
is already applied.
- **Between the check and the COMMIT, nobody else can save** the travel,
because the lock is held. A check before the UPDATE would leave a gap in
which a second save could slip through.

## Step 3: Tell the User

A refused save is not an error of the user. Say what happened and what to do
next, close the popup and show the current data, so the next attempt starts
from what is really saved:

```abap
client->popup_destroy( ).
data_read( ).
view_display( ).
client->message_box_display( text = |Travel { s_edit-travel_id } was changed by someone else after you opened it. | &&
`Your change was not saved - open the travel again to see the current values.`
type = `warning` ).
```

Do not offer "save anyway". Overwriting a change the user has not seen is
exactly what the check exists to prevent.

## When Another Strategy Fits Better

| Situation | Strategy |
|---|---|
| Many users, short edits, conflicts are rare | This one: no lock while typing, a check at save |
| The user must see "locked by X" when opening | A soft lock, or a stateful session with an enqueue - both in [Lock](/cookbook/expert_more/lock) |
| Long edits that may be interrupted, a draft-enabled business object | RAP drafts: the draft holds an exclusive lock and survives the session - see [Draft Handling](/cookbook/eml_cds_sql/draft_handling) |
| No business object, a plain table | The same check on a timestamp column of the table - [Lock](/cookbook/expert_more/lock#_3-optimistic-check) |

[Lock](/cookbook/expert_more/lock) compares eight strategies side by side;
this guide is its "combined" strategy for an instance of a RAP business
object, where the business object brings both the lock and the version.

## Related

- [Statefulness](/cookbook/expert_more/statefulness) - why nothing survives a
roundtrip except the app instance.
- [EML](/cookbook/eml_cds_sql/eml) - `READ ENTITIES`, `MODIFY ENTITIES` and
the responses.
- [Popup](/cookbook/popup_popover/popup) - the edit dialog.

<!-- samples:start (generated by scripts/link-samples.mjs — do not edit) -->

## Working Samples

Complete apps from the [sample catalog](https://abap2ui5.github.io/samples-site/)
that use what this page describes. Each is a single class in [abap2UI5/samples](https://github.com/abap2UI5/samples)
unless its row names another of the three sample repositories — pull that repository with
[abapGit](https://abapgit.org) and start the class with `?app_start=<class>`.

| Sample | Class |
|---|---|
| Edit with Conflict Check — Edit a travel without a stateful session - the app remembers LastChangedAt when it reads, and Save refuses the change if someone else saved the travel in between | [`Z2UI5_CL_SMPS_APP_032`](https://github.com/abap2UI5/samples-stack/blob/main/src/03/z2ui5_cl_smps_app_032.clas.abap) · [samples-stack](https://github.com/abap2UI5/samples-stack), branch `03-rap` |

<!-- samples:end -->
Loading
Loading