- @can('update', $task)
+ @if($canUpdateTask)
@unless($hitlEnabled)
Case #: {{ $caseNumber }}
@@ -113,7 +121,7 @@ class="card border-0"
@else
@include('tasks.partials.hitl-iframe', ['iframeSrc' => $iframeSrc ?? null])
@endunless
- @endcan
+ @endif
);
const task = @json($task);
+
let draftTask = task.draft;
- const userHasAccessToTask = {{ Auth::user()->can('update', $task) ? "true": "false" }};
+ const userHasAccessToTask = @json($canUpdateTask);
const userIsAdmin = {{ Auth::user()->is_administrator ? "true": "false" }};
const userIsProcessManager = {{ in_array(Auth::user()->id, $task->process?->manager_id ?? []) ? "true": "false" }};
const caseNumber = @json($caseNumber);
diff --git a/routes/v1_1/api.php b/routes/v1_1/api.php
index 10547dc52f..53d6355a6f 100644
--- a/routes/v1_1/api.php
+++ b/routes/v1_1/api.php
@@ -12,6 +12,10 @@
->group(function () {
// Tasks Endpoints
Route::name('tasks.')->prefix('tasks')->group(function () {
+ // Route to list optimized tasks
+ Route::get('/tasksOptimized', [TaskController::class, 'indexOptimized'])
+ ->name('indexOptimized');
+
// Route to list tasks
Route::get('/', [TaskController::class, 'index'])
->name('index');
diff --git a/routes/web.php b/routes/web.php
index c0cf1141bf..a8ca8fbb07 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -206,7 +206,7 @@
Route::get('tasks/search', [TaskController::class, 'search'])->name('tasks.search');
Route::get('tasks', [TaskController::class, 'index'])
->name('tasks.index')
- ->middleware('no-cache');
+ ->middleware('tasks-page-etag');
Route::get('tasks/{task}/edit', [TaskController::class, 'edit'])->name('tasks.edit');
Route::get('tasks/{task}/edit/quickfill', [TaskController::class, 'quickFillEdit'])->name('tasks.edit.quickfill');
Route::get('tasks/{task}/edit/{preview}', [TaskController::class, 'edit'])->name('tasks.preview');
diff --git a/tests/Feature/TasksTest.php b/tests/Feature/TasksTest.php
index e63b2bb18d..6fb7aa3b57 100644
--- a/tests/Feature/TasksTest.php
+++ b/tests/Feature/TasksTest.php
@@ -7,6 +7,7 @@
use ProcessMaker\Models\ProcessRequestToken;
use ProcessMaker\Models\ProcessTaskAssignment;
use ProcessMaker\Models\User;
+use ProcessMaker\Models\UserConfiguration;
use Tests\Feature\Shared\RequestHelper;
use Tests\TestCase;
@@ -39,6 +40,79 @@ public function testIndex()
$response->assertSee('Tasks');
}
+ public function testTasksPageSendsPrivateEtagHeaders()
+ {
+ $response = $this->webGet(self::TASKS_URL, []);
+
+ $response->assertStatus(200);
+ $response->assertHeader('ETag');
+ $this->assertCacheControlHasPrivateMustRevalidate($response);
+ $response->assertHeaderMissing('Pragma');
+ $response->assertHeaderMissing('Expires');
+ }
+
+ public function testTasksPageReturnsNotModifiedWhenEtagMatches()
+ {
+ $response = $this->webGet(self::TASKS_URL, []);
+ $etag = $response->headers->get('ETag');
+
+ $responseWithMatchingEtag = $this->actingAs($this->user, 'web')
+ ->withHeaders(['If-None-Match' => $etag])
+ ->get(self::TASKS_URL);
+
+ $responseWithMatchingEtag->assertStatus(304);
+ $this->assertEquals(
+ $this->stripWeakEtagPrefix($etag),
+ $this->stripWeakEtagPrefix($responseWithMatchingEtag->headers->get('ETag'))
+ );
+ $this->assertCacheControlHasPrivateMustRevalidate($responseWithMatchingEtag);
+ $this->assertEmpty($responseWithMatchingEtag->getContent());
+ }
+
+ public function testTasksPageEtagChangesWhenUserConfigurationChanges()
+ {
+ $response = $this->webGet(self::TASKS_URL, []);
+ $etag = $response->headers->get('ETag');
+
+ UserConfiguration::create([
+ 'user_id' => $this->user->id,
+ 'ui_configuration' => json_encode([
+ 'tasks' => [
+ 'isMenuCollapse' => false,
+ ],
+ ]),
+ ]);
+
+ $updatedResponse = $this->webGet(self::TASKS_URL, []);
+
+ $this->assertNotEquals($etag, $updatedResponse->headers->get('ETag'));
+ }
+
+ public function testTasksPageEtagChangesWhenFeatureConfigChanges()
+ {
+ $response = $this->webGet(self::TASKS_URL, []);
+ $etag = $response->headers->get('ETag');
+
+ config()->set('app.task_drafts_enabled', !config('app.task_drafts_enabled'));
+
+ $updatedResponse = $this->webGet(self::TASKS_URL, []);
+
+ $this->assertNotEquals($etag, $updatedResponse->headers->get('ETag'));
+ }
+
+ private function assertCacheControlHasPrivateMustRevalidate($response): void
+ {
+ $cacheControl = $response->headers->get('Cache-Control');
+
+ $this->assertStringContainsString('private', $cacheControl);
+ $this->assertStringContainsString('must-revalidate', $cacheControl);
+ }
+
+ private function stripWeakEtagPrefix(?string $etag): ?string
+ {
+ return $etag ? str_replace('W/', '', $etag) : null;
+ }
+
public function testViewTaskWithComments()
{
//Start a process request
diff --git a/tests/js/apiClientCache.test.js b/tests/js/apiClientCache.test.js
new file mode 100644
index 0000000000..563c6e234d
--- /dev/null
+++ b/tests/js/apiClientCache.test.js
@@ -0,0 +1,286 @@
+import { buildApiClientCacheKey, installApiClientCache } from "../../resources/js/apiClientCache";
+
+const createApiClient = (adapter) => ({
+ defaults: {
+ adapter,
+ },
+});
+
+const createResponse = (data, config = {}) => ({
+ data,
+ status: 200,
+ statusText: "OK",
+ headers: {},
+ config,
+});
+
+describe("apiClient cache", () => {
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ test("does not cache GET requests unless caching is enabled", async () => {
+ const adapter = jest.fn()
+ .mockResolvedValueOnce(createResponse({ count: 1 }))
+ .mockResolvedValueOnce(createResponse({ count: 2 }));
+ const apiClient = installApiClientCache(createApiClient(adapter));
+ const config = { method: "get", baseURL: "/api/1.0/", url: "tasks" };
+
+ const first = await apiClient.defaults.adapter(config);
+ const second = await apiClient.defaults.adapter(config);
+
+ expect(adapter).toHaveBeenCalledTimes(2);
+ expect(first.data).toEqual({ count: 1 });
+ expect(second.data).toEqual({ count: 2 });
+ });
+
+ test("deduplicates concurrent cache-enabled GET requests", async () => {
+ let resolveRequest;
+ const adapter = jest.fn(() => new Promise((resolve) => {
+ resolveRequest = resolve;
+ }));
+ const apiClient = installApiClientCache(createApiClient(adapter));
+ const config = {
+ method: "get",
+ baseURL: "/api/1.0/",
+ url: "task_schema/584421",
+ cache: { enabled: true },
+ };
+
+ const first = apiClient.defaults.adapter(config);
+ const second = apiClient.defaults.adapter(config);
+
+ expect(adapter).toHaveBeenCalledTimes(1);
+ expect(first).toBe(second);
+
+ resolveRequest(createResponse({ id: 584421 }, config));
+
+ await expect(first).resolves.toMatchObject({ data: { id: 584421 } });
+ await expect(second).resolves.toMatchObject({ data: { id: 584421 } });
+ });
+
+ test("uses the default TTL and refetches after it expires", async () => {
+ let now = 1000;
+ jest.spyOn(Date, "now").mockImplementation(() => now);
+
+ const adapter = jest.fn()
+ .mockResolvedValueOnce(createResponse({ count: 1 }))
+ .mockResolvedValueOnce(createResponse({ count: 2 }));
+ const apiClient = installApiClientCache(createApiClient(adapter));
+ const config = {
+ method: "get",
+ baseURL: "/api/1.0/",
+ url: "tasks",
+ cache: { enabled: true },
+ };
+
+ const first = await apiClient.defaults.adapter(config);
+ now = 5999;
+ const second = await apiClient.defaults.adapter(config);
+ now = 6000;
+ const third = await apiClient.defaults.adapter(config);
+
+ expect(apiClient.DEFAULT_CACHE_TTL).toBe(5000);
+ expect(adapter).toHaveBeenCalledTimes(2);
+ expect(first.data).toEqual({ count: 1 });
+ expect(second.data).toEqual({ count: 1 });
+ expect(third.data).toEqual({ count: 2 });
+ });
+
+ test("uses a per-request TTL override", async () => {
+ let now = 1000;
+ jest.spyOn(Date, "now").mockImplementation(() => now);
+
+ const adapter = jest.fn()
+ .mockResolvedValueOnce(createResponse({ count: 1 }))
+ .mockResolvedValueOnce(createResponse({ count: 2 }));
+ const apiClient = installApiClientCache(createApiClient(adapter));
+ const config = {
+ method: "get",
+ baseURL: "/api/1.0/",
+ url: "tasks",
+ cache: { enabled: true, ttl: 30_000 },
+ };
+
+ await apiClient.defaults.adapter(config);
+ now = 30_000;
+ const cached = await apiClient.defaults.adapter(config);
+ now = 31_000;
+ const refetched = await apiClient.defaults.adapter(config);
+
+ expect(adapter).toHaveBeenCalledTimes(2);
+ expect(cached.data).toEqual({ count: 1 });
+ expect(refetched.data).toEqual({ count: 2 });
+ });
+
+ test("does not cache failed GET requests", async () => {
+ const error = new Error("Network failed");
+ const adapter = jest.fn()
+ .mockRejectedValueOnce(error)
+ .mockResolvedValueOnce(createResponse({ count: 1 }));
+ const apiClient = installApiClientCache(createApiClient(adapter));
+ const config = {
+ method: "get",
+ baseURL: "/api/1.0/",
+ url: "tasks",
+ cache: { enabled: true },
+ };
+
+ await expect(apiClient.defaults.adapter(config)).rejects.toThrow("Network failed");
+ await expect(apiClient.defaults.adapter(config)).resolves.toMatchObject({ data: { count: 1 } });
+
+ expect(adapter).toHaveBeenCalledTimes(2);
+ });
+
+ test("bypasses cache and deduplication for non-GET requests", async () => {
+ const adapter = jest.fn()
+ .mockResolvedValueOnce(createResponse({ count: 1 }))
+ .mockResolvedValueOnce(createResponse({ count: 2 }));
+ const apiClient = installApiClientCache(createApiClient(adapter));
+ const config = {
+ method: "post",
+ baseURL: "/api/1.0/",
+ url: "tasks",
+ cache: { enabled: true },
+ };
+
+ await apiClient.defaults.adapter(config);
+ await apiClient.defaults.adapter(config);
+
+ expect(adapter).toHaveBeenCalledTimes(2);
+ });
+
+ test("globally enables caching while allowing a request to opt out", async () => {
+ const adapter = jest.fn()
+ .mockResolvedValueOnce(createResponse({ count: 1 }))
+ .mockResolvedValueOnce(createResponse({ count: 2 }))
+ .mockResolvedValueOnce(createResponse({ count: 3 }));
+ const apiClient = installApiClientCache(createApiClient(adapter));
+ const config = { method: "get", baseURL: "/api/1.0/", url: "tasks" };
+
+ apiClient.cache.enable();
+ await apiClient.defaults.adapter(config);
+ await apiClient.defaults.adapter(config);
+ await apiClient.defaults.adapter({ ...config, cache: { enabled: false } });
+ await apiClient.defaults.adapter({ ...config, cache: { enabled: false } });
+
+ expect(adapter).toHaveBeenCalledTimes(3);
+ });
+
+ test("invalidates cached responses by exact URL and pattern", async () => {
+ const adapter = jest.fn()
+ .mockResolvedValueOnce(createResponse({ count: 1 }))
+ .mockResolvedValueOnce(createResponse({ count: 2 }))
+ .mockResolvedValueOnce(createResponse({ count: 3 }));
+ const apiClient = installApiClientCache(createApiClient(adapter));
+ const config = {
+ method: "get",
+ baseURL: "/api/1.0/",
+ url: "tasks",
+ params: { include: "data" },
+ cache: { enabled: true },
+ };
+
+ await apiClient.defaults.adapter(config);
+ apiClient.cache.invalidate("/api/1.0/tasks?include=data");
+ const second = await apiClient.defaults.adapter(config);
+ apiClient.cache.invalidateByPattern("/tasks");
+ const third = await apiClient.defaults.adapter(config);
+
+ expect(adapter).toHaveBeenCalledTimes(3);
+ expect(second.data).toEqual({ count: 2 });
+ expect(third.data).toEqual({ count: 3 });
+ });
+
+ test("disable bypasses even explicitly cache-enabled requests in the current window", async () => {
+ const adapter = jest.fn()
+ .mockResolvedValueOnce(createResponse({ count: 1 }))
+ .mockResolvedValueOnce(createResponse({ count: 2 }));
+ const apiClient = installApiClientCache(createApiClient(adapter));
+ const config = {
+ method: "get",
+ baseURL: "/api/1.0/",
+ url: "tasks",
+ cache: { enabled: true },
+ };
+
+ apiClient.cache.disable();
+
+ const first = await apiClient.defaults.adapter(config);
+ const second = await apiClient.defaults.adapter(config);
+
+ expect(adapter).toHaveBeenCalledTimes(2);
+ expect(first.data).toEqual({ count: 1 });
+ expect(second.data).toEqual({ count: 2 });
+ });
+
+ test("builds stable keys from URL, params, and relevant config", () => {
+ const first = buildApiClientCacheKey({
+ baseURL: "/api/1.0/",
+ url: "tasks",
+ params: { b: 2, a: 1 },
+ headers: { Accept: "application/json" },
+ });
+ const second = buildApiClientCacheKey({
+ baseURL: "/api/1.0/",
+ url: "tasks",
+ params: { a: 1, b: 2 },
+ headers: { accept: "application/json" },
+ });
+
+ expect(first).toEqual(second);
+ expect(first.url).toBe("/api/1.0/tasks?a=1&b=2");
+ });
+
+ test("keeps responses with distinct relevant configuration separate", async () => {
+ const adapter = jest.fn()
+ .mockResolvedValueOnce(createResponse({ format: "json" }))
+ .mockResolvedValueOnce(createResponse({ format: "blob" }));
+ const apiClient = installApiClientCache(createApiClient(adapter));
+ const baseConfig = {
+ method: "get",
+ baseURL: "/api/1.0/",
+ url: "tasks",
+ cache: { enabled: true },
+ };
+
+ const json = await apiClient.defaults.adapter({
+ ...baseConfig,
+ headers: { Accept: "application/json" },
+ });
+ const blob = await apiClient.defaults.adapter({
+ ...baseConfig,
+ headers: { Accept: "application/octet-stream" },
+ responseType: "blob",
+ });
+
+ expect(adapter).toHaveBeenCalledTimes(2);
+ expect(json.data).toEqual({ format: "json" });
+ expect(blob.data).toEqual({ format: "blob" });
+ });
+
+ test("logs cache decisions when debug is enabled", async () => {
+ const log = jest.spyOn(console, "log").mockImplementation(() => {});
+ const adapter = jest.fn().mockResolvedValue(createResponse({ count: 1 }));
+ const apiClient = installApiClientCache(createApiClient(adapter));
+ const config = {
+ method: "get",
+ baseURL: "/api/1.0/",
+ url: "tasks",
+ cache: { enabled: true },
+ };
+
+ apiClient.cache.enableDebug();
+
+ await apiClient.defaults.adapter(config);
+
+ expect(log).toHaveBeenCalledWith(
+ "[ProcessMaker.apiClient.cache] cache miss; sending network request",
+ expect.objectContaining({ url: "/api/1.0/tasks" }),
+ );
+ expect(log).toHaveBeenCalledWith(
+ "[ProcessMaker.apiClient.cache] response cached",
+ expect.objectContaining({ status: 200 }),
+ );
+ });
+});