Skip to content

Instantly share code, notes, and snippets.

@wsydney76
Last active October 25, 2024 10:04
Show Gist options
  • Save wsydney76/60d4be815fd52c3b9be4c2ff5c12ae53 to your computer and use it in GitHub Desktop.
Save wsydney76/60d4be815fd52c3b9be4c2ff5c12ae53 to your computer and use it in GitHub Desktop.
Reference implemention in Alpine JS, what would that look like in Spark/DataStar???
<!DOCTYPE html>
<html lang="en-US">
<head>
<title>Alpine Demo</title>
<meta charset="utf-8"/>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/full.min.css" rel="stylesheet" type="text/css"/>
{# {{ craft.vite.script("/resources/js/app.js", false) }} #}
<script src="https://cdn.tailwindcss.com?plugins=forms"></script>
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/[email protected]/dist/cdn.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>
<style>
[x-cloak] {
display: none !important;
}
</style>
</head>
<body>
{# Extract relevant fields, so that we don't pass the whole entry data into the component #}
{% set entries = craft.entries
.uri(':notempty:')
.limit(10)
.all()|
map (e => {
id: e.id,
title: e.title,
teaser: e.teaser,
dateUpdated: e.dateUpdated|atom,
authorUsername: e.author.username ?? 'n/a'
}) %}
{# https://tailwindcss.com/docs/container #}
<section class="container mx-auto max-w-screen-xl py-8"
x-data="formLib"
>
{# Simplified, see @extras/_actions for better notices #}
<div class="flex justify-center h-10">
<button class="min-w-96 text-center cursor-pointer text-white text-sm px-4 py-2 rounded-full"
:class="noticeType === 'error' ? 'bg-red-700' : 'bg-green-700'"
x-show="noticeType"
x-text="notice"
x-transition
x-cloak></button>
</div>
{# https://daisyui.com/components/table/ #}
<div class="overflow-x-auto mt-4">
<table class="table table-pin-rows">
<thead>
<tr>
<th class="w-10">{{ 'ID'|t }}</th>
<th class="w-1/4">{{ 'Title'|t('app') }}</th>
<th class="w-1/4">{{ 'Teaser'|t }}</th>
<th>{{ 'Author'|t }}</th>
<th>{{ 'Updated'|t }}</th>
<th class="w-72"></th>
</tr>
</thead>
<tbody>
{% for entry in entries %}
<tr x-data="editEntry({{ entry|json_encode }})"
x-trap.noscroll.inert.noautofocus="open"
@keydown.escape="open && abortEdit"
@mousedown.outside="open && abortEdit"
@beforeUnload.window="beforeUnload"
>
<td x-text="data.id"></td>
<td>
{{ _self.textEdit('title', 'Title'|t('app')) }}
</td>
<td>
{{ _self.textEdit('teaser', 'Teaser'|t) }}
</td>
<td x-text="data.authorUsername"></td>
<td x-text="new Date(data.dateUpdated).toLocaleString()"></td>
<td class="flex items-center min-h-20">
{{ _self.buttons('title', true) }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% macro textEdit(field, label = '') %}
<div class=""
title="Click to edit"
x-show="!open">
<div>
<button class="cursor-pointer border border-transparent hover:border-gray-400 p-4 w-full text-left h-14"
x-text="editedData.{{ field }}"
@click="openAndFocus($refs.{{ field }})">
</button>
<div class="ml-4 flex items-center"
x-show="editedData.{{ field }} !== data.{{ field }}" x-cloak>
<div class="inline-block bg-red-700 text-white px-2 py-1 rounded-full text-xs">
{{ 'Changed'|t }}
</div>
<div class="ml-2 text-gray-500 text-xs"
x-text="'{{ "From"|t }}: ' + data.{{ field }}">
</div>
</div>
</div>
</div>
<div x-show="open" x-cloak>
<input class="input input-bordered w-full"
type="text"
aria-label="{{ label }}"
x-ref="{{ field }}"
x-model="editedData.{{ field }}"
@keydown.enter="save"
>
<div class="mt-2 text-red-500 text-sm"
x-show="errors.{{ field }}"
x-text="errors.{{ field }}">
</div>
</div>
{% endmacro %}
{% macro buttons(ref, showRevert = false) %}
<button class="btn"
x-show="!open"
@click="openAndFocus($refs['{{ ref }}'])"
x-ref="editBtn">
{{ 'Edit'|t }}
</button>
<button class="btn"
x-show="open" x-cloak
@click="abortEdit">
{{ 'Close'|t }}
</button>
<button class="btn btn-primary ml-2"
x-show="open" x-cloak
@click="save">
{{ 'Save'|t }}
</button>
{% if showRevert %}
<button class="btn ml-2"
x-show="isDirty" x-cloak
@click="revert">
{{ 'Revert'|t }}
</button>
{% endif %}
{% endmacro %}
<section class="my-8 max-w-screen-md mx-auto">
<p>Original example in https://github.com/putyourlightson/craft-spark/tree/develop/examples</p>
<p class="mt-4">Everything is thrown in to a (lengthy) single file, so this should work without dependencies. Exception: A
custom field 'teaser' is used, you should change that to a field used in your setup.</p>
<p class="mt-4">(Internal note: most logic/code <s>stolen from</s> inspired by filmdb/artVideo component)</p>
<p class="mt-4">Backend components required: None, Craft's native entries/save is used. Drawback: It returns all
entry data, which may send a lot of unused data back. Maybe a custom controller is useful that strips out unused
data. </p>
<p class="mt-4">Todos for a near-real-word Spark demo:</p>
<ul class="mt-4 ml-5 space-y-2 list-disc">
<li>Reusable component (formLib) for common tasks</li>
<li>Reusable macros/includes for form fields/buttons</li>
<li>Show notice for permission/system/network errors</li>
<li>Translatable messages</li>
<li>Edit a custom field</li>
<li>Don't let changes quietly disappear</li>
<li>Let user revert changes if row is 'dirty'</li>
<li><b>Trap focus inside row if opened</b></li>
<li>Focus title input field when 'Edit' button is clicked</li>
<li>If 'ESC' is pressed, or outside mouse click occurs, ask for 'save' and close row</li>
<li>Focus 'Edit' button if row is closed.</li>
<li>Show 'Changed' marker and old field content if row with changes is closed without saving.</li>
<li>Warn when leaving the page if unsaved changes exist.</li>
<li>Show notice if user wants to save without changes</li>
<li>Show field specific errors beneath input</li>
<li>If row is closed: make fields focusable, so that pressing 'ENTER' opens the row and focuses the input
field
</li>
<li>If row is closed: make fields clickable, so that a click opens the row and focuses the input field</li>
<li>Check how to 'isolate' rows without messing around with global data</li>
</ul>
</section>
<script>
document.addEventListener('alpine:init', () => {
Alpine.store('messages', {
error403: '{{ 'Not logged in or insufficient permissions.'|t }}',
error500: '{{ 'An unexpected application error occurred, see console log.'|t }}',
errorSystem: '{{ 'A system or network error occurred, see console log.'|t }}',
confirmSave: '{{ 'Save changes?'|t }}',
unload: '{{ 'You have unsaved changes. Are you sure you want to leave this page?'|t }}',
notDirty: '{{ 'Nothing has changed'|t }}',
confirmRevert: '{{ 'Revert changes?'|t }}',
})
Alpine.data('editEntry', (initialData) => ({
// naming as required by formLib
open: false,
data: {},
editedData: {},
errors: {},
init() {
this.resetData(initialData);
},
abortEdit() {
if (this.isDirty() && confirm(this.$store.messages.confirmSave)) {
this.save(false);
} else {
this.closeAndFocus(this.$refs.editBtn);
}
},
revert(confirmRevert = true) {
if (confirmRevert && !confirm(this.$store.messages.confirmRevert)) {
return;
}
Object.assign(this.editedData, this.data);
this.errors = {};
this.focusElement(this.open ? this.$refs.title : this.$refs.editBtn);
},
save(confirmSave = true) {
if (!this.isDirty()) {
this.setNotice('error', this.$store.messages.notDirty);
return;
}
if (confirmSave && !confirm(this.$store.messages.confirmSave)) {
return;
}
this.postAction('entries/save-entry',
{
canonicalId: this.data.id,
title: this.editedData.title,
fields: {
teaser: this.editedData.teaser
}
},
(data, status, ok) => {
if (!ok) {
this.errors = data.errors;
this.setNotice('error', data.message);
return;
}
this.closeAndFocus(this.$refs.editBtn);
// All edit data is returned by entries/save action, extract relevant fields
this.resetData({
id: data.model.id,
title: data.model.title,
teaser: data.model.teaser,
dateUpdated: data.model.dateUpdated,
authorUsername: data.authorUsername ?? 'n/a'
})
this.setNotice('success', data.message)
});
}
}))
// This would live in your asset bundle
Alpine.data('formLib', () => ({
noticeType: '',
notice: '',
setNotice(type, notice) {
this.notice = notice;
this.noticeType = type;
setTimeout(() => {
this.noticeType = '';
}, 4000);
},
resetData(data) {
this.data = data;
Object.assign(this.editedData, this.data);
this.errors = {}
},
openAndFocus(element) {
this.open = true;
this.focusElement(element);
},
closeAndFocus(element) {
this.open = false;
this.focusElement(element);
},
isDirty() {
return Object.keys(this.data).some(key => this.data[key] !== this.editedData[key]);
},
async postAction(action, data, callback) {
try {
const response = await fetch(this.getActionUrl(action), {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-Csrf-Token': '{{ craft.app.request.csrfToken }}'
},
body: JSON.stringify(data),
});
const json = await response.json();
switch (response.status) {
case 200:
case 400: {
callback(json, response.status, response.ok);
break;
}
case 403: {
this.setNotice('error', this.$store.messages.error403);
break;
}
default: {
this.setNotice('error', json.message || this.$store.messages.error500);
console.error('Response: ', response);
console.error('JSON: ', json);
}
}
} catch (error) {
this.setNotice('error', this.$store.messages.errorSystem);
console.error('Error: ', error);
}
},
getActionUrl(action) {
return '{{ siteUrl }}/{{ craft.app.config.general.actionTrigger }}/' + action;
},
focusElement(element) {
// this.$focus.focus(element)
setTimeout(() => {
element.focus()
}, 50);
},
beforeUnload() {
if (this.isDirty()) {
const message = this.$store.messages.unload;
event.returnValue = message; // For most browsers
return message; // Some browsers
}
},
}))
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment