bedrock-ddui-forms 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.es.md +101 -0
- package/README.md +102 -1
- package/package.json +1 -1
- package/src/core/createRouter.js +74 -0
- package/src/index.js +1 -0
package/README.es.md
CHANGED
|
@@ -24,6 +24,7 @@ En lugar de encadenar llamadas sobre `CustomForm`, describes la vista como datos
|
|
|
24
24
|
- **Vistas declarativas**: un formulario es un objeto con `title` y un arreglo `content`.
|
|
25
25
|
- **Tipado con la API oficial**: los modelos son typedefs de JSDoc que reutilizan los tipos de `@minecraft/server-ui` (`ButtonOptions`, `SliderOptions`, …), así que se mantienen sincronizados con el módulo.
|
|
26
26
|
- **Navegación sencilla**: un botón puede abrir otra vista con `to`, combinable con `onClick`.
|
|
27
|
+
- **Router**: registra vistas por nombre, pásales parámetros y vuelve atrás con `"back"`.
|
|
27
28
|
- **Fácil de extender**: añadir un tipo de elemento nuevo es una entrada en una tabla y una función.
|
|
28
29
|
- **Fallback seguro**: un `type` no soportado muestra un label visible y registra una advertencia, en vez de romper todo el formulario.
|
|
29
30
|
|
|
@@ -151,6 +152,105 @@ Un botón puede abrir otra vista con `to`. Se ejecuta **después** de `onClick`,
|
|
|
151
152
|
- Si hace algo más, usa `onClick`, y añade `to` si además debe navegar.
|
|
152
153
|
- Dentro de `onClick` y de `image.options.onClick`, `this` es el `CustomForm`, así que `this.close()` funciona si necesitas cerrar sin abrir nada.
|
|
153
154
|
|
|
155
|
+
Para muchas vistas, parámetros y un botón de volver automático, mira el [Router](#router-vistas-con-nombre-y-atrás).
|
|
156
|
+
|
|
157
|
+
## Router: vistas con nombre y "atrás"
|
|
158
|
+
|
|
159
|
+
Cuando tus menús crecen, conectar cada botón con el siguiente `openX(player)` se vuelve repetitivo. `createRouter` registra tus vistas por nombre, permite que los botones apunten a ellas y guarda un **historial por jugador** para que un botón pueda volver atrás.
|
|
160
|
+
|
|
161
|
+
```js
|
|
162
|
+
import { ObservableBoolean } from "@minecraft/server-ui";
|
|
163
|
+
import { createRouter } from "bedrock-ddui-forms/index"; // o desde tu carpeta libs/
|
|
164
|
+
|
|
165
|
+
const router = createRouter({
|
|
166
|
+
menu: (player) => ({
|
|
167
|
+
title: "Menú",
|
|
168
|
+
content: [
|
|
169
|
+
{ type: "button", label: "Mi perfil", to: { view: "profile", params: { name: player.name } } },
|
|
170
|
+
{ type: "button", label: "Ajustes", to: "settings" },
|
|
171
|
+
{ type: "close-button" }
|
|
172
|
+
]
|
|
173
|
+
}),
|
|
174
|
+
|
|
175
|
+
profile: (player, params) => ({
|
|
176
|
+
title: `Perfil: ${params.name}`,
|
|
177
|
+
content: [
|
|
178
|
+
{ type: "label", text: "Todavía no hay nada aquí." },
|
|
179
|
+
{ type: "button", label: "Volver", to: "back" }
|
|
180
|
+
]
|
|
181
|
+
}),
|
|
182
|
+
|
|
183
|
+
settings: (player) => ({
|
|
184
|
+
title: "Ajustes",
|
|
185
|
+
content: [
|
|
186
|
+
{ type: "toggle", label: "Notificaciones", toggled: new ObservableBoolean(true, { clientWritable: true }) },
|
|
187
|
+
{ type: "button", label: "Volver", to: "back" }
|
|
188
|
+
]
|
|
189
|
+
})
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Iniciar un flujo
|
|
193
|
+
router.open(player, "menu");
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Cada vista es una función `(player, params) => formData`. Devuelve el mismo objeto que le darías a `customFormRender`, incluido `onExit`.
|
|
197
|
+
|
|
198
|
+
### Valores que acepta `to`
|
|
199
|
+
|
|
200
|
+
| `to` | Resultado |
|
|
201
|
+
| --- | --- |
|
|
202
|
+
| `"settings"` | Abre la vista registrada con ese nombre. |
|
|
203
|
+
| `{ view: "profile", params: { ... } }` | Lo mismo, pasándole `params` a la vista. |
|
|
204
|
+
| `"back"` | Vuelve a la vista anterior del historial del jugador. Si no hay ninguna, el formulario simplemente se cierra. |
|
|
205
|
+
| una función | Funciona como el `to` normal (ver [Navegación](#navegación)), pero queda **fuera** del router: el historial del jugador se descarta. |
|
|
206
|
+
|
|
207
|
+
Si usas el nombre de una vista que no registraste, el router lanza `Unknown View: "nombre"`.
|
|
208
|
+
|
|
209
|
+
### Parámetros
|
|
210
|
+
|
|
211
|
+
`params` es el segundo argumento de la función de la vista. Puedes pasarlos desde un botón (`to: { view, params }`) o al iniciar el flujo (`router.open(player, "profile", { name: "Alex" })`).
|
|
212
|
+
|
|
213
|
+
Qué pasas es decisión tuya. El router guarda los `params` de cada paso en el historial y los vuelve a usar al volver atrás, de modo que la vista se reconstruye con los mismos `params`. Por eso los datos simples (nombres, ids, números) son la opción más segura: una entidad o un objeto de jugador guardado ahí puede dejar de ser válido cuando el jugador regrese a esa vista.
|
|
214
|
+
|
|
215
|
+
Volver atrás **reconstruye** la vista llamando otra vez a su función, así que los valores que el jugador escribió (tus `Observable`) no se conservan: vuelven a sus valores iniciales.
|
|
216
|
+
|
|
217
|
+
### Opciones del router
|
|
218
|
+
|
|
219
|
+
```js
|
|
220
|
+
const router = createRouter(views, { onInvalid: "skip" });
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
El segundo argumento acepta las mismas opciones que el tercer argumento de `customFormRender` (ver [Validación y mensajes de error](#validación-y-mensajes-de-error)) y se aplica a todas las vistas.
|
|
224
|
+
|
|
225
|
+
### Qué devuelve `open`
|
|
226
|
+
|
|
227
|
+
`router.open(...)` devuelve una promesa que se resuelve con el motivo de cierre de **la vista que abrió**, no de todo el flujo. Cuando un botón navega, el servidor cierra esa vista, así que se resuelve con `ServerClosed`. El `onExit` de cada vista sigue la misma regla descrita en [Cerrar una vista](#cerrar-una-vista-onexit).
|
|
228
|
+
|
|
229
|
+
### Vida del historial y limpieza
|
|
230
|
+
|
|
231
|
+
El router guarda en memoria el historial de cada jugador. Se borra cuando:
|
|
232
|
+
|
|
233
|
+
- el jugador cierra el formulario,
|
|
234
|
+
- un formulario se cierra desde código sin navegar (por ejemplo `this.close()` dentro de `onClick`),
|
|
235
|
+
- vuelves a llamar a `router.open(...)`: un flujo nuevo siempre empieza limpio,
|
|
236
|
+
- llamas a `router.clear(playerId)`.
|
|
237
|
+
|
|
238
|
+
Si un jugador sale del servidor a mitad de un flujo, su historial queda en memoria hasta que ocurra una de esas cosas. Es una entrada pequeña por jugador, pero puedes liberarla cuando se vaya:
|
|
239
|
+
|
|
240
|
+
```js
|
|
241
|
+
import { world } from "@minecraft/server";
|
|
242
|
+
|
|
243
|
+
world.afterEvents.playerLeave.subscribe(({ playerId }) => router.clear(playerId));
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
La librería no se suscribe a este evento por ti, a propósito:
|
|
247
|
+
|
|
248
|
+
- así la librería depende solo de `@minecraft/server-ui`,
|
|
249
|
+
- crear un router no engancha ningún evento global,
|
|
250
|
+
- tú decides cómo y cuándo limpiar según tu addon.
|
|
251
|
+
|
|
252
|
+
Suscríbete **una sola vez** (como arriba), no una vez por vista: una suscripción por vista se iría acumulando mientras los jugadores sigan conectados.
|
|
253
|
+
|
|
154
254
|
## Leer valores
|
|
155
255
|
|
|
156
256
|
Los valores son los `Observable` que tú creas, así que guarda una referencia y léelos cuando los necesites:
|
|
@@ -247,6 +347,7 @@ core/
|
|
|
247
347
|
customFormRender.js renderiza una vista y la muestra al jugador
|
|
248
348
|
propertiesFactory.js asocia cada `type` con su llamada a CustomForm
|
|
249
349
|
validation.js revisa las vistas y construye los mensajes de error
|
|
350
|
+
createRouter.js vistas con nombre y historial de "atrás" por jugador
|
|
250
351
|
models/properties/
|
|
251
352
|
*.js typedefs de JSDoc de cada elemento
|
|
252
353
|
```
|
package/README.md
CHANGED
|
@@ -24,6 +24,7 @@ Instead of chaining calls on `CustomForm`, you describe a view as data and let a
|
|
|
24
24
|
- **Declarative views**: a form is an object with a `title` and a `content` array.
|
|
25
25
|
- **Typed with the official API**: models are JSDoc typedefs that reuse the types of `@minecraft/server-ui` (`ButtonOptions`, `SliderOptions`, …), so they stay in sync with the module.
|
|
26
26
|
- **Simple navigation**: a button can open another view with `to`, composed with `onClick`.
|
|
27
|
+
- **Router**: register views by name, pass parameters between them and go back with `"back"`.
|
|
27
28
|
- **Easy to extend**: adding a new element type is one entry in a lookup table and one function.
|
|
28
29
|
- **Safe fallback**: an unsupported `type` renders a visible label and logs a warning instead of breaking the whole form.
|
|
29
30
|
|
|
@@ -44,7 +45,7 @@ BP/
|
|
|
44
45
|
manifest.json
|
|
45
46
|
scripts/
|
|
46
47
|
main.js
|
|
47
|
-
|
|
48
|
+
libs/bedrock-ddui-forms/
|
|
48
49
|
index.js
|
|
49
50
|
core/
|
|
50
51
|
models/
|
|
@@ -151,6 +152,105 @@ A button can open another view with `to`. It runs **after** `onClick`, once the
|
|
|
151
152
|
- If the button does something else, use `onClick`, and add `to` if it should also navigate.
|
|
152
153
|
- Inside `onClick` and `image.options.onClick`, `this` is the `CustomForm`, so `this.close()` works if you need to close without opening anything.
|
|
153
154
|
|
|
155
|
+
For many views, parameters and an automatic back button, see [Router](#router-named-views-and-back).
|
|
156
|
+
|
|
157
|
+
## Router: named views and "back"
|
|
158
|
+
|
|
159
|
+
When your menus grow, wiring every button to the next `openX(player)` gets repetitive. `createRouter` registers your views by name, lets buttons point to them, and keeps a **history per player** so a button can go back.
|
|
160
|
+
|
|
161
|
+
```js
|
|
162
|
+
import { ObservableBoolean } from "@minecraft/server-ui";
|
|
163
|
+
import { createRouter } from "bedrock-ddui-forms/index"; // or from your libs/ folder
|
|
164
|
+
|
|
165
|
+
const router = createRouter({
|
|
166
|
+
menu: (player) => ({
|
|
167
|
+
title: "Menu",
|
|
168
|
+
content: [
|
|
169
|
+
{ type: "button", label: "My profile", to: { view: "profile", params: { name: player.name } } },
|
|
170
|
+
{ type: "button", label: "Settings", to: "settings" },
|
|
171
|
+
{ type: "close-button" }
|
|
172
|
+
]
|
|
173
|
+
}),
|
|
174
|
+
|
|
175
|
+
profile: (player, params) => ({
|
|
176
|
+
title: `Profile: ${params.name}`,
|
|
177
|
+
content: [
|
|
178
|
+
{ type: "label", text: "Nothing here yet." },
|
|
179
|
+
{ type: "button", label: "Back", to: "back" }
|
|
180
|
+
]
|
|
181
|
+
}),
|
|
182
|
+
|
|
183
|
+
settings: (player) => ({
|
|
184
|
+
title: "Settings",
|
|
185
|
+
content: [
|
|
186
|
+
{ type: "toggle", label: "Notifications", toggled: new ObservableBoolean(true, { clientWritable: true }) },
|
|
187
|
+
{ type: "button", label: "Back", to: "back" }
|
|
188
|
+
]
|
|
189
|
+
})
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Start a flow
|
|
193
|
+
router.open(player, "menu");
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Each view is a function `(player, params) => formData`. It returns the same object you would give to `customFormRender`, including `onExit`.
|
|
197
|
+
|
|
198
|
+
### Values accepted by `to`
|
|
199
|
+
|
|
200
|
+
| `to` | Result |
|
|
201
|
+
| --- | --- |
|
|
202
|
+
| `"settings"` | Opens the view registered with that name. |
|
|
203
|
+
| `{ view: "profile", params: { ... } }` | Same, passing `params` to the view. |
|
|
204
|
+
| `"back"` | Returns to the previous view in the player's history. If there is none, the form just closes. |
|
|
205
|
+
| a function | Works like the normal `to` (see [Navigation](#navigation)), but it is **outside** the router: the player's history is discarded. |
|
|
206
|
+
|
|
207
|
+
If you use a view name that was not registered, the router throws `Unknown View: "name"`.
|
|
208
|
+
|
|
209
|
+
### Parameters
|
|
210
|
+
|
|
211
|
+
`params` is the second argument of the view function. You can pass it from a button (`to: { view, params }`) or when you start the flow (`router.open(player, "profile", { name: "Alex" })`).
|
|
212
|
+
|
|
213
|
+
What you pass is up to you. The router stores the `params` of every step in the history and uses them again when going back, so the view is rebuilt with the same `params`. Because of that, plain data (names, ids, numbers) is the safest choice: an entity or a player object stored there may no longer be valid when the player returns to that view.
|
|
214
|
+
|
|
215
|
+
Going back **rebuilds** the view by calling its function again, so the values the player typed (your `Observable`s) are not kept: they return to their initial values.
|
|
216
|
+
|
|
217
|
+
### Router options
|
|
218
|
+
|
|
219
|
+
```js
|
|
220
|
+
const router = createRouter(views, { onInvalid: "skip" });
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
The second argument takes the same options as the third argument of `customFormRender` (see [Validation and error messages](#validation-and-error-messages)) and applies to every view.
|
|
224
|
+
|
|
225
|
+
### What `open` returns
|
|
226
|
+
|
|
227
|
+
`router.open(...)` returns a promise that resolves with the closing reason of **the view it opened**, not of the whole flow. When a button navigates, that view is closed by the server, so it resolves with `ServerClosed`. The `onExit` of each view follows the same rule described in [Closing a view](#closing-a-view-onexit).
|
|
228
|
+
|
|
229
|
+
### History lifetime and cleanup
|
|
230
|
+
|
|
231
|
+
The router keeps each player's history in memory. It is cleared when:
|
|
232
|
+
|
|
233
|
+
- the player closes the form,
|
|
234
|
+
- a form is closed from code without navigating (for example `this.close()` inside `onClick`),
|
|
235
|
+
- you call `router.open(...)` again: a new flow always starts clean,
|
|
236
|
+
- you call `router.clear(playerId)`.
|
|
237
|
+
|
|
238
|
+
If a player leaves the server in the middle of a flow, their history stays in memory until one of those things happens. It is a small entry per player, but you can release it when they leave:
|
|
239
|
+
|
|
240
|
+
```js
|
|
241
|
+
import { world } from "@minecraft/server";
|
|
242
|
+
|
|
243
|
+
world.afterEvents.playerLeave.subscribe(({ playerId }) => router.clear(playerId));
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
The library does not subscribe to this event for you, on purpose:
|
|
247
|
+
|
|
248
|
+
- it keeps the library dependent only on `@minecraft/server-ui`,
|
|
249
|
+
- creating a router does not hook any global event,
|
|
250
|
+
- you decide how and when to clean up for your own addon.
|
|
251
|
+
|
|
252
|
+
Subscribe **once** (as above), not once per view: a subscription per view would pile up while players stay online.
|
|
253
|
+
|
|
154
254
|
## Reading values
|
|
155
255
|
|
|
156
256
|
The values are the `Observable` objects you create, so keep a reference and read them when you need them:
|
|
@@ -245,6 +345,7 @@ core/
|
|
|
245
345
|
customFormRender.js renders a view and shows it to the player
|
|
246
346
|
propertiesFactory.js maps each `type` to its CustomForm call
|
|
247
347
|
validation.js checks views and builds the error messages
|
|
348
|
+
createRouter.js named views with a per-player "back" history
|
|
248
349
|
models/properties/
|
|
249
350
|
*.js JSDoc typedefs for each element
|
|
250
351
|
```
|
package/package.json
CHANGED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { DataDrivenScreenClosedReason } from "@minecraft/server-ui";
|
|
2
|
+
import { customFormRender } from "./customFormRender";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @param {Record<string, (player: import("@minecraft/server").Player, params?: Record<string, any>) => import("./customFormRender").FormData>} views
|
|
6
|
+
* @param {import("./customFormRender").RenderOptions} [renderOptions]
|
|
7
|
+
*/
|
|
8
|
+
export const createRouter = (views, renderOptions) => {
|
|
9
|
+
const stacks = new Map();
|
|
10
|
+
|
|
11
|
+
const open = async (player, name, params = {}, push = true) => {
|
|
12
|
+
const build = views[name];
|
|
13
|
+
if (!build) throw new Error(`Unknown View: "${name}"`);
|
|
14
|
+
|
|
15
|
+
const form = build(player, params);
|
|
16
|
+
|
|
17
|
+
const stack = stacks.get(player.id) ?? [];
|
|
18
|
+
const entry = { name, params };
|
|
19
|
+
if (push) stack.push(entry);
|
|
20
|
+
stacks.set(player.id, stack);
|
|
21
|
+
|
|
22
|
+
let navigated = false; // ¿este formulario se cerró porque el router navegó?
|
|
23
|
+
|
|
24
|
+
const content = form.content.map((prop) => {
|
|
25
|
+
if (prop.type !== "button" || prop.to === undefined || typeof prop.to === "function") return prop;
|
|
26
|
+
|
|
27
|
+
if (prop.to === "back") {
|
|
28
|
+
return {
|
|
29
|
+
...prop,
|
|
30
|
+
to: () => {
|
|
31
|
+
navigated = true;
|
|
32
|
+
stack.pop();
|
|
33
|
+
const prev = stack[stack.length - 1];
|
|
34
|
+
if (prev) open(player, prev.name, prev.params, false);
|
|
35
|
+
else stacks.delete(player.id); // no hay a dónde volver
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const target = typeof prop.to === "string" ? { view: prop.to, params: {} } : prop.to;
|
|
41
|
+
return {
|
|
42
|
+
...prop,
|
|
43
|
+
to: () => {
|
|
44
|
+
navigated = true;
|
|
45
|
+
open(player, target.view, target.params);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return Promise.resolve(customFormRender(player, { ...form, content }, renderOptions)).then((reason) => {
|
|
51
|
+
// Si otra apertura ya reemplazó la pila, no tocar nada
|
|
52
|
+
if (stacks.get(player.id) !== stack) return reason;
|
|
53
|
+
|
|
54
|
+
if (
|
|
55
|
+
reason === DataDrivenScreenClosedReason.ClientClosed ||
|
|
56
|
+
(reason === DataDrivenScreenClosedReason.ServerClosed && !navigated)
|
|
57
|
+
) {
|
|
58
|
+
stacks.delete(player.id); // cerró el jugador, o se cerró por código
|
|
59
|
+
} else if (push && (reason === undefined || reason === DataDrivenScreenClosedReason.UserBusy)) {
|
|
60
|
+
const i = stack.lastIndexOf(entry);
|
|
61
|
+
if (i !== -1) stack.splice(i, 1); // nunca se mostró
|
|
62
|
+
}
|
|
63
|
+
return reason;
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
open: (player, name, params) => {
|
|
69
|
+
stacks.delete(player.id); // una apertura raíz siempre empieza limpia
|
|
70
|
+
return open(player, name, params);
|
|
71
|
+
},
|
|
72
|
+
clear: (playerId) => stacks.delete(playerId), // opcional, para quien lo quiera
|
|
73
|
+
};
|
|
74
|
+
};
|
package/src/index.js
CHANGED