Skip to content

Commit df3cb78

Browse files
authored
Merge pull request #12 from QuickFlo/development
keyvalue component
2 parents 5397c25 + ec7adef commit df3cb78

16 files changed

Lines changed: 632 additions & 10 deletions

File tree

docs/guide/components.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,104 @@ const formOptions = {
404404

405405
---
406406

407+
## KeyValueField
408+
409+
Renders a dynamic key-value pair editor for record types.
410+
411+
### Handles
412+
413+
- `type: 'object'` with typed `additionalProperties` (e.g., `{ type: 'string' }`) but no defined `properties`
414+
415+
### Example Schema
416+
417+
**Basic record type:**
418+
```typescript
419+
{
420+
type: 'object',
421+
title: 'Additional Parameters',
422+
description: 'Dynamic key-value pairs',
423+
additionalProperties: {
424+
type: 'string'
425+
}
426+
}
427+
```
428+
429+
**OAuth headers example:**
430+
```typescript
431+
{
432+
type: 'object',
433+
title: 'Custom OAuth Headers',
434+
description: 'Add custom headers for OAuth requests',
435+
additionalProperties: {
436+
type: 'string'
437+
}
438+
}
439+
```
440+
441+
### Features
442+
443+
- **Dynamic rows**: Add/remove key-value pairs
444+
- **Type-safe**: Values are typed based on `additionalProperties.type`
445+
- **Clean UI**: Grid layout with aligned inputs
446+
- **Theme inheritance**: Inherits from parent Quasar theme (no hardcoded colors)
447+
- **Validation**: Empty keys are automatically filtered out
448+
- **Button customization**: Customize add/remove buttons via `x-quickforms-quasar`
449+
450+
### UI Behavior
451+
452+
- **Add button**: Creates a new empty key-value pair
453+
- **Remove button**: Deletes a specific pair
454+
- **Auto-cleanup**: Pairs with empty keys are not saved to form data
455+
456+
### Rendering
457+
458+
- **Plain Vue**: Standard HTML inputs with responsive grid layout
459+
- **Quasar**: `QInput` components with Quasar styling and icons
460+
461+
### Button Customization (Quasar)
462+
463+
**Per-field customization:**
464+
```typescript
465+
{
466+
type: 'object',
467+
title: 'Environment Variables',
468+
additionalProperties: { type: 'string' },
469+
'x-quickforms-quasar': {
470+
addButton: {
471+
label: 'Add Variable',
472+
icon: 'add_circle',
473+
color: 'secondary',
474+
outline: false
475+
},
476+
removeButton: {
477+
icon: 'delete',
478+
color: 'negative'
479+
}
480+
}
481+
}
482+
```
483+
484+
**Global defaults:**
485+
```typescript
486+
const formOptions = {
487+
quickformsDefaults: {
488+
keyvalue: {
489+
addButton: {
490+
label: 'Add Item',
491+
icon: 'add',
492+
color: 'primary'
493+
},
494+
removeButton: {
495+
icon: 'close',
496+
color: 'negative'
497+
}
498+
}
499+
}
500+
}
501+
```
502+
503+
---
504+
407505
## ArrayField
408506

409507
Renders dynamic array fields with add/remove buttons.

packages/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@quickflo/quickforms",
3-
"version": "1.2.0",
3+
"version": "1.3.0",
44
"description": "Framework-agnostic core for QuickForms - JSON Schema form generator",
55
"type": "module",
66
"main": "./dist/index.js",

packages/core/src/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,12 @@ export {
2020
isNumberType,
2121
isIntegerType,
2222
isBooleanType,
23+
isEnumType,
2324
isObjectType,
2425
isArrayType,
25-
isNullType,
26-
isEnumType,
26+
isRecordType,
2727
isJsonType,
28+
isNullType,
2829
hasConst,
2930
hasFormat,
3031
isEmailFormat,

packages/core/src/testers.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,23 @@ export const isArrayType = (schema: JSONSchema): boolean => {
4242
return schema.type === 'array';
4343
};
4444

45+
/**
46+
* Record type tester - for dynamic key-value pairs
47+
* Matches objects with typed additionalProperties (e.g., Record<string, string>)
48+
*/
49+
export const isRecordType = (schema: JSONSchema): boolean => {
50+
return (
51+
schema.type === 'object' &&
52+
schema.additionalProperties !== undefined &&
53+
typeof schema.additionalProperties === 'object' &&
54+
Object.keys(schema.additionalProperties).length > 0 && // Has typed additionalProperties
55+
(!schema.properties || Object.keys(schema.properties).length === 0)
56+
);
57+
};
58+
4559
/**
4660
* JSON object tester - for freeform JSON editing
47-
* Matches objects with additionalProperties but no defined properties,
61+
* Matches objects with empty additionalProperties (freeform),
4862
* or any field with x-render: "jsoneditor"
4963
*/
5064
export const isJsonType = (schema: JSONSchema): boolean => {
@@ -53,10 +67,12 @@ export const isJsonType = (schema: JSONSchema): boolean => {
5367
return true;
5468
}
5569

56-
// Automatic detection: object with additionalProperties but no defined properties
70+
// Automatic detection: object with additionalProperties={} (empty, freeform)
5771
return (
5872
schema.type === 'object' &&
5973
schema.additionalProperties !== undefined &&
74+
typeof schema.additionalProperties === 'object' &&
75+
Object.keys(schema.additionalProperties).length === 0 && // Empty = freeform
6076
(!schema.properties || Object.keys(schema.properties).length === 0)
6177
);
6278
};

packages/quasar/dev/App.vue

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,26 @@ const schema: JSONSchema = {
379379
},
380380
},
381381
382+
// === KEY-VALUE EDITOR (RECORD TYPE) ===
383+
additionalParams: {
384+
type: "object",
385+
title: "Additional Parameters",
386+
description: "Dynamic key-value pairs (Record<string, string>)",
387+
additionalProperties: {
388+
type: "string",
389+
},
390+
},
391+
392+
// === KEY-VALUE EDITOR (OAUTH EXAMPLE) ===
393+
oauthHeaders: {
394+
type: "object",
395+
title: "Custom OAuth Headers",
396+
description: "Add custom headers for OAuth requests",
397+
additionalProperties: {
398+
type: "string",
399+
},
400+
},
401+
382402
// === URL FIELD ===
383403
website: {
384404
type: "string",

packages/quasar/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@quickflo/quickforms-quasar",
3-
"version": "1.2.0",
3+
"version": "1.3.0",
44
"description": "Quasar UI components for QuickForms - JSON Schema form generator",
55
"type": "module",
66
"main": "./dist/index.js",
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
<script setup lang="ts">
2+
import { ref, watch, computed } from 'vue';
3+
import { QInput, QBtn, QIcon } from 'quasar';
4+
import { useFormField, generateFieldId, useFormContext } from '@quickflo/quickforms-vue';
5+
import type { FieldProps } from '@quickflo/quickforms-vue';
6+
7+
const props = withDefaults(defineProps<FieldProps>(), {
8+
disabled: false,
9+
readonly: false,
10+
});
11+
12+
const { value, setValue, label, hint, errorMessage, required } = useFormField(
13+
props.path,
14+
props.schema,
15+
{ label: props.label }
16+
);
17+
18+
const formContext = useFormContext();
19+
const fieldId = generateFieldId(props.path);
20+
21+
// Merge QuickForms convenience features for button customization
22+
const quickformsFeatures = computed(() => {
23+
const globalDefaults = (formContext as any)?.quickformsDefaults?.keyvalue || {};
24+
const schemaFeatures = (props.schema as any)["x-quickforms-quasar"] || {};
25+
26+
// Merge QBtn props: defaults -> global -> schema (schema has highest priority)
27+
const addButtonDefaults = {
28+
outline: true,
29+
color: "primary",
30+
icon: "add",
31+
label: formContext?.labels?.addItem || "Add Parameter",
32+
size: "sm",
33+
};
34+
35+
const removeButtonDefaults = {
36+
flat: true,
37+
round: true,
38+
dense: true,
39+
size: "sm",
40+
icon: "close",
41+
color: "negative",
42+
};
43+
44+
const addButton = {
45+
...addButtonDefaults,
46+
...(globalDefaults.addButton || {}),
47+
...(schemaFeatures.addButton || {}),
48+
};
49+
50+
const removeButton = {
51+
...removeButtonDefaults,
52+
...(globalDefaults.removeButton || {}),
53+
...(schemaFeatures.removeButton || {}),
54+
};
55+
56+
return {
57+
addButton,
58+
removeButton,
59+
};
60+
});
61+
62+
// Convert object to array of key-value pairs for editing
63+
interface KeyValuePair {
64+
key: string;
65+
value: string;
66+
id: number;
67+
}
68+
69+
let nextId = 0;
70+
const pairs = ref<KeyValuePair[]>([]);
71+
const isInternalUpdate = ref(false);
72+
73+
// Initialize from value
74+
watch(
75+
() => value.value,
76+
(newValue) => {
77+
if (isInternalUpdate.value) {
78+
isInternalUpdate.value = false;
79+
return;
80+
}
81+
82+
if (newValue && typeof newValue === 'object' && !Array.isArray(newValue)) {
83+
pairs.value = Object.entries(newValue).map(([key, val]) => ({
84+
key,
85+
value: String(val),
86+
id: nextId++
87+
}));
88+
} else if (!pairs.value.length) {
89+
pairs.value = [];
90+
}
91+
},
92+
{ immediate: true }
93+
);
94+
95+
// Update value when pairs change
96+
watch(
97+
pairs,
98+
(newPairs) => {
99+
const obj: Record<string, string> = {};
100+
newPairs.forEach(pair => {
101+
if (pair.key.trim()) {
102+
obj[pair.key] = pair.value;
103+
}
104+
});
105+
isInternalUpdate.value = true;
106+
setValue(obj);
107+
},
108+
{ deep: true }
109+
);
110+
111+
function addPair() {
112+
pairs.value.push({ key: '', value: '', id: nextId++ });
113+
}
114+
115+
function removePair(id: number) {
116+
pairs.value = pairs.value.filter(p => p.id !== id);
117+
}
118+
</script>
119+
120+
<template>
121+
<div class="quickform-keyvalue-field">
122+
<div v-if="label" class="text-subtitle2 q-mb-xs">
123+
{{ label }}
124+
<span v-if="required" style="color: red; margin-left: 0.25rem">*</span>
125+
</div>
126+
127+
<div v-if="hint" class="text-caption text-grey-7 q-mb-sm">
128+
{{ hint }}
129+
</div>
130+
131+
<div class="q-pa-md rounded-borders">
132+
<div v-if="pairs.length" class="row items-center q-gutter-sm q-mb-sm">
133+
<div class="col text-weight-medium text-caption">Key</div>
134+
<div class="col text-weight-medium text-caption">Value</div>
135+
<div style="width: 40px"></div>
136+
</div>
137+
138+
<div
139+
v-for="pair in pairs"
140+
:key="pair.id"
141+
class="row items-center q-gutter-sm q-mb-sm"
142+
>
143+
<QInput
144+
v-model="pair.key"
145+
outlined
146+
dense
147+
placeholder="key"
148+
class="col"
149+
:disable="disabled"
150+
:readonly="readonly"
151+
/>
152+
<QInput
153+
v-model="pair.value"
154+
outlined
155+
dense
156+
placeholder="value"
157+
class="col"
158+
:disable="disabled"
159+
:readonly="readonly"
160+
/>
161+
<QBtn
162+
v-bind="quickformsFeatures.removeButton"
163+
:disable="disabled || readonly"
164+
@click="removePair(pair.id)"
165+
:title="formContext?.labels?.removeItem || 'Remove'"
166+
>
167+
<q-tooltip>{{
168+
formContext?.labels?.removeItem || "Remove"
169+
}}</q-tooltip>
170+
</QBtn>
171+
</div>
172+
173+
<QBtn
174+
v-bind="quickformsFeatures.addButton"
175+
class="full-width"
176+
:disable="disabled || readonly"
177+
@click="addPair"
178+
/>
179+
</div>
180+
181+
<div v-if="errorMessage" class="text-negative text-caption q-mt-xs">
182+
{{ errorMessage }}
183+
</div>
184+
</div>
185+
</template>

0 commit comments

Comments
 (0)