Source: server/routes/fields.js

/**
 *
 * Field Router
 */

const Router = require("express-promise-router");

const { getState } = require("@saltcorn/data/db/state");
const { renderForm } = require("@saltcorn/markup");
const Field = require("@saltcorn/data/models/field");
const Table = require("@saltcorn/data/models/table");
const Form = require("@saltcorn/data/models/form");
const Workflow = require("@saltcorn/data/models/workflow");
const User = require("@saltcorn/data/models/user");
const {
  expressionValidator,
  get_async_expression_function,
  get_expression_function,
} = require("@saltcorn/data/models/expression");
const db = require("@saltcorn/data/db");

const { setTenant, isAdmin, error_catcher } = require("./utils.js");
const expressionBlurb = require("../markup/expression_blurb");
const { readState } = require("@saltcorn/data/plugin-helper");
const { wizardCardTitle } = require("../markup/forms.js");
const router = new Router();
module.exports = router;

const fieldForm = async (req, fkey_opts, existing_names, id, hasData) => {
  let isPrimary = false;
  let primaryTypes = Object.entries(getState().types)
    .filter(([k, v]) => v.primaryKey)
    .map(([k, v]) => k);
  if (id) {
    const field = await Field.findOne({ id });
    if (field) {
      isPrimary = !!field.primary_key;
    }
  }
  return new Form({
    action: "/field",
    validator: (vs) => {
      if (vs.calculated && vs.type == "File")
        return req.__("Calculated fields cannot have File type");
      if (vs.calculated && vs.type.startsWith("Key to"))
        return req.__("Calculated fields cannot have Key type");
    },
    fields: [
      new Field({
        label: req.__("Label"),
        name: "label",
        sublabel: req.__("Name of the field"),
        input_type: "text",
        validator(s) {
          if (!s || s === "") return req.__("Missing label");
          if (!id && existing_names.includes(Field.labelToName(s)))
            return req.__("Column %s already exists", s);
        },
      }),
      // description
      new Field({
        label: req.__("Description"),
        name: "description",
        sublabel: req.__("Description allows to give more information about field"),
        input_type: "text",
      }),
      new Field({
        label: req.__("Type"),
        name: "type",
        sublabel:
            req.__("The type determines the kind of data that can be stored in the field"),
        input_type: "select",
        options: isPrimary
          ? primaryTypes
          : getState().type_names.concat(fkey_opts || []),
        disabled:
          !!id &&
          !getState().getConfig("development_mode", false) &&
          (hasData || db.isSQLite),
      }),
      new Field({
        label: req.__("Calculated"),
        name: "calculated",
        sublabel: req.__("Calculated from other fields with a formula"),
        type: "Bool",
        disabled: !!id,
      }),
      new Field({
        label: req.__("Required"),
        name: "required",
        type: "Bool",
        sublabel: req.__("There must be a value in every row"),
        disabled: !!id && db.isSQLite,
        showIf: { calculated: false },
      }),
      new Field({
        label: req.__("Unique"),
        name: "is_unique",
        sublabel: req.__("Different rows must have different values for this field"),
        showIf: { calculated: false },
        type: "Bool",
      }),

      new Field({
        label: req.__("Stored"),
        name: "stored",
        type: "Bool",
        disabled: !!id,
        showIf: { calculated: true },
      }),
    ],
  });
};
const calcFieldType = (ctxType) =>
  ctxType.startsWith("Key to")
    ? { type: "Key", reftable_name: ctxType.replace("Key to ", "") }
    : { type: ctxType };

const translateAttributes = (attrs, req) =>
  Array.isArray(attrs)
    ? attrs.map((attr) => translateAttribute(attr, req))
    : attrs;

const translateAttribute = (attr, req) => {
  const res = { ...attr };
  if (res.sublabel) res.sublabel = req.__(res.sublabel);
  return res;
};
const fieldFlow = (req) =>
  new Workflow({
    action: "/field",
    onDone: async (context) => {
      const thetype = getState().types[context.type];
      var attributes = context.attributes || {};
      attributes.default = context.default;
      attributes.summary_field = context.summary_field;
      const {
        table_id,
        name,
        label,
        required,
        is_unique,
        calculated,
        expression,
        stored,
        description,
      } = context;
      const { reftable_name, type } = calcFieldType(context.type);
      const fldRow = {
        table_id,
        name,
        label,
        type,
        required,
        is_unique,
        reftable_name,
        attributes,
        calculated,
        expression,
        stored,
        description,
      };
      if (fldRow.calculated) {
        fldRow.is_unique = false;
        fldRow.required = false;
      }
      if (context.id) {
        const field = await Field.findOne({ id: context.id });
        try {
          await field.update(fldRow);
        } catch (e) {
          return {
            redirect: `/table/${context.table_id}`,
            flash: ["error", e.message],
          };
        }
      } else {
        try {
          await Field.create(fldRow);
        } catch (e) {
          return {
            redirect: `/table/${context.table_id}`,
            flash: ["error", e.message],
          };
        }
      }

      return {
        redirect: `/table/${context.table_id}`,
        flash: [
          "success",
          context.id
            ? req.__("Field %s saved", label)
            : req.__("Field %s created", label),
        ],
      };
    },
    steps: [
      {
        name: req.__("Basic properties"),
        form: async (context) => {
          const tables = await Table.find({});
          const table = tables.find((t) => t.id === context.table_id);
          const nrows = await table.countRows({});
          const existing_fields = await table.getFields();
          const existingNames = existing_fields.map((f) => f.name);
          const fkey_opts = ["File", ...tables.map((t) => `Key to ${t.name}`)];
          const form = await fieldForm(
            req,
            fkey_opts,
            existingNames,
            context.id,
            nrows > 0
          );
          if (context.type === "Key" && context.reftable_name) {
            form.values.type = `Key to ${context.reftable_name}`;
          }
          return form;
        },
      },
      {
        name: req.__("Attributes"),
        contextField: "attributes",
        onlyWhen: (context) => {
          if (context.calculated) return false;
          if (context.type === "File") return true;
          if (new Field(context).is_fkey) return false;
          const type = getState().types[context.type];
          return type.attributes && type.attributes.length > 0;
        },
        form: async (context) => {
          if (context.type === "File") {
            const roles = await User.get_roles();
            return new Form({
              fields: [
                {
                  name: "min_role_read",
                  label: req.__("Role required to access added files"),
                  sublabel: req.__(
                    "The user uploading the file has access irrespective of their role"
                  ),
                  input_type: "select",
                  options: roles.map((r) => ({ value: r.id, label: r.role })),
                },
              ],
            });
          } else {
            const type = getState().types[context.type];
            return new Form({
              validator(vs) {
                if (type.validate_attributes) {
                  const res = type.validate_attributes(vs);
                  if (!res) return req.__("Invalid attributes");
                }
              },
              fields: translateAttributes(type.attributes, req),
            });
          }
        },
      },
      {
        name: req.__("Expression"),
        onlyWhen: (context) => context.calculated,
        form: async (context) => {
          const table = await Table.findOne({ id: context.table_id });
          const fields = await table.getFields();
          return new Form({
            blurb: expressionBlurb(context.type, context.stored, fields, req),
            fields: [
              new Field({
                name: "expression",
                label: req.__("Formula"),
                // todo sublabel
                type: "String",
                validator: expressionValidator,
              }),
              new Field({
                name: "test_btn",
                label: req.__("Test"),
                // todo sublabel
                input_type: "custom_html",
                attributes: {
                  html: `<button type="button" id="test_formula_btn" onclick="test_formula('${
                    table.name
                  }', ${JSON.stringify(
                    context.stored
                  )})" class="btn btn-outline-secondary">${req.__(
                    "Test"
                  )}</button>
                  <div id="test_formula_output"></div>`,
                },
              }),
            ],
          });
        },
      },
      {
        name: req.__("Summary"),
        onlyWhen: (context) =>
          context.type !== "File" && new Field(context).is_fkey,
        form: async (context) => {
          const fld = new Field(context);
          const table = await Table.findOne({ name: fld.reftable_name });
          const fields = await table.getFields();
          const orderedFields = [
            ...fields.filter((f) => !f.primary_key),
            ...fields.filter((f) => f.primary_key),
          ];
          const keyfields = orderedFields
            .filter((f) => !f.calculated || f.stored)
            .map((f) => ({
              value: f.name,
              label: f.label,
            }));
          return new Form({
            fields: [
              new Field({
                name: "summary_field",
                label: req.__("Summary field"),
                input_type: "select",
                options: keyfields,
              }),
            ],
          });
        },
      },
      {
        name: req.__("Default"),
        onlyWhen: async (context) => {
          if (!context.required || context.id || context.calculated)
            return false;
          const table = await Table.findOne({ id: context.table_id });
          const nrows = await table.countRows();
          return nrows > 0;
        },
        form: async (context) => {
          const formfield = new Field({
            name: "default",
            label: req.__("Default"),
            // todo sublabel
            type: context.type,
            required: true,
            attributes: {
              summary_field: context.summary_field,
              ...(context.attributes || {}),
            },
          });
          await formfield.fill_fkey_options();
          return new Form({
            blurb: req.__(
              "A default value is required when adding required fields to nonempty tables"
            ),
            fields: [formfield],
          });
        },
      },
    ],
  });
router.get(
  "/:id",
  setTenant,
  isAdmin,
  error_catcher(async (req, res) => {
    const { id } = req.params;
    const field = await Field.findOne({ id });
    const table = await Table.findOne({ id: field.table_id });
    const wf = fieldFlow(req);
    const wfres = await wf.run(
      {
        ...field.toJson,
        ...field.attributes,
      },
      req
    );
    res.sendWrap(req.__(`Edit field`), {
      above: [
        {
          type: "breadcrumbs",
          crumbs: [
            { text: req.__("Tables"), href: "/table" },
            { href: `/table/${table.id}`, text: table.name },
            { text: req.__(`Edit %s field`, field.label) },
            { workflow: wf, step: wfres },
          ],
        },
        {
          type: "card",
          title: wizardCardTitle(field.label, wf, wfres),
          contents: renderForm(wfres.renderForm, req.csrfToken()),
        },
      ],
    });
  })
);

router.get(
  "/new/:table_id",
  setTenant,
  isAdmin,
  error_catcher(async (req, res) => {
    const { table_id } = req.params;
    const table = await Table.findOne({ id: table_id });
    const wf = fieldFlow(req);
    const wfres = await wf.run({ table_id: +table_id }, req);
    res.sendWrap(req.__(`New field`), {
      above: [
        {
          type: "breadcrumbs",
          crumbs: [
            { text: req.__("Tables"), href: "/table" },
            { href: `/table/${table.id}`, text: table.name },
            { text: req.__(`Add field`) },
            //{ text: wfres.stepName },
            { workflow: wf, step: wfres },
          ],
        },
        {
          type: "card",
          title: wizardCardTitle(req.__(`New field`), wf, wfres),
          contents: renderForm(wfres.renderForm, req.csrfToken()),
        },
      ],
    });
  })
);

router.post(
  "/delete/:id",
  setTenant,
  isAdmin,
  error_catcher(async (req, res) => {
    const { id } = req.params;
    const f = await Field.findOne({ id });
    const table_id = f.table_id;

    await f.delete();
    req.flash("success", req.__(`Field %s deleted`, f.label));
    res.redirect(`/table/${table_id}`);
  })
);

router.post(
  "/",
  setTenant,
  isAdmin,
  error_catcher(async (req, res) => {
    const wf = fieldFlow(req);
    const wfres = await wf.run(req.body, req);
    if (wfres.renderForm) {
      const table = await Table.findOne({ id: wfres.context.table_id });
      res.sendWrap(req.__(`Field attributes`), {
        above: [
          {
            type: "breadcrumbs",
            crumbs: [
              { text: req.__("Tables"), href: "/table" },
              { href: `/table/${table.id}`, text: table.name },
              {
                text: req.__(
                  `Edit %s field`,
                  wfres.context.label || req.__("new")
                ),
              },
              { workflow: wf, step: wfres },
            ],
          },
          {
            type: "card",
            title: wizardCardTitle(
              wfres.context.label || req.__("New field"),
              wf,
              wfres
            ),
            contents: renderForm(wfres.renderForm, req.csrfToken()),
          },
        ],
      });
    } else {
      if (wfres.flash) req.flash(...wfres.flash);
      res.redirect(wfres.redirect);
    }
  })
);

router.post(
  "/test-formula",
  setTenant,
  isAdmin,
  error_catcher(async (req, res) => {
    const { formula, tablename, stored } = req.body;
    const table = await Table.findOne({ name: tablename });
    const fields = await table.getFields();
    const rows = await table.getRows({}, { orderBy: "RANDOM()", limit: 1 });
    if (rows.length < 1) return "No rows in table";
    let result;
    try {
      if (stored) {
        const f = get_async_expression_function(formula, fields);
        result = await f(rows[0]);
      } else {
        const f = get_expression_function(formula, fields);
        result = f(rows[0]);
      }
      res.send(
        `Result of running on row with id=${
          rows[0].id
        } is: <pre>${JSON.stringify(result)}</pre>`
      );
    } catch (e) {
      return res.send(
        `Error on running on row with id=${rows[0].id}: ${e.message}`
      );
    }
  })
);
router.post(
  "/show-calculated/:tableName/:fieldName/:fieldview",
  setTenant,
  isAdmin,
  error_catcher(async (req, res) => {
    const { tableName, fieldName, fieldview } = req.params;
    const table = await Table.findOne({ name: tableName });
    const fields = await table.getFields();
    const field = fields.find((f) => f.name === fieldName);
    const formula = field.expression;
    const row = { ...req.body };
    readState(row, fields);
    let result;
    try {
      if (field.stored) {
        const f = get_async_expression_function(formula, fields);
        result = await f(row);
      } else {
        const f = get_expression_function(formula, fields);
        result = f(row);
      }
      const fv = field.type.fieldviews[fieldview];
      res.send(fv.run(result));
    } catch (e) {
      return res.status(400).send(`Error: ${e.message}`);
    }
  })
);

router.post(
  "/preview/:tableName/:fieldName/:fieldview",
  setTenant,
  isAdmin,
  error_catcher(async (req, res) => {
    const { tableName, fieldName, fieldview } = req.params;
    const table = await Table.findOne({ name: tableName });
    const fields = await table.getFields();
    let field, row, value;
    if (fieldName.includes(".")) {
      const [refNm, targetNm] = fieldName.split(".");
      const ref = fields.find((f) => f.name === refNm);
      const reftable = await Table.findOne({ name: ref.reftable_name });
      const reffields = await reftable.getFields();
      field = reffields.find((f) => f.name === targetNm);
      row = await reftable.getRow({});
      value = row && row[targetNm];
    } else {
      field = fields.find((f) => f.name === fieldName);
      row = await table.getRow({});
      value = row && row[fieldName];
    }

    const configuration = req.body.configuration;
    if (!field) {
      res.send("");
      return;
    }
    const fieldviews =
      field.type === "Key"
        ? getState().keyFieldviews
        : field.type === "File"
        ? getState().fileviews
        : field.type.fieldviews;
    if (!field.type || !fieldviews) {
      res.send("");
      return;
    }
    const fv = fieldviews[fieldview];
    if (!fv && field.type === "Key" && fieldview === "select")
      res.send("<select disabled></select>");
    else if (!fv) res.send("");
    else if (fv.isEdit)
      res.send(
        fv.run(
          field.name,
          undefined,
          { disabled: true, ...configuration },
          "",
          false,
          field
        )
      );
    else if (field.type === "File") {
      res.send(fv.run(value, "filename.ext"));
    } else res.send(fv.run(value, req, configuration));
  })
);

router.post(
  "/preview/:tableName/:fieldName/",
  setTenant,
  isAdmin,
  error_catcher(async (req, res) => {
    res.send("");
  })
);