Skip to content

Commit 120694c

Browse files
committed
first commit 🔥
1 parent 15b326c commit 120694c

20 files changed

Lines changed: 1689 additions & 43 deletions

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,11 @@
4848
"prepublishOnly": "npm run build:npm",
4949
"format": "prettier --write resources/",
5050
"format:check": "prettier --check resources/",
51-
"lint": "eslint . --fix"
51+
"lint": "eslint . --fix",
52+
"test": "npx playwright test",
53+
"test:headed": "npx playwright test --headed",
54+
"test:ui": "npx playwright test --ui",
55+
"test:debug": "npx playwright test --debug"
5256
},
5357
"peerDependencies": {
5458
"@inertiajs/vue3": "^2.0.0",

playwright-report/index.html

Lines changed: 85 additions & 0 deletions
Large diffs are not rendered by default.

playwright.config.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { defineConfig, devices } from '@playwright/test'
2+
3+
/**
4+
* Laravilt Forms Package - E2E Testing Configuration
5+
*
6+
* Tests form components (TextInput, Select, Toggle, DatePicker, etc.)
7+
* against the demo application
8+
*/
9+
export default defineConfig({
10+
testDir: './tests/e2e',
11+
fullyParallel: true,
12+
forbidOnly: !!process.env.CI,
13+
retries: process.env.CI ? 2 : 0,
14+
workers: process.env.CI ? 1 : undefined,
15+
reporter: [['html', { open: 'never' }], ['list']],
16+
17+
use: {
18+
baseURL: process.env.APP_URL || 'https://laravilt.test',
19+
trace: 'on-first-retry',
20+
screenshot: 'only-on-failure',
21+
video: 'on-first-retry',
22+
ignoreHTTPSErrors: true,
23+
actionTimeout: 10000,
24+
navigationTimeout: 30000,
25+
},
26+
27+
projects: [
28+
{
29+
name: 'setup',
30+
testMatch: /.*\.setup\.ts/,
31+
},
32+
{
33+
name: 'chromium',
34+
use: {
35+
...devices['Desktop Chrome'],
36+
storageState: 'tests/e2e/.auth/user.json',
37+
},
38+
dependencies: ['setup'],
39+
},
40+
],
41+
42+
timeout: 30000,
43+
expect: { timeout: 5000 },
44+
outputDir: 'test-results/',
45+
})

resources/js/app.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,13 @@
11
// Laravilt Forms Package Entry Point
2-
export { default as FormRenderer } from './components/FormRenderer.vue';
2+
import FormRenderer from './components/FormRenderer.vue'
3+
import Select from './components/fields/Select.vue'
4+
5+
export { FormRenderer, Select }
6+
7+
export default {
8+
install(app, options = {}) {
9+
// Register global components with laravilt- prefix for LaraviltComponentRenderer
10+
app.component('laravilt-select', Select)
11+
app.component('FormRenderer', FormRenderer)
12+
}
13+
}

resources/js/components/ErrorProvider.vue

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
<script setup lang="ts">
2-
import { provide, computed } from 'vue'
2+
import { provide, computed, watch } from 'vue'
33
44
const props = defineProps<{
55
errors: Record<string, string | string[]>
66
}>()
77
88
// Provide errors to all child components
9-
provide('errors', computed(() => props.errors || {}))
9+
const errorsComputed = computed(() => props.errors || {})
10+
provide('errors', errorsComputed)
1011
</script>
1112

1213
<template>

resources/js/components/FormRenderer.vue

Lines changed: 158 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,60 @@
11
<template>
22
<form ref="formRef" :class="containerClass" @submit.prevent>
33
<template v-for="(component, index) in internalSchema" :key="component.name || component.id || index">
4-
<!-- Render action buttons with form data -->
4+
<!-- Group consecutive actions together (only render on first action in group) -->
5+
<div v-if="isAction(component) && !isPreviousItemAction(index) && isNextItemAction(index)" class="flex flex-wrap gap-2 items-start">
6+
<FormActionButton
7+
v-bind="component"
8+
:getFormData="getFormData"
9+
/>
10+
<template v-for="(nextComponent, nextIndex) in getConsecutiveActions(index)" :key="nextComponent.name || nextComponent.id || (index + nextIndex + 1)">
11+
<FormActionButton
12+
v-bind="nextComponent"
13+
:getFormData="getFormData"
14+
/>
15+
</template>
16+
</div>
17+
18+
<!-- Render single action (not part of a group) -->
519
<FormActionButton
6-
v-if="isAction(component)"
20+
v-else-if="isAction(component) && !isPreviousItemAction(index) && !isNextItemAction(index)"
721
v-bind="component"
822
:getFormData="getFormData"
923
/>
1024

1125
<!-- Render regular form components -->
1226
<component
13-
v-else
27+
v-else-if="!isAction(component)"
1428
:is="getComponent(component)"
1529
v-bind="getComponentProps(component)"
1630
:value="isSchemaComponent(component) ? undefined : internalFormData[component.name]"
1731
:modelValue="isSchemaComponent(component) ? internalFormData : undefined"
32+
:error="getError(component)"
1833
@update:model-value="(value) => handleComponentUpdate(component, value)"
1934
/>
2035
</template>
2136
</form>
2237
</template>
2338

2439
<script setup lang="ts">
25-
import { defineAsyncComponent, onMounted, onUnmounted, computed, h, ref, watch, provide, nextTick } from 'vue'
40+
import { defineAsyncComponent, onMounted, onUnmounted, computed, h, ref, watch, provide, inject, nextTick } from 'vue'
2641
import ActionButton from '@laravilt/actions/components/ActionButton.vue'
2742
2843
const formRef = ref<HTMLFormElement | null>(null)
2944
const internalFormData = ref<Record<string, any>>({})
3045
46+
// Local validation errors for client-side validation
47+
const localErrors = ref<Record<string, string>>({})
48+
49+
// Inject errors from ErrorProvider (it's a computed ref)
50+
const injectedErrors = inject<any>('errors', {})
51+
52+
// Merged errors: local client-side errors + server-side errors
53+
const errors = computed(() => {
54+
const serverErrors = injectedErrors?.value || injectedErrors || {}
55+
return { ...localErrors.value, ...serverErrors }
56+
})
57+
3158
// Create a wrapper component for form actions that can collect form data
3259
const FormActionButton = {
3360
props: {
@@ -295,20 +322,95 @@ const getFormData = () => {
295322
return { ...internalFormData.value }
296323
}
297324
298-
// Validate the form using HTML5 validation
325+
// Extract required fields from schema recursively
326+
const extractRequiredFields = (schema: Array<any>): Array<{ name: string; label: string }> => {
327+
const requiredFields: Array<{ name: string; label: string }> = []
328+
const schemaComponentTypes = ['tabs', 'section', 'grid']
329+
330+
if (!schema || !Array.isArray(schema)) {
331+
return requiredFields
332+
}
333+
334+
for (const component of schema) {
335+
// Skip actions
336+
if (component.hasAction === true || (component.name && !component.component)) {
337+
continue
338+
}
339+
340+
// If it's a tabs component, extract from all tabs
341+
if (component.component === 'tabs' && component.tabs && Array.isArray(component.tabs)) {
342+
for (const tab of component.tabs) {
343+
if (tab.schema && Array.isArray(tab.schema)) {
344+
requiredFields.push(...extractRequiredFields(tab.schema))
345+
}
346+
}
347+
continue
348+
}
349+
350+
// If it's a schema component (section, grid), recurse into its schema
351+
if (schemaComponentTypes.includes(component.component) && component.schema && Array.isArray(component.schema)) {
352+
requiredFields.push(...extractRequiredFields(component.schema))
353+
continue
354+
}
355+
356+
// If it has a name and is required, add to the list
357+
if (component.name && component.required) {
358+
requiredFields.push({
359+
name: component.name,
360+
label: component.label || component.name
361+
})
362+
}
363+
}
364+
365+
return requiredFields
366+
}
367+
368+
// Check if a value is empty (null, undefined, empty string, empty array)
369+
const isValueEmpty = (value: any): boolean => {
370+
if (value === null || value === undefined) return true
371+
if (typeof value === 'string' && value.trim() === '') return true
372+
if (Array.isArray(value) && value.length === 0) return true
373+
return false
374+
}
375+
376+
// Validate the form - combines HTML5 validation with custom required field checks
299377
const validateForm = () => {
300-
if (!formRef.value) {
301-
return true
378+
// Clear previous local validation errors
379+
localErrors.value = {}
380+
381+
// Get all required fields from schema
382+
const requiredFields = extractRequiredFields(internalSchema.value)
383+
384+
// Check each required field
385+
const newErrors: Record<string, string> = {}
386+
for (const field of requiredFields) {
387+
const value = internalFormData.value[field.name]
388+
if (isValueEmpty(value)) {
389+
newErrors[field.name] = `${field.label} is required.`
390+
}
302391
}
303392
304-
const isValid = formRef.value.checkValidity()
393+
// If there are validation errors, set them and return false
394+
if (Object.keys(newErrors).length > 0) {
395+
localErrors.value = newErrors
396+
return false
397+
}
305398
306-
if (!isValid) {
307-
// Trigger validation UI (show error messages)
308-
formRef.value.reportValidity()
399+
// Also run HTML5 validation for native form elements
400+
if (formRef.value) {
401+
const isHtml5Valid = formRef.value.checkValidity()
402+
if (!isHtml5Valid) {
403+
formRef.value.reportValidity()
404+
return false
405+
}
309406
}
310407
311-
return isValid
408+
return true
409+
}
410+
411+
// Clear local validation errors (useful when modal closes or form resets)
412+
const clearValidationErrors = () => {
413+
localErrors.value = {}
312414
}
313415
314416
// Function to update schema (for reactive fields)
@@ -338,18 +440,52 @@ const updateSchema = (newSchema: any[]) => {
338440
})
339441
}
340442
341-
// Provide getFormData, validateForm, updateSchema, and schemaId to all child components
443+
// Provide getFormData, validateForm, updateSchema, schemaId, and errors to all child components
342444
provide('getFormData', getFormData)
343445
provide('validateForm', validateForm)
344446
provide('updateSchema', updateSchema)
345447
provide('schemaId', props.schemaId || null)
448+
// Override the errors provided by ErrorProvider with our merged errors (local + server)
449+
provide('errors', errors)
450+
451+
// Expose getFormData, validateForm, and clearValidationErrors to parent components via template ref
452+
defineExpose({
453+
getFormData,
454+
validateForm,
455+
clearValidationErrors
456+
})
346457
347458
// Check if an item is an action
348459
const isAction = (item: any) => {
349460
// Actions have hasAction property or don't have a component property
350461
return item.hasAction === true || (item.name && !item.component)
351462
}
352463
464+
// Check if the next item is an action
465+
const isNextItemAction = (index: number) => {
466+
const nextItem = internalSchema.value[index + 1]
467+
return nextItem && isAction(nextItem)
468+
}
469+
470+
// Check if the previous item is an action
471+
const isPreviousItemAction = (index: number) => {
472+
const prevItem = internalSchema.value[index - 1]
473+
return prevItem && isAction(prevItem)
474+
}
475+
476+
// Get all consecutive actions starting from the next index
477+
const getConsecutiveActions = (startIndex: number) => {
478+
const actions = []
479+
let currentIndex = startIndex + 1
480+
481+
while (currentIndex < internalSchema.value.length && isAction(internalSchema.value[currentIndex])) {
482+
actions.push(internalSchema.value[currentIndex])
483+
currentIndex++
484+
}
485+
486+
return actions
487+
}
488+
353489
// Check if a component is a schema component (needs entire modelValue, not just a field value)
354490
const isSchemaComponent = (component: any) => {
355491
const schemaComponents = ['tabs', 'section', 'grid']
@@ -416,4 +552,13 @@ const getComponentProps = (component: any) => {
416552
const { value, modelValue, ...props } = component
417553
return props
418554
}
555+
556+
// Get error message for a component
557+
const getError = (component: any) => {
558+
// errors is a computed ref, access its value
559+
const errorsObj = errors.value
560+
if (!component.name || !errorsObj) return undefined
561+
const error = errorsObj[component.name]
562+
return Array.isArray(error) ? error[0] : error
563+
}
419564
</script>

resources/js/components/LaraviltComponentRenderer.vue

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,18 +32,15 @@ const toKebabCase = (str) => {
3232
const resolvedComponent = computed(() => {
3333
const kebabName = toKebabCase(props.component);
3434
const componentName = `laravilt-${kebabName}`;
35-
console.log('Trying to resolve component:', componentName, 'from', props.component);
3635
3736
// Try to get the component from the app context
3837
const component = instance?.appContext.components[componentName];
3938
4039
if (component) {
41-
console.log('✓ Resolved component:', componentName);
4240
return component;
4341
}
4442
4543
console.error(`Failed to resolve component: ${componentName}`);
46-
console.log('Available components:', Object.keys(instance?.appContext.components || {}));
4744
return 'div'; // Fallback to div
4845
});
4946

resources/js/components/fields/FileUpload.vue

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -315,17 +315,10 @@ const normalizePath = (path?: string | null) => {
315315
return path.startsWith('/') ? path : `/${path}`
316316
}
317317
318-
// Build upload URL from the current panel path so custom panel paths work
319-
const uploadUrl = computed(() => {
320-
// Check if we have a panel context from Inertia
321-
if (page?.props?.panel?.path) {
322-
const panelPath = normalizePath(page.props.panel.path)
323-
return `${panelPath}/uploads`
324-
}
325-
326-
// Fallback to root uploads route (for standalone usage or demo)
327-
return '/uploads'
328-
})
318+
// Upload URL is always at /uploads (global route from forms package)
319+
// Panel-specific upload routes are not used since the forms package
320+
// registers routes at the root level for all panels
321+
const uploadUrl = computed(() => '/uploads')
329322
330323
const temporaryUrlEndpoint = computed(() => `${uploadUrl.value}/temporary-url`)
331324

0 commit comments

Comments
 (0)