Source: @@app-state/model/state.js

/**
 * @file Definition of the state keys for model and their changes
 * @module @@app-state/model/state
 */
import {
  compose,
  lensProp,
  curry,
  filter,
  view,
  pipe,
  keys,
  prop,
  set,
  map,
  assoc,
  mergeDeepRight,
  mergeRight,
  uniq, partition,
  lens,
  pick,
  over,
  omit,
  propEq,
  mapObjIndexed,
  values,
  groupBy,
  uniqBy,
  mergeLeft,
  without,
  cond,
  T,
  identity,
  any, paths
} from 'ramda';
import { entityTypes } from '@@constants/entity-types';
import { getSuffix } from '@@data/parsePrefix';

export const initial = {
  entities: {
    ...Object.keys(entityTypes).reduce((acc, type) => Object.assign(acc, {[type]: {}}), {})
  },
  customPrefixes: {},
  dirty: false,
  selectionOrder: [],
  endpoint: '',
  dataSchemaURL: '',
  filename: 'Untitled',
  description: '',
  cartesianProduct: false,
  propertyLanguages: [],
  prefixes: {}
};

const defaultEntityProps = {
  [entityTypes.property]: {
    asVariable: true,
    selected: false
  },
  [entityTypes.class]: {
    selected: false,
    asVariable: true,
    varName: '',
    info: {
      label: '', // From remote
      description: '' // From remote
    },
    propertyIds: [],
    hidden: false,
    expanded: false
  },
  [entityTypes.edge]: {
    selected: false
  }
};

const E = {
  selected: prop('selected'),
  varName: prop('varName'),
  propertyIds: prop('propertyIds'),
  hidden: prop('hidden')
};

const P = {
  target: prop('target'),
  id: prop('id'),
  dataProperty: prop('dataProperty'),
  selected: prop('selected'),
  source: prop('source')
}

const root = 'model';
export const rootLens = lensProp(root);

const forKey = k => compose(rootLens, lensProp(k));

export const dirty = forKey('dirty');
export const entities = forKey('entities');

export const prefixes = forKey('prefixes');
export const prefixById = id => compose(prefixes, lensProp(id));
export const classes = compose(entities, lensProp(entityTypes.class));
export const properties = compose(entities, lensProp(entityTypes.property));
export const edges = compose(entities, lensProp(entityTypes.edge));
const byTypeAndId = curry((type, id) => compose(entitiesByType[type], lensProp(id)));
const byTypeAndIds = curry((type, ids) => compose(entitiesByType[type], lens(pick(ids), (val, obj) => ids.reduce((acc, id) => Object.assign({}, acc, {[id]: val}), obj))));
export const propertiesByIds = byTypeAndIds(entityTypes.property);
export const propertyById = byTypeAndId(entityTypes.property);
export const propertyTargetById = id => compose(propertyById(id), lensProp('target'));
export const classById = byTypeAndId(entityTypes.class);
export const edgeById = byTypeAndId(entityTypes.edge);
const propertyIdsByClassId = id => compose(classById(id), lensProp('propertyIds'));
export const propertyLanguages = forKey('propertyLanguages');

export const endpoint = forKey('endpoint');
export const dataSchemaURL = forKey('dataSchemaURL');
export const filename = forKey('filename');
export const description = forKey('description');
export const customPrefixes = forKey('customPrefixes');
export const cartesianProduct = forKey('cartesianProduct');
export const query = forKey('query');
export const customPrefixById = id => compose(customPrefixes, lensProp(id));

const entitiesByType = {
  [entityTypes.class]: classes,
  [entityTypes.property]: properties,
  [entityTypes.edge]: edges
};
export const selectionOrder = forKey('selectionOrder');

const update = curry((type, key, id, value, s) => set(compose(byTypeAndId(type, id), lensProp(key)), value, s));
const updateProperty = update(entityTypes.property);
const updateClass = update(entityTypes.class);
const updateEdge = update(entityTypes.edge);

export const togglePropertyOptional = updateProperty('optional');

/**
 * Marks the property if it should be queried as a variable or not.
 * For object properties also propagates the change to the target classes.
 * @function
 * @type {*}
 */
export const togglePropertyAsVariable = curry((id, asVariable, s) => {
  const {target, dataProperty} = view(propertyById(id), s);

  if (dataProperty) {
    return updateProperty('asVariable', id, asVariable, s);
  } else {
    return toggleClassSelected(target, asVariable, s);
  }
});
export const savePropertyName = updateProperty('varName');
export const toggleClassHidden = updateClass('hidden');
export const toggleClassExpanded = updateClass('expanded');
export const toggleClassAsVariable = updateClass('asVariable');
export const toggleEdgeHighlighted = updateEdge('highlighted');
export const unhighlightEdges = cond([
  [pipe(view(edges), values, any(prop('highlighted'))), over(edges, map(assoc('highlighted', false)))],
  [T, identity]
]);

const getLanguageField = (languageOrder, field, data) => paths(languageOrder.map(l => [l, field]), data).find(a => a);

/**
 * Extracts the language information for given language to the root info object of the entity.
 * @function
 * @type {*}
 */
export const updateLanguageInfo = curry((language, e) => {
  const languageOrder = [language, 'en', 'de', 'default'];

  const info = e.info || {};

  // Object.assign({}, ...) to get a new copy
  return Object.assign({}, e, {
    info: {
      ...info,
      label: getLanguageField(languageOrder, 'label', e.info.byLanguage),
      description: getLanguageField(languageOrder, 'description', e.info.byLanguage)
    }
  })
});

export const updateLanguageInfos = curry((language, entities) => updateClasses(map(updateLanguageInfo(language), entities)));

export const updateClassName = curry((id, newName, s) => {
  const updated = map(assoc('varName', newName), getPropertiesByTarget(id, s))
  s = over(properties, mergeLeft(updated), s);
  return updateClass('varName', id, newName)(s);
});

const updateSelected = curry((type, id, selected, s) => {
  s = update(type, 'selected', id, selected)(s);
  const order = view(selectionOrder, s);
  if (selected) {
    return set(selectionOrder, uniq(order.concat(id)), s);
  }

  order.splice(order.indexOf(id), 1);
  return set(selectionOrder, order, s);
});

/**
 * Toggles the property as selected.
 * Based on whether it is the last deselected or first selected also selected/deselects the target and source entities.
 * @function
 * @type {*}
 */
export const togglePropertySelected = curry((id, selected, s) => {
  const property = view(propertyById(id), s);
  const {source, target, dataProperty} = property;
  const propertyIds = E.propertyIds(view(classById(source), s));
  const selectedPropertiesCount = values(view(propertiesByIds(propertyIds), s)).filter(P.selected).length;

  // Select/deselect source entity if the property is the first/last one being changed
  const propertiesTargetingSourceCount = keys(filter(prop('selected'), getPropertiesByTarget(source, s))).length;
  if (selectedPropertiesCount === 0 && selected) {
    s = toggleClassSelected(source, true, s);
  } else if (selectedPropertiesCount === 1 && !selected && propertiesTargetingSourceCount === 0) {
    s = toggleClassSelected(source, false, s);
  }

  const sameTargetPropsSelectedCount = keys(filter(prop('selected'), getPropertiesByTarget(target, s))).length;
  // Select/deselect target entity if the property is the first/last one being changed
  if (!dataProperty) {
    if (sameTargetPropsSelectedCount === 0 && selected) {
      s = toggleClassSelected(target, true, s);
    } else if (sameTargetPropsSelectedCount === 1 && !selected) {
      s = toggleClassSelected(target, false, s);
    }
  }

  return updateSelected(entityTypes.property, id, selected, s);
});

export const toggleClassSelected = curry((id, selected, s) => {
  const properties = getPropertiesByTarget(id, s);

  const withUpdatedClass = updateSelected(entityTypes.class, id, selected, s);
  return keys(properties).reduce((acc, id) => updateProperty('asVariable', id, selected, acc), withUpdatedClass);
});

export const toggleSelected = (type, ...args) => {
  if (type === entityTypes.property) {
    return togglePropertySelected(...args);
  }
  if (type === entityTypes.class) {
    return toggleClassSelected(...args)
  }
  if (type === entityTypes.edge) {
    return update(entityTypes.edge, 'selected', ...args);
  }
}

export const getSelectedProperties = pipe(view(properties), filter(P.selected), values);
export const getSelectedClasses = pipe(view(classes), filter(E.selected));
export const getSelectedEntities = pipe(view(entities), map(filter(E.selected)));
export const clearData = set(rootLens, initial);
export const setBoundingBoxes = curry((boxesById, state) => {
  const toUpdate = view(classes, state);
  const updated = mergeDeepRight(toUpdate, boxesById);

  return set(classes, updated, state);
});
export const deselectAll = s => {
  const toDeselect = view(entities, s);
  const newEntities = map(map(assoc('selected', false)), toDeselect);
  return pipe(set(entities, newEntities), set(selectionOrder, []))(s);
};
export const toggleSelections = curry((type, selection, s) => {
  const entityLens = entitiesByType[type];
  const oldEntities = view(entityLens, s);

  // Change selection order if properties or classes are selected
  if (type !== entityTypes.edge) {
    const oldSelected = view(selectionOrder, s);
    const [newSelected, newDeselected] = partition(E.selected, selection);
    const newSelectionOrder = oldSelected.concat(keys(newSelected)).filter(id => !newDeselected[id]);
    s = set(selectionOrder, uniq(newSelectionOrder), s);
  }

  return set(entityLens, mergeDeepRight(oldEntities, selection), s);
});
export const updateClasses = curry((newClasses, s) => {
  const oldClasses = view(classes, s);
  return set(classes, mergeRight(oldClasses, newClasses), s);
});
export const registerResources = curry((entityType, resources, s) => {
  const withDefaultProps = map(mergeRight(defaultEntityProps[entityType] || {}), resources);
  return set(entitiesByType[entityType], withDefaultProps, s);
});

/**
 * For a list of property ids binds their names to their current target.
 * @function
 * @type {*}
 */
export const bindProperties = curry((propertyIds, state) => {
  if (!propertyIds || !propertyIds.length) {
    propertyIds = Object.keys(view(properties, state));
  }

  return propertyIds.reduce((acc, id) => {
    const property = view(propertyById(id), acc);
    if (P.dataProperty(property)) {
      return acc;
    }
    const target = P.target(property);
    const targetClass = view(classById(target), acc);
    const varName = E.varName(targetClass);
    return updateProperty('varName', id, varName)(acc);
  }, state);
});

export const loadView = curry((json, s) => set(entities, mergeDeepRight(view(entities, s), json), s));

const suffixId = curry((getterFn, state, id) => {
  let i = 1;
  while (view(getterFn(`${id}_${i}`), state)) {
    i++;
  }
  return `${id}_${i}`;
});

const registerProperties = curry((source, propertyIds, s) => {
  const {ids, state} = propertyIds.reduce(({ids, state}, propertyId) => {
    const property = mergeRight(view(propertyById(propertyId), state), defaultEntityProps[entityTypes.property]);
    const propSuffix = getSuffix(property.predicate);
    const entitySuffix = getSuffix(source)

    Object.assign(property, {
      source,
      varName: `${entitySuffix}_${propSuffix}` // Since the property is duplicate, prefix it with the varName
    });
    const newId = `property_${source}-${property.predicate}-${property.target}`;

    return {
      ids: ids.concat(newId),
      state: set(propertyById(newId), property, state)
    };
  }, {ids: [], state: s})

  return {ids, state};
});

const createNewPropertiesForTargetType = curry(({type: targetType, id: target, varName}, s) => {
  const properties = getPropertiesByTargetType(targetType, s);
  const propertiesBySource = map(pipe(values, uniqBy(prop('predicate'))), groupBy(P.source, values(properties)))

  return Object.entries(propertiesBySource).reduce((acc, [source, properties]) => {
    const pIds = view(classById(source), s).propertyIds.slice();

    s = values(properties).reduce((acc, p) => {
      const property = mergeRight(p, defaultEntityProps[entityTypes.property])
      const newId = `property_${source}-${property.predicate}-${target}`;
      property.target = target;
      property.varName = varName;
      pIds.push(newId);

      return set(propertyById(newId), property, acc);
    }, s);

    return set(propertyIdsByClassId(source), pIds, s);
  }, s);
});

/**
 * Creates a new class entity based on the provided id.
 * This in turn creates duplicate properties on other entities that can target this new entity.
 * @function
 * @param id
 * @param s
 * @returns {{instance: any, newId: *, state: *}}
 */
const createNewClassInstance = (id, s) => {
  const entity = view(classById(id), s);
  const newId = suffixId(classById, s, entity.type || id);

  const typeCount = Object.keys(filter(propEq('type', entity.type), view(classes, s))).length;
  const varName = `${entity.type.replace(/.*:/, '')}_${typeCount}`;

  s = createNewPropertiesForTargetType({type: entity.type, id: newId, varName}, s)

  // Keep info intact
  const toRegister = Object.assign({}, entity, defaultEntityProps[entityTypes.class], {info: entity.info});
  const {ids: propertyIds, state} = registerProperties(newId, entity.propertyIds, s);

  return {
    newId,
    instance: Object.assign(toRegister, {
      type: entity.type,
      propertyIds,
      varName,
      id: newId
    }),
    state
  }
}

export const registerNewClass = curry((id, s) => {
  const {newId, instance, state} = createNewClassInstance(id, s);

  return set(classById(newId), instance, state);
});

export const registerNewClassWithCallback = curry((id, callback, s) => {
  const {newId, instance, state} = createNewClassInstance(id, s);
  const propertiesOfWhichTarget = getPropertiesByTarget(newId, state);
  const propertiesOfWhichSource = view(propertiesByIds(instance.propertyIds), state);
  const properties = Object.assign(propertiesOfWhichSource, propertiesOfWhichTarget);
  callback({newId, instance, properties});
  return set(classById(newId), instance, state);
});

export const getPropertiesByTarget = curry((id, s) => filter(propEq('target', id), view(properties, s)));
const getPropertiesByTargetType = curry((id, s) => filter(propEq('targetType', id), view(properties, s)));

export const deleteClass = curry((id, s) => {
  const propertiesWithTarget = getPropertiesByTarget(id, s);
  const propertyIdsToRemove = keys(propertiesWithTarget).concat(view(propertyIdsByClassId(id), s));
  const sourceIds = uniq(values(propertiesWithTarget).map(P.source));

  const newClasses = sourceIds.reduce((acc, sourceId) => {
    const entity = view(classById(sourceId), s);
    return Object.assign(acc, {
      [sourceId]: assoc('propertyIds', without(propertyIdsToRemove, entity.propertyIds), entity)
    })
  }, {});

  return pipe(
    over(properties, omit(propertyIdsToRemove)),
    over(classes, mergeLeft(newClasses)),
    over(classes, omit([id]))
  )(s);
});

export const changePropertyTarget = curry((id, newTarget, s) => set(propertyTargetById(id), newTarget, s));
export const createNewPropertyTarget = curry((id, s) => {
  const currentTargetId = view(propertyTargetById(id), s);
  const {newId, instance, state} = createNewClassInstance(currentTargetId, s);

  const registerNewTarget = set(classById(newId), instance);
  const changeTarget = set(propertyTargetById(id), newId);

  return pipe(registerNewTarget, changeTarget)(state);
});

export const showAll = over(classes, map(assoc('hidden', false)));

export const hideUnselected = s => {
  const toKeepShown = getSelectedProperties(s).reduce((acc, p) => Object.assign(acc, {
    [P.target(p)]: true,
    [P.source(p)]: true
  }), {});

  return over(classes, mapObjIndexed((c, id) => assoc('hidden', !E.selected(c) && !toKeepShown[id] , c)), s);
}