mirror of
https://github.com/bigskysoftware/htmx.git
synced 2026-01-25 05:06:13 +00:00
Upsert swap extension (#3595)
* add upsert swap extension * improve upsert * simplify upsert to not use morph * add doco * Add hx-upsert tag support as well --------- Co-authored-by: MichaelWest22 <michael.west@docuvera.com>
This commit is contained in:
89
src/ext/hx-upsert.js
Normal file
89
src/ext/hx-upsert.js
Normal file
@@ -0,0 +1,89 @@
|
||||
//==========================================================
|
||||
// hx-upsert.js
|
||||
//
|
||||
// An extension to add 'upsert' swap style that updates
|
||||
// existing elements by ID and inserts new ones.
|
||||
//
|
||||
// Modifiers:
|
||||
// key:attr - attribute name for sorting (default: id)
|
||||
// sort - sort ascending
|
||||
// sort:desc - sort descending
|
||||
// prepend - prepend elements without keys (default: append)
|
||||
//==========================================================
|
||||
(() => {
|
||||
let api;
|
||||
|
||||
htmx.registerExtension('upsert', {
|
||||
init: (internalAPI) => {
|
||||
api = internalAPI;
|
||||
},
|
||||
htmx_process_upsert: (templateElt, detail) => {
|
||||
let {ctx, tasks} = detail;
|
||||
let swapSpec = {style: 'upsert'};
|
||||
let key = templateElt.getAttribute('key');
|
||||
let sort = templateElt.getAttribute('sort');
|
||||
let prepend = templateElt.hasAttribute('prepend');
|
||||
if (key) swapSpec.key = key;
|
||||
if (sort !== null) swapSpec.sort = sort || true;
|
||||
if (prepend) swapSpec.prepend = true;
|
||||
tasks.push({
|
||||
type: 'partial',
|
||||
fragment: templateElt.content.cloneNode(true),
|
||||
target: api.attributeValue(templateElt, 'hx-target'),
|
||||
swapSpec,
|
||||
sourceElement: ctx.sourceElement
|
||||
});
|
||||
},
|
||||
handle_swap: (style, target, fragment, swapSpec) => {
|
||||
if (style === 'upsert') {
|
||||
let keyAttr = swapSpec.key || 'id';
|
||||
let desc = swapSpec.sort === 'desc';
|
||||
let firstChild = target.firstChild;
|
||||
|
||||
let getKey = (el) => el.getAttribute(keyAttr) || el.id;
|
||||
|
||||
let compare = (a, b) => {
|
||||
let result = a.localeCompare(b, undefined, {numeric: true});
|
||||
return desc ? -result : result;
|
||||
};
|
||||
|
||||
for (let newEl of Array.from(fragment.children)) {
|
||||
let id = newEl.id;
|
||||
if (id) {
|
||||
let existing = document.getElementById(id);
|
||||
if (existing) {
|
||||
existing.outerHTML = newEl.outerHTML
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let newKey = getKey(newEl);
|
||||
if (!newKey) {
|
||||
if (swapSpec.prepend) {
|
||||
target.insertBefore(newEl, firstChild);
|
||||
} else {
|
||||
target.appendChild(newEl);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let inserted = false;
|
||||
for (let child of target.children) {
|
||||
let childKey = getKey(child);
|
||||
if (childKey && compare(newKey, childKey) < 0) {
|
||||
target.insertBefore(newEl, child);
|
||||
inserted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!inserted) {
|
||||
target.appendChild(newEl);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -81,7 +81,8 @@ var htmx = (() => {
|
||||
createRequestContext: this.__createRequestContext.bind(this),
|
||||
collectFormData: this.__collectFormData.bind(this),
|
||||
handleHxVals: this.__handleHxVals.bind(this),
|
||||
insertContent: this.__insertContent.bind(this)
|
||||
insertContent: this.__insertContent.bind(this),
|
||||
morph: this.__morph.bind(this)
|
||||
};
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
this.__initHistoryHandling();
|
||||
@@ -1422,7 +1423,7 @@ var htmx = (() => {
|
||||
let methods = this.__extMethods.get('handle_swap')
|
||||
let handled = false;
|
||||
for (const method of methods) {
|
||||
let result = method(swapSpec.style, target, fragment);
|
||||
let result = method(swapSpec.style, target, fragment, swapSpec);
|
||||
if (result) {
|
||||
handled = true;
|
||||
if (Array.isArray(result)) {
|
||||
|
||||
@@ -158,6 +158,7 @@
|
||||
<!-- ============================================ -->
|
||||
<script src="./tests/ext/hx-optimistic.js"></script>
|
||||
<script src="./tests/ext/hx-preload.js"></script>
|
||||
<script src="./tests/ext/hx-upsert.js"></script>
|
||||
<script src="./tests/ext/hx-ws.js"></script>
|
||||
|
||||
<!-- ============================================ -->
|
||||
|
||||
231
test/tests/ext/hx-upsert.js
Normal file
231
test/tests/ext/hx-upsert.js
Normal file
@@ -0,0 +1,231 @@
|
||||
describe('hx-upsert extension', function() {
|
||||
|
||||
let extBackup;
|
||||
|
||||
before(async () => {
|
||||
extBackup = backupExtensions();
|
||||
clearExtensions();
|
||||
let script = document.createElement('script');
|
||||
script.src = '../src/ext/hx-upsert.js';
|
||||
await new Promise(resolve => {
|
||||
script.onload = resolve;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
})
|
||||
|
||||
after(() => {
|
||||
restoreExtensions(extBackup);
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
setupTest(this.currentTest)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanupTest(this.currentTest)
|
||||
})
|
||||
|
||||
it('updates existing element by id', async function () {
|
||||
mockResponse('GET', '/test', '<div id="item-1">Updated</div>')
|
||||
let div = createProcessedHTML('<div hx-get="/test" hx-swap="upsert"><div id="item-1">Original</div></div>');
|
||||
div.click()
|
||||
await htmx.timeout(20)
|
||||
assert.equal(div.querySelector('#item-1').textContent, 'Updated')
|
||||
})
|
||||
|
||||
it('inserts new element', async function () {
|
||||
mockResponse('GET', '/test', '<div id="item-2">New</div>')
|
||||
let div = createProcessedHTML('<div hx-get="/test" hx-swap="upsert"><div id="item-1">Original</div></div>');
|
||||
div.click()
|
||||
await htmx.timeout(20)
|
||||
assert.equal(div.children.length, 2)
|
||||
assert.equal(div.querySelector('#item-2').textContent, 'New')
|
||||
})
|
||||
|
||||
it('preserves existing elements not in response', async function () {
|
||||
mockResponse('GET', '/test', '<div id="item-2">New</div>')
|
||||
let div = createProcessedHTML('<div hx-get="/test" hx-swap="upsert"><div id="item-1">Original</div></div>');
|
||||
div.click()
|
||||
await htmx.timeout(20)
|
||||
assert.equal(div.children.length, 2)
|
||||
assert.equal(div.querySelector('#item-1').textContent, 'Original')
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
it('prepends unmatched elements', async function () {
|
||||
mockResponse('GET', '/test', '<div>No Key</div>')
|
||||
let div = createProcessedHTML('<div hx-get="/test" hx-swap="upsert prepend"><div id="item-1">Original</div></div>');
|
||||
div.click()
|
||||
await htmx.timeout(20)
|
||||
assert.equal(div.children[0].textContent, 'No Key')
|
||||
assert.equal(div.children[1].id, 'item-1')
|
||||
})
|
||||
|
||||
it('appends unmatched elements by default', async function () {
|
||||
mockResponse('GET', '/test', '<div>No Key</div>')
|
||||
let div = createProcessedHTML('<div hx-get="/test" hx-swap="upsert"><div id="item-1">Original</div></div>');
|
||||
div.click()
|
||||
await htmx.timeout(20)
|
||||
assert.equal(div.children[0].id, 'item-1')
|
||||
assert.equal(div.children[1].textContent, 'No Key')
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
it('updates multiple existing elements', async function () {
|
||||
mockResponse('GET', '/test', '<div id="item-1">Updated 1</div><div id="item-2">Updated 2</div>')
|
||||
let div = createProcessedHTML('<div hx-get="/test" hx-swap="upsert"><div id="item-1">Original 1</div><div id="item-2">Original 2</div></div>');
|
||||
div.click()
|
||||
await htmx.timeout(20)
|
||||
assert.equal(div.querySelector('#item-1').textContent, 'Updated 1')
|
||||
assert.equal(div.querySelector('#item-2').textContent, 'Updated 2')
|
||||
})
|
||||
|
||||
it('handles mixed keyed and unkeyed elements', async function () {
|
||||
mockResponse('GET', '/test', '<div id="item-2">Two</div><div>Unkeyed</div>')
|
||||
let div = createProcessedHTML('<div hx-get="/test" hx-swap="upsert"><div id="item-1">One</div></div>');
|
||||
div.click()
|
||||
await htmx.timeout(20)
|
||||
assert.equal(div.children.length, 3)
|
||||
assert.equal(div.children[0].id, 'item-1')
|
||||
assert.equal(div.children[1].id, 'item-2')
|
||||
assert.equal(div.children[2].textContent, 'Unkeyed')
|
||||
})
|
||||
|
||||
it('sort with prepend puts unkeyed first', async function () {
|
||||
mockResponse('GET', '/test', '<div id="item-2">Two</div><div>Unkeyed</div>')
|
||||
let div = createProcessedHTML('<div hx-get="/test" hx-swap="upsert sort prepend"><div id="item-1">One</div></div>');
|
||||
div.click()
|
||||
await htmx.timeout(20)
|
||||
assert.equal(div.children[0].textContent, 'Unkeyed')
|
||||
assert.equal(div.children[1].id, 'item-1')
|
||||
assert.equal(div.children[2].id, 'item-2')
|
||||
})
|
||||
|
||||
it('preserves element order when all matched', async function () {
|
||||
mockResponse('GET', '/test', '<div id="item-2">Updated 2</div><div id="item-1">Updated 1</div>')
|
||||
let div = createProcessedHTML('<div hx-get="/test" hx-swap="upsert"><div id="item-1">Original 1</div><div id="item-2">Original 2</div></div>');
|
||||
div.click()
|
||||
await htmx.timeout(20)
|
||||
assert.equal(div.children[0].id, 'item-1')
|
||||
assert.equal(div.children[1].id, 'item-2')
|
||||
assert.equal(div.children[0].textContent, 'Updated 1')
|
||||
assert.equal(div.children[1].textContent, 'Updated 2')
|
||||
})
|
||||
|
||||
it('handles empty response', async function () {
|
||||
mockResponse('GET', '/test', '')
|
||||
let div = createProcessedHTML('<div hx-get="/test" hx-swap="upsert"><div id="item-1">Original</div></div>');
|
||||
div.click()
|
||||
await htmx.timeout(20)
|
||||
assert.equal(div.children.length, 1)
|
||||
assert.equal(div.querySelector('#item-1').textContent, 'Original')
|
||||
})
|
||||
|
||||
it('works with hx-swap-oob upsert', async function () {
|
||||
mockResponse('GET', '/test', '<div id="main-item">Main</div><div id="other" hx-swap-oob="upsert"><div id="oob-1">OOB</div></div>')
|
||||
let container = createProcessedHTML('<div><div hx-get="/test" hx-swap="innerHTML">Original</div><div id="other"><div id="oob-2">Existing</div></div></div>');
|
||||
let div = container.children[0]
|
||||
div.click()
|
||||
await htmx.timeout(50)
|
||||
let updatedOther = container.querySelector('#other')
|
||||
let mainItem = div.querySelector('#main-item')
|
||||
assert.isNotNull(mainItem)
|
||||
assert.equal(mainItem.textContent, 'Main')
|
||||
assert.equal(updatedOther.children.length, 2)
|
||||
assert.equal(updatedOther.querySelector('#oob-1').textContent, 'OOB')
|
||||
assert.equal(updatedOther.querySelector('#oob-2').textContent, 'Existing')
|
||||
})
|
||||
|
||||
|
||||
|
||||
it('works with hx-partial', async function () {
|
||||
mockResponse('GET', '/test', '<hx-partial hx-target="#list1" hx-swap="upsert"><div id="item-2">Two</div></hx-partial><hx-partial hx-target="#list2" hx-swap="upsert"><div id="item-b">B</div></hx-partial>')
|
||||
let container = createProcessedHTML('<div hx-get="/test"><div id="list1"><div id="item-1">One</div></div><div id="list2"><div id="item-a">A</div></div></div>');
|
||||
container.click()
|
||||
await htmx.timeout(20)
|
||||
let list1 = container.querySelector('#list1')
|
||||
let list2 = container.querySelector('#list2')
|
||||
assert.equal(list1.children.length, 2)
|
||||
assert.equal(list1.querySelector('#item-1').textContent, 'One')
|
||||
assert.equal(list1.querySelector('#item-2').textContent, 'Two')
|
||||
assert.equal(list2.children.length, 2)
|
||||
assert.equal(list2.querySelector('#item-a').textContent, 'A')
|
||||
assert.equal(list2.querySelector('#item-b').textContent, 'B')
|
||||
})
|
||||
|
||||
|
||||
|
||||
it('sorts descending with sort:desc', async function () {
|
||||
mockResponse('GET', '/test', '<div id="item-2">Two</div>')
|
||||
let div = createProcessedHTML('<div hx-get="/test" hx-swap="upsert sort:desc"><div id="item-3">Three</div><div id="item-1">One</div></div>');
|
||||
div.click()
|
||||
await htmx.timeout(20)
|
||||
assert.equal(div.children[0].id, 'item-3')
|
||||
assert.equal(div.children[1].id, 'item-2')
|
||||
assert.equal(div.children[2].id, 'item-1')
|
||||
})
|
||||
|
||||
it('hx-upsert tag with basic upsert', async function () {
|
||||
mockResponse('GET', '/test', '<hx-upsert hx-target="#list"><div id="item-2">Two</div></hx-upsert>')
|
||||
let container = createProcessedHTML('<div hx-get="/test"><div id="list"><div id="item-1">One</div></div></div>');
|
||||
container.click()
|
||||
await htmx.timeout(20)
|
||||
let list = container.querySelector('#list')
|
||||
assert.equal(list.children.length, 2)
|
||||
assert.equal(list.querySelector('#item-1').textContent, 'One')
|
||||
assert.equal(list.querySelector('#item-2').textContent, 'Two')
|
||||
})
|
||||
|
||||
it('hx-upsert tag with sort attribute', async function () {
|
||||
mockResponse('GET', '/test', '<hx-upsert hx-target="#list" sort><div id="item-2">Two</div></hx-upsert>')
|
||||
let container = createProcessedHTML('<div hx-get="/test"><div id="list"><div id="item-1">One</div><div id="item-3">Three</div></div></div>');
|
||||
container.click()
|
||||
await htmx.timeout(20)
|
||||
let list = container.querySelector('#list')
|
||||
assert.equal(list.children[0].id, 'item-1')
|
||||
assert.equal(list.children[1].id, 'item-2')
|
||||
assert.equal(list.children[2].id, 'item-3')
|
||||
})
|
||||
|
||||
it('hx-upsert tag with sort="desc"', async function () {
|
||||
mockResponse('GET', '/test', '<hx-upsert hx-target="#list" sort="desc"><div id="item-2">Two</div></hx-upsert>')
|
||||
let container = createProcessedHTML('<div hx-get="/test"><div id="list"><div id="item-3">Three</div><div id="item-1">One</div></div></div>');
|
||||
container.click()
|
||||
await htmx.timeout(20)
|
||||
let list = container.querySelector('#list')
|
||||
assert.equal(list.children[0].id, 'item-3')
|
||||
assert.equal(list.children[1].id, 'item-2')
|
||||
assert.equal(list.children[2].id, 'item-1')
|
||||
})
|
||||
|
||||
it('hx-upsert tag with key attribute', async function () {
|
||||
mockResponse('GET', '/test', '<hx-upsert hx-target="#list" key="data-priority" sort><div id="task-2" data-priority="2">Medium</div></hx-upsert>')
|
||||
let container = createProcessedHTML('<div hx-get="/test"><div id="list"><div id="task-3" data-priority="1">High</div><div id="task-1" data-priority="3">Low</div></div></div>');
|
||||
container.click()
|
||||
await htmx.timeout(20)
|
||||
let list = container.querySelector('#list')
|
||||
assert.equal(list.children[0].getAttribute('data-priority'), '1')
|
||||
assert.equal(list.children[1].getAttribute('data-priority'), '2')
|
||||
assert.equal(list.children[2].getAttribute('data-priority'), '3')
|
||||
})
|
||||
|
||||
it('hx-upsert tag with prepend attribute', async function () {
|
||||
mockResponse('GET', '/test', '<hx-upsert hx-target="#list" prepend><div>No Key</div></hx-upsert>')
|
||||
let container = createProcessedHTML('<div hx-get="/test"><div id="list"><div id="item-1">One</div></div></div>');
|
||||
container.click()
|
||||
await htmx.timeout(20)
|
||||
let list = container.querySelector('#list')
|
||||
assert.equal(list.children[0].textContent, 'No Key')
|
||||
assert.equal(list.children[1].id, 'item-1')
|
||||
})
|
||||
|
||||
|
||||
})
|
||||
@@ -22,6 +22,7 @@ The possible values of this attribute are:
|
||||
* `after` or `afterend` - Insert the response after the target element
|
||||
* `delete` - Deletes the target element regardless of the response
|
||||
* `none`- Does not append content from response (out of band items will still be processed).
|
||||
* `upsert` - Updates existing elements by ID and inserts new ones (requires [upsert extension](/extensions/upsert))
|
||||
|
||||
These options are based on standard DOM naming and the
|
||||
[`Element.insertAdjacentHTML`](https://developer.mozilla.org/en-US/docs/Web/API/Element/insertAdjacentHTML)
|
||||
|
||||
252
www/content/extensions/upsert.md
Normal file
252
www/content/extensions/upsert.md
Normal file
@@ -0,0 +1,252 @@
|
||||
+++
|
||||
title = "htmx Upsert Extension"
|
||||
+++
|
||||
|
||||
The `upsert` extension adds a new swap style that intelligently updates existing elements by ID and inserts new ones, while preserving elements not in the response. This is particularly useful for maintaining dynamic lists where you want to update specific items without replacing the entire container.
|
||||
|
||||
## Installing
|
||||
|
||||
### Via CDN
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-alpha6/dist/htmx.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-alpha6/dist/ext/hx-upsert.js"></script>
|
||||
```
|
||||
|
||||
### Download
|
||||
|
||||
Download the files and include them in your project:
|
||||
|
||||
```html
|
||||
<script src="/path/to/htmx.min.js"></script>
|
||||
<script src="/path/to/hx-upsert.js"></script>
|
||||
```
|
||||
|
||||
### npm
|
||||
|
||||
For npm-style build systems:
|
||||
|
||||
```sh
|
||||
npm install htmx.org@4.0.0-alpha6
|
||||
```
|
||||
|
||||
Then include both files:
|
||||
|
||||
```html
|
||||
<script src="node_modules/htmx.org/dist/htmx.min.js"></script>
|
||||
<script src="node_modules/htmx.org/dist/ext/hx-upsert.js"></script>
|
||||
```
|
||||
|
||||
### Module Imports
|
||||
|
||||
When using module bundlers:
|
||||
|
||||
```javascript
|
||||
import htmx from 'htmx.org';
|
||||
import 'htmx.org/dist/ext/hx-upsert';
|
||||
```
|
||||
|
||||
The extension registers automatically when loaded. No `hx-ext` attribute is needed in htmx 4.
|
||||
|
||||
## Usage
|
||||
|
||||
Once loaded, simply use `hx-swap="upsert"` to apply the upsert behavior:
|
||||
|
||||
```html
|
||||
<button hx-get="/items" hx-swap="upsert" hx-target="#item-list">
|
||||
Refresh Items
|
||||
</button>
|
||||
|
||||
<div id="item-list">
|
||||
<div id="item-1">Original Item 1</div>
|
||||
<div id="item-2">Original Item 2</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
When the server responds with:
|
||||
|
||||
```html
|
||||
<div id="item-2">Updated Item 2</div>
|
||||
<div id="item-3">New Item 3</div>
|
||||
```
|
||||
|
||||
The result will be:
|
||||
|
||||
```html
|
||||
<div id="item-list">
|
||||
<div id="item-1">Original Item 1</div>
|
||||
<div id="item-2">Updated Item 2</div>
|
||||
<div id="item-3">New Item 3</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Using `<hx-upsert>` Tags
|
||||
|
||||
You can also use `<hx-upsert>` tags in server responses for targeted upserts:
|
||||
|
||||
```html
|
||||
<div id="main">Main content</div>
|
||||
<hx-upsert hx-target="#item-list" key="data-id" sort="desc">
|
||||
<div id="item-2" data-id="2">Updated Item 2</div>
|
||||
<div id="item-4" data-id="4">New Item 4</div>
|
||||
</hx-upsert>
|
||||
```
|
||||
|
||||
The `<hx-upsert>` tag supports:
|
||||
- `hx-target` - target selector for the upsert
|
||||
- `key` - attribute name for sorting (e.g., `key="data-priority"`)
|
||||
- `sort` - sort ascending (use `sort="desc"` for descending)
|
||||
- `prepend` - prepend elements without keys
|
||||
|
||||
### Using with `<hx-partial>`
|
||||
|
||||
You can use `<hx-partial>` with `hx-swap="upsert"` for targeted upserts:
|
||||
|
||||
```html
|
||||
<hx-partial hx-target="#main" hx-swap="innerHTML">
|
||||
<div>Updated main content</div>
|
||||
</hx-partial>
|
||||
<hx-partial hx-target="#item-list" hx-swap="upsert sort">
|
||||
<div id="item-2">Updated Item 2</div>
|
||||
<div id="item-5">New Item 5</div>
|
||||
</hx-partial>
|
||||
```
|
||||
|
||||
This allows you to update the main content normally while upserting items in a list, all in a single response.
|
||||
|
||||
## How It Works
|
||||
|
||||
The upsert swap style:
|
||||
|
||||
1. **Updates** elements with matching IDs (replaces their outerHTML)
|
||||
2. **Inserts** new elements that don't have matching IDs
|
||||
3. **Preserves** existing elements not present in the response
|
||||
|
||||
## Configuration
|
||||
|
||||
### Basic Upsert
|
||||
|
||||
```html
|
||||
<div hx-get="/items" hx-swap="upsert">
|
||||
<div id="item-1">Item 1</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Sorting
|
||||
|
||||
Add `sort` to maintain elements in ascending order by ID:
|
||||
|
||||
```html
|
||||
<div hx-get="/items" hx-swap="upsert sort">
|
||||
<div id="item-1">Item 1</div>
|
||||
<div id="item-3">Item 3</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
After receiving `<div id="item-2">Item 2</div>`, the order will be: item-1, item-2, item-3.
|
||||
|
||||
**Note:** Sorting only applies to newly inserted elements. The existing elements in the target should already be in sorted order. The sort feature finds the correct position for new elements within the existing sorted list.
|
||||
|
||||
### Descending Sort
|
||||
|
||||
Use `sort:desc` for descending order:
|
||||
|
||||
```html
|
||||
<div hx-get="/items" hx-swap="upsert sort:desc">
|
||||
<div id="item-1">Item 1</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Custom Key Attribute
|
||||
|
||||
Use `key:attr` to sort by a different attribute:
|
||||
|
||||
```html
|
||||
<div hx-get="/items" hx-swap="upsert key:data-priority sort">
|
||||
<div id="task-2" data-priority="1">High Priority</div>
|
||||
<div id="task-1" data-priority="5">Low Priority</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
New items will be inserted in the correct position based on their `data-priority` value.
|
||||
|
||||
### Prepend Unkeyed Elements
|
||||
|
||||
By default, elements without IDs are appended. Use `prepend` to insert them at the beginning:
|
||||
|
||||
```html
|
||||
<div hx-get="/items" hx-swap="upsert prepend">
|
||||
<div id="item-1">Item 1</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
When receiving `<div>No ID</div>`, it will be inserted before item-1.
|
||||
|
||||
### Combined Modifiers
|
||||
|
||||
```html
|
||||
<div hx-get="/items" hx-swap="upsert sort:desc prepend">
|
||||
<!-- Sorts descending and prepends unkeyed elements -->
|
||||
</div>
|
||||
```
|
||||
|
||||
## Use with hx-swap-oob
|
||||
|
||||
The upsert swap style works with out-of-band swaps:
|
||||
|
||||
```html
|
||||
<div hx-get="/update" hx-swap="innerHTML">
|
||||
<div id="main">Main content</div>
|
||||
</div>
|
||||
|
||||
<div id="sidebar">
|
||||
<div id="item-1">Sidebar Item 1</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Server response:
|
||||
|
||||
```html
|
||||
<div id="main">Updated main content</div>
|
||||
<div id="sidebar" hx-swap-oob="upsert">
|
||||
<div id="item-2">New Sidebar Item</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
The sidebar will be upserted while main content is replaced normally.
|
||||
|
||||
## Examples
|
||||
|
||||
### Dynamic Todo List
|
||||
|
||||
```html
|
||||
<form hx-post="/todos" hx-swap="upsert sort" hx-target="#todo-list">
|
||||
<input name="task" placeholder="New task">
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
|
||||
<div id="todo-list">
|
||||
<div id="todo-1">Buy groceries</div>
|
||||
<div id="todo-2">Walk the dog</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Live Scoreboard
|
||||
|
||||
```html
|
||||
<div hx-get="/scores"
|
||||
hx-trigger="every 5s"
|
||||
hx-swap="upsert key:data-score sort:desc"
|
||||
id="scoreboard">
|
||||
<div id="player-1" data-score="100">Alice: 100</div>
|
||||
<div id="player-2" data-score="85">Bob: 85</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
* Only elements with `id` attributes can be matched and updated
|
||||
* The extension uses `document.getElementById()` for matching, so IDs must be unique across the entire document
|
||||
* Sorting uses numeric-aware `localeCompare`, which may have performance implications for very large lists
|
||||
* Elements without keys (no ID or key attribute) cannot be individually updated
|
||||
|
||||
Reference in New Issue
Block a user