Source: domain/codes/useCodes.js

import { usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
import { Codebooks } from '../codebooks/Codebooks.js';
import { Codes } from './Codes.js';
import { Selections } from '../../Pages/coding/selections/Selections.js';
import { CodeList } from './CodeList.js';
import { createCodeSchema } from './createCodeSchema.js';

export const useCodes = () => {
  const page = usePage();
  const { allCodes, codebooks, projectId, source } = page.props;
  const sourceId = source.id;
  const key = `${projectId}-${sourceId}`;
  const codeStore = Codes.by(key);
  const codebookStore = Codebooks.by(projectId);
  const selectionStore = Selections.by(key);
  const initCoding = async () => {
    codebookStore.init(codebooks);
    codeStore.init(allCodes);
    selectionStore.init(source.selections, (id) => codeStore.entry(id));
  };

  //---------------------------------------------------------------------------
  // CREATE
  //---------------------------------------------------------------------------
  const createCode = async (options) => {
    ['title', 'color', 'codebookId'].forEach((key) => {
      if (!options[key]) {
        throw new Error(`${key} is required!`);
      }
    });

    const { response, error, code } = await Codes.create({
      projectId,
      source,
      ...options,
    });
    if (error) throw error;
    if (response.status < 400) {
      return code;
    }
  };
  //---------------------------------------------------------------------------
  // UPDATE
  //---------------------------------------------------------------------------
  const updateCode = async ({ id, ...value }) => {
    const entry = codeStore.entry(id);
    const diff = {};
    const restore = {};

    if (value.title !== entry.name) {
      diff.name = value.title;
      restore.name = entry.name;
    }
    if ((value.description ?? '') !== entry.description) {
      diff.description = value.description ?? '';
      restore.description = entry.description;
    }

    if (value.color !== entry.color) {
      diff.color = value.color;
      restore.color = entry.color;
    }

    if (Object.keys(diff).length === 0) {
      throw new Error(`Make at least one change in order to update`);
    }

    // optimistic UI
    codeStore.update(id, diff);
    const updatedCode = codeStore.entry(id);
    selectionStore.update((selections) => {
      const updated = [];
      selections.forEach((selection) => {
        if (selection.code.id === updatedCode.id) {
          selection.code = updatedCode;
          updated.push(selection);
        }
      });
      return updated;
    });

    const { error, response } = await Codes.update({
      projectId,
      code: entry,
      ...diff,
    });
    if (error) {
      codeStore.update(id, restore);
      throw error;
    }
    if (response.status >= 400) {
      codeStore.update(id, restore);
      throw new Error(response.data.message);
    }

    return true;
  };
  //---------------------------------------------------------------------------
  // DELETE
  //---------------------------------------------------------------------------
  const deleteCode = async (code) => {
    // here we do not optistic UI, because
    // adding-back will destroy the code-order
    const { response, error } = await Codes.delete({ projectId, source, code });
    if (error) throw error;
    if (response?.status >= 400) throw new Error(response.data.message);

    codeStore.remove(code.id);
    const selections = selectionStore
      .all()
      .filter((selection) => selection.code.id === code.id);
    selectionStore.remove(...selections.map((s) => s.id));
    return true;
  };

  /**
   *
   * @param codeId
   * @param parentId
   * @return {Promise<boolean>}
   */
  const addCodeToParent = async ({ codeId, parentId }) => {
    if (codeId === parentId) {
      throw new Error('Cannot make code its own parent!');
    }
    if (!codeId) {
      throw new Error('Cannot add code without id to a parent');
    }

    const code = codeStore.entry(codeId);
    const parent = parentId && codeStore.entry(parentId);

    // check legitimacy of this operation
    if (parent && !CodeList.dropAllowed(code, parent)) {
      throw new Error(
        `${code?.name} is not allowed to become a child of ${parent?.name}!`
      );
    }

    const oldParent = code.parent;
    //
    // // optimistic UI
    codeStore.update(code.id, { parent });
    // if (parent) {
    //   parent.children = parent.children ?? [];
    //   parent.children.push(code);
    // }
    // code.parent = parent;
    //
    // // TODO make optimistic UI procedures
    // //   a command-pattern that can be undone
    const rollback = () => {
      codeStore.update(code.id, { parent: oldParent });
      parent.children.pop();
      code.parent = oldParent;
    };
    //
    const { response, error } = await Codes.update({
      projectId,
      source,
      code,
      parent,
    });
    //
    if (error) {
      rollback();
      throw error;
    }
    if (response?.status >= 400) {
      rollback();
      throw new Error(response.data.message);
    }
    //
    // return true;
  };

  const computedCodes = computed(() => {
    return codeStore.all().filter((c) => !c.parent);
  });

  const computedCodebooks = computed(() => {
    return codebookStore.all();
  });

  const getCodebook = (id) => codebookStore.entry(id);
  const toggleCodebook = async (codebook) => {
    const active = !codebook.active;
    codebookStore.active(codebook.id, active);
    activateCodes({
      codes: codeStore.all().filter((c) => c.codebook === codebook.id),
      active,
      withIntersections: false,
    });
  };

  const activateCodes = ({ codes, active, withIntersections }) => {
    const updatedSelections = new Map();
    const addSelection = (selection) => {
      const target = selection?.value ?? selection;
      if (!updatedSelections.has(target.id)) {
        updatedSelections.set(target.id, selectionStore.entry(target.id));
      }
    };
    codeStore.update(() => {
      codes.forEach((code) => {
        code.active = active;

        // side-effect: mark selections to update
        if (code.text?.length) {
          code.text.forEach(addSelection);
        }
      });
      return codes;
    });

    if (withIntersections) {
      const interSections = selectionStore.getIntersecting([
        ...updatedSelections.values(),
      ]);
      if (interSections.length) {
        interSections.forEach(addSelection);
      }
    }

    selectionStore.observable.run('updated', [...updatedSelections.values()]);
  };

  const toggleCode = (code) => {
    const active = !code.active;
    const codes = [];
    const addCode = (cd) => {
      codes.push(cd);
      if (cd.children?.length) {
        cd.children.forEach((c) => addCode(c));
      }
    };
    addCode(code);
    activateCodes({ codes, active, withIntersections: true });
  };

  const selections = computed(() => {
    return selectionStore.all().toSorted(Selections.sort.byRange);
  });

  const overlaps = computed(() => {
    return selectionStore.getIntersections(selectionStore.all());
  });
  const selectionsByIndex = (index) => {
    return selections.value.filter(({ start, end }) => {
      return start <= index && end >= index;
    });
  };

  const observe = (name, callbacks) => {
    switch (name) {
      case codeStore.key:
        return codeStore.observe(callbacks);
      case codebookStore.key:
        return codebookStore.observe(callbacks);
      case selectionStore.key:
        return selectionStore.observe(callbacks);
      default:
        throw new Error(`Unknown observe name: ${name}`);
    }
  };

  return {
    createCode,
    createCodeSchema,
    updateCode,
    deleteCode,
    initCoding,
    codes: computedCodes,
    addCodeToParent,
    getCode: (id) => codeStore.entry(id),
    getCodebook,
    observe,
    codebooks: computedCodebooks,
    selections,
    overlaps,
    selectionStore,
    toggleCodebook,
    toggleCode,
    selectionsByIndex,
    sorter: {
      byIndex: useCodes,
    },
  };
};