Files
api/unraid-ui/src/forms/ObjectArrayField.vue
Eli Bosley 77cfc07dda refactor: enhance CSS structure with @layer for component styles (#1660)
- Introduced @layer directive to ensure base styles have lower priority
than Tailwind utilities.
- Organized CSS resets for box-sizing, figures, headings, paragraphs,
and unordered lists under a single @layer base block for improved
maintainability.

These changes streamline the CSS structure and enhance compatibility
with Tailwind CSS utilities.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- Style
- Wrapped core resets in a base style layer, adjusting cascade with
utility classes.
  - Applied global box-sizing within the base layer.
  - Consolidated heading and paragraph resets into the layer.
- Added a reset for unordered lists to remove default bullets and
padding.
  - Retained the logo figure reset within the layer.
- Updated formatting and header comments to reflect the layering
approach.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-04 14:36:25 -04:00

254 lines
8.6 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { Button } from '@/components/common/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/common/tabs';
import { jsonFormsAjv } from '@/forms/config';
// Import the renderers and AJV directly
import { jsonFormsRenderers } from '@/forms/renderers';
import type { ControlElement, JsonSchema, UISchemaElement } from '@jsonforms/core';
import { JsonForms, useJsonFormsControl } from '@jsonforms/vue';
import type { RendererProps } from '@jsonforms/vue';
import { Plus, X } from 'lucide-vue-next';
import { computed, ref, watch } from 'vue';
const props = defineProps<RendererProps<ControlElement>>();
const { control, handleChange } = useJsonFormsControl(props);
// Use the imported renderers
const renderers = jsonFormsRenderers;
const items = computed({
get: () => {
const data = control.value.data ?? [];
return Array.isArray(data) ? data : [];
},
set: (newValue: unknown[]) => {
handleChange(control.value.path, newValue);
},
});
// Track active tab
const activeTab = ref<string>('0');
// Update active tab when items change
watch(
() => items.value.length,
(newLength, oldLength) => {
if (newLength > oldLength) {
// When adding a new item, switch to the new tab
activeTab.value = String(newLength - 1);
} else if (newLength < oldLength && Number(activeTab.value) >= newLength) {
// When removing an item, ensure active tab is valid
activeTab.value = String(Math.max(0, newLength - 1));
}
}
);
// Get the detail layout from options or create a default one
const detailLayout = computed(() => {
const options = control.value.uischema?.options;
if (options?.detail) {
return options.detail as UISchemaElement;
}
// Create a default vertical layout with all properties
const schema = control.value.schema;
if (schema?.items && typeof schema.items === 'object' && !Array.isArray(schema.items)) {
const properties = schema.items.properties;
if (properties && typeof properties === 'object') {
return {
type: 'VerticalLayout',
elements: Object.keys(properties).map((key) => ({
type: 'Control',
scope: `#/properties/${key}`,
})),
} as UISchemaElement;
}
}
return { type: 'VerticalLayout', elements: [] } as UISchemaElement;
});
// Get the property to use as the item label
const elementLabelProp = computed(() => {
const options = control.value.uischema?.options as Record<string, unknown> | undefined;
return (options?.elementLabelProp as string) ?? 'name';
});
// Get the item type name (e.g., "Provider", "Rule", etc.)
const itemTypeName = computed(() => {
const options = control.value.uischema?.options as Record<string, unknown> | undefined;
return (options?.itemTypeName as string) ?? 'Provider';
});
const getItemLabel = (item: unknown, index: number) => {
if (item && typeof item === 'object' && item !== null && elementLabelProp.value in item) {
const itemObj = item as Record<string, unknown>;
return String(itemObj[elementLabelProp.value] || `${itemTypeName.value} ${index + 1}`);
}
return `${itemTypeName.value} ${index + 1}`;
};
// Check if an item is protected based on options configuration
const isItemProtected = (item: unknown): boolean => {
const options = control.value.uischema?.options as Record<string, unknown> | undefined;
const protectedItems = options?.protectedItems as Array<{ field: string; value: unknown }> | undefined;
if (!protectedItems || !item || typeof item !== 'object') {
return false;
}
const itemObj = item as Record<string, unknown>;
return protectedItems.some((rule) => rule.field in itemObj && itemObj[rule.field] === rule.value);
};
// Get warning message for an item if it matches warning conditions
const getItemWarning = (item: unknown): { title: string; description: string } | null => {
const options = control.value.uischema?.options as Record<string, unknown> | undefined;
const itemWarnings = options?.itemWarnings as
| Array<{
condition: { field: string; value: unknown };
title: string;
description: string;
}>
| undefined;
if (!itemWarnings || !item || typeof item !== 'object') {
return null;
}
const itemObj = item as Record<string, unknown>;
const warning = itemWarnings.find(
(w) => w.condition.field in itemObj && itemObj[w.condition.field] === w.condition.value
);
return warning ? { title: warning.title, description: warning.description } : null;
};
const addItem = () => {
const schema = control.value.schema;
const newItem: Record<string, unknown> = {};
// Initialize with default values if available
if (schema?.items && typeof schema.items === 'object' && !Array.isArray(schema.items)) {
const properties = schema.items.properties;
if (properties && typeof properties === 'object') {
Object.entries(properties).forEach(([key, propSchema]) => {
const schema = propSchema as JsonSchema;
if (schema.default !== undefined) {
newItem[key] = schema.default;
} else if (schema.type === 'array') {
newItem[key] = [];
} else if (schema.type === 'string') {
newItem[key] = '';
} else if (schema.type === 'number' || schema.type === 'integer') {
newItem[key] = 0;
} else if (schema.type === 'boolean') {
newItem[key] = false;
}
});
}
}
items.value = [...items.value, newItem];
};
const removeItem = (index: number) => {
// Create a completely new array by filtering out the item
const newItems = items.value.filter((_, i) => i !== index);
items.value = newItems;
};
const updateItem = (index: number, newValue: unknown) => {
const newItems = [...items.value];
newItems[index] = newValue;
items.value = newItems;
};
</script>
<template>
<div class="w-full">
<Tabs v-if="items.length > 0" v-model="activeTab" class="w-full">
<div class="mb-4 flex items-center gap-2">
<TabsList class="flex-1 flex-wrap">
<TabsTrigger
v-for="(item, index) in items"
:key="index"
:value="String(index)"
class="flex items-center gap-2"
>
{{ getItemLabel(item, index) }}
</TabsTrigger>
</TabsList>
<Button
variant="outline"
size="icon"
class="h-9 w-9"
:disabled="!control.enabled"
@click="addItem"
>
<Plus class="h-4 w-4" />
</Button>
</div>
<TabsContent
v-for="(item, index) in items"
:key="index"
:value="String(index)"
class="mt-0 w-full"
>
<div class="border-muted w-full rounded-lg border p-1 sm:p-6">
<div class="mb-4 flex justify-end">
<Button
v-if="!isItemProtected(item)"
variant="ghost"
size="sm"
class="text-destructive hover:text-destructive"
:disabled="!control.enabled"
@click="removeItem(index)"
>
<X class="mr-2 h-4 w-4" />
Remove {{ getItemLabel(item, index) }}
</Button>
</div>
<div class="w-full max-w-none">
<!-- Show warning if item matches protected condition -->
<div
v-if="getItemWarning(item)"
class="bg-warning/10 border-warning/20 border-muted mb-4 rounded-lg border p-3"
>
<div class="flex items-start gap-2">
<span class="text-warning"></span>
<div>
<div class="text-warning font-medium">{{ getItemWarning(item)?.title }}</div>
<div class="text-muted-foreground mt-1 text-sm">
{{ getItemWarning(item)?.description }}
</div>
</div>
</div>
</div>
<JsonForms
:data="item"
:schema="control.schema.items as JsonSchema"
:uischema="detailLayout"
:renderers="renderers"
:ajv="jsonFormsAjv"
:readonly="!control.enabled"
@change="({ data }) => updateItem(index, data)"
/>
</div>
</div>
</TabsContent>
</Tabs>
<div v-else class="border-muted rounded-lg border-2 border-dashed py-8 text-center">
<p class="text-muted-foreground mb-4 text-center">
No {{ itemTypeName.toLowerCase() }}s configured
</p>
<Button variant="outline" size="md" :disabled="!control.enabled" @click="addItem">
<Plus class="mr-2 h-4 w-4" />
Add First {{ itemTypeName }}
</Button>
</div>
</div>
</template>