Source: saltcorn-data/models/view.js

/**
 *
 * View Data Access Layer
 */

const db = require("../db");
const Form = require("../models/form");
const { contract, is } = require("contractis");
const { fieldlike, is_viewtemplate, is_tablely } = require("../contracts");
const {
  removeEmptyStrings,
  numberToBool,
  stringToJSON,
  InvalidConfiguration,
  satisfies,
  structuredClone,
} = require("../utils");
const { remove_from_menu } = require("./config");
const { div } = require("@saltcorn/markup/tags");
const { renderForm } = require("@saltcorn/markup");

/**
 * View Class
 */
class View {
  constructor(o) {
    this.name = o.name;
    this.id = o.id;
    this.viewtemplate = o.viewtemplate;
    this.exttable_name = o.exttable_name;
    this.description = o.description;
    if (o.table_id) this.table_id = o.table_id;
    if (o.table && !o.table_id) {
      this.table_id = o.table.id;
    }
    if (o.table_name) this.table_name = o.table_name;
    this.configuration = stringToJSON(o.configuration);
    this.min_role =
      !o.min_role && typeof o.is_public !== "undefined"
        ? o.is_public
          ? 10
          : 8
        : +o.min_role;
    const { getState } = require("../db/state");
    this.viewtemplateObj = getState().viewtemplates[this.viewtemplate];
    this.default_render_page = o.default_render_page;
    contract.class(this);
  }
  static findOne(where) {
    const { getState } = require("../db/state");
    const v = getState().views.find(
      where.id
        ? (v) => v.id === +where.id
        : where.name
        ? (v) => v.name === where.name
        : satisfies(where)
    );
    return v
      ? new View({ ...v, configuration: structuredClone(v.configuration) })
      : v;
  }

  /**
   *
   * @param where
   * @param selectopts
   * @returns {Promise<*>}
   */
  static async find(where, selectopts = { orderBy: "name", nocase: true }) {
    const views = await db.select("_sc_views", where, selectopts);

    return views.map((v) => new View(v));
  }

  /**
   *
   * @returns {Promise<*|*[]>}
   */
  async get_state_fields() {
    if (this.viewtemplateObj.get_state_fields) {
      return await this.viewtemplateObj.get_state_fields(
        this.table_id,
        this.name,
        this.configuration
      );
    } else return [];
  }

  /**
   * Get menu label
   * @returns {*|undefined}
   */
  get menu_label() {
    const { getState } = require("../db/state");
    const menu_items = getState().getConfig("menu_items", []);
    const item = menu_items.find((mi) => mi.viewname === this.name);
    return item ? item.label : undefined;
  }

  /**
   *
   * @param table
   * @param pred
   * @returns {Promise<*[]>}
   */
  static async find_table_views_where(table, pred) {
    var link_view_opts = [];
    const link_views = await View.find(
      table.id
        ? {
            table_id: table.id,
          }
        : table.name
        ? { exttable_name: table.name }
        : typeof table === "string"
        ? { exttable_name: table }
        : { table_id: table },
      { orderBy: "name", nocase: true }
    );

    for (const viewrow of link_views) {
      // may fail if incomplete view
      const sfs = await viewrow.get_state_fields();
      if (
        pred({
          viewrow,
          viewtemplate: viewrow.viewtemplateObj,
          state_fields: sfs,
        })
      )
        link_view_opts.push(viewrow);
    }
    return link_view_opts;
  }

  get select_option() {
    return {
      name: this.name,
      label: `${this.name} [${this.viewtemplate}${
        this.table
          ? ` ${this.table.name}`
          : this.table_name
          ? ` ${this.table_name}`
          : ""
      }]`,
    };
  }

  static async find_all_views_where(pred) {
    var link_view_opts = [];
    const link_views = await View.find({}, { orderBy: "name", nocase: true });

    for (const viewrow of link_views) {
      // may fail if incomplete view
      const sfs = await viewrow.get_state_fields();
      if (
        pred({
          viewrow,
          viewtemplate: viewrow.viewtemplateObj,
          state_fields: sfs,
        })
      )
        link_view_opts.push(viewrow);
    }
    return link_view_opts;
  }

  static async find_possible_links_to_table(table) {
    return View.find_table_views_where(table, ({ state_fields }) =>
      state_fields.some((sf) => sf.name === "id" || sf.primary_key)
    );
  }

  /**
   * Create view in database
   * @param v
   * @returns {Promise<View>}
   */
  // todo there hard code about roles and flag is_public
  static async create(v) {
    // is_public flag processing
    if (!v.min_role && typeof v.is_public !== "undefined") {
      v.min_role = v.is_public ? 10 : 8;
      delete v.is_public;
    }
    // insert view defintion into _sc_views
    const id = await db.insert("_sc_views", v);
    // refresh views list cache
    await require("../db/state").getState().refresh_views();
    return new View({ id, ...v });
  }

  /**
   * Clone View
   * @returns {Promise<View>}
   */
  async clone() {
    const basename = this.name + " copy";
    let newname;
    // todo there is hard code linmitation about 100 copies of veiew
    for (let i = 0; i < 100; i++) {
      newname = i ? `${basename} (${i})` : basename;
      const existing = await View.findOne({ name: newname });
      if (!existing) break;
    }
    const createObj = {
      ...this,
      name: newname,
    };
    delete createObj.viewtemplateObj;
    delete createObj.id;
    return await View.create(createObj);
  }

  /**
   * Delete current view from db
   * @returns {Promise<void>}
   */
  async delete() {
    if (this.viewtemplateObj && this.viewtemplateObj.on_delete)
      await this.viewtemplateObj.on_delete(
        this.table_id,
        this.name,
        this.configuration
      );
    // delete view from _sc_view
    await db.deleteWhere("_sc_views", { id: this.id });
    // remove view from menu
    await remove_from_menu({ name: this.name, type: "View" });
    // fresh view list cache
    await require("../db/state").getState().refresh_views();
  }

  /**
   * Delete list of views
   * @param where - condition
   * @returns {Promise<void>}
   */
  static async delete(where) {
    const vs = await View.find(where);
    for (const v of vs) await v.delete();
  }

  /**
   * Update View description
   * @param v - view name
   * @param id - id
   * @returns {Promise<void>}
   */
  static async update(v, id) {
    // update view description
    await db.update("_sc_views", v, id);
    // fresh view list cache
    await require("../db/state").getState().refresh_views();
  }

  async authorise_post(arg) {
    if (!this.viewtemplateObj.authorise_post) return false;
    return await this.viewtemplateObj.authorise_post(arg);
  }
  async authorise_get(arg) {
    if (!this.viewtemplateObj.authorise_get) return false;
    return await this.viewtemplateObj.authorise_get(arg);
  }

  /**
   * Run (Execute) View
   * @param query
   * @param extraArgs
   * @returns {Promise<*>}
   */
  async run(query, extraArgs) {
    return await this.viewtemplateObj.run(
      this.exttable_name || this.table_id,
      this.name,
      this.configuration,
      removeEmptyStrings(query),
      extraArgs
    );
  }

  async run_possibly_on_page(query, req, res) {
    const view = this;
    if (view.default_render_page && (!req.xhr || req.headers.pjaxpageload)) {
      const Page = require("../models/page");
      const db_page = await Page.findOne({ name: view.default_render_page });
      if (db_page) {
        const contents = await db_page.run(query, { res, req });
        return contents;
      }
    }
    const state = view.combine_state_and_default_state(query);
    const resp = await view.run(state, { res, req });
    const state_form = await view.get_state_form(state, req);
    const contents = div(
      state_form ? renderForm(state_form, req.csrfToken()) : "",
      resp
    );
    return contents;
  }

  async runMany(query, extraArgs) {
    if (this.viewtemplateObj.runMany)
      return await this.viewtemplateObj.runMany(
        this.table_id,
        this.name,
        this.configuration,
        query,
        extraArgs
      );
    if (this.viewtemplateObj.renderRows) {
      const Table = require("./table");
      const { stateFieldsToWhere } = require("../plugin-helper");

      const tbl = await Table.findOne({ id: this.table_id });
      const fields = await tbl.getFields();
      const qstate = await stateFieldsToWhere({ fields, state: query });
      const rows = await tbl.getRows(qstate);
      const rendered = await this.viewtemplateObj.renderRows(
        tbl,
        this.name,
        this.configuration,
        extraArgs,
        rows
      );

      return rendered.map((html, ix) => ({ html, row: rows[ix] }));
    }

    throw new InvalidConfiguration(
      `runMany on view ${this.name}: viewtemplate ${this.viewtemplate} does not have renderRows or runMany methods`
    );
  }
  async runPost(query, body, extraArgs) {
    return await this.viewtemplateObj.runPost(
      this.table_id,
      this.name,
      this.configuration,
      removeEmptyStrings(query),
      removeEmptyStrings(body),
      extraArgs
    );
  }

  async runRoute(route, body, res, extraArgs) {
    const result = await this.viewtemplateObj.routes[route](
      this.table_id,
      this.name,
      this.configuration,
      body,
      extraArgs
    );
    if (result && result.json) res.json(result.json);
    else if (result && result.html) res.send(result.html);
    else res.json({ success: "ok" });
  }

  combine_state_and_default_state(req_query) {
    var state = { ...req_query };
    const defstate = this.viewtemplateObj.default_state_form
      ? this.viewtemplateObj.default_state_form(this.configuration)
      : {};

    Object.entries(defstate || {}).forEach(([k, v]) => {
      if (!state[k]) {
        state[k] = v;
      }
    });
    return state;
  }
  async get_state_form(query, req) {
    const vt_display_state_form = this.viewtemplateObj.display_state_form;
    const display_state_form =
      typeof vt_display_state_form === "function"
        ? vt_display_state_form(this.configuration)
        : vt_display_state_form;
    if (display_state_form) {
      const fields = await this.get_state_fields();

      fields.forEach((f) => {
        f.required = false;
        if (f.label === "Anywhere" && f.name === "_fts")
          f.label = req.__(f.label);
        if (f.type && f.type.name === "Bool") f.fieldview = "tristate";
        if (f.type && f.type.read && typeof query[f.name] !== "undefined") {
          query[f.name] = f.type.read(query[f.name]);
        }
      });
      const form = new Form({
        methodGET: true,
        action: `/view/${encodeURIComponent(this.name)}`,
        fields,
        submitLabel: req.__("Apply"),
        isStateForm: true,
        __: req.__,
        values: removeEmptyStrings(query),
      });
      await form.fill_fkey_options(true);
      return form;
    } else return null;
  }

  async get_config_flow(req) {
    const configFlow = this.viewtemplateObj.configuration_workflow(req);
    configFlow.action = `/viewedit/config/${encodeURIComponent(this.name)}`;
    const oldOnDone = configFlow.onDone || ((c) => c);
    configFlow.onDone = async (ctx) => {
      const { table_id, ...configuration } = await oldOnDone(ctx);

      await View.update({ configuration }, this.id);
      return {
        redirect: `/viewedit`,
        flash: ["success", `View ${this.name || ""} saved`],
      };
    };
    return configFlow;
  }
}

View.contract = {
  variables: {
    name: is.str,
    id: is.maybe(is.posint),
    table_id: is.maybe(is.posint),
    viewtemplate: is.str,
    min_role: is.posint,
    viewtemplateObj: is.maybe(is_viewtemplate),
    default_render_page: is.maybe(is.str),
  },
  methods: {
    get_state_fields: is.fun([], is.promise(is.array(fieldlike))),
    get_state_form: is.fun(
      [is.obj(), is.obj({ __: is.fun(is.str, is.str) })],
      is.promise(is.maybe(is.class("Form")))
    ),
    get_config_flow: is.fun(
      is.obj({ __: is.fun(is.str, is.str) }),
      is.promise(is.class("Workflow"))
    ),
    delete: is.fun([], is.promise(is.undefined)),
    menu_label: is.getter(is.maybe(is.str)),
    run: is.fun(
      [is.obj(), is.obj({ req: is.defined, res: is.defined })],
      is.promise(is.any)
    ),
    runPost: is.fun(
      [is.obj(), is.obj(), is.obj({ req: is.defined, res: is.defined })],
      is.promise(is.any)
    ),
    runRoute: is.fun(
      [
        is.str,
        is.obj(),
        is.obj(),
        is.obj({ req: is.defined, res: is.defined }),
      ],
      is.promise(is.any)
    ),
    runMany: is.fun(
      [is.obj(), is.obj({ req: is.defined, res: is.defined })],
      is.promise(is.array(is.obj({ html: is.defined, row: is.obj() })))
    ),
    combine_state_and_default_state: is.fun(is.obj(), is.obj()),
  },
  static_methods: {
    find: is.fun(
      [is.maybe(is.obj()), is.maybe(is.obj())],
      is.promise(is.array(is.class("View")))
    ),
    findOne: is.fun(is.obj(), is.maybe(is.class("View"))),
    create: is.fun(
      is.obj({
        name: is.str,
        table_id: is.maybe(is.posint),
        viewtemplate: is.str,
      }),
      is.promise(is.class("View"))
    ),
    update: is.fun([is.obj(), is.posint], is.promise(is.undefined)),
    delete: is.fun(is.obj(), is.promise(is.undefined)),

    find_possible_links_to_table: is.fun(
      is.or(is.posint, is_tablely, is.str),
      is.promise(is.array(is.class("View")))
    ),
    find_all_views_where: is.fun(
      is.fun(
        is.obj({
          viewrow: is.class("View"),
          viewtemplate: is.obj(),
          state_fields: is.array(fieldlike),
        }),
        is.bool
      ),
      is.promise(is.array(is.class("View")))
    ),
    find_table_views_where: is.fun(
      [
        is.or(is.posint, is_tablely, is.str),
        is.fun(
          is.obj({
            viewrow: is.class("View"),
            viewtemplate: is.obj(),
            state_fields: is.array(fieldlike),
          }),
          is.bool
        ),
      ],
      is.promise(is.array(is.class("View")))
    ),
  },
};
module.exports = View;