@mapomodule/form 2.0.0-beta.10 → 2.0.0-beta.11
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.md +37 -119
- package/dist/__tests__/tabVisibility.test.d.ts +2 -0
- package/dist/__tests__/tabVisibility.test.d.ts.map +1 -0
- package/dist/module.json +1 -1
- package/dist/runtime/components/MapoForm.vue +12 -3
- package/dist/runtime/components/MapoForm.vue.d.ts.map +1 -1
- package/dist/runtime/components/MapoFormTabs.vue +17 -5
- package/dist/runtime/components/MapoFormTabs.vue.d.ts.map +1 -1
- package/dist/runtime/components/fields/MapoMediaField.vue +5 -3
- package/dist/runtime/components/fields/MapoMediaField.vue.d.ts.map +1 -1
- package/dist/runtime/components/fields/MapoMediaM2mField.vue +3 -2
- package/dist/runtime/components/fields/MapoMediaM2mField.vue.d.ts.map +1 -1
- package/dist/runtime/components/fields/MapoWygEditor.vue +18 -12
- package/dist/runtime/components/fields/MapoWygEditor.vue.d.ts.map +1 -1
- package/dist/runtime/utils/tabVisibility.d.ts +18 -0
- package/dist/runtime/utils/tabVisibility.d.ts.map +1 -0
- package/dist/runtime/utils/tabVisibility.js +18 -0
- package/package.json +4 -4
- package/src/__tests__/tabVisibility.test.ts +102 -0
- package/src/runtime/components/MapoForm.vue +17 -4
- package/src/runtime/components/MapoFormTabs.vue +25 -5
- package/src/runtime/components/fields/MapoMediaField.vue +5 -3
- package/src/runtime/components/fields/MapoMediaM2mField.vue +3 -2
- package/src/runtime/components/fields/MapoWygEditor.vue +18 -12
- package/src/runtime/utils/tabVisibility.ts +44 -0
package/README.md
CHANGED
|
@@ -1,44 +1,28 @@
|
|
|
1
1
|
# @mapomodule/form
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
##
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
- **Typed extension API**: `defineFormField(type, entry, opts)` to register custom fields with collision detection
|
|
12
|
-
- **JSON Schema bridge**: `useFormFromSchema()` generates descriptors from Pydantic v2 / DRF-spectacular schemas
|
|
13
|
-
- **Multilingual fields**: `translatable: true` writes to `model.translations[lang].field` automatically
|
|
14
|
-
- **Client validation**: `validate(value, ctx)` per descriptor + `required` shorthand
|
|
15
|
-
- **Tab/group layout**: declare `tab` and `group` per field — tabs render automatically (incl. nested tabs), groups are collapsible. Tab panels expose `tab.<name>.before/after`, full override, `.label` and `tab-bar-end` slots
|
|
16
|
-
- **Consistent repeater items**: `MapoRepeater` drives each item through `useMapoForm`, so nested-path keys, `translatable`, accessors, `descriptor.is` overrides and **client validation** behave identically inside a repeater
|
|
17
|
-
- **Draft persistence**: `draftKey` option on `useMapoForm()` auto-saves to localStorage every 2 s; `checkDraft()` / `draftBanner` offer restore/discard; clears on successful `submit()`
|
|
18
|
-
- **Fail-soft**: unknown field types render a non-destructive placeholder, never crash the form
|
|
19
|
-
- **Media pickers**: `media`, `media-m2m` and `enhanced-media` field types are built in; they drive the Media Manager from [`@mapomodule/uikit`](../uikit/README.md), resolved at runtime — install it to enable them
|
|
20
|
-
|
|
21
|
-
## Install
|
|
22
|
-
|
|
23
|
-
`@mapomodule/form` is included automatically when you install `mapomodule` (recommended). No additional module entry is needed.
|
|
24
|
-
|
|
25
|
-
```ts
|
|
26
|
-
// nuxt.config.ts
|
|
27
|
-
export default defineNuxtConfig({
|
|
28
|
-
modules: ["@nuxt/ui", "mapomodule"],
|
|
29
|
-
mapo: {
|
|
30
|
-
form: {
|
|
31
|
-
// optional — global field registry defaults
|
|
32
|
-
},
|
|
33
|
-
},
|
|
34
|
-
});
|
|
3
|
+
The form engine of Mapo. A form is an array of `FieldDescriptor` objects; the engine resolves each descriptor's `type` through a field registry to a lazily loaded component, and renders the result in `MapoDetail` (from `@mapomodule/uikit`), in `MapoForm`, or headless through `useMapoForm()`.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Installed by the meta-package [`mapomodule`](https://www.npmjs.com/package/mapomodule), which configures it under `mapo.form`. On its own it reads the `mapoForm` key:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add @mapomodule/form@next
|
|
35
11
|
```
|
|
36
12
|
|
|
13
|
+
| Option | Default | Description |
|
|
14
|
+
| ----------------- | ------- | ---------------------------------------------------------------------------------------- |
|
|
15
|
+
| `fields.attrs` | `{}` | Default `attrs` per field type, merged into the registry defaults |
|
|
16
|
+
| `groups.expanded` | `true` | Whether collapsible field groups start open |
|
|
17
|
+
| `debounce` | `300` | Milliseconds before a field change reaches the model; `descriptor.debounce` overrides it |
|
|
18
|
+
|
|
19
|
+
Mapo v2 is published on the `next` dist-tag while it is in beta.
|
|
20
|
+
|
|
37
21
|
## Quick start
|
|
38
22
|
|
|
39
23
|
```vue
|
|
40
24
|
<script setup lang="ts">
|
|
41
|
-
import type { FieldDescriptor } from "
|
|
25
|
+
import type { FieldDescriptor } from "mapomodule/types";
|
|
42
26
|
|
|
43
27
|
interface Article {
|
|
44
28
|
title: string;
|
|
@@ -61,96 +45,30 @@ const fields: FieldDescriptor<Article>[] = [
|
|
|
61
45
|
</template>
|
|
62
46
|
```
|
|
63
47
|
|
|
64
|
-
The
|
|
65
|
-
|
|
66
|
-
## Field types
|
|
67
|
-
|
|
68
|
-
| Type | Component | Notes |
|
|
69
|
-
| ---------------------------- | --------------------- | ----------------------------------------------------------- |
|
|
70
|
-
| `text` | `UInput` | |
|
|
71
|
-
| `textarea` | `UTextarea` | |
|
|
72
|
-
| `number` | `UInput[type=number]` | |
|
|
73
|
-
| `boolean` | `UCheckbox` | |
|
|
74
|
-
| `switch` | `USwitch` | |
|
|
75
|
-
| `color` | `UInput[type=color]` | |
|
|
76
|
-
| `slider` | `USlider` | |
|
|
77
|
-
| `file` | `MapoFileField` | Current-file preview, image thumbnail, remove button |
|
|
78
|
-
| `select` | `USelectMenu` | requires `attrs.options` |
|
|
79
|
-
| `date` / `datetime` / `time` | `MapoDateField` etc. | ISO string in model; `tz: 'naive' \| 'utc'` for `datetime` |
|
|
80
|
-
| `fks` | `MapoFksField` | FK autocomplete; `attrs.endpoint` required |
|
|
81
|
-
| `m2m` | `MapoFksField` | Multi-select autocomplete, removable chips |
|
|
82
|
-
| `editor` | `MapoWygEditor` | Tiptap v2; sanitized output, safe `Link` validator |
|
|
83
|
-
| `seo` | `MapoSeoPreview` | Title / description with live SERP preview |
|
|
84
|
-
| `map` | `MapoMapField` | Leaflet, `{ lat, lng }` in model, SSR-safe via `ClientOnly` |
|
|
85
|
-
| `repeater` | `MapoRepeater` | Nested fields, drag-and-drop, undo stack, templates |
|
|
86
|
-
|
|
87
|
-
## JSON Schema → Form
|
|
88
|
-
|
|
89
|
-
```ts
|
|
90
|
-
const schema = await $fetch("/api/schema/");
|
|
91
|
-
const fields = useFormFromSchema(schema, {
|
|
92
|
-
exclude: ["id", "created_at"],
|
|
93
|
-
overrides: {
|
|
94
|
-
body: { type: "editor", translatable: true },
|
|
95
|
-
},
|
|
96
|
-
});
|
|
97
|
-
```
|
|
48
|
+
The built-in types cover text and numbers, choices, dates and times, files, relations (`fks`, `m2m`), rich text, SEO preview, maps, repeaters and media pickers. The media pickers drive the Media Manager of `@mapomodule/uikit` and show a notice when it is not installed. A `type` the registry does not know renders `MapoUnknownField` instead of breaking the form.
|
|
98
49
|
|
|
99
|
-
##
|
|
50
|
+
## What you get
|
|
100
51
|
|
|
101
|
-
|
|
102
|
-
const form = useMapoForm({ model, fields, registry: $mapoFormRegistry });
|
|
52
|
+
Components and composables are auto-imported.
|
|
103
53
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
54
|
+
| API | Purpose |
|
|
55
|
+
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
56
|
+
| `MapoForm` | Renders a `FieldDescriptor[]` on your model, with tabs, collapsible groups, a responsive grid and slots |
|
|
57
|
+
| `useMapoForm(options)` | The same engine headless: dirty state, client validation, `getPatch()`, `submit()`, drafts. Pass `registry` explicitly |
|
|
58
|
+
| `defineFormField(type, component, opts?)` | Registers a custom field type from a plugin: a component or a lazy `import()`, with optional default `attrs` and accessor. Re-registering a type warns unless you pass `{ override: true }` |
|
|
59
|
+
| `useFormFromSchema(schema, opts?)` | Builds descriptors from a JSON Schema, such as a DRF-spectacular or Pydantic schema |
|
|
60
|
+
| `when`, `whenAny`, `whenNot`, `matchesField`, `isOneOf`, … | Build `visible` conditions declaratively |
|
|
61
|
+
| `useFormDraft`, `useFocusMode`, `useCurrentLang` | Draft autosave, focus mode, the language being edited |
|
|
62
|
+
| `$mapoFormRegistry` | The global registry: type → component, default `attrs`, accessor |
|
|
110
63
|
|
|
111
|
-
|
|
112
|
-
const form = useMapoForm({
|
|
113
|
-
model,
|
|
114
|
-
fields,
|
|
115
|
-
registry: $mapoFormRegistry,
|
|
116
|
-
draftKey: `article:${route.params.id}`,
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
// After fetch:
|
|
120
|
-
form.checkDraft(); // populates form.draftBanner if a draft is found
|
|
121
|
-
```
|
|
122
|
-
|
|
123
|
-
`form.draftBanner.value` exposes `{ savedAt, restore(), discard() }`. On `submit()` success the draft is cleared automatically. When using `<MapoDetail :draft="true">`, all of this is handled for you.
|
|
124
|
-
|
|
125
|
-
## Custom fields
|
|
126
|
-
|
|
127
|
-
The recommended way to register a custom field is the typed `defineFormField()` helper, called from a Nuxt plugin:
|
|
128
|
-
|
|
129
|
-
```ts
|
|
130
|
-
// app/plugins/my-fields.ts
|
|
131
|
-
export default defineNuxtPlugin(() => {
|
|
132
|
-
defineFormField("rich-text", () => import("~/components/RichText.vue"), {
|
|
133
|
-
attrs: { rows: 6 },
|
|
134
|
-
});
|
|
135
|
-
});
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
Plugins in `app/plugins/` run after module plugins, so `$mapoFormRegistry` is always available. Re-registering an existing type prints a development warning unless you pass `{ override: true }` explicitly.
|
|
139
|
-
|
|
140
|
-
Any component with `modelValue` + `descriptor` + `errors` + `readonly` props works as a field.
|
|
64
|
+
A custom field is any component that accepts the `modelValue`, `descriptor`, `errors`, `readonly` and `disabled` props and emits `update:modelValue`.
|
|
141
65
|
|
|
142
66
|
## Documentation
|
|
143
67
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
- [
|
|
147
|
-
- [Validation](
|
|
148
|
-
- [
|
|
149
|
-
- [
|
|
150
|
-
- [
|
|
151
|
-
- [Slot system](../../../docs/uikit/form/slots.md)
|
|
152
|
-
- [JSON Schema bridge](../../../docs/uikit/form/schema-to-form.md)
|
|
153
|
-
|
|
154
|
-
## License
|
|
155
|
-
|
|
156
|
-
MIT
|
|
68
|
+
- [Form Engine](https://lotrekagency.github.io/mapo-new/uikit/form/): your first form, tabs and groups, how the engine works
|
|
69
|
+
- [All field types](https://lotrekagency.github.io/mapo-new/uikit/form/add-fields)
|
|
70
|
+
- [Field registry](https://lotrekagency.github.io/mapo-new/uikit/form/registry) and [Custom fields](https://lotrekagency.github.io/mapo-new/uikit/form/custom-fields)
|
|
71
|
+
- [Validation](https://lotrekagency.github.io/mapo-new/uikit/form/validation), [Multilingual fields](https://lotrekagency.github.io/mapo-new/uikit/form/i18n), [Draft autosave](https://lotrekagency.github.io/mapo-new/uikit/form/draft), [Progressive Disclosure](https://lotrekagency.github.io/mapo-new/uikit/form/progressive-disclosure)
|
|
72
|
+
- [`useMapoForm()` reference](https://lotrekagency.github.io/mapo-new/uikit/form/composable)
|
|
73
|
+
- [From JSON Schema to form](https://lotrekagency.github.io/mapo-new/uikit/form/schema-to-form)
|
|
74
|
+
- [Form Engine — Component API Reference](https://lotrekagency.github.io/mapo-new/uikit/form/api)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tabVisibility.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/tabVisibility.test.ts"],"names":[],"mappings":""}
|
package/dist/module.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { computed, useSlots, watchEffect, inject, onUnmounted } from "vue";
|
|
3
3
|
import { useNuxtApp, useRuntimeConfig } from "#app";
|
|
4
|
+
import { useI18n } from "vue-i18n";
|
|
4
5
|
import { useMapoForm, injectMapoForm } from "../composables/useMapoForm.js";
|
|
5
6
|
import { provideCurrentLang } from "../composables/useCurrentLang.js";
|
|
6
7
|
import MapoFormGroup from "./MapoFormGroup.vue";
|
|
@@ -73,6 +74,8 @@ const fieldSlotNames = computed(
|
|
|
73
74
|
)
|
|
74
75
|
);
|
|
75
76
|
provideCurrentLang(currentLangRef);
|
|
77
|
+
const { t } = useI18n();
|
|
78
|
+
const DEFAULT_TAB = "__default__";
|
|
76
79
|
function getOrCreateTab(map, name) {
|
|
77
80
|
if (!map.has(name)) {
|
|
78
81
|
map.set(name, { name, groups: /* @__PURE__ */ new Map(), children: /* @__PURE__ */ new Map() });
|
|
@@ -83,7 +86,7 @@ const grouped = computed(() => {
|
|
|
83
86
|
const tabs = /* @__PURE__ */ new Map();
|
|
84
87
|
for (const field of props.fields) {
|
|
85
88
|
const rawTab = field.tab;
|
|
86
|
-
const tabPath = !rawTab ? [
|
|
89
|
+
const tabPath = !rawTab ? [DEFAULT_TAB] : Array.isArray(rawTab) ? rawTab : rawTab.split("/");
|
|
87
90
|
const groupName = field.group ?? "__flat__";
|
|
88
91
|
let currentMap = tabs;
|
|
89
92
|
for (let i = 0; i < tabPath.length - 1; i++) {
|
|
@@ -104,14 +107,20 @@ const grouped = computed(() => {
|
|
|
104
107
|
grp.fields.push(field);
|
|
105
108
|
}
|
|
106
109
|
}
|
|
110
|
+
const defaultTab = tabs.get(DEFAULT_TAB);
|
|
111
|
+
if (defaultTab) defaultTab.label = t("mapo.form.defaultTab");
|
|
107
112
|
return tabs;
|
|
108
113
|
});
|
|
109
114
|
const hasTabs = computed(() => {
|
|
110
115
|
const keys = [...grouped.value.keys()];
|
|
111
|
-
|
|
116
|
+
const hasFields = keys.length > 0;
|
|
117
|
+
if (!hasFields) return false;
|
|
118
|
+
const hasSeveralTabs = keys.length > 1;
|
|
119
|
+
const hasNamedTab = keys[0] !== DEFAULT_TAB;
|
|
120
|
+
return hasSeveralTabs || hasNamedTab;
|
|
112
121
|
});
|
|
113
122
|
const defaultGroups = computed(
|
|
114
|
-
() => grouped.value.get(
|
|
123
|
+
() => grouped.value.get(DEFAULT_TAB)?.groups ?? /* @__PURE__ */ new Map()
|
|
115
124
|
);
|
|
116
125
|
const tabList = computed(() => [...grouped.value.values()]);
|
|
117
126
|
</script>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MapoForm.vue.d.ts","sourceRoot":"","sources":["../../../src/runtime/components/MapoForm.vue"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"MapoForm.vue.d.ts","sourceRoot":"","sources":["../../../src/runtime/components/MapoForm.vue"],"names":[],"mappings":"AAwQA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,KAAK,CAAC;AAG/B,OAAO,KAAK,EACV,kBAAkB,EAClB,aAAa,EAEd,MAAM,mBAAmB,CAAC;AAQ3B,QAAA,MAAM,YAAY,GAAK,CAAC,SAAS,MAAM,EACtC,aAAa,WAAW,CAAC,OAAO,CAAC,OAAO,WAAW,CAAC,CAAC,CAAC,OAAO,CAAC,EAC9D,YAAY,mBAAmB,CAAC,IAAI,CAAC,WAAW,CAAC,OAAO,CAAC,OAAO,WAAW,CAAC,CAAC,EAAE,OAAO,GAAG,MAAM,GAAG,OAAO,CAAC,CAAC,EAC3G,gBAAgB,WAAW,CAAC,OAAO,CAAC,OAAO,WAAW,CAAC,CAAC,CAAC,QAAQ,CAAC,EAClE;WA+UO,OAAO,KAAK,EAAE,WAAW,GAAG,mBAAmB,CAAC;oBA7UxC,CAAC;gBACL,kBAAkB,CAAC,CAAC,CAAC,EAAE;iBACtB,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC;oBACrB,MAAM,EAAE;sBACN,MAAM;mBACT,OAAO;oBACN,OAAO;QACnB;;;;WAIG;mBACQ,aAAa;;;KAiU+D,CAAC,GAAG,CAAC,OAAO,UAAU,SAAS;QAAE,oBAAoB,EAAE,MAAM,CAAC,CAAA;KAAE,GAAG,CAAC,GAAG,EAAE,CAAC;YAC5J,CAAC,OAAO,EAAE,OAAO,KAAK,EAAE,gBAAgB;;;;;;;;;;MAAsB,KAAK,IAAI;WACxE,GAAG;kCA/R+B,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,OAAO;;EAmSrE,KACQ,OAAO,KAAK,EAAE,KAAK,GAAG;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC,OAAO,WAAW,CAAC,CAAA;CAAI,CAAC;wBACvD,OAAO,YAAY;AAAxC,wBAAyC;AACzC,KAAK,mBAAmB,CAAC,CAAC,IAAI,CAAC,CAAC,SAAS,GAAG,GAAG;KAAG,CAAC,IAAI,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;CAAG,GAAG;KAAG,CAAC,IAAI,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;CAAG,CAAC,GAAG,EAAE,CAAC"}
|
|
@@ -2,14 +2,27 @@
|
|
|
2
2
|
import { ref, computed } from "vue";
|
|
3
3
|
import { useI18n } from "vue-i18n";
|
|
4
4
|
import { injectMapoForm } from "../composables/useMapoForm.js";
|
|
5
|
+
import { pickActiveTab, tabHasShownFields } from "../utils/tabVisibility.js";
|
|
5
6
|
import MapoFormGroup from "./MapoFormGroup.vue";
|
|
6
7
|
import MapoFormFlatSection from "./MapoFormFlatSection.vue";
|
|
7
8
|
const { t } = useI18n();
|
|
8
9
|
const props = defineProps({
|
|
9
10
|
tabs: { type: Array, required: true }
|
|
10
11
|
});
|
|
11
|
-
defineSlots();
|
|
12
|
-
const
|
|
12
|
+
const slots = defineSlots();
|
|
13
|
+
const form = injectMapoForm();
|
|
14
|
+
function tabHasContent(tab) {
|
|
15
|
+
const hasPanelSlot = Boolean(slots[`tab.${tab.name}`]);
|
|
16
|
+
const hasBeforeSlot = Boolean(slots[`tab.${tab.name}.before`]);
|
|
17
|
+
const hasAfterSlot = Boolean(slots[`tab.${tab.name}.after`]);
|
|
18
|
+
if (hasPanelSlot || hasBeforeSlot || hasAfterSlot) return true;
|
|
19
|
+
return tabHasShownFields(tab, form.model.value);
|
|
20
|
+
}
|
|
21
|
+
const shownTabs = computed(() => props.tabs.filter(tabHasContent));
|
|
22
|
+
const pickedTab = ref(null);
|
|
23
|
+
const activeTab = computed(
|
|
24
|
+
() => pickActiveTab(shownTabs.value, pickedTab.value)
|
|
25
|
+
);
|
|
13
26
|
function tabHasErrors(tab, errorKeys) {
|
|
14
27
|
const directError = [...tab.groups.values()].some(
|
|
15
28
|
(g) => g.fields.some((f) => errorKeys.has(f.key))
|
|
@@ -19,7 +32,6 @@ function tabHasErrors(tab, errorKeys) {
|
|
|
19
32
|
(child) => tabHasErrors(child, errorKeys)
|
|
20
33
|
);
|
|
21
34
|
}
|
|
22
|
-
const form = injectMapoForm();
|
|
23
35
|
const tabsWithErrors = computed(() => {
|
|
24
36
|
const errorKeys = new Set(Object.keys(form.errors.value));
|
|
25
37
|
return new Set(
|
|
@@ -33,14 +45,14 @@ const tabsWithErrors = computed(() => {
|
|
|
33
45
|
<!-- Tab bar -->
|
|
34
46
|
<div class="flex gap-1 border-b border-gray-200 pb-px">
|
|
35
47
|
<button
|
|
36
|
-
v-for="tab in
|
|
48
|
+
v-for="tab in shownTabs"
|
|
37
49
|
:key="tab.name"
|
|
38
50
|
type="button"
|
|
39
51
|
class="relative flex items-center gap-1.5 rounded-t px-4 py-2 text-sm font-medium transition-colors"
|
|
40
52
|
:class="[
|
|
41
53
|
activeTab === tab.name ? 'border-b-2 border-primary-500 text-primary-600' : 'text-gray-500 hover:bg-gray-50 hover:text-gray-700'
|
|
42
54
|
]"
|
|
43
|
-
@click="
|
|
55
|
+
@click="pickedTab = tab.name"
|
|
44
56
|
>
|
|
45
57
|
<slot :name="`tab.${tab.name}.label`" :tab="tab">
|
|
46
58
|
{{ tab.label ?? tab.name }}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MapoFormTabs.vue.d.ts","sourceRoot":"","sources":["../../../src/runtime/components/MapoFormTabs.vue"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"MapoFormTabs.vue.d.ts","sourceRoot":"","sources":["../../../src/runtime/components/MapoFormTabs.vue"],"names":[],"mappings":"AAgJA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAQnD,KAAK,WAAW,GAAG;IAAE,IAAI,EAAE,QAAQ,EAAE,CAAA;CAAE,CAAC;AAGxC,KAAK,WAAW,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AA2P3C,QAAA,MAAM,UAAU,kSAEd,CAAC;AACH,QAAA,MAAM,YAAY,EAAS,eAAe,CAAC,OAAO,UAAU,EAAE,WAAW,CAAC,CAAC;wBACtD,OAAO,YAAY;AAAxC,wBAAyC;AACzC,KAAK,eAAe,CAAC,CAAC,EAAE,CAAC,IAAI,CAAC,GAAG;IAChC,QAAO;QACN,MAAM,EAAE,CAAC,CAAC;KACV,CAAA;CACD,CAAC"}
|
|
@@ -35,10 +35,12 @@ function clear() {
|
|
|
35
35
|
<MapoMediaUnavailable v-if="!available" />
|
|
36
36
|
|
|
37
37
|
<template v-else>
|
|
38
|
-
<!-- Selected media preview
|
|
38
|
+
<!-- Selected media preview. It and the trigger below paint their own
|
|
39
|
+
surface, like an input: `soft` buttons and transparent cards take
|
|
40
|
+
the page background and vanish on a tinted page. -->
|
|
39
41
|
<div
|
|
40
42
|
v-if="current"
|
|
41
|
-
class="flex items-center gap-3 rounded-lg border border-default p-2"
|
|
43
|
+
class="flex items-center gap-3 rounded-lg border border-default bg-default p-2"
|
|
42
44
|
>
|
|
43
45
|
<MapoMediaPreview :media="current" size="sm" />
|
|
44
46
|
<div class="flex-1 min-w-0">
|
|
@@ -64,7 +66,7 @@ function clear() {
|
|
|
64
66
|
v-if="!readonly && !disabled"
|
|
65
67
|
:icon="current ? 'i-lucide-refresh-cw' : 'i-lucide-image-plus'"
|
|
66
68
|
size="sm"
|
|
67
|
-
variant="
|
|
69
|
+
variant="outline"
|
|
68
70
|
color="neutral"
|
|
69
71
|
:block="!current"
|
|
70
72
|
@click="dialogOpen = true"
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MapoMediaField.vue.d.ts","sourceRoot":"","sources":["../../../../src/runtime/components/fields/MapoMediaField.vue"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"MapoMediaField.vue.d.ts","sourceRoot":"","sources":["../../../../src/runtime/components/fields/MapoMediaField.vue"],"names":[],"mappings":"AA+GA,OAAO,KAAK,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAGvE,KAAK,WAAW,GAAG;IACjB,UAAU,EAAE,SAAS,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;IAClD,UAAU,EAAE,eAAe,CAAC;IAC5B,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AAiOF,QAAA,MAAM,YAAY;;;;kFAGhB,CAAC;wBACkB,OAAO,YAAY;AAAxC,wBAAyC"}
|
|
@@ -77,12 +77,13 @@ function onDragEnd() {
|
|
|
77
77
|
</div>
|
|
78
78
|
</div>
|
|
79
79
|
|
|
80
|
-
<!-- Add button
|
|
80
|
+
<!-- Add button: outlined like MapoMediaField's trigger, so it keeps its
|
|
81
|
+
own surface on a tinted page. -->
|
|
81
82
|
<UButton
|
|
82
83
|
v-if="!readonly && !disabled"
|
|
83
84
|
icon="i-lucide-plus"
|
|
84
85
|
size="sm"
|
|
85
|
-
variant="
|
|
86
|
+
variant="outline"
|
|
86
87
|
color="neutral"
|
|
87
88
|
@click="dialogOpen = true"
|
|
88
89
|
>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MapoMediaM2mField.vue.d.ts","sourceRoot":"","sources":["../../../../src/runtime/components/fields/MapoMediaM2mField.vue"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"MapoMediaM2mField.vue.d.ts","sourceRoot":"","sources":["../../../../src/runtime/components/fields/MapoMediaM2mField.vue"],"names":[],"mappings":"AAwIA,OAAO,KAAK,EAAE,kBAAkB,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAG1E,KAAK,WAAW,GAAG;IACjB,UAAU,EAAE,SAAS,EAAE,GAAG,MAAM,EAAE,GAAG,IAAI,GAAG,SAAS,CAAC;IACtD,UAAU,EAAE,kBAAkB,CAAC;IAC/B,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AA+QF,QAAA,MAAM,YAAY;;;;kFAGhB,CAAC;wBACkB,OAAO,YAAY;AAAxC,wBAAyC"}
|
|
@@ -98,10 +98,13 @@ function onImageConfirm(selection) {
|
|
|
98
98
|
</script>
|
|
99
99
|
|
|
100
100
|
<template>
|
|
101
|
+
<!-- An input surface of its own, like the Nuxt UI inputs next to it
|
|
102
|
+
(`bg-default`, `accented` border): without it the text area takes the
|
|
103
|
+
page background and disappears on a tinted page. -->
|
|
101
104
|
<div
|
|
102
|
-
class="mapo-wyg-editor rounded border"
|
|
105
|
+
class="mapo-wyg-editor rounded-md border bg-default"
|
|
103
106
|
:class="[
|
|
104
|
-
errors?.length ? 'border-red-300' : 'border-
|
|
107
|
+
errors?.length ? 'border-red-300' : 'border-accented',
|
|
105
108
|
readonly || disabled ? 'opacity-60' : ''
|
|
106
109
|
]"
|
|
107
110
|
>
|
|
@@ -238,15 +241,18 @@ function onImageConfirm(selection) {
|
|
|
238
241
|
>
|
|
239
242
|
{{ err }}
|
|
240
243
|
</p>
|
|
241
|
-
</div>
|
|
242
244
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
245
|
+
<!-- Image picker dialog — only rendered when MapoMediaManagerDialog is
|
|
246
|
+
globally available. Inside the root on purpose: a second root would
|
|
247
|
+
drop what MapoFormField passes down (`flex-1`, `id`, `style`), and the
|
|
248
|
+
editor would shrink to its toolbar. As a modal it renders in <body>. -->
|
|
249
|
+
<component
|
|
250
|
+
:is="MediaManagerDialog"
|
|
251
|
+
v-if="hasMediaManager"
|
|
252
|
+
v-model="imagePickerOpen"
|
|
253
|
+
selection-mode="single"
|
|
254
|
+
mime="image/*"
|
|
255
|
+
@confirm="onImageConfirm"
|
|
256
|
+
/>
|
|
257
|
+
</div>
|
|
252
258
|
</template>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MapoWygEditor.vue.d.ts","sourceRoot":"","sources":["../../../../src/runtime/components/fields/MapoWygEditor.vue"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"MapoWygEditor.vue.d.ts","sourceRoot":"","sources":["../../../../src/runtime/components/fields/MapoWygEditor.vue"],"names":[],"mappings":"AA2TA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAE7D,KAAK,WAAW,GAAG;IACjB,UAAU,EAAE,OAAO,CAAC;IACpB,UAAU,EAAE,gBAAgB,CAAC;IAC7B,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AA6pBF,QAAA,MAAM,YAAY;;;;kFAGhB,CAAC;wBACkB,OAAO,YAAY;AAAxC,wBAAyC"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { AnyFieldDescriptor } from "../types/index.js";
|
|
2
|
+
import type { TabEntry } from "../types/layout.js";
|
|
3
|
+
/**
|
|
4
|
+
* Whether a field shows for this model: neither its `visible` predicate, which
|
|
5
|
+
* removes it, nor its `show` predicate, which hides it, keeps it out.
|
|
6
|
+
*/
|
|
7
|
+
export declare function isFieldShown(field: AnyFieldDescriptor, model: Record<string, unknown>): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Whether a tab has a field to show for this model, in one of its groups (sub-tabs
|
|
10
|
+
* included) or in one of its child tabs.
|
|
11
|
+
*/
|
|
12
|
+
export declare function tabHasShownFields(tab: TabEntry, model: Record<string, unknown>): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* The tab to open among the ones shown: the tab the user picked, while it is
|
|
15
|
+
* shown; otherwise the first one. Empty when no tab is shown.
|
|
16
|
+
*/
|
|
17
|
+
export declare function pickActiveTab(shownTabs: TabEntry[], picked: string | null): string;
|
|
18
|
+
//# sourceMappingURL=tabVisibility.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tabVisibility.d.ts","sourceRoot":"","sources":["../../../src/runtime/utils/tabVisibility.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAC5D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAEnD;;;GAGG;AACH,wBAAgB,YAAY,CAC1B,KAAK,EAAE,kBAAkB,EACzB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC7B,OAAO,CAIT;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,QAAQ,EACb,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC7B,OAAO,CAQT;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,SAAS,EAAE,QAAQ,EAAE,EACrB,MAAM,EAAE,MAAM,GAAG,IAAI,GACpB,MAAM,CAGR"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function isFieldShown(field, model) {
|
|
2
|
+
const removed = field.visible ? !field.visible({ model }) : false;
|
|
3
|
+
const hidden = field.show ? !field.show({ model }) : false;
|
|
4
|
+
return !removed && !hidden;
|
|
5
|
+
}
|
|
6
|
+
export function tabHasShownFields(tab, model) {
|
|
7
|
+
for (const group of tab.groups.values()) {
|
|
8
|
+
const fields = [...group.fields, ...[...group.subtabs.values()].flat()];
|
|
9
|
+
if (fields.some((field) => isFieldShown(field, model))) return true;
|
|
10
|
+
}
|
|
11
|
+
return [...tab.children.values()].some(
|
|
12
|
+
(child) => tabHasShownFields(child, model)
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
export function pickActiveTab(shownTabs, picked) {
|
|
16
|
+
const pickedTab = shownTabs.find((tab) => tab.name === picked);
|
|
17
|
+
return (pickedTab ?? shownTabs[0])?.name ?? "";
|
|
18
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mapomodule/form",
|
|
3
|
-
"version": "2.0.0-beta.
|
|
3
|
+
"version": "2.0.0-beta.11",
|
|
4
4
|
"description": "Mapo declarative typed form engine and field components",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -47,9 +47,9 @@
|
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"@internationalized/date": "^3.7.0",
|
|
50
|
-
"@mapomodule/i18n": "2.0.0-beta.
|
|
50
|
+
"@mapomodule/i18n": "2.0.0-beta.3",
|
|
51
51
|
"@mapomodule/store": "2.0.0-beta.6",
|
|
52
|
-
"@mapomodule/utils": "2.0.0-beta.
|
|
52
|
+
"@mapomodule/utils": "2.0.0-beta.6",
|
|
53
53
|
"@tiptap/extension-image": "^2.11.5",
|
|
54
54
|
"@tiptap/extension-link": "^2.11.5",
|
|
55
55
|
"@tiptap/extension-underline": "^2.11.5",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"@tiptap/vue-3": "^2.11.5",
|
|
58
58
|
"leaflet": "^1.9.4",
|
|
59
59
|
"vue-draggable-plus": "^0.6.0",
|
|
60
|
-
"vue-i18n": "^11.4.
|
|
60
|
+
"vue-i18n": "^11.4.8"
|
|
61
61
|
},
|
|
62
62
|
"optionalDependencies": {
|
|
63
63
|
"@types/leaflet": "^1.9.18"
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { AnyFieldDescriptor } from "../runtime/types/index.js";
|
|
3
|
+
import type { TabEntry } from "../runtime/types/layout.js";
|
|
4
|
+
import {
|
|
5
|
+
isFieldShown,
|
|
6
|
+
pickActiveTab,
|
|
7
|
+
tabHasShownFields,
|
|
8
|
+
} from "../runtime/utils/tabVisibility.js";
|
|
9
|
+
|
|
10
|
+
type Predicates = Pick<AnyFieldDescriptor, "visible" | "show">;
|
|
11
|
+
|
|
12
|
+
const field = (key: string, predicates: Predicates = {}) =>
|
|
13
|
+
({ key, type: "text", ...predicates }) as AnyFieldDescriptor;
|
|
14
|
+
|
|
15
|
+
/** A tab holding its fields in one flat group, as MapoForm builds it. */
|
|
16
|
+
const tab = (
|
|
17
|
+
name: string,
|
|
18
|
+
fields: AnyFieldDescriptor[],
|
|
19
|
+
children: TabEntry[] = [],
|
|
20
|
+
): TabEntry => ({
|
|
21
|
+
name,
|
|
22
|
+
groups: new Map([["__flat__", { fields, subtabs: new Map() }]]),
|
|
23
|
+
children: new Map(children.map((child) => [child.name, child])),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Like a page whose content fields depend on the template picked in the sidebar.
|
|
27
|
+
const hasTemplate = ({ model }: { model: Record<string, unknown> }) =>
|
|
28
|
+
Boolean(model.template);
|
|
29
|
+
|
|
30
|
+
describe("isFieldShown", () => {
|
|
31
|
+
it("shows a field without predicates", () => {
|
|
32
|
+
expect(isFieldShown(field("title"), {})).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("hides a field that `visible` removes", () => {
|
|
36
|
+
const body = field("body", { visible: hasTemplate });
|
|
37
|
+
expect(isFieldShown(body, { template: "" })).toBe(false);
|
|
38
|
+
expect(isFieldShown(body, { template: "Home" })).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("hides a field that `show` hides", () => {
|
|
42
|
+
const body = field("body", { show: hasTemplate });
|
|
43
|
+
expect(isFieldShown(body, { template: "" })).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("tabHasShownFields", () => {
|
|
48
|
+
it("is false while every field is hidden and true once one shows", () => {
|
|
49
|
+
const general = tab("__default__", [
|
|
50
|
+
field("body", { visible: hasTemplate }),
|
|
51
|
+
field("intro", { visible: hasTemplate }),
|
|
52
|
+
]);
|
|
53
|
+
expect(tabHasShownFields(general, { template: "" })).toBe(false);
|
|
54
|
+
expect(tabHasShownFields(general, { template: "Home" })).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("counts the fields of a sub-tab", () => {
|
|
58
|
+
const seo: TabEntry = {
|
|
59
|
+
name: "SEO",
|
|
60
|
+
groups: new Map([
|
|
61
|
+
[
|
|
62
|
+
"meta",
|
|
63
|
+
{ fields: [], subtabs: new Map([["basic", [field("meta_title")]]]) },
|
|
64
|
+
],
|
|
65
|
+
]),
|
|
66
|
+
children: new Map(),
|
|
67
|
+
};
|
|
68
|
+
expect(tabHasShownFields(seo, {})).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("counts the fields of a child tab", () => {
|
|
72
|
+
const settings = tab(
|
|
73
|
+
"Settings",
|
|
74
|
+
[field("legacy", { visible: () => false })],
|
|
75
|
+
[tab("SEO", [field("meta_title")])],
|
|
76
|
+
);
|
|
77
|
+
expect(tabHasShownFields(settings, {})).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("pickActiveTab", () => {
|
|
82
|
+
const general = tab("__default__", []);
|
|
83
|
+
const seo = tab("SEO", []);
|
|
84
|
+
|
|
85
|
+
it("opens the first shown tab until the user picks one", () => {
|
|
86
|
+
expect(pickActiveTab([general, seo], null)).toBe("__default__");
|
|
87
|
+
// The first tab is hidden: the next one opens, not an empty panel.
|
|
88
|
+
expect(pickActiveTab([seo], null)).toBe("SEO");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("keeps the picked tab while it is shown", () => {
|
|
92
|
+
expect(pickActiveTab([general, seo], "SEO")).toBe("SEO");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("falls back to the first shown tab while the picked one is hidden", () => {
|
|
96
|
+
expect(pickActiveTab([seo], "__default__")).toBe("SEO");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("is empty when no tab is shown", () => {
|
|
100
|
+
expect(pickActiveTab([], null)).toBe("");
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { computed, useSlots, watchEffect, inject, onUnmounted } from "vue";
|
|
3
3
|
import type { Ref } from "vue";
|
|
4
4
|
import { useNuxtApp, useRuntimeConfig } from "#app";
|
|
5
|
+
import { useI18n } from "vue-i18n";
|
|
5
6
|
import type {
|
|
6
7
|
AnyFieldDescriptor,
|
|
7
8
|
FieldRegistry,
|
|
@@ -135,6 +136,12 @@ provideCurrentLang(currentLangRef);
|
|
|
135
136
|
|
|
136
137
|
// ─── Group fields by tab/group ───────────────────────────────────────────────
|
|
137
138
|
|
|
139
|
+
const { t } = useI18n();
|
|
140
|
+
|
|
141
|
+
// Fields without a `tab` go here. It becomes a tab of its own only when other
|
|
142
|
+
// fields have one, and then it shows as "General" (`mapo.form.defaultTab`).
|
|
143
|
+
const DEFAULT_TAB = "__default__";
|
|
144
|
+
|
|
138
145
|
function getOrCreateTab(map: Map<string, TabEntry>, name: string): TabEntry {
|
|
139
146
|
if (!map.has(name)) {
|
|
140
147
|
map.set(name, { name, groups: new Map(), children: new Map() });
|
|
@@ -151,7 +158,7 @@ const grouped = computed<Map<string, TabEntry>>(() => {
|
|
|
151
158
|
// Normalise: 'a/b', ['a','b'], or 'a' all become a string[]
|
|
152
159
|
const rawTab = field.tab;
|
|
153
160
|
const tabPath: string[] = !rawTab
|
|
154
|
-
? [
|
|
161
|
+
? [DEFAULT_TAB]
|
|
155
162
|
: Array.isArray(rawTab)
|
|
156
163
|
? rawTab
|
|
157
164
|
: rawTab.split("/");
|
|
@@ -179,17 +186,23 @@ const grouped = computed<Map<string, TabEntry>>(() => {
|
|
|
179
186
|
}
|
|
180
187
|
}
|
|
181
188
|
|
|
189
|
+
const defaultTab = tabs.get(DEFAULT_TAB);
|
|
190
|
+
if (defaultTab) defaultTab.label = t("mapo.form.defaultTab");
|
|
191
|
+
|
|
182
192
|
return tabs;
|
|
183
193
|
});
|
|
184
194
|
|
|
185
195
|
const hasTabs = computed(() => {
|
|
186
196
|
const keys = [...grouped.value.keys()];
|
|
187
|
-
|
|
197
|
+
const hasFields = keys.length > 0;
|
|
198
|
+
if (!hasFields) return false;
|
|
199
|
+
const hasSeveralTabs = keys.length > 1;
|
|
200
|
+
const hasNamedTab = keys[0] !== DEFAULT_TAB;
|
|
201
|
+
return hasSeveralTabs || hasNamedTab;
|
|
188
202
|
});
|
|
189
203
|
|
|
190
204
|
const defaultGroups = computed(
|
|
191
|
-
() =>
|
|
192
|
-
grouped.value.get("__default__")?.groups ?? new Map<string, GroupEntry>(),
|
|
205
|
+
() => grouped.value.get(DEFAULT_TAB)?.groups ?? new Map<string, GroupEntry>(),
|
|
193
206
|
);
|
|
194
207
|
|
|
195
208
|
const tabList = computed<TabEntry[]>(() => [...grouped.value.values()]);
|
|
@@ -3,6 +3,7 @@ import { ref, computed } from "vue";
|
|
|
3
3
|
import { useI18n } from "vue-i18n";
|
|
4
4
|
import type { TabEntry } from "../types/layout.js";
|
|
5
5
|
import { injectMapoForm } from "../composables/useMapoForm.js";
|
|
6
|
+
import { pickActiveTab, tabHasShownFields } from "../utils/tabVisibility.js";
|
|
6
7
|
import MapoFormGroup from "./MapoFormGroup.vue";
|
|
7
8
|
import MapoFormFlatSection from "./MapoFormFlatSection.vue";
|
|
8
9
|
|
|
@@ -10,9 +11,29 @@ const { t } = useI18n();
|
|
|
10
11
|
|
|
11
12
|
const props = defineProps<{ tabs: TabEntry[] }>();
|
|
12
13
|
|
|
13
|
-
defineSlots<Record<string, unknown>>();
|
|
14
|
+
const slots = defineSlots<Record<string, unknown>>();
|
|
14
15
|
|
|
15
|
-
const
|
|
16
|
+
const form = injectMapoForm<Record<string, unknown>>()!;
|
|
17
|
+
|
|
18
|
+
// A tab whose fields are all hidden by `visible` or `show` would open on an
|
|
19
|
+
// empty panel, so it leaves the tab bar until one of them shows. Content passed
|
|
20
|
+
// through the tab's own slots counts as something to show.
|
|
21
|
+
function tabHasContent(tab: TabEntry): boolean {
|
|
22
|
+
const hasPanelSlot = Boolean(slots[`tab.${tab.name}`]);
|
|
23
|
+
const hasBeforeSlot = Boolean(slots[`tab.${tab.name}.before`]);
|
|
24
|
+
const hasAfterSlot = Boolean(slots[`tab.${tab.name}.after`]);
|
|
25
|
+
if (hasPanelSlot || hasBeforeSlot || hasAfterSlot) return true;
|
|
26
|
+
return tabHasShownFields(tab, form.model.value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const shownTabs = computed(() => props.tabs.filter(tabHasContent));
|
|
30
|
+
|
|
31
|
+
// The tab the user clicked; until then, or while it is hidden, the first shown
|
|
32
|
+
// tab is open.
|
|
33
|
+
const pickedTab = ref<string | null>(null);
|
|
34
|
+
const activeTab = computed(() =>
|
|
35
|
+
pickActiveTab(shownTabs.value, pickedTab.value),
|
|
36
|
+
);
|
|
16
37
|
|
|
17
38
|
// Recursively check if a tab (or any of its descendants) contains errors.
|
|
18
39
|
function tabHasErrors(tab: TabEntry, errorKeys: Set<string>): boolean {
|
|
@@ -26,7 +47,6 @@ function tabHasErrors(tab: TabEntry, errorKeys: Set<string>): boolean {
|
|
|
26
47
|
}
|
|
27
48
|
|
|
28
49
|
// Highlight tabs containing errors.
|
|
29
|
-
const form = injectMapoForm()!;
|
|
30
50
|
const tabsWithErrors = computed(() => {
|
|
31
51
|
const errorKeys = new Set(Object.keys(form.errors.value));
|
|
32
52
|
return new Set(
|
|
@@ -40,7 +60,7 @@ const tabsWithErrors = computed(() => {
|
|
|
40
60
|
<!-- Tab bar -->
|
|
41
61
|
<div class="flex gap-1 border-b border-gray-200 pb-px">
|
|
42
62
|
<button
|
|
43
|
-
v-for="tab in
|
|
63
|
+
v-for="tab in shownTabs"
|
|
44
64
|
:key="tab.name"
|
|
45
65
|
type="button"
|
|
46
66
|
class="relative flex items-center gap-1.5 rounded-t px-4 py-2 text-sm font-medium transition-colors"
|
|
@@ -49,7 +69,7 @@ const tabsWithErrors = computed(() => {
|
|
|
49
69
|
? 'border-b-2 border-primary-500 text-primary-600'
|
|
50
70
|
: 'text-gray-500 hover:bg-gray-50 hover:text-gray-700',
|
|
51
71
|
]"
|
|
52
|
-
@click="
|
|
72
|
+
@click="pickedTab = tab.name"
|
|
53
73
|
>
|
|
54
74
|
<slot :name="`tab.${tab.name}.label`" :tab="tab">
|
|
55
75
|
{{ tab.label ?? tab.name }}
|
|
@@ -48,10 +48,12 @@ function clear() {
|
|
|
48
48
|
<MapoMediaUnavailable v-if="!available" />
|
|
49
49
|
|
|
50
50
|
<template v-else>
|
|
51
|
-
<!-- Selected media preview
|
|
51
|
+
<!-- Selected media preview. It and the trigger below paint their own
|
|
52
|
+
surface, like an input: `soft` buttons and transparent cards take
|
|
53
|
+
the page background and vanish on a tinted page. -->
|
|
52
54
|
<div
|
|
53
55
|
v-if="current"
|
|
54
|
-
class="flex items-center gap-3 rounded-lg border border-default p-2"
|
|
56
|
+
class="flex items-center gap-3 rounded-lg border border-default bg-default p-2"
|
|
55
57
|
>
|
|
56
58
|
<MapoMediaPreview :media="current" size="sm" />
|
|
57
59
|
<div class="flex-1 min-w-0">
|
|
@@ -77,7 +79,7 @@ function clear() {
|
|
|
77
79
|
v-if="!readonly && !disabled"
|
|
78
80
|
:icon="current ? 'i-lucide-refresh-cw' : 'i-lucide-image-plus'"
|
|
79
81
|
size="sm"
|
|
80
|
-
variant="
|
|
82
|
+
variant="outline"
|
|
81
83
|
color="neutral"
|
|
82
84
|
:block="!current"
|
|
83
85
|
@click="dialogOpen = true"
|
|
@@ -95,12 +95,13 @@ function onDragEnd() {
|
|
|
95
95
|
</div>
|
|
96
96
|
</div>
|
|
97
97
|
|
|
98
|
-
<!-- Add button
|
|
98
|
+
<!-- Add button: outlined like MapoMediaField's trigger, so it keeps its
|
|
99
|
+
own surface on a tinted page. -->
|
|
99
100
|
<UButton
|
|
100
101
|
v-if="!readonly && !disabled"
|
|
101
102
|
icon="i-lucide-plus"
|
|
102
103
|
size="sm"
|
|
103
|
-
variant="
|
|
104
|
+
variant="outline"
|
|
104
105
|
color="neutral"
|
|
105
106
|
@click="dialogOpen = true"
|
|
106
107
|
>
|
|
@@ -142,10 +142,13 @@ function onImageConfirm(
|
|
|
142
142
|
</script>
|
|
143
143
|
|
|
144
144
|
<template>
|
|
145
|
+
<!-- An input surface of its own, like the Nuxt UI inputs next to it
|
|
146
|
+
(`bg-default`, `accented` border): without it the text area takes the
|
|
147
|
+
page background and disappears on a tinted page. -->
|
|
145
148
|
<div
|
|
146
|
-
class="mapo-wyg-editor rounded border"
|
|
149
|
+
class="mapo-wyg-editor rounded-md border bg-default"
|
|
147
150
|
:class="[
|
|
148
|
-
errors?.length ? 'border-red-300' : 'border-
|
|
151
|
+
errors?.length ? 'border-red-300' : 'border-accented',
|
|
149
152
|
readonly || disabled ? 'opacity-60' : '',
|
|
150
153
|
]"
|
|
151
154
|
>
|
|
@@ -284,15 +287,18 @@ function onImageConfirm(
|
|
|
284
287
|
>
|
|
285
288
|
{{ err }}
|
|
286
289
|
</p>
|
|
287
|
-
</div>
|
|
288
290
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
291
|
+
<!-- Image picker dialog — only rendered when MapoMediaManagerDialog is
|
|
292
|
+
globally available. Inside the root on purpose: a second root would
|
|
293
|
+
drop what MapoFormField passes down (`flex-1`, `id`, `style`), and the
|
|
294
|
+
editor would shrink to its toolbar. As a modal it renders in <body>. -->
|
|
295
|
+
<component
|
|
296
|
+
:is="MediaManagerDialog"
|
|
297
|
+
v-if="hasMediaManager"
|
|
298
|
+
v-model="imagePickerOpen"
|
|
299
|
+
selection-mode="single"
|
|
300
|
+
mime="image/*"
|
|
301
|
+
@confirm="onImageConfirm"
|
|
302
|
+
/>
|
|
303
|
+
</div>
|
|
298
304
|
</template>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { AnyFieldDescriptor } from "../types/index.js";
|
|
2
|
+
import type { TabEntry } from "../types/layout.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whether a field shows for this model: neither its `visible` predicate, which
|
|
6
|
+
* removes it, nor its `show` predicate, which hides it, keeps it out.
|
|
7
|
+
*/
|
|
8
|
+
export function isFieldShown(
|
|
9
|
+
field: AnyFieldDescriptor,
|
|
10
|
+
model: Record<string, unknown>,
|
|
11
|
+
): boolean {
|
|
12
|
+
const removed = field.visible ? !field.visible({ model }) : false;
|
|
13
|
+
const hidden = field.show ? !field.show({ model }) : false;
|
|
14
|
+
return !removed && !hidden;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Whether a tab has a field to show for this model, in one of its groups (sub-tabs
|
|
19
|
+
* included) or in one of its child tabs.
|
|
20
|
+
*/
|
|
21
|
+
export function tabHasShownFields(
|
|
22
|
+
tab: TabEntry,
|
|
23
|
+
model: Record<string, unknown>,
|
|
24
|
+
): boolean {
|
|
25
|
+
for (const group of tab.groups.values()) {
|
|
26
|
+
const fields = [...group.fields, ...[...group.subtabs.values()].flat()];
|
|
27
|
+
if (fields.some((field) => isFieldShown(field, model))) return true;
|
|
28
|
+
}
|
|
29
|
+
return [...tab.children.values()].some((child) =>
|
|
30
|
+
tabHasShownFields(child, model),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The tab to open among the ones shown: the tab the user picked, while it is
|
|
36
|
+
* shown; otherwise the first one. Empty when no tab is shown.
|
|
37
|
+
*/
|
|
38
|
+
export function pickActiveTab(
|
|
39
|
+
shownTabs: TabEntry[],
|
|
40
|
+
picked: string | null,
|
|
41
|
+
): string {
|
|
42
|
+
const pickedTab = shownTabs.find((tab) => tab.name === picked);
|
|
43
|
+
return (pickedTab ?? shownTabs[0])?.name ?? "";
|
|
44
|
+
}
|