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:
177
apps/app/src/composables/api/useFormSchemas.ts
Normal file
177
apps/app/src/composables/api/useFormSchemas.ts
Normal 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] })
|
||||
},
|
||||
})
|
||||
}
|
||||
191
apps/app/src/types/formSchema.ts
Normal file
191
apps/app/src/types/formSchema.ts
Normal 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
|
||||
Reference in New Issue
Block a user