Last active
October 25, 2024 10:04
-
-
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???
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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