# BackButton Widget & NavHistory — Source of Truth

> **Scope:** Dokumen ini adalah referensi teknis definitif untuk implementasi tombol Back dan sistem navigasi multi-level di seluruh proyek ini. Setiap halaman baru yang menampilkan tombol Back **wajib** menggunakan `BackButton::widget()` sesuai standar di sini. Dilarang menggunakan `Html::a(..., ['index'])` atau `window.history.back()` secara langsung di view.

---

## Daftar Isi

1. [Overview & Arsitektur](#1-overview--arsitektur)
2. [Komponen: NavHistory](#2-komponen-navhistory)
3. [Komponen: NavController](#3-komponen-navcontroller)
4. [Widget: BackButton](#4-widget-backbutton)
5. [Registrasi & Konfigurasi Global](#5-registrasi--konfigurasi-global)
6. [Priority Chain BackButton](#6-priority-chain-backbutton)
7. [Sistem Eksklusi (Exclusion System)](#7-sistem-eksklusi-exclusion-system)
8. [Stack Invariants & Edge Cases](#8-stack-invariants--edge-cases)
9. [Alur Navigasi Sirkular](#9-alur-navigasi-sirkular)
10. [Boilerplate Siap Pakai](#10-boilerplate-siap-pakai)

---

## 1. Overview & Arsitektur

### Mengapa sistem ini dipakai?

Pendekatan lama menggunakan satu `returnUrl` tunggal yang disimpan per-action secara manual di session. Ini tidak mendukung navigasi bertingkat: alur `Index → View → View2 → View3` lalu Back berulang akan selalu kembali ke satu URL yang sama.

Sistem ini menggantikan pendekatan tersebut dengan **navigation history stack** berbasis session, sehingga:

- Back selalu kembali ke halaman yang **benar-benar dikunjungi sebelumnya**, bukan ke URL hardcoded.
- Navigasi bertingkat `Index → View → View2 → View3 → Back → Back → Back` bekerja dengan urutan yang tepat.
- Developer **tidak perlu menulis satu baris pun** di controller — push ke stack terjadi secara otomatis via global `beforeAction` event.

### Arsitektur Keseluruhan

```
┌─────────────────────────────────────────────────────────────┐
│ backend/config/main.php                                     │
│   'on beforeAction' event                                   │
│     └─ Yii::$app->navHistory->push()  ← otomatis, global   │
└──────────────────────────┬──────────────────────────────────┘
                           │ setiap GET request
                           ▼
┌─────────────────────────────────────────────────────────────┐
│ common/components/NavHistory.php  (ApplicationComponent)    │
│                                                             │
│  Session key: 'navStack'  →  array of absolute URLs        │
│                                                             │
│  push()    — append current URL ke stack                   │
│  backUrl() — stack[-2] = URL halaman sebelumnya            │
│  pop()     — hapus top (current page) dari stack           │
│  peek()    — baca top tanpa menghapus                      │
│  clear()   — kosongkan stack (misal: saat logout)          │
└──────────────────────────┬──────────────────────────────────┘
                           │ dibaca oleh
                           ▼
┌─────────────────────────────────────────────────────────────┐
│ common/components/widgets/BackButton.php  (Widget)          │
│                                                             │
│  Priority 1: GET param ?returnUrl=...    (cross-controller) │
│  Priority 2: navHistory->backUrl()       (multi-level)      │
│  Priority 3: JS window.history.back()   (fallback)          │
└──────────────────────────┬──────────────────────────────────┘
                           │ Priority 2 → redirect melalui
                           ▼
┌─────────────────────────────────────────────────────────────┐
│ backend/controllers/NavController.php                       │
│   actionGoback(?string $fallback)                           │
│     1. pop()  — remove current page dari stack             │
│     2. peek() — ambil previous page                        │
│     3. setFlash('isBackNav') — sinyal skip push            │
│     4. redirect ke previous page (atau $fallback)          │
└─────────────────────────────────────────────────────────────┘
```

### File yang Relevan

| File | Deskripsi |
|------|-----------|
| `common/components/NavHistory.php` | Core session stack component |
| `backend/controllers/NavController.php` | Pop-and-redirect endpoint |
| `common/components/widgets/BackButton.php` | Widget yang dirender di view |
| `backend/config/main.php` | Registrasi component + global push |
| `backend/config/routes.php` | URL rule `nav/go-back` → `nav/goback` |

---

## 2. Komponen: NavHistory

**Namespace:** `common\components\NavHistory`
**File:** `common/components/NavHistory.php`
**Base class:** `yii\base\Component`
**Diakses via:** `Yii::$app->navHistory`

### Properties

| Property | Type | Default | Deskripsi |
|----------|------|---------|-----------|
| `$maxItems` | `int` | `10` | Maksimum entry di stack. Entry terlama di-trim saat limit terlampaui. |
| `$sessionKey` | `string` | `'navStack'` | Key yang digunakan untuk menyimpan stack di session. |
| `$backFlashKey` | `string` | `'isBackNav'` | Flash key yang diset oleh `actionGoback()` untuk memberi sinyal bahwa load berikutnya adalah hasil navigasi Back — sehingga `push()` tidak menambah entry baru. |
| `$excludedRoutes` | `array` | lihat bawah | Routes yang tidak boleh masuk stack. |
| `$excludedParams` | `array` | `[]` | GET parameter yang jika ada, menyebabkan request di-skip (misal modal/popup). |

**Default `$excludedRoutes`:**
```php
['site/login', 'site/logout', 'site/error', 'nav/goback']
```

### Method: `push()`

Menambahkan URL request saat ini ke atas stack. Dipanggil otomatis dari `'on beforeAction'` event di `backend/config/main.php`.

**Kondisi yang menyebabkan push di-skip:**

| Kondisi | Alasan |
|---------|--------|
| `$request->isGet === false` | POST/PUT/DELETE tidak merepresentasikan halaman yang dikunjungi |
| `$request->isAjax === true` | AJAX request bukan navigasi halaman |
| Header `X-Pjax` ada | Pjax partial reload bukan full-page visit |
| Route ada di `$excludedRoutes` | Login, logout, error, goback tidak perlu di-track |
| Flash `isBackNav` aktif | Ini adalah redirect dari `actionGoback()` — skip untuk mencegah re-push |
| GET param ada di `$excludedParams` | Modal atau popup URL |

**Logika deduplication & truncation:**

```
URL sudah ada di top stack?  → skip (idempotent, mencegah duplikat saat refresh)
URL ada di posisi lebih dalam?  → truncate stack ke posisi itu
   (user manual browser-back lalu navigasi lagi — fork history dicegah)
URL baru?  → append ke stack
Stack melebihi $maxItems?  → slice dari belakang
```

### Method: `backUrl(): ?string`

Mengembalikan entry `stack[n-2]` (second-to-last). Ini adalah URL halaman yang dikunjungi **sebelum** halaman saat ini.

```
Stack: [A, B, C]   →  backUrl() = B
Stack: [A]         →  backUrl() = null  (tidak ada history)
Stack: []          →  backUrl() = null
```

### Method: `pop(): ?string`

Menghapus dan mengembalikan entry paling atas (current page). Dipanggil oleh `NavController::actionGoback()` sebagai langkah pertama proses back-navigation.

### Method: `peek(): ?string`

Mengembalikan entry paling atas **tanpa** menghapusnya. Dipanggil oleh `actionGoback()` setelah `pop()` untuk mengetahui tujuan redirect.

### Method: `clear(): void`

Menghapus seluruh stack dari session. Dapat dipanggil saat logout untuk menghindari stack lama terbawa ke session baru.

---

## 3. Komponen: NavController

**Namespace:** `backend\controllers`
**File:** `backend/controllers/NavController.php`
**Base class:** `common\base\MyController`
**URL:** `GET /nav/go-back?fallback=/some/url`

### `actionGoback(?string $fallback = null): Response`

Endpoint tunggal untuk semua operasi Back di proyek ini. Alur eksekusi:

```
1. Validasi $fallback: harus same-host, jika tidak → default Url::to(['index'])
2. $nav->pop()    → hapus current page dari stack
3. $nav->peek()   → ambil previous page (new top setelah pop)
4. Jika $destination ada:
     setFlash('isBackNav', true)  ← sinyal ke push() agar di-skip
5. redirect($destination ?? $fallback)
```

**Mengapa pop() kemudian peek(), bukan langsung backUrl()?**

Saat BackButton merender link, stack masih berisi current page di top. `backUrl()` mengembalikan `stack[-2]`. Namun saat redirect terjadi di server, kita perlu:
1. Hapus current page dari stack (`pop()`).
2. Baca top baru yang sudah diupdate (`peek()`).

Jika menggunakan `backUrl()` tanpa `pop()`, stack tidak pernah menyusut dan navigasi Back berulang akan selalu kembali ke URL yang sama.

### URL Rule

Di `backend/config/routes.php`:
```php
'nav/go-back' => 'nav/goback',
```

Sehingga URL yang di-generate oleh `Url::toRoute(['/nav/goback', 'fallback' => $url])` akan menghasilkan `/nav/go-back?fallback=...`.

---

## 4. Widget: BackButton

**Namespace:** `common\components\widgets`
**File:** `common/components/widgets/BackButton.php`
**Base class:** `yii\base\Widget`

### Properties

| Property | Type | Default | Deskripsi |
|----------|------|---------|-----------|
| `$label` | `string` | `'<i class="fa fa-arrow-left"></i> Back'` | HTML label tombol. Mendukung HTML. |
| `$fallbackRoute` | `array` | `['index']` | Route Yii2 yang digunakan saat tidak ada history. Format sama dengan `Url::to()`. |
| `$useSession` | `bool` | `true` | Jika `false`, Priority 2 (NavHistory) dilewati. |
| `$options` | `array` | `[]` | HTML attributes untuk tag `<a>`. Jika `class` tidak di-set, default ke `'btn btn-default btn-sm btn-loader'`. |

### Cara Pemakaian Dasar

```php
use common\components\widgets\BackButton;

<?= BackButton::widget() ?>
```

### Kustomisasi Label

```php
<?= BackButton::widget(['label' => '<i class="fa fa-arrow-left"></i> Kembali']) ?>
```

### Kustomisasi Fallback Route

Fallback digunakan hanya ketika stack kosong **dan** tidak ada `?returnUrl` di GET parameter.

```php
<?= BackButton::widget(['fallbackRoute' => ['reservasi-invoice/index']]) ?>
```

### Kustomisasi HTML Options

```php
<?= BackButton::widget([
    'options' => ['class' => 'btn btn-secondary btn-sm'],
]) ?>
```

### Menonaktifkan NavHistory (Priority 2)

Jarang dipakai. Gunakan hanya jika halaman sengaja tidak ingin menggunakan stack.

```php
<?= BackButton::widget(['useSession' => false]) ?>
```

---

## 5. Registrasi & Konfigurasi Global

### Component Registration (`backend/config/main.php`)

```php
'components' => [
    'navHistory' => [
        'class' => 'common\components\NavHistory',
        // Optional overrides:
        // 'maxItems'       => 10,
        // 'excludedParams' => ['_popup'],
    ],
    // ... other components
],
```

### Auto-Push via Global Event (`backend/config/main.php`)

```php
'on beforeAction' => function ($event) {
    if (User::getActiveUser()) {
        // ... existing timezone setup ...

        Yii::$app->navHistory->push();
    }
},
```

**Catatan kritis:** Push dilakukan di global event di `config/main.php`, **bukan** di `MyController::beforeAction()`. Ini penting karena:
- Global event mencakup **semua** controller di backend app, termasuk controller yang tidak extend `MyController` (misalnya `SiteController`, `BarangController`, dll).
- Jika push dipindah ke `MyController::beforeAction()`, controller-controller tersebut tidak akan masuk stack.

### `$excludedParams` untuk Modal/Popup

Jika ada URL modal yang tidak seharusnya masuk stack, tambahkan GET param-nya:

```php
'navHistory' => [
    'class'          => 'common\components\NavHistory',
    'excludedParams' => ['_popup', '_modal'],
],
```

Setiap request yang memiliki `?_popup=1` atau `?_modal=1` di URL-nya akan di-skip oleh `push()`.

---

## 6. Priority Chain BackButton

BackButton menerapkan tiga prioritas secara berurutan. Prioritas lebih tinggi selalu menang.

```
┌──────────────────────────────────────────────────────────────┐
│ Priority 1: GET param ?returnUrl=...                         │
│                                                              │
│  Digunakan untuk: cross-controller link yang sudah tahu      │
│  tujuan baliknya secara eksplisit.                           │
│                                                              │
│  Contoh: Html::a('Buat Payment', ['/payment/create',         │
│              'returnUrl' => Url::current()])                 │
│                                                              │
│  Validasi: URL harus same-host (hostInfo atau diawali '/').  │
│  Behavior: direct link, tidak melalui /nav/go-back.          │
│  Stack: TIDAK dimodifikasi.                                  │
└───────────────────────────┬──────────────────────────────────┘
                            │ jika Priority 1 tidak ada
                            ▼
┌──────────────────────────────────────────────────────────────┐
│ Priority 2: navHistory->backUrl()                            │
│                                                              │
│  Digunakan untuk: navigasi normal dalam satu alur.           │
│                                                              │
│  Behavior: link ke Url::toRoute(['/nav/goback',              │
│                'fallback' => $fallbackUrl])                  │
│  Stack: pop() + flash isBackNav, lalu redirect.              │
│                                                              │
│  Aktif jika: Yii::$app->has('navHistory') === true           │
│              && backUrl() !== null                           │
│              && $useSession === true                         │
└───────────────────────────┬──────────────────────────────────┘
                            │ jika Priority 2 tidak ada (stack kosong / sesi baru)
                            ▼
┌──────────────────────────────────────────────────────────────┐
│ Priority 3: JS window.history.back() + fallback              │
│                                                              │
│  Behavior: onclick JS                                        │
│    – Jika referrer internal & history.length > 1             │
│      && bukan setelah POST: history.back()                   │
│    – Selainnya: location.href = $fallbackUrl                 │
│                                                              │
│  Flash FLASH_AFTER_POSTED (dari Constanta): mencegah         │
│  history.back() ke form POST yang sudah disubmit.           │
└──────────────────────────────────────────────────────────────┘
```

---

## 7. Sistem Eksklusi (Exclusion System)

### Excluded Routes

Routes berikut **tidak pernah** masuk ke stack:

| Route | Alasan |
|-------|--------|
| `site/login` | Halaman login tidak relevan sebagai tujuan Back |
| `site/logout` | Logout bukan halaman — redirect otomatis |
| `site/error` | Error page tidak boleh masuk history |
| `nav/goback` | Endpoint Back itu sendiri tidak boleh di-track |

Pencocokan route menggunakan dua kondisi:
```php
$route === $excluded
// OR
str_ends_with($route, '/' . $excluded)
```

Kondisi kedua menangani route yang di-prefix modul, misalnya `somemodule/nav/goback`.

### Excluded Request Types

| Kondisi | Penjelasan |
|---------|------------|
| `!$request->isGet` | POST, PUT, PATCH, DELETE tidak merepresentasikan halaman |
| `$request->isAjax` | `XMLHttpRequest` (header `X-Requested-With`) |
| Header `X-Pjax` ada | Pjax partial page update |

### `isBackNav` Flash

Flash key `'isBackNav'` adalah mekanisme single-use yang mencegah halaman tujuan Back men-duplikat entry di stack.

```
actionGoback() → setFlash('isBackNav', true) → redirect ke halaman B
Halaman B loads → push() → getFlash('isBackNav') → true → SKIP push
Flash di-consume oleh getFlash() → tidak aktif untuk request berikutnya
```

`getFlash()` di Yii2 bersifat **consume-once** — setelah dibaca, flash otomatis dihapus dari session. Ini menjamin bahwa hanya **satu** halaman (tujuan redirect) yang skip push, bukan semua halaman setelahnya.

---

## 8. Stack Invariants & Edge Cases

### Invariant: Stack selalu merepresentasikan forward-navigation path

Stack tidak pernah memiliki entry duplikat yang bersebelahan. Entry yang sama di posisi berbeda juga di-truncate ke posisi pertama ditemukannya.

### Edge Case: Refresh halaman

```
Stack sebelum refresh: [A, B, C]
User refresh C → push() → end($stack) === $url → skip
Stack setelah refresh: [A, B, C]  ← tidak berubah
```

### Edge Case: Browser Back manual (tidak via BackButton)

```
Stack: [A, B, C]
User tekan browser back ke B (manual, tidak via BackButton)
B loads → push() → array_search(B, stack) = 1 → truncate ke index 1
Stack: [A, B]  ← stack disesuaikan, C dibuang
```

### Edge Case: Buka halaman lama yang ada di tengah stack

```
Stack: [A, B, C, D, E]
User buka B (via bookmark atau link langsung)
B loads → push() → array_search(B, stack) = 1 → slice(0, 2)
Stack: [A, B]  ← C, D, E dibuang karena sudah tidak relevan
```

### Edge Case: Stack melebihi maxItems

```
maxItems = 10
Stack sudah berisi 10 entry, push URL baru
→ array_slice($stack, -10)  ← entry paling lama (index 0) dibuang
```

### Edge Case: Session baru (sesi pertama kali atau setelah logout)

```
Stack: []
BackButton → backUrl() = null → Priority 2 tidak aktif
→ Priority 3: JS history.back() atau fallbackUrl
```

### Edge Case: Stack hanya berisi 1 entry (halaman pertama dikunjungi)

```
Stack: [A]
BackButton di A → backUrl() = null (n < 2) → Priority 2 tidak aktif
→ Priority 3: JS history.back() atau fallbackUrl
```

---

## 9. Alur Navigasi Sirkular

Skenario: `payment/index → payment/view → invoice/view → Back → payment/view → invoice/view → Back → ...`

### Trace Stack

```
[1] payment/index loads   → push → stack: [payment/index]
[2] payment/view/ABC      → push → stack: [payment/index, payment/view/ABC]
[3] invoice/view/XYZ      → push → stack: [payment/index, payment/view/ABC, invoice/view/XYZ]
```

**Siklus Back pertama:**
```
[4] Klik Back di invoice/view/XYZ
    BackButton: backUrl() = payment/view/ABC
    → link ke /nav/go-back?fallback=/payment/index

[5] NavController::actionGoback():
    pop()    → buang invoice/view/XYZ
    stack:   [payment/index, payment/view/ABC]
    peek()   → payment/view/ABC
    setFlash('isBackNav', true)
    redirect → payment/view/ABC

[6] payment/view/ABC loads:
    push() → getFlash('isBackNav') = true → SKIP
    stack tetap: [payment/index, payment/view/ABC]
    Flash di-consume → tidak aktif lagi
```

**Navigasi maju ke invoice lagi:**
```
[7] Klik link ke invoice/view/XYZ dari payment/view
    push() normal (isBackNav flash sudah consumed)
    stack: [payment/index, payment/view/ABC, invoice/view/XYZ]
```

**Siklus Back kedua:** identik dengan siklus pertama. Stack tidak tumbuh.

### Kesimpulan Invariant Sirkular

Setiap siklus `Back → Forward` adalah net-zero terhadap stack:
- Back: `pop()` → -1 entry
- Forward: `push()` → +1 entry
- Net: 0

Stack tidak pernah tumbuh tak terbatas dalam skenario sirkular.

---

## 10. Boilerplate Siap Pakai

### View Standar (minimalist)

Gunakan ini sebagai template untuk setiap halaman baru yang memiliki tombol Back.

```php
<?php
use common\components\widgets\BackButton;
?>

<?= BackButton::widget() ?>
```

### View dengan Custom Fallback Route

Gunakan saat halaman tidak selalu bisa mengandalkan stack (misalnya halaman yang bisa dibuka langsung dari bookmark).

```php
<?= BackButton::widget([
    'fallbackRoute' => ['reservasi-invoice/index'],
]) ?>
```

### View dengan Custom Label & Class

```php
<?= BackButton::widget([
    'label'   => '<i class="fa fa-chevron-left"></i> Kembali ke Daftar',
    'options' => ['class' => 'btn btn-outline-secondary btn-sm btn-loader'],
]) ?>
```

### Link Cross-Controller (Priority 1: returnUrl)

Digunakan saat membuat link ke form Create/Update dari halaman lain yang ingin mendapat Back ke halaman asal secara eksplisit.

```php
use yii\helpers\Html;
use yii\helpers\Url;

// Di view halaman asal (misal: deposit/view)
<?= Html::a('Buat Payment', ['/reservasi-payment/create',
    'customer_id' => $model->customer_id,
    'returnUrl'   => Url::current(),  // atau Url::to(['deposit/view', 'id' => $model->hashId])
], ['class' => 'btn btn-primary btn-sm']) ?>

// Di form target (/reservasi-payment/_form.php)
// BackButton akan otomatis membaca ?returnUrl dan menggunakannya via Priority 1
<?= BackButton::widget(['fallbackRoute' => ['index']]) ?>
```

### Clear Stack saat Logout

Jika proyek memiliki custom logout handler, tambahkan:

```php
// Di SiteController::actionLogout() atau event afterLogout
if (Yii::$app->has('navHistory')) {
    Yii::$app->navHistory->clear();
}
```

---

## Anti-Pattern yang Harus Dihindari

| Anti-Pattern | Alasan | Solusi |
|---|---|---|
| `Html::a('Back', ['index'])` di view | Hardcoded, tidak mendukung multi-level | `BackButton::widget()` |
| `window.history.back()` di view | Tidak bekerja untuk sesi baru atau fresh tab | `BackButton::widget()` (Priority 3 sudah handle ini) |
| `$this->storeReturnUrl(...)` di controller | Method ini sudah dihapus | Tidak perlu melakukan apapun — push otomatis |
| Menambahkan `push()` manual di controller | Duplikat dengan global event | Tidak perlu — global event sudah cover semua controller |
| Mendaftarkan route `nav/goback` dua kali | Konflik URL rule | Satu rule di `routes.php` sudah cukup |
| Menggunakan `backUrl()` tanpa `pop()` | Stack tidak menyusut, Back selalu ke URL yang sama | Gunakan `NavController::actionGoback()` sebagai intermediary |
