(in-package #:cl-forms) (defvar *form-renderer* nil "The current form renderer. Bind using WITH-FORM-RENDERER.") (defvar *default-form-renderer* :who "The default form renderer. The default renderer is CL-WHO.") (defvar *form* nil "The current form.") (defvar *base64-encode* nil "Whether to encode form parameters in base64 or not.") (defvar *field-required-message* nil "The default message to displayed when a field is required.") (defun check-duplicate-fields (fields) (when (not (equal (length (remove-duplicates fields :key (alexandria:compose 'string 'car) :test 'string=)) (length fields))) (error "Form contains duplicate fields"))) (defmethod call-with-form-renderer (renderer function) (let ((*form-renderer* renderer)) (funcall function))) (defmacro with-form-renderer (renderer &body body) "Bind *FORM-RENDERER* to RENDERER and evaluate BODY in that context." `(call-with-form-renderer ,renderer (lambda () ,@body))) (defun call-with-form-theme (form-theme function) (let ((theme (if (symbolp form-theme) (make-instance form-theme) form-theme))) (let ((*form-theme* theme)) (funcall function)))) (defmacro with-form-theme (form-theme &body body) "Bind *FORM-THEME* to FORM-THEME and evaluate BODY in that context." `(call-with-form-theme ,form-theme (lambda () ,@body))) (defun call-with-form (form function) (let ((*form* form)) (funcall function))) (defmacro with-form (form &body body) "Bind *FORM* to FORM and evaluate BODY in that context." `(call-with-form ,form (lambda () ,@body))) (defmacro defform (form-name args fields) "Define a form at top-level. ARGS are the arguments passed to FORM class via MAKE-INSTANCE. FIELDS are the form field specs. @lisp (forms:defform client-validated-form (:action \"/client-validation-post\" :client-validation t) ((name :string :value \"\" :constraints (list (clavier:is-a-string) (clavier:not-blank) (clavier:len :max 5)) :validation-triggers '(:focusin)) (single :boolean :value t) (sex :choice :choices (list \"Male\" \"Female\") :value \"Male\") (age :integer :constraints (list (clavier:is-an-integer) (clavier:greater-than -1) (clavier:less-than 200))) (email :email) (submit :submit :label \"Create\"))) @end lisp" (check-duplicate-fields fields) (alexandria:with-gensyms (fargs) `(setf (get ',form-name :form) (lambda (&rest ,fargs) (apply #'make-instance 'form :name ',form-name :fields (list ,@(loop for field in fields collect (destructuring-bind (field-name field-type &rest field-args) field `(cons ',field-name (make-form-field ,field-type :name ',(intern (string field-name)) ,@field-args))))) (append ,fargs (list ,@args))))))) (defmacro defform-builder (form-name args &body body) "Registers a function with arguments ARGS and body BODY as a form builder. BODY is expected to instantiate a FORM object using ARGS in some way. FORM-NAME is the symbol under which the FORM is registered. Use FIND-FORM with FORM-NAME and expected arguments to obtain the registered form." (alexandria:with-unique-names (form) `(setf (get ',form-name :form) (lambda ,args (let ((,form (progn ,@body))) (check-type ,form forms:form) (setf (form-name ,form) ',form-name) ,form))))) (defun make-form (name fields &rest options) (apply #'make-instance 'form :name name :fields (make-form-fields fields) options)) (defun make-form-fields (fields) (loop for field in fields collect (destructuring-bind (field-name field-type &rest field-args) field (cons field-name (apply #'make-form-field field-type :name field-name field-args))))) (defun find-form (name &rest args) "Get the form named NAME. ARGS is the list of arguments to pass to a possible form builder function. See: DEFFORM-BUILDER macro." (let ((form-builder (get name :form))) (when (not form-builder) (error "Form not found: ~A" name)) (apply form-builder args))) (defun get-form (&rest args) (warn "FORMS:GET-FORM is deprecated. Use FORMS:FIND-FORM instead.") (apply #'find-form args)) (defmacro with-form-fields (fields form &body body) "Bind FIELDS to the form fields in FORM. Example: @lisp (with-form-fields (name) form (print (field-value name))) @end lisp Also see: WITH-FORM-FIELD-VALUES " `(let ,(loop for field in fields collect `(,field (get-field ,form ',field))) ,@body)) (defmacro with-form-field-values (fields form &body body) "Bind the value of FIELDS in FORM. Example: @lisp (with-form-field-values (name) form (print name)) @end lisp" `(let ,(loop for field in fields collect `(,field (field-value (get-field ,form ',field)))) ,@body)) (defun get-field (form field-name &optional (error-p t)) (or (cdr (assoc field-name (form-fields form))) (when error-p (error "Field ~S not found in ~A" field-name form)))) (defun get-field-value (form field-name &optional (error-p t)) (declare (ignorable error-p)) (field-value (get-field form field-name))) (defun set-field-value (form field-name value) (setf (field-value (get-field form field-name)) value)) (defclass form () ((id :initarg :id :accessor form-id :initform (string (gensym)) :type string :documentation "The form id") (name :initarg :name :accessor form-name :initform (error "Provide a name for the form") :type symbol :documentation "The form name") (action :initarg :action :initform nil :type (or null string) :accessor form-action :documentation "The form action") (method :initarg :method :initform :post :type (member :get :post) :accessor form-method :documentation "The form method") (enctype :initarg :enctype :initform nil :type (or null string) :accessor form-enctype :documentation "Form encoding type. i.e. Use multipart/form-data for file uploads") (fields :initarg :fields :initform nil :accessor form-fields :documentation "Form fields") (model :initarg :model :initform nil :accessor form-model :documentation "The form model object") (csrf-protection :initarg :csrf-protection :initform nil :type boolean :accessor form-csrf-protection-p :documentation "T when csrf protection is enabled") (csrf-field-name :initarg :csrf-field-name :initform "_token" :type (or null string) :accessor form-csrf-field-name :documentation "csrf field name") (errors :initform nil :accessor form-errors :type list :documentation "Form errors after validation. An association list with elements ( . ).") (display-errors :initarg :display-errors :initform (list :list :inline) :type list :accessor display-errors :documentation "A list containing the places where to display errors. Valid options are :list and :inline") (client-validation :initarg :client-validation :initform nil :type boolean :accessor client-validation :documentation "When T, form client validation is enabled")) (:documentation "A form")) (defmethod print-object ((form form) stream) (print-unreadable-object (form stream :type t :identity t) (format stream "~A~@{ ~A~}" (form-name form) (form-action form)))) (defmethod initialize-instance :after ((form form) &rest initargs) (declare (ignorable initargs)) (loop for field in (form-fields form) do (setf (field-form (cdr field)) form))) (defun post-parameters (&optional (request (backend-request))) (let ((post-parameters (request-post-parameters request))) (if *base64-encode* (mapcar (lambda (param) (cons (base64:base64-string-to-string (car param) :uri t) (cdr param))) post-parameters) post-parameters))) ;; csrf api (defgeneric set-form-session-csrf-token (form) (:documentation "Set csrf token for FORM and return the token.")) (defclass form-field () ((name :initarg :name :initform (error "Provide the field name") :accessor field-name :type symbol :documentation "The field name") (label :initarg :label :initform nil :type (or null string) :documentation "The field label") (value :initarg :value :initform nil :documentation "Field value") (default-value :initarg :default-value :initform nil :accessor field-default-value :documentation "Value to use when the field value is nil") (placeholder :initarg :placeholder :accessor field-placeholder :type (or null string) :initform nil :documentation "Field placeholder (text that appears when the field is empty)") (help-text :initarg :help-text :initform nil :type (or null string) :accessor field-help-text :documentation "Field help text") (parser :initarg :parser :initform nil :accessor field-parser :type (or null symbol function) :documentation "Custom field value parser") (formatter :initarg :formatter :initform nil :type (or null symbol function) :accessor field-formatter :documentation "The field formatter. The function takes two arguments, a VALUE and STREAM to format it into.") (constraints :initarg :constraints :initform nil :accessor field-constraints :documentation "A list of CLAVIER validators.") (required :initarg :required-p :initform t :type boolean :accessor field-required-p :documentation "Whether the field is required") (required-message :initarg :required-message :initform *field-required-message* :accessor field-required-message :type (or null string) :documentation "Message to display when field is required") (invalid-message :initarg :invalid-message :initform nil :accessor field-invalid-message :type (or null string function) :documentation "Message to display when field is invalid") (read-only :initarg :read-only-p :initform nil :type boolean :accessor field-read-only-p :documentation "Whether the field is read only") (disabled :initarg :disabled-p :initform nil :type boolean :accessor field-disabled-p :documentation "Whether the field is disabled") (accessor :initarg :accessor :initform nil :type (or null symbol) :accessor field-accessor :documentation "The field accessor to the underlying model") (reader :initarg :reader :initform nil :type (or null symbol function) :documentation "The function to use to read from the underlying model") (writer :initarg :writer :initform nil :type (or null symbol function) :documentation "The function to use to write to underlying model") (trim :initarg :trim-p :initform t :type boolean :accessor field-trim-p :documentation "Trim the input") (validation-triggers :initarg :validation-triggers :initform nil :accessor field-validation-triggers :documentation "Client side validation triggers. A list of :change, :focus, :focusout, :focusin, etc") (html-name :initarg :html-name :type string :documentation "The name to use in HTML. Unbound by default. If unbound, a string representation of FIELD-NAME is used. See FIELD-REQUEST-NAME.") (form :initarg :form :initform nil :type (or null form) :accessor field-form :documentation "The form the field belongs to")) (:documentation "A form field")) (defmethod print-object ((field form-field) stream) (print-unreadable-object (field stream :type t :identity t) (format stream "~S value: ~A" (field-name field) (field-value field)))) (defmethod field-label ((field form-field)) (or (slot-value field 'label) (str:sentence-case (princ-to-string (field-name field))))) (defun add-field (form field) (setf (form-fields form) (append (form-fields form) (list (cons (field-name field) field))))) (defun remove-field (form field) (setf (form-fields form) (remove (if (not (symbolp field)) (field-name field) field) (form-fields form) :key 'car))) (defmethod field-render-label-p ((field form-field)) t) (defvar *field-path* nil "This is the path in a tree of forms (subform fields) used to calculate its request name.") (defgeneric field-add-to-path (form-field form &optional path) (:method (form-field form &optional (path *field-path*)) (let ((html-name (if (slot-boundp form-field 'html-name) (slot-value form-field 'html-name) (string-downcase (string (field-name form-field)))))) (cons html-name path)))) (defun field-request-name (form-field form) (declare (ignorable form form-field)) (str:join #\. (remove-if #'str:emptyp (mapcar #'princ-to-string (reverse *field-path*))))) (defun render-field-request-name (form-field form) (let ((request-name (field-request-name form-field form))) (if *base64-encode* (base64:string-to-base64-string request-name :uri t) request-name))) (defmethod field-reader ((field form-field)) (or (slot-value field 'reader) (field-accessor field))) (defmethod field-writer ((field form-field)) (or (slot-value field 'writer) (and (field-accessor field) (fdefinition `(setf ,(field-accessor field)))))) (defmethod field-value ((field form-field)) (if (and (field-reader field) (form-model (field-form field))) (funcall (field-reader field) (form-model (field-form field))) (slot-value field 'value))) (defmethod field-value :around ((field form-field)) (or (call-next-method) (field-default-value field))) (defmethod (setf field-value) (value (field form-field)) (if (and (field-writer field) (form-model (field-form field))) (funcall (field-writer field) value (form-model (field-form field))) (setf (slot-value field 'value) value))) (defun fill-form-from-model (form model) "Fill a FORM from a MODEL. Read MODEL using FORM accessors and set the FORM field values." (loop for field in (mapcar 'cdr (forms::form-fields form)) when (forms:field-reader field) do (setf (forms:field-value field) (funcall (forms:field-reader field) model))) form) (defun fill-model-from-form (form model) "Set a MODEL's values from FORM field values." (loop for field in (mapcar 'cdr (forms::form-fields form)) when (forms:field-writer field) do (funcall (forms:field-writer field) (forms:field-value field) model))) (defclass field-validator (clavier::validator) ((field :initarg :field :initform (error "Provide the field") :accessor validator-field :documentation "The validator field")) (:metaclass closer-mop:funcallable-standard-class) (:documentation "Generic field validator. Needs a field to be initialized.")) (defun empty-value-p (val) (or (null val) (and (stringp val) (zerop (length val))))) (defgeneric validate-form-field (form-field)) (defmethod validate-form-field :around ((form-field form-field)) (cond ((and (field-required-p form-field) (empty-value-p (field-value form-field))) (values nil (list (format nil (or (field-required-message form-field) "~A is required") (or (field-label form-field) (field-name form-field)))))) ((and (not (field-required-p form-field)) (empty-value-p (field-value form-field))) (values t nil)) (t (call-next-method)))) (defmethod validate-form-field ((form-field form-field)) (let ((errors nil)) (loop for constraint in (field-constraints form-field) do (multiple-value-bind (valid-p error-msg) (funcall constraint (field-value form-field)) (when (not valid-p) (push error-msg errors)))) (values (not errors) errors))) (defmethod field-valid-p ((form-field form-field) &optional (form *form*)) "Determines if a field is valid. This method assumes the form has already been validated via validate-form." (not (find form-field (form-errors form) :key 'car))) (defgeneric format-field-value (form-field field-value &optional stream)) (defmethod format-field-value :around ((form-field form-field) field-value &optional (stream *standard-output*)) (if (field-formatter form-field) (funcall (field-formatter form-field) field-value stream) (call-next-method))) (defmethod format-field-value ((form-field form-field) field-value &optional (stream *standard-output*)) (format stream "~A" field-value)) (defun format-field-value-to-string (form-field &optional (field-value (field-value form-field))) (with-output-to-string (s) (format-field-value form-field field-value s))) (defmethod form-session-csrf-entry ((form form)) (alexandria:make-keyword (format nil "~A-CSRF-TOKEN" (form-name form)))) (defun validate-form (&optional (form *form*)) "Validates a form. Usually called after HANDLE-REQUEST. Returns multiple values; first value is true if the form is valid; second value a list of errors. The list of errors is an association list with elements ( . )." (setf (form-errors form) (loop for field in (form-fields form) appending (multiple-value-bind (valid-p errors) (validate-form-field (cdr field)) (when (not valid-p) (list (cons (cdr field) errors)))))) (values (null (form-errors form)) (form-errors form))) (defun form-valid-p (form) (null (form-errors form))) (defun add-form-error (field error-msg &optional (form *form*)) "Add an error on FIELD" (let ((field (if (symbolp field) (forms::get-field form field) field))) (if (assoc field (form-errors form)) (push error-msg (cdr (assoc field (form-errors form)))) (push (cons field (list error-msg)) (form-errors form))))) (defun render-form (&optional (form *form*) &rest args) "Top level function to render the web form FORM. *FORM-RENDERER* and *FORM-THEME* need to be bound. See: WITH-FORM-RENDERER, WITH-FORM-THEME" (apply #'renderer-render-form (or *form-renderer* (error "Form renderer is unbound. See *FORM-RENDERER* and WITH-FORM-RENDERER.")) *form-theme* form args)) (defun render-form-start (&optional (form *form*) &rest args) "Render only the beggining of the web form FORM. Use RENDER-FIELD, RENDER-FIELD-LABEL, etc manually, after." (apply #'renderer-render-form-start (or *form-renderer* (error "Form renderer is unbound. See *FORM-RENDERER* and WITH-FORM-RENDERER.")) *form-theme* form args)) (defun render-form-end (&optional (form *form*)) "Render the end of the web form FORM." (renderer-render-form-end *form-renderer* *form-theme* form)) (defun render-form-errors (&optional (form *form*) &rest args) "Render a section for displaying form validation errors." (when (member :list (display-errors form)) (apply #'renderer-render-form-errors *form-renderer* *form-theme* form args))) (defun render-field (field &optional (form *form*) &rest args) "Render form FIELD, both label and widget." (let ((field (if (symbolp field) (get-field form field) field))) (apply #'renderer-render-field *form-renderer* *form-theme* field form args))) (defun render-field-label (field &optional (form *form*) &rest args) "Render the label of FIELD." (let ((field (if (symbolp field) (get-field form field) field))) (unless (typep field 'submit-form-field) (apply #'renderer-render-field-label *form-renderer* *form-theme* field form args)))) (defun render-field-errors (field &optional (form *form*) &rest args) "Render the validation errors associated with FIELD." (let ((field (if (symbolp field) (get-field form field) field))) (apply #'renderer-render-field-errors *form-renderer* *form-theme* field form args))) (defun render-field-widget (field &optional (form *form*) &rest args) "Render FIELD widget." (let ((field (if (symbolp field) (get-field form field) field))) (apply #'renderer-render-field-widget *form-renderer* *form-theme* field form args))) (defgeneric renderer-render-form (renderer theme form &rest args)) (defgeneric renderer-render-form-start (renderer theme form &rest args)) (defgeneric renderer-render-form-end (renderer theme form)) (defgeneric renderer-render-form-errors (renderer theme form &rest args)) (defgeneric renderer-render-field (renderer theme field form &rest args)) (defgeneric renderer-render-field-label (renderer theme field form &rest args)) (defgeneric renderer-render-field-errors (renderer theme field form &rest args)) (defgeneric renderer-render-field-widget (renderer theme field form &rest argss)) (defmethod renderer-render-field-errors :around (renderer theme field form &rest args) (declare (ignorable args)) ;; Render field errors inline only when :inline is specified on form (when (member :inline (forms::display-errors form)) (call-next-method))) (defmethod renderer-render-form-errors :around (renderer theme form &rest args) (declare (ignorable args)) ;; Render form errors only when :list is specified on form (when (member :list (forms::display-errors form)) (call-next-method))) (defmethod renderer-render-field-widget :around (renderer theme field form &rest args) (declare (ignorable args)) (let ((*field-path* (field-add-to-path field form *field-path*))) (call-next-method))) (defgeneric backend-request () (:documentation "Return current request object for backend")) (defgeneric backend-handle-request (form request) (:documentation "Handle REQUEST for FORM. Populates FORM from parameters in HTTP request. After this, the form field contains values, but they are not validated. To validate call VALIDATE-FORM after.")) ;; Abstract away the HTTP request, so we can make cl-forms testable (defgeneric request-post-parameters (request)) (defun handle-request (&optional (form *form*) (request (backend-request))) "Populates FORM from parameters in HTTP request. After this, the form field contains values, but they are not validated. To validate call VALIDATE-FORM after." (backend-handle-request form request)) (defgeneric field-read-from-request (field form parameters)) (defmethod field-read-from-request :around (field form parameters) (let ((*field-path* (field-add-to-path field form *field-path*))) (call-next-method))) (defmethod field-read-from-request :after (field form parameters) "Use the parser function after reading the value from the request" (when (field-parser field) (setf (field-value field) (funcall (field-parser field) (field-value field))))) (defgeneric make-form-field (field-type &rest args)) (defmethod make-form-field (field-type &rest args) "Provide a good error message when a field cannot be built" (if (and (symbolp field-type) (not (keywordp field-type))) (apply #'make-instance field-type args) ;; else (error "Form field of type ~s not defined. Check that ~s is a correct field type." field-type field-type))) ;; Form templates (defun collect-replace-fields (form) (let (fields) (let ((new-form (%collect-replace-fields form (lambda (field) (push field fields))))) (values new-form fields)))) (defun %collect-replace-fields (form collect-field) (if (atom form) form (if (eql (first form) 'forms:form-field) (progn (funcall collect-field (cdr form)) `(forms:render-field ',(second form))) (loop for part in form collect (%collect-replace-fields part collect-field))))) (defmacro with-form-template ((&optional form-var) form-name args &body body) "Define a FORM named FORM-NAME and render it at the same time." (multiple-value-bind (new-body fields) (collect-replace-fields body) (let ((form-bind (or form-var (gensym "FORM")))) `(progn (defform ,form-name ,args ,fields) (let ((,form-bind (or ,form-var (find-form ',form-name)))) (with-form ,form-bind (render-form-start) ,@new-body (render-form-end))))))) (defun make-formatter (symbol) "Create a field formatter. SYMBOL is the function to call." (lambda (x stream) (format stream "~A" (funcall symbol x)))) ;; Type declarations (declaim (ftype (function (form t) (values t &optional)) fill-form-from-model) (ftype (function (form t) (values null &optional)) fill-model-from-form) (ftype (function (form) list) form-fields) (ftype (function (&optional form &rest t) string) render-form) (ftype (function (&optional form &rest t) string) render-form-errors) (ftype (function (form-field &optional form &rest t) string) render-field-errors) (ftype (function (form-field) *) field-value) (ftype (function (form-field) (or null symbol)) field-accessor) (ftype (function ((or symbol form-field) &optional form &rest t) string) render-field) (ftype (function (form-field) string) field-label) (ftype (function (form symbol &optional boolean) (or form-field null)) get-field) (ftype (function (form-field) (or null symbol function)) field-parser) (ftype (function (form-field &optional t) (values simple-string &optional)) cl-forms:format-field-value-to-string) (ftype (function (form (or symbol form-field) &optional boolean) *) cl-forms:get-field-value) (ftype function cl-forms:render-form-start) (ftype (function (&optional t) *) cl-forms:render-form-end) (ftype (function (t) (values function &optional)) cl-forms:make-formatter) (ftype (function (&optional form) (values boolean t &optional)) cl-forms:validate-form) (ftype (function (form-field t &optional t) *) cl-forms:format-field-value) (ftype (function (form) boolean) cl-forms:form-valid-p) (ftype (function (t) *) cl-forms:field-formatter) (ftype (function (form) list) form-errors) (ftype (function (symbol &rest t) form) get-form find-form) (ftype (function (t &rest t) *) cl-forms:render-field-widget) (ftype (function (t) *) cl-forms:field-reader) (ftype (function (t) *) cl-forms:field-writer) (ftype (function ((or symbol form-field) &optional form) boolean) cl-forms:field-valid-p) (ftype (function ((or symbol form-field) &optional form &rest t) *) cl-forms:render-field-label) (ftype (function (t t &optional t) *) cl-forms:add-form-error) (ftype (function (t t t) *) cl-forms:set-field-value) (ftype (function (form (or symbol form-field)) *) remove-field) (ftype (function (form form-field) *) cl-forms:add-field))