Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions docs/guide/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,104 @@ const formOptions = {

---

## KeyValueField

Renders a dynamic key-value pair editor for record types.

### Handles

- `type: 'object'` with typed `additionalProperties` (e.g., `{ type: 'string' }`) but no defined `properties`

### Example Schema

**Basic record type:**
```typescript
{
type: 'object',
title: 'Additional Parameters',
description: 'Dynamic key-value pairs',
additionalProperties: {
type: 'string'
}
}
```

**OAuth headers example:**
```typescript
{
type: 'object',
title: 'Custom OAuth Headers',
description: 'Add custom headers for OAuth requests',
additionalProperties: {
type: 'string'
}
}
```

### Features

- **Dynamic rows**: Add/remove key-value pairs
- **Type-safe**: Values are typed based on `additionalProperties.type`
- **Clean UI**: Grid layout with aligned inputs
- **Theme inheritance**: Inherits from parent Quasar theme (no hardcoded colors)
- **Validation**: Empty keys are automatically filtered out
- **Button customization**: Customize add/remove buttons via `x-quickforms-quasar`

### UI Behavior

- **Add button**: Creates a new empty key-value pair
- **Remove button**: Deletes a specific pair
- **Auto-cleanup**: Pairs with empty keys are not saved to form data

### Rendering

- **Plain Vue**: Standard HTML inputs with responsive grid layout
- **Quasar**: `QInput` components with Quasar styling and icons

### Button Customization (Quasar)

**Per-field customization:**
```typescript
{
type: 'object',
title: 'Environment Variables',
additionalProperties: { type: 'string' },
'x-quickforms-quasar': {
addButton: {
label: 'Add Variable',
icon: 'add_circle',
color: 'secondary',
outline: false
},
removeButton: {
icon: 'delete',
color: 'negative'
}
}
}
```

**Global defaults:**
```typescript
const formOptions = {
quickformsDefaults: {
keyvalue: {
addButton: {
label: 'Add Item',
icon: 'add',
color: 'primary'
},
removeButton: {
icon: 'close',
color: 'negative'
}
}
}
}
```

---

## ArrayField

Renders dynamic array fields with add/remove buttons.
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@quickflo/quickforms",
"version": "1.2.0",
"version": "1.3.0",
"description": "Framework-agnostic core for QuickForms - JSON Schema form generator",
"type": "module",
"main": "./dist/index.js",
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@ export {
isNumberType,
isIntegerType,
isBooleanType,
isEnumType,
isObjectType,
isArrayType,
isNullType,
isEnumType,
isRecordType,
isJsonType,
isNullType,
hasConst,
hasFormat,
isEmailFormat,
Expand Down
20 changes: 18 additions & 2 deletions packages/core/src/testers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,23 @@ export const isArrayType = (schema: JSONSchema): boolean => {
return schema.type === 'array';
};

/**
* Record type tester - for dynamic key-value pairs
* Matches objects with typed additionalProperties (e.g., Record<string, string>)
*/
export const isRecordType = (schema: JSONSchema): boolean => {
return (
schema.type === 'object' &&
schema.additionalProperties !== undefined &&
typeof schema.additionalProperties === 'object' &&
Object.keys(schema.additionalProperties).length > 0 && // Has typed additionalProperties
(!schema.properties || Object.keys(schema.properties).length === 0)
);
};

/**
* JSON object tester - for freeform JSON editing
* Matches objects with additionalProperties but no defined properties,
* Matches objects with empty additionalProperties (freeform),
* or any field with x-render: "jsoneditor"
*/
export const isJsonType = (schema: JSONSchema): boolean => {
Expand All @@ -53,10 +67,12 @@ export const isJsonType = (schema: JSONSchema): boolean => {
return true;
}

// Automatic detection: object with additionalProperties but no defined properties
// Automatic detection: object with additionalProperties={} (empty, freeform)
return (
schema.type === 'object' &&
schema.additionalProperties !== undefined &&
typeof schema.additionalProperties === 'object' &&
Object.keys(schema.additionalProperties).length === 0 && // Empty = freeform
(!schema.properties || Object.keys(schema.properties).length === 0)
);
};
Expand Down
20 changes: 20 additions & 0 deletions packages/quasar/dev/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,26 @@ const schema: JSONSchema = {
},
},

// === KEY-VALUE EDITOR (RECORD TYPE) ===
additionalParams: {
type: "object",
title: "Additional Parameters",
description: "Dynamic key-value pairs (Record<string, string>)",
additionalProperties: {
type: "string",
},
},

// === KEY-VALUE EDITOR (OAUTH EXAMPLE) ===
oauthHeaders: {
type: "object",
title: "Custom OAuth Headers",
description: "Add custom headers for OAuth requests",
additionalProperties: {
type: "string",
},
},

// === URL FIELD ===
website: {
type: "string",
Expand Down
2 changes: 1 addition & 1 deletion packages/quasar/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@quickflo/quickforms-quasar",
"version": "1.2.0",
"version": "1.3.0",
"description": "Quasar UI components for QuickForms - JSON Schema form generator",
"type": "module",
"main": "./dist/index.js",
Expand Down
185 changes: 185 additions & 0 deletions packages/quasar/src/components/QuasarKeyValueField.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue';
import { QInput, QBtn, QIcon } from 'quasar';
import { useFormField, generateFieldId, useFormContext } from '@quickflo/quickforms-vue';
import type { FieldProps } from '@quickflo/quickforms-vue';

const props = withDefaults(defineProps<FieldProps>(), {
disabled: false,
readonly: false,
});

const { value, setValue, label, hint, errorMessage, required } = useFormField(
props.path,
props.schema,
{ label: props.label }
);

const formContext = useFormContext();
const fieldId = generateFieldId(props.path);

// Merge QuickForms convenience features for button customization
const quickformsFeatures = computed(() => {
const globalDefaults = (formContext as any)?.quickformsDefaults?.keyvalue || {};
const schemaFeatures = (props.schema as any)["x-quickforms-quasar"] || {};

// Merge QBtn props: defaults -> global -> schema (schema has highest priority)
const addButtonDefaults = {
outline: true,
color: "primary",
icon: "add",
label: formContext?.labels?.addItem || "Add Parameter",
size: "sm",
};

const removeButtonDefaults = {
flat: true,
round: true,
dense: true,
size: "sm",
icon: "close",
color: "negative",
};

const addButton = {
...addButtonDefaults,
...(globalDefaults.addButton || {}),
...(schemaFeatures.addButton || {}),
};

const removeButton = {
...removeButtonDefaults,
...(globalDefaults.removeButton || {}),
...(schemaFeatures.removeButton || {}),
};

return {
addButton,
removeButton,
};
});

// Convert object to array of key-value pairs for editing
interface KeyValuePair {
key: string;
value: string;
id: number;
}

let nextId = 0;
const pairs = ref<KeyValuePair[]>([]);
const isInternalUpdate = ref(false);

// Initialize from value
watch(
() => value.value,
(newValue) => {
if (isInternalUpdate.value) {
isInternalUpdate.value = false;
return;
}

if (newValue && typeof newValue === 'object' && !Array.isArray(newValue)) {
pairs.value = Object.entries(newValue).map(([key, val]) => ({
key,
value: String(val),
id: nextId++
}));
} else if (!pairs.value.length) {
pairs.value = [];
}
},
{ immediate: true }
);

// Update value when pairs change
watch(
pairs,
(newPairs) => {
const obj: Record<string, string> = {};
newPairs.forEach(pair => {
if (pair.key.trim()) {
obj[pair.key] = pair.value;
}
});
isInternalUpdate.value = true;
setValue(obj);
},
{ deep: true }
);

function addPair() {
pairs.value.push({ key: '', value: '', id: nextId++ });
}

function removePair(id: number) {
pairs.value = pairs.value.filter(p => p.id !== id);
}
</script>

<template>
<div class="quickform-keyvalue-field">
<div v-if="label" class="text-subtitle2 q-mb-xs">
{{ label }}
<span v-if="required" style="color: red; margin-left: 0.25rem">*</span>
</div>

<div v-if="hint" class="text-caption text-grey-7 q-mb-sm">
{{ hint }}
</div>

<div class="q-pa-md rounded-borders">
<div v-if="pairs.length" class="row items-center q-gutter-sm q-mb-sm">
<div class="col text-weight-medium text-caption">Key</div>
<div class="col text-weight-medium text-caption">Value</div>
<div style="width: 40px"></div>
</div>

<div
v-for="pair in pairs"
:key="pair.id"
class="row items-center q-gutter-sm q-mb-sm"
>
<QInput
v-model="pair.key"
outlined
dense
placeholder="key"
class="col"
:disable="disabled"
:readonly="readonly"
/>
<QInput
v-model="pair.value"
outlined
dense
placeholder="value"
class="col"
:disable="disabled"
:readonly="readonly"
/>
<QBtn
v-bind="quickformsFeatures.removeButton"
:disable="disabled || readonly"
@click="removePair(pair.id)"
:title="formContext?.labels?.removeItem || 'Remove'"
>
<q-tooltip>{{
formContext?.labels?.removeItem || "Remove"
}}</q-tooltip>
</QBtn>
</div>

<QBtn
v-bind="quickformsFeatures.addButton"
class="full-width"
:disable="disabled || readonly"
@click="addPair"
/>
</div>

<div v-if="errorMessage" class="text-negative text-caption q-mt-xs">
{{ errorMessage }}
</div>
</div>
</template>
Loading