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
76 changes: 76 additions & 0 deletions ProcessMaker/Http/Controllers/Api/CasesRetentionController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

namespace ProcessMaker\Http\Controllers\Api;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use ProcessMaker\Http\Controllers\Controller;
use ProcessMaker\Http\Resources\ApiCollection;
use ProcessMaker\Models\CaseRetentionPolicyLog;

class CasesRetentionController extends Controller
{
private const LOG_SORT_COLUMNS = [
'id',
'process_id',
'case_ids',
'deleted_count',
'total_time_taken',
'deleted_at',
'created_at',
];

/**
* Search log id, process_id, numeric columns, and JSON case_ids — not date columns.
*/
private function applyLogsFilter($query, string $term): void
{
$term = trim($term);
if ($term === '') {
return;
}

$like = '%' . $term . '%';
$driver = $query->getConnection()->getDriverName();

$query->where(function ($q) use ($like, $driver) {
$q->where('id', 'like', $like)
->orWhere('process_id', 'like', $like)
->orWhere('deleted_count', 'like', $like)
->orWhere('total_time_taken', 'like', $like);

if ($driver === 'pgsql') {
$q->orWhereRaw('case_ids::text ILIKE ?', [$like]);
} else {
$q->orWhereRaw('CAST(case_ids AS CHAR) LIKE ?', [$like]);
}
});
}

public function logs(Request $request): ApiCollection
{
$query = CaseRetentionPolicyLog::query();

if ($request->filled('filter')) {
$this->applyLogsFilter($query, (string) $request->input('filter'));
}

$orderBy = $request->input('order_by');
if ($orderBy && in_array($orderBy, self::LOG_SORT_COLUMNS, true)) {
$orderBy = DB::raw(preg_replace('/\.(.+)/', "->>'\$.$1'", $orderBy, 1));

$orderDirection = strtolower((string) $request->input('order_direction', 'asc'));
if (!in_array($orderDirection, ['asc', 'desc'], true)) {
$orderDirection = 'asc';
}

$query->orderBy($orderBy, $orderDirection);
} else {
$query->orderByDesc('created_at');
}

$response = $query->paginate($request->input('per_page', 10));

return new ApiCollection($response);
}
}
2 changes: 1 addition & 1 deletion ProcessMaker/Jobs/EvaluateProcessRetentionJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ public function handle(): void

CaseRetentionPolicyLog::create([
'process_id' => $this->processId,
'case_ids' => json_encode($caseIds),
'case_ids' => $caseIds,
'deleted_count' => $chunkSize,
'total_time_taken' => $chunkTimeMs,
'deleted_at' => Carbon::now(),
Expand Down
4 changes: 4 additions & 0 deletions ProcessMaker/Models/CaseRetentionPolicyLog.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,8 @@ class CaseRetentionPolicyLog extends ProcessMakerModel
'total_time_taken',
'deleted_at',
];

protected $casts = [
'case_ids' => 'array',
];
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public function definition()
'process_id' => function () {
return Process::factory()->create()->id;
},
'case_ids' => json_encode(CaseNumber::factory()->count($this->faker->numberBetween(1, 1000))->create()->pluck('id')->toArray()),
'case_ids' => CaseNumber::factory()->count($this->faker->numberBetween(1, 1000))->create()->pluck('id')->toArray(),
'deleted_count' => $this->faker->numberBetween(1, 1000),
'total_time_taken' => $this->faker->numberBetween(1, 1000000),
'deleted_at' => $this->faker->dateTimeBetween('-1 year', 'now'),
Expand Down
172 changes: 172 additions & 0 deletions resources/js/admin/cases-retention/components/CaseIdsTableCell.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
<template>
<div
v-uni-id="'case-id-' + rowId"
class="case-ids-cell"
>
<template v-if="!ids.length">
</template>
<template v-else-if="!hasOverflow">
<span class="case-ids-preview">{{ ids.join(", ") }}</span>
</template>
<template v-else>
<button
:id="popoverTriggerId"
type="button"
class="case-ids-trigger"
:title="$t('Show full list')"
>
<span class="case-ids-preview">{{ previewHead }}</span><span class="case-ids-ellipsis">…</span><span class="case-ids-more-count text-muted">+{{ moreHiddenCount }}</span>
</button>
<b-popover
:target="popoverTriggerId"
triggers="click"
placement="auto"
boundary="viewport"
container="body"
custom-class="case-ids-popover"
>
<template #title>
{{ $t("Case IDs") }}
<span class="text-muted font-weight-normal">({{ ids.length }})</span>
</template>
<div class="case-ids-popover-inner">
<pre class="case-ids-popover-pre mb-0">{{ fullListText }}</pre>
</div>
</b-popover>
</template>
</div>
</template>

<script>
import { createUniqIdsMixin } from "vue-uniq-ids";

const uniqIdsMixin = createUniqIdsMixin();

export default {
name: "CaseIdsTableCell",
mixins: [uniqIdsMixin],
props: {
caseIds: {
type: [Array, String, Number],
default: null,
},
rowId: {
type: [Number, String],
required: true,
},
previewLimit: {
type: Number,
default: 5,
},
},
computed: {
ids() {
return this.parseCaseIdsArray(this.caseIds);
},
hasOverflow() {
return this.ids.length > this.previewLimit;
},
previewHead() {
return this.ids.slice(0, this.previewLimit).join(", ");
},
moreHiddenCount() {
return this.ids.length - this.previewLimit;
},
fullListText() {
return this.ids.join(", ");
},
popoverTriggerId() {
return `retention-case-ids-pop-${this.rowId}`;
},
},
methods: {
parseCaseIdsArray(caseIds) {
if (caseIds == null || caseIds === "") {
return [];
}
if (Array.isArray(caseIds)) {
return caseIds.map(String);
}
if (typeof caseIds === "string") {
try {
const parsed = JSON.parse(caseIds);
return Array.isArray(parsed) ? parsed.map(String) : [];
} catch {
return [];
}
}
return [String(caseIds)];
},
},
};
</script>

<style lang="scss" scoped>
.case-ids-preview {
color: #4e5663;
}

.case-ids-trigger {
cursor: pointer;
border: none;
background: transparent;
font: inherit;
color: inherit;
border-radius: 4px;
margin: -2px -4px;
padding: 2px 4px;
display: inline;
text-align: left;
line-height: inherit;
vertical-align: baseline;

&:hover,
&:focus {
background-color: rgba(0, 0, 0, 0.04);
}

&:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(13, 110, 253, 0.25);
}
}

.case-ids-ellipsis {
color: #4e5663;
letter-spacing: 0.02em;
}

.case-ids-more-count {
font-size: 12px;
margin-left: 2px;
font-weight: 500;
white-space: nowrap;
}
</style>

<!-- Popover is teleported to body; scoped styles do not apply. -->
<style lang="scss">
.popover.case-ids-popover {
max-width: min(440px, 92vw);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12);
}

.popover.case-ids-popover .popover-body {
padding: 0;
}

.case-ids-popover-inner {
max-height: min(320px, 50vh);
overflow: auto;
padding: 0.75rem 1rem;
}

.case-ids-popover-pre {
white-space: pre-wrap;
word-break: break-all;
font-size: 13px;
line-height: 1.45;
color: #4e5663;
}
</style>
Loading
Loading