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
1 change: 1 addition & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Items tagged _(judgement)_ are genuine line-calls worth revisiting.

- Inertia admin panel — new `lunarphp/panel` package: auth, extension points (navigation, slots, table columns, row/bulk/page actions with shared ordering), Customers CRUD, Channels settings (spec 0049)
- Panel order-value chart on the customer edit page + `TimeSeriesChart` on the add-on surface (spec 0050)
- Panel edit drafts — autosaved pending edits with field-level conflict detection (spec 0051)
- Default professional customer notifications for the order lifecycle (spec 0036) _(judgement)_
- Bulk order operations — goal-oriented bulk actions on the orders table (spec 0026)
- Line-item refunds — refund specific lines/quantities via a dedicated refund page (spec 0028)
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"Lunar\\Meilisearch\\": "packages/meilisearch/src/",
"Lunar\\Opayo\\": "packages/opayo/src/",
"Lunar\\Panel\\": "packages/panel/src/",
"Lunar\\Panel\\Database\\Factories\\": "packages/panel/database/factories",
"Lunar\\Paypal\\": "packages/paypal/src/",
"Lunar\\Search\\": "packages/search/src/",
"Lunar\\Shipping\\": "packages/table-rate-shipping/src",
Expand Down
3 changes: 2 additions & 1 deletion packages/panel/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
"license": "MIT",
"autoload": {
"psr-4": {
"Lunar\\Panel\\": "src/"
"Lunar\\Panel\\": "src/",
"Lunar\\Panel\\Database\\Factories\\": "database/factories"
}
},
"minimum-stability": "dev",
Expand Down
13 changes: 13 additions & 0 deletions packages/panel/config/panel.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,19 @@

'storefront_url' => null,

/*
|--------------------------------------------------------------------------
| Edit Drafts
|--------------------------------------------------------------------------
|
| Drafts untouched for ttl_days are pruned by the scheduled model:prune
| run; their base snapshots are too stale for reliable conflict checks.
|
*/
'drafts' => [
'ttl_days' => 7,
],

'support_url' => 'https://docs.lunarphp.com/',

/*
Expand Down
35 changes: 35 additions & 0 deletions packages/panel/database/factories/EditDraftFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

namespace Lunar\Panel\Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;
use Lunar\Core\Models\Customer;
use Lunar\Core\Models\Staff;
use Lunar\Panel\Models\EditDraft;

/**
* Fixture-style drafts (pruning, listing, cleanup tests). Behavioural tests
* should create drafts through DraftManager instead, so base-snapshot capture
* and normalisation stay exercised.
*/
class EditDraftFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*/
protected $model = EditDraft::class;

/**
* Define the model's default state.
*/
public function definition(): array
{
return [
'draftable_type' => Customer::morphName(),
'draftable_id' => Customer::factory(),
'staff_id' => Staff::factory(),
'data' => ['first_name' => $this->faker->firstName()],
'base_snapshot' => ['first_name' => $this->faker->firstName()],
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Lunar\Core\Database\Migration;

return new class extends Migration
{
public function up(): void
{
Schema::create($this->prefix.'edit_drafts', function (Blueprint $table) {
$table->id();
$table->morphs('draftable');
$table->foreignId('staff_id')->constrained($this->prefix.'staff')->cascadeOnDelete();
$table->json('data');
$table->json('base_snapshot');
$table->timestamps();

$table->unique(['draftable_type', 'draftable_id', 'staff_id']);
});
}

public function down(): void
{
Schema::dropIfExists($this->prefix.'edit_drafts');
}
};
113 changes: 113 additions & 0 deletions packages/panel/resources/js/components/DraftActions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, expect, it, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { computed, nextTick, reactive, ref } from 'vue';
import DraftActions from './DraftActions.vue';
import type { EditDraftForm } from '../composables/useEditDraft';

function fakeForm(overrides: Partial<Record<string, unknown>> = {}): EditDraftForm<Record<string, unknown>> {
return {
values: reactive({}),
errors: ref({}),
conflicts: ref([]),
isDirty: computed(() => false),
saving: ref(false),
committing: ref(false),
savedAt: ref<string | null>(null),
hasDraft: ref(false),
restoredFrom: ref<string | null>(null),
commit: vi.fn().mockResolvedValue(true),
resolve: vi.fn().mockResolvedValue(true),
discard: vi.fn().mockResolvedValue(undefined),
...overrides,
} as EditDraftForm<Record<string, unknown>>;
}

const buttons = (wrapper: ReturnType<typeof mount>) => wrapper.findAll('button');

describe('DraftActions', () => {
it('renders nothing while the form is clean', () => {
const wrapper = mount(DraftActions, { props: { form: fakeForm() } });

expect(wrapper.find('button').exists()).toBe(false);
});

it('shows discard and save once the form is dirty', () => {
const wrapper = mount(DraftActions, {
props: { form: fakeForm({ isDirty: computed(() => true) }) },
});

const labels = buttons(wrapper).map((button) => button.text());

expect(labels).toEqual(['drafts.discard', 'common.save_changes']);
});

it('stays visible for a stored draft even when local values are clean', () => {
const wrapper = mount(DraftActions, {
props: { form: fakeForm({ hasDraft: ref(true) }) },
});

expect(buttons(wrapper)).toHaveLength(2);
});

it('shows the saving status text while an autosave is in flight', () => {
const wrapper = mount(DraftActions, {
props: { form: fakeForm({ isDirty: computed(() => true), saving: ref(true) }) },
});

expect(wrapper.find('[role="status"]').text()).toBe('drafts.saving');
});

it('shows the saved note after a save lands and clears it after 10 seconds', async () => {
vi.useFakeTimers();

try {
const saving = ref(true);
const savedAt = ref<string | null>(null);
const wrapper = mount(DraftActions, {
props: { form: fakeForm({ isDirty: computed(() => true), saving, savedAt }) },
});

saving.value = false;
savedAt.value = '2026-07-17T00:00:00Z';
await nextTick();

expect(wrapper.find('[role="status"]').text()).toBe('drafts.saved');

await vi.advanceTimersByTimeAsync(10_000);

expect(wrapper.find('[role="status"]').exists()).toBe(false);
} finally {
vi.useRealTimers();
}
});

it('does not show a stale saved note for a restored draft on load', () => {
const wrapper = mount(DraftActions, {
props: { form: fakeForm({ hasDraft: ref(true), savedAt: ref('2026-07-16T00:00:00Z') }) },
});

expect(wrapper.find('[role="status"]').exists()).toBe(false);
expect(buttons(wrapper)).toHaveLength(2);
});

it('commits on save and discards on discard', async () => {
const form = fakeForm({ isDirty: computed(() => true) });
const wrapper = mount(DraftActions, { props: { form } });

await buttons(wrapper)[1].trigger('click');
expect(form.commit).toHaveBeenCalledTimes(1);

await buttons(wrapper)[0].trigger('click');
expect(form.discard).toHaveBeenCalledTimes(1);
});

it('disables both buttons while committing', () => {
const wrapper = mount(DraftActions, {
props: { form: fakeForm({ isDirty: computed(() => true), committing: ref(true) }) },
});

for (const button of buttons(wrapper)) {
expect(button.attributes('disabled')).toBeDefined();
}
});
});
74 changes: 74 additions & 0 deletions packages/panel/resources/js/components/DraftActions.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from './Button.vue';
import type { EditDraftForm } from '../composables/useEditDraft';

// The standard home for a draft-backed form's save cluster: drop into the
// sticky Breadcrumbs #actions slot. Shopify-style contextual bar — rendered
// only while there is something to save or throw away, so a clean page shows
// nothing.
const props = defineProps<{
form: EditDraftForm<Record<string, unknown>>;
}>();

const { t } = useI18n();

const active = computed(() => props.form.isDirty.value || props.form.hasDraft.value);

// "Draft saved" is a confirmation, not a permanent label: it shows for a few
// seconds after each autosave lands, then clears until the next one.
const SAVED_NOTE_MS = 10_000;

const showSaved = ref(false);
let savedNoteTimer: ReturnType<typeof setTimeout> | null = null;

watch(
[() => props.form.saving.value, () => props.form.savedAt.value],
([saving, savedAt]) => {
if (savedNoteTimer !== null) {
clearTimeout(savedNoteTimer);
savedNoteTimer = null;
}

showSaved.value = !saving && savedAt !== null;

if (showSaved.value) {
savedNoteTimer = setTimeout(() => {
showSaved.value = false;
savedNoteTimer = null;
}, SAVED_NOTE_MS);
}
},
);

onBeforeUnmount(() => {
if (savedNoteTimer !== null) {
clearTimeout(savedNoteTimer);
}
});

const status = computed(() => {
if (props.form.saving.value) {
return t('drafts.saving');
}

return showSaved.value ? t('drafts.saved') : null;
});

const save = (): void => {
void props.form.commit();
};

const discard = (): void => {
void props.form.discard();
};
</script>

<template>
<template v-if="active">
<span v-if="status" role="status" class="hidden sm:inline text-[11px] text-ink-500 mr-1">{{ status }}</span>
<Button :disabled="form.committing.value" @click="discard">{{ t('drafts.discard') }}</Button>
<Button variant="primary" :disabled="form.committing.value" @click="save">{{ t('common.save_changes') }}</Button>
</template>
</template>
Loading
Loading