feat(form-builder): form schema types and TanStack Vue Query composables

Adds apps/app/src/types/formSchema.ts with FormSchema, FormSchemaSummary,
FormSchemaPurpose, FormSubmissionMode, FormSchemaSnapshotMode, and the
payload/response shapes for schema CRUD plus lifecycle operations
(publish, unpublish, duplicate, rotate-public-token).

Adds apps/app/src/composables/api/useFormSchemas.ts mirroring the
useSections pattern: useFormSchemaList, useFormSchema, plus seven
mutations covering CRUD, duplicate, publish/unpublish and public-token
rotation. All queries and mutations invalidate the right cache keys.

Fields and sections on the full FormSchema are typed as unknown[] with
a TODO pointing to PR-b3 when the organizer field types land. No UI,
routes, or navigation — those come in PR-b2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 01:52:44 +02:00
parent 214a2debee
commit 7df37b8823
2 changed files with 368 additions and 0 deletions

View File

@@ -0,0 +1,177 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import type { Ref } from 'vue'
import { apiClient } from '@/lib/axios'
import type {
CreateFormSchemaPayload,
FormSchema,
FormSchemaSummary,
RotatePublicTokenPayload,
RotatePublicTokenResponse,
UpdateFormSchemaPayload,
} from '@/types/formSchema'
// Form schemas are organisation-scoped on the backend (see
// api/routes/api.php — /organisations/{organisation}/forms/schemas).
// There is no event in the path or controller signature; a single
// schema is reusable across all events of an organisation.
interface ApiResponse<T> {
success: boolean
data: T
message?: string
}
interface PaginatedResponse<T> {
data: T[]
}
export function useFormSchemaList(orgId: Ref<string>) {
return useQuery({
queryKey: ['form-schemas', orgId],
queryFn: async () => {
const { data } = await apiClient.get<PaginatedResponse<FormSchemaSummary>>(
`/organisations/${orgId.value}/forms/schemas`,
)
return data.data
},
enabled: () => !!orgId.value,
})
}
export function useFormSchema(orgId: Ref<string>, schemaId: Ref<string>) {
return useQuery({
queryKey: ['form-schemas', orgId, schemaId],
queryFn: async () => {
const { data } = await apiClient.get<ApiResponse<FormSchema>>(
`/organisations/${orgId.value}/forms/schemas/${schemaId.value}`,
)
return data.data
},
enabled: () => !!orgId.value && !!schemaId.value,
})
}
export function useCreateFormSchema(orgId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: CreateFormSchemaPayload): Promise<FormSchema> => {
const { data } = await apiClient.post<ApiResponse<FormSchema>>(
`/organisations/${orgId.value}/forms/schemas`,
payload,
)
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['form-schemas', orgId.value] })
},
})
}
export function useUpdateFormSchema(orgId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id, ...payload }: UpdateFormSchemaPayload & { id: string }): Promise<FormSchema> => {
const { data } = await apiClient.put<ApiResponse<FormSchema>>(
`/organisations/${orgId.value}/forms/schemas/${id}`,
payload,
)
return data.data
},
onSuccess: (_updated, variables) => {
queryClient.invalidateQueries({ queryKey: ['form-schemas', orgId.value] })
queryClient.invalidateQueries({ queryKey: ['form-schemas', orgId.value, variables.id] })
},
})
}
export function useDeleteFormSchema(orgId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id, confirmed_name }: { id: string; confirmed_name?: string }) => {
await apiClient.delete(`/organisations/${orgId.value}/forms/schemas/${id}`, {
params: confirmed_name ? { confirmed_name } : undefined,
})
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['form-schemas', orgId.value] })
},
})
}
export function useDuplicateFormSchema(orgId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (id: string): Promise<FormSchema> => {
const { data } = await apiClient.post<ApiResponse<FormSchema>>(
`/organisations/${orgId.value}/forms/schemas/${id}/duplicate`,
)
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['form-schemas', orgId.value] })
},
})
}
export function usePublishFormSchema(orgId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (id: string): Promise<FormSchema> => {
const { data } = await apiClient.post<ApiResponse<FormSchema>>(
`/organisations/${orgId.value}/forms/schemas/${id}/publish`,
)
return data.data
},
onSuccess: (_updated, id) => {
queryClient.invalidateQueries({ queryKey: ['form-schemas', orgId.value] })
queryClient.invalidateQueries({ queryKey: ['form-schemas', orgId.value, id] })
},
})
}
export function useUnpublishFormSchema(orgId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (id: string): Promise<FormSchema> => {
const { data } = await apiClient.post<ApiResponse<FormSchema>>(
`/organisations/${orgId.value}/forms/schemas/${id}/unpublish`,
)
return data.data
},
onSuccess: (_updated, id) => {
queryClient.invalidateQueries({ queryKey: ['form-schemas', orgId.value] })
queryClient.invalidateQueries({ queryKey: ['form-schemas', orgId.value, id] })
},
})
}
export function useRotatePublicToken(orgId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id, ...payload }: RotatePublicTokenPayload & { id: string }): Promise<RotatePublicTokenResponse> => {
const { data } = await apiClient.post<ApiResponse<FormSchema>>(
`/organisations/${orgId.value}/forms/schemas/${id}/rotate-public-token`,
payload,
)
return data.data
},
onSuccess: (_updated, variables) => {
queryClient.invalidateQueries({ queryKey: ['form-schemas', orgId.value, variables.id] })
},
})
}

View File

@@ -0,0 +1,191 @@
// Organizer-facing form schema types. Mirrors the backend resources at
// api/app/Http/Resources/FormBuilder/FormSchema(Summary)Resource.php and
// the enums at api/app/Enums/FormBuilder/.
//
// Shared schema types (FormFieldType, FormFieldDisplayWidth, etc.) are
// imported from @form-schema so portal and app stay in sync on the
// submit-side contract. Organizer-only types (lifecycle, payloads,
// purpose/submission_mode enums) live here.
import type {
ConditionalLogic,
FormFieldDisplayWidth,
FormFieldType,
FormFieldValidationRules,
} from '@form-schema/types/formBuilder'
// Re-export shared field primitives so consumers of this module don't
// need to reach into @form-schema directly.
export type { ConditionalLogic, FormFieldDisplayWidth, FormFieldType, FormFieldValidationRules }
// Mirrors api/app/Enums/FormBuilder/FormPurpose.php
export const FormSchemaPurpose = {
EVENT_REGISTRATION: 'event_registration',
USER_PROFILE: 'user_profile',
ARTIST_PROFILE: 'artist_profile',
COMPANY_PROFILE: 'company_profile',
ARTIST_ADVANCE: 'artist_advance',
SUPPLIER_INTAKE: 'supplier_intake',
INCIDENT_REPORT: 'incident_report',
FEEDBACK: 'feedback',
POST_EVENT_EVALUATION: 'post_event_evaluation',
SIGNATURE_CONTRACT: 'signature_contract',
SIGNATURE_CODE_OF_CONDUCT: 'signature_code_of_conduct',
SIGNATURE_RECEIPT: 'signature_receipt',
ABSENCE_REPORT: 'absence_report',
CHECK_OUT_INVENTORY: 'check_out_inventory',
PUBLIC_COMPLAINT: 'public_complaint',
PUBLIC_PRESS_REQUEST: 'public_press_request',
PUBLIC_RSVP: 'public_rsvp',
ONBOARDING_WIZARD: 'onboarding_wizard',
EVENT_SETUP_WIZARD: 'event_setup_wizard',
COMPANY_CUSTOM: 'company_custom',
ARTIST_CUSTOM: 'artist_custom',
CUSTOM: 'custom',
} as const
export type FormSchemaPurpose = typeof FormSchemaPurpose[keyof typeof FormSchemaPurpose]
// Mirrors api/app/Enums/FormBuilder/FormSubmissionMode.php
export const FormSubmissionMode = {
SINGLE: 'single',
MULTIPLE: 'multiple',
DRAFT_SINGLE: 'draft_single',
} as const
export type FormSubmissionMode = typeof FormSubmissionMode[keyof typeof FormSubmissionMode]
// Mirrors api/app/Enums/FormBuilder/FormSchemaSnapshotMode.php
export const FormSchemaSnapshotMode = {
NEVER: 'never',
ON_SUBMIT: 'on_submit',
ALWAYS: 'always',
} as const
export type FormSchemaSnapshotMode = typeof FormSchemaSnapshotMode[keyof typeof FormSchemaSnapshotMode]
// Derived client-side status. The backend does not emit a dedicated
// status enum — `is_published: boolean` on the resource is the source
// of truth. Add 'archived' here if/when we surface soft-deleted schemas.
export type FormSchemaStatus = 'draft' | 'published'
// Shape returned by GET /organisations/{organisation}/forms/schemas
// (FormSchemaSummaryResource — index endpoint, paginated 25/page).
export interface FormSchemaSummary {
id: string
organisation_id: string
name: string
slug: string
purpose: FormSchemaPurpose
submission_mode: FormSubmissionMode
is_published: boolean
version: number
updated_at: string | null
// Both counts are always present in the index response because the
// controller calls ->withCount(['fields', 'submissions']).
submissions_count: number
fields_count: number
}
// Shape returned by GET /organisations/{organisation}/forms/schemas/{form_schema}
// (FormSchemaResource — show, store, update, duplicate, publish, unpublish, rotate).
export interface FormSchema {
id: string
organisation_id: string
owner_type: string | null
owner_id: string | null
name: string
slug: string
purpose: FormSchemaPurpose
custom_purpose_slug: string | null
description: string | null
is_published: boolean
submission_mode: FormSubmissionMode
locale: string | null
settings: Record<string, unknown> | null
snapshot_mode: FormSchemaSnapshotMode
freeze_on_submit: boolean
retention_days: number | null
consent_version: string | null
section_level_submit: boolean
auto_save_enabled: boolean
max_submissions: number | null
version: number
public_token: string | null
public_token_previous: string | null
public_token_rotated_at: string | null
submission_deadline: string | null
created_by_user_id: string | null
last_updated_by_user_id: string | null
edit_lock_user_id: string | null
edit_lock_expires_at: string | null
is_locked: boolean
public_form_url: string | null
fields_count: number
submissions_count: number | null
has_submissions: boolean | null
// TODO(PR-b3): replace with FormField[] once organizer field types land
fields: unknown[]
// TODO(PR-b3): replace with FormSchemaSection[] once organizer section types land
sections: unknown[]
created_at: string | null
updated_at: string | null
}
// Body of POST /organisations/{organisation}/forms/schemas
// Mirrors StoreFormSchemaRequest.
export interface CreateFormSchemaPayload {
name: string
slug?: string | null
purpose: FormSchemaPurpose
custom_purpose_slug?: string | null
description?: string | null
is_published?: boolean
submission_mode?: FormSubmissionMode | null
locale?: string | null
settings?: Record<string, unknown> | null
snapshot_mode?: FormSchemaSnapshotMode | null
freeze_on_submit?: boolean
retention_days?: number | null
consent_version?: string | null
section_level_submit?: boolean
auto_save_enabled?: boolean
max_submissions?: number | null
owner_type?: string | null
owner_id?: string | null
}
// Body of PUT /organisations/{organisation}/forms/schemas/{form_schema}
// Mirrors UpdateFormSchemaRequest — all fields are optional (`sometimes`).
export interface UpdateFormSchemaPayload {
name?: string
slug?: string
purpose?: FormSchemaPurpose
custom_purpose_slug?: string | null
description?: string | null
is_published?: boolean
submission_mode?: FormSubmissionMode
locale?: string
settings?: Record<string, unknown>
snapshot_mode?: FormSchemaSnapshotMode
freeze_on_submit?: boolean
retention_days?: number | null
consent_version?: string | null
section_level_submit?: boolean
auto_save_enabled?: boolean
max_submissions?: number | null
submission_deadline?: string | null
}
// Body of POST /organisations/{organisation}/forms/schemas/{form_schema}/duplicate.
// The backend's duplicate action accepts no body today; the type exists for
// forward compatibility so callers can evolve without signature churn.
export type DuplicateFormSchemaPayload = Record<string, never>
// Body of POST /organisations/{organisation}/forms/schemas/{form_schema}/rotate-public-token
// Mirrors RotatePublicTokenRequest.
export interface RotatePublicTokenPayload {
grace_days?: number
}
// Response of the rotate endpoint. The backend returns the full
// FormSchemaResource with the new public_token, public_token_previous,
// public_token_rotated_at, and refreshed public_form_url.
export type RotatePublicTokenResponse = FormSchema