Skip to content

Commit f25ce57

Browse files
authored
feat: Add action to move array item to target index. (#223)
1 parent f2b9bd0 commit f25ce57

13 files changed

Lines changed: 871 additions & 222 deletions

File tree

nx/blocks/form/data/model.js

Lines changed: 21 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,17 @@ import { daFetch } from 'https://da.live/blocks/shared/utils.js';
33
import HTMLConverter from '../utils/html2json.js';
44
import JSONConverter from '../utils/json2html.js';
55
import { validateJson } from '../utils/validator.js';
6-
import { annotateFromSchema, dereferenceSchema, isEmpty, pruneRecursive } from '../utils/utils.js';
7-
import { getValue, setValue, removeValue } from '../utils/pointer.js';
6+
import { annotateFromSchema, dereferenceSchema, findNodeByPointer, isEmpty, pruneRecursive } from '../utils/utils.js';
7+
import {
8+
append,
9+
getValue,
10+
setValue,
11+
removeValue,
12+
moveBefore,
13+
insertBefore,
14+
} from '../utils/pointer.js';
815
import { generateValue, resolveValue } from '../utils/value-resolver.js';
916

10-
/**
11-
* Find annotation node by pointer.
12-
* @param {Object} node - Annotation node
13-
* @param {string} pointer - Target pointer
14-
* @returns {Object|null}
15-
*/
16-
function findNodeByPointer(node, pointer) {
17-
if (!node) return null;
18-
if (node.pointer === pointer) return node;
19-
const children = node.children ?? [];
20-
for (const child of children) {
21-
const found = findNodeByPointer(child, pointer);
22-
if (found) return found;
23-
}
24-
return null;
25-
}
26-
2717
/**
2818
* A data model that represents a piece of structured content.
2919
*/
@@ -94,21 +84,26 @@ export default class FormModel {
9484
}
9585

9686
addArrayItem(pointer, items) {
97-
if (!items) {
98-
// eslint-disable-next-line no-console
99-
console.warn('The array schema has no items definition for pointer "%s"', pointer);
100-
return;
101-
}
87+
if (!items) return;
10288
const array = getValue(this._json, pointer) ?? [];
10389
const newItem = generateValue(items, true);
104-
array.push(newItem);
105-
setValue(this._json, pointer, array);
90+
insertBefore(this._json, append(pointer, array.length), newItem);
91+
}
92+
93+
insertArrayItem(pointer, items) {
94+
if (!items) return false;
95+
const newItem = generateValue(items, true);
96+
return insertBefore(this._json, pointer, newItem);
10697
}
10798

10899
removeArrayItem(pointer) {
109100
return removeValue(this._json, pointer);
110101
}
111102

103+
moveArrayItem(pointer, beforePointer) {
104+
return moveBefore(this._json, pointer, beforePointer);
105+
}
106+
112107
async saveHtml() {
113108
const prunedData = pruneRecursive(this._json.data);
114109
const json = { ...this._json, data: prunedData ?? {} };

nx/blocks/form/form.js

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import getPathDetails from 'https://da.live/blocks/shared/pathDetails.js';
44
import FormModel from './data/model.js';
55

66
// Internal utils
7+
import { getParentPointer } from './utils/pointer.js';
78
import { schemas as schemasPromise } from './utils/schema.js';
8-
import { loadHtml } from './utils/utils.js';
9+
import { findNodeByPointer, loadHtml } from './utils/utils.js';
910

1011
import 'https://da.live/blocks/edit/da-title/da-title.js';
1112

@@ -89,6 +90,22 @@ class FormEditor extends LitElement {
8990
await this.formModel.saveHtml();
9091
}
9192

93+
async handleInsertItem({ detail }) {
94+
const { pointer } = detail;
95+
const parentPointer = getParentPointer(pointer);
96+
const node = this.formModel?.annotated && parentPointer
97+
? findNodeByPointer(this.formModel.annotated, parentPointer)
98+
: null;
99+
const items = node?.items;
100+
if (!this.formModel.insertArrayItem(pointer, items)) return;
101+
102+
// Update the view with the new values
103+
this.formModel = this.formModel.clone();
104+
105+
// Persist the data
106+
await this.formModel.saveHtml();
107+
}
108+
92109
async handleRemoveItem({ detail }) {
93110
const { pointer } = detail;
94111
if (!this.formModel.removeArrayItem(pointer)) return;
@@ -100,6 +117,17 @@ class FormEditor extends LitElement {
100117
await this.formModel.saveHtml();
101118
}
102119

120+
async handleMoveArrayItem({ detail }) {
121+
const { pointer, beforePointer } = detail;
122+
if (!this.formModel.moveArrayItem(pointer, beforePointer)) return;
123+
124+
// Update the view with the new values
125+
this.formModel = this.formModel.clone();
126+
127+
// Persist the data
128+
await this.formModel.saveHtml();
129+
}
130+
103131
renderSchemaSelector() {
104132
return html`
105133
<p class="da-form-title">Please select a schema to get started</p>
@@ -126,7 +154,9 @@ class FormEditor extends LitElement {
126154
<da-form-editor
127155
@update=${this.handleUpdate}
128156
@add-item=${this.handleAddItem}
157+
@insert-item=${this.handleInsertItem}
129158
@remove-item=${this.handleRemoveItem}
159+
@move-array-item=${this.handleMoveArrayItem}
130160
.formModel=${this.formModel}
131161
></da-form-editor>
132162
<da-form-preview .formModel=${this.formModel}></da-form-preview>

nx/blocks/form/utils/pointer.js

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,24 @@ function unescapeSegment(segment) {
1717
* @param {string} pointer - RFC 6901 pointer (e.g. "/data/items/0")
1818
* @returns {string[]} Unescaped segments (empty for root)
1919
*/
20-
function parsePointer(pointer) {
20+
export function parsePointer(pointer) {
2121
if (!pointer || typeof pointer !== 'string') return [];
2222
const trimmed = pointer.startsWith('/') ? pointer.slice(1) : pointer;
2323
if (!trimmed) return [];
2424
return trimmed.split('/').map(unescapeSegment);
2525
}
2626

27+
/**
28+
* Get parent pointer (pointer without last segment).
29+
* @param {string} pointer - RFC 6901 pointer (e.g. "/data/items/0")
30+
* @returns {string} Parent pointer (e.g. "/data/items") or empty string for root
31+
*/
32+
export function getParentPointer(pointer) {
33+
const segments = parsePointer(pointer);
34+
if (segments.length <= 1) return '';
35+
return `/${segments.slice(0, -1).map((s) => escapeSegment(String(s))).join('/')}`;
36+
}
37+
2738
/**
2839
* Append segment to pointer.
2940
* @param {string} pointer - Base pointer
@@ -127,3 +138,63 @@ export function removeValue(data, pointer) {
127138
}
128139
return false;
129140
}
141+
142+
/**
143+
* Move array item before another item, or to end if beforePointer is empty.
144+
* @param {Object} data - Root object
145+
* @param {string} pointer - RFC 6901 pointer to the item to move
146+
* @param {string} [beforePointer] - Pointer to the item before which to insert, or empty for append
147+
* @returns {boolean} True if moved
148+
*/
149+
export function moveBefore(data, pointer, beforePointer) {
150+
const parentPointer = getParentPointer(pointer);
151+
const array = getValue(data, parentPointer);
152+
if (!parentPointer || !Array.isArray(array)) return false;
153+
154+
const currentIndex = parseInt(parsePointer(pointer).pop(), 10);
155+
if (currentIndex < 0 || currentIndex >= array.length) return false;
156+
157+
const isEmpty = !beforePointer || !String(beforePointer).trim();
158+
let targetIndex;
159+
if (isEmpty) {
160+
targetIndex = array.length;
161+
} else {
162+
if (getParentPointer(beforePointer) !== parentPointer) return false;
163+
targetIndex = Math.max(0, Math.min(
164+
parseInt(parsePointer(beforePointer).pop(), 10),
165+
array.length,
166+
));
167+
}
168+
169+
if (currentIndex === targetIndex) return false;
170+
171+
const [item] = array.splice(currentIndex, 1);
172+
array.splice(targetIndex, 0, item);
173+
return true;
174+
}
175+
176+
/**
177+
* Insert value before the item at pointer.
178+
* @param {Object} data - Root object
179+
* @param {string} pointer - RFC 6901 pointer to the item or append position
180+
* @param {*} value - Value to insert
181+
* @returns {boolean} True if inserted
182+
*/
183+
export function insertBefore(data, pointer, value) {
184+
const parentPointer = getParentPointer(pointer);
185+
if (!parentPointer) return false;
186+
187+
const segments = parsePointer(pointer);
188+
const index = parseInt(segments[segments.length - 1], 10);
189+
if (!Number.isInteger(index) || index < 0) return false;
190+
191+
let array = getValue(data, parentPointer);
192+
if (!Array.isArray(array)) {
193+
array = [];
194+
setValue(data, parentPointer, array);
195+
}
196+
197+
const clampedIndex = Math.max(0, Math.min(index, array.length));
198+
array.splice(clampedIndex, 0, value);
199+
return true;
200+
}

nx/blocks/form/utils/utils.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,22 @@ export function pruneRecursive(value) {
150150
// General utilities
151151
// -----------------------------------------------------------------------------
152152

153+
/**
154+
* Find annotation node by pointer.
155+
* @param {Object} node - Annotation node
156+
* @param {string} pointer - Target pointer
157+
* @returns {Object|null}
158+
*/
159+
export function findNodeByPointer(node, pointer) {
160+
if (!node) return null;
161+
if (node.pointer === pointer) return node;
162+
for (const child of node.children ?? []) {
163+
const found = findNodeByPointer(child, pointer);
164+
if (found) return found;
165+
}
166+
return null;
167+
}
168+
153169
/** Fetch HTML from source URL. */
154170
export async function loadHtml(details) {
155171
const resp = await daFetch(details.sourceUrl);
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
:host {
2+
display: inline-block;
3+
}
4+
5+
.array-item-menu {
6+
position: relative;
7+
display: inline-flex;
8+
}
9+
10+
.menu-trigger {
11+
width: 28px;
12+
height: 28px;
13+
padding: 0;
14+
margin: 0;
15+
background: transparent;
16+
border: none;
17+
border-radius: 6px;
18+
cursor: pointer;
19+
display: inline-flex;
20+
align-items: center;
21+
justify-content: center;
22+
transition: background 0.15s ease;
23+
color: var(--s2-gray-600, #4b5563);
24+
25+
&:hover {
26+
background: var(--s2-gray-200, #e5e7eb);
27+
}
28+
29+
&.active {
30+
background: var(--s2-blue-200, #bfdbfe);
31+
color: var(--s2-blue-700, #1d4ed8);
32+
}
33+
34+
svg {
35+
width: 18px;
36+
height: 18px;
37+
}
38+
}
39+
40+
.menu-dropdown {
41+
position: absolute;
42+
top: 100%;
43+
right: 0;
44+
margin-top: 4px;
45+
min-width: 180px;
46+
padding: 4px;
47+
background: white;
48+
border: 1px solid var(--s2-gray-300, #d1d5db);
49+
border-radius: 8px;
50+
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
51+
z-index: 100;
52+
}
53+
54+
.menu-item {
55+
display: flex;
56+
align-items: center;
57+
gap: 8px;
58+
width: 100%;
59+
padding: 8px 12px;
60+
font-size: 13px;
61+
text-align: left;
62+
background: transparent;
63+
border: none;
64+
border-radius: 6px;
65+
cursor: pointer;
66+
color: var(--s2-gray-800, #1f2937);
67+
transition: background 0.15s ease;
68+
69+
&:disabled {
70+
opacity: 0.5;
71+
cursor: not-allowed;
72+
}
73+
74+
&:hover:not(:disabled) {
75+
background: var(--s2-gray-100, #f3f4f6);
76+
}
77+
78+
&.confirm {
79+
color: var(--s2-red-600, #dc2626);
80+
}
81+
82+
svg,
83+
.insert-icon,
84+
.check-icon {
85+
width: 16px;
86+
height: 16px;
87+
flex-shrink: 0;
88+
}
89+
90+
.check-icon {
91+
font-weight: bold;
92+
}
93+
}

0 commit comments

Comments
 (0)