▣🔗 Asset Link - Harvest Plugin Fun!

Moving this to a new topic so we can go a bit in depth here since I think this is a great example plugin that other folks will want to adopt/customize.

I’ll reply with some more detailed suggestions shortly…

3 Likes

Sounds great, Yeah I haven’t worked with vue before and only a little with javascript so suggestions are always welcome. I started by just modified the harvest eggs module that you have.

2 Likes

I’m looking at https://github.com/jorblad/FarmOS-Asset-link-plugins/blob/0e3d69727ac06cdfc007be405b9ad13452b55b61/Plants/HarvestingPlants.alink.vue

<script setup>
import { ref } from 'vue';
import { useDialogPluginComponent } from 'quasar'

const props = defineProps({
  asset: {
    type: Object,
    required: true,
  },
});

defineEmits([
  ...useDialogPluginComponent.emits
]);

const { dialogRef, onDialogOK, onDialogCancel } = useDialogPluginComponent();

const harvestCount = ref(0);

/* // Define the assetLink ref
//const assetLink = ref(null);

// Create a function to fetch assetLink and store it in the ref
const fetchAssetLink = async () => {
  assetLink.value = await getAssetLink(); // Replace getAssetLink with your code to retrieve assetLink
};

// Fetch the assetLink on component mount
onMounted(fetchAssetLink);

const findUnitTerms = async (entitySource) => {
  const results = await entitySource.query((q) =>
    q.findRecords('taxonomy_term--unit')
  );

  const unitTerms = results.flatMap((l) => l);

  console.log('All taxonomy_term--unit records:', unitTerms);

  return unitTerms.find((a) => a);
};

const unitTerms = findUnitTerms(assetLink.entitySource); */

const quantityType = ref(null);
const quantityOptions = ['st', 'gram']

const onSubmit = () => {
  onDialogOK({ harvestCount: harvestCount.value, quantityType: quantityType.value });
};
</script>

<template>
  <q-dialog ref="dialogRef" @hide="onDialogHide">
    <q-card class="q-dialog-plugin q-gutter-md" style="width: 700px; max-width: 80vw;">
      <h4>How much did you harvest from {{ props.asset.attributes.name }}?</h4>
      <div class="q-pa-md">
      <q-slider
        v-model="harvestCount"
        :min="0"
        :max="20"
        :step="1"
        snap
        label
      />
      <q-input
        v-model.number="harvestCount"
        type="number"
        filled
      />
      </div>
      <div class="q-pa-md">

      <q-select
        filled v-model="quantityType"
        :options="quantityOptions"
        label="Standard"
      />
      
      </div>
      <div class="q-pa-sm q-gutter-sm row justify-end">
        <q-btn color="secondary" label="Cancel" @click="onDialogCancel" />
        <q-btn
          color="primary"
          label="Record"
          @click="onSubmit"
          :disabled="harvestCount <= 0"
        />
      </div>
    </q-card>
  </q-dialog>
</template>

<script>
import { h } from 'vue';
import { QBtn } from 'quasar';

import { formatRFC3339, summarizeAssetNames, uuidv4 } from "assetlink-plugin-api";

const UNIT_NAME = "st";

export default {
  async onLoad(handle, assetLink) {
    await assetLink.booted;


    handle.defineSlot('net.symbioquine.farmos_asset_link.actions.v0.harvestPlant', action => {

      action.type('asset-action');

      action.showIf(({ asset }) => asset.attributes.status !== 'archived');


      const doActionWorkflow = async (asset) => {
        const dialogResult = await assetLink.ui.dialog.custom(handle.thisPlugin, { asset });
        console.log('Dialog result:', dialogResult);
        const harvestCount = dialogResult.harvestCount;
        console.log('Harvest Count:', harvestCount);
        const UNIT_NAME = dialogResult.quantityType;
        console.log('QuantityType:', UNIT_NAME);

        const findUnitTerm = async entitySource => {
          const results = await entitySource.query(q => q
              .findRecords('taxonomy_term--unit')
              .filter({ attribute: 'name', op: 'equal', value: UNIT_NAME }));

          return results.flatMap(l => l).find(a => a);
        };

        let harvestUnitTerm = await findUnitTerm(assetLink.entitySource.cache);

        if (!harvestUnitTerm) {
          harvestUnitTerm = await findUnitTerm(assetLink.entitySource);
        }

        if (!harvestUnitTerm) {
          const unitTermToCreate = {
              type: 'taxonomy_term--unit',
              id: uuidv4(),
              attributes: {
                name: UNIT_NAME,
              },
          };

          harvestUnitTerm = await assetLink.entitySource.update(
              (t) => t.addRecord(unitTermToCreate),
              {label: `Add '${UNIT_NAME}' unit`});
        }

        if (!harvestCount || harvestCount <= 0) {
          return;
        }

        let harvestQuantityMeasure = "count";
        if (UNIT_NAME === "gram" ) {
          harvestQuantityMeasure = "weight";
        } else {
          harvestQuantityMeasure = "count";
        }



        const harvestQuantity = {
          type: 'quantity--standard',
          id: uuidv4(),
          attributes: {
            measure: harvestQuantityMeasure,
            value: {
              numerator: harvestCount,
              denominator: 1,
              decimal: `${harvestCount}`,
            },
          },
          relationships: {
            units: {
              data: {
                type: harvestUnitTerm.type,
                id: harvestUnitTerm.id,
              }
            },
          },
        };

        const harvestLog = {
          type: 'log--harvest',
          attributes: {
            name: `Harvested ${harvestCount} from ${asset.attributes.name}`,
            timestamp: formatRFC3339(new Date()),
            status: "done",
          },
          relationships: {
            asset: {
              data: [
                {
                  type: asset.type,
                  id: asset.id,
                }
              ]
            },
            quantity: {
              data: [
                {
                  type: harvestQuantity.type,
                  id: harvestQuantity.id,
                }
              ]
            },
          },
        };

        assetLink.entitySource.update(
            (t) => [
              t.addRecord(harvestQuantity),
              t.addRecord(harvestLog),
            ],
            {label: `Record harvest harvest for ${asset.attributes.name}`});
      };

      action.component(({ asset }) =>
        h(QBtn, { block: true, color: 'secondary', onClick: () => doActionWorkflow(asset), 'no-caps': true },  "Record Harvest" ));
    });

  }
}
</script>

Injecting AssetLink Instance

Instead of;

/* // Define the assetLink ref
//const assetLink = ref(null);

// Create a function to fetch assetLink and store it in the ref
const fetchAssetLink = async () => {
  assetLink.value = await getAssetLink(); // Replace getAssetLink with your code to retrieve assetLink
};

// Fetch the assetLink on component mount
onMounted(fetchAssetLink);

The assetLink instance is always available in Vue components via inject;

-import { ref } from 'vue';
+import { inject, ref } from 'vue';
...
+const assetLink = inject('assetLink');
...

Unit Term Selection

This is basically on the right track…

const findUnitTerms = async (entitySource) => {
  const results = await entitySource.query((q) =>
    q.findRecords('taxonomy_term--unit')
  );

  const unitTerms = results.flatMap((l) => l);

  console.log('All taxonomy_term--unit records:', unitTerms);

  return unitTerms.find((a) => a);
};

const unitTerms = findUnitTerms(assetLink.entitySource);

We can’t call the async function findUnitTerms in the setup of a Quasar dialog though so we need to do that in a onMounted function;

-import { inject, ref } from 'vue';
+import { inject, ref, onMounted } from 'vue';
...
-const unitTerms = findUnitTerms(assetLink.entitySource);
+const unitTerms = ref([]);
+
+onMounted(async () => {
+  unitTerms.value = await findUnitTerms(assetLink.entitySource);
+});
...

We should also make findUnitTerms return a list of unit terms;

-return unitTerms.find((a) => a);
+return unitTerms;

Then we can change the select component to use those terms;

const unitLabelFn = unitTerm => unitTerm.attributes.name;

const quantityType = ref(null);
-const quantityOptions = ['st', 'gram']
+const unitLabelFn = unitTerm => unitTerm.attributes.name;
...
       <q-select filled
         v-model="quantityType"
-        :options="quantityOptions"
+        :options="unitTerms"
+        :option-label="unitLabelFn"
         label="Standard"
      />

Now our quantityType is a unit term object though so we’ll need to modify the doActionWorkflow function some…

Saving with the Unit Term

-        const UNIT_NAME = dialogResult.quantityType;
+        const harvestUnitTerm = dialogResult.quantityType;
-        console.log('QuantityType:', UNIT_NAME);
+        console.log('QuantityType:', harvestUnitTerm);
-
-        const findUnitTerm = async entitySource => {
-          const results = await entitySource.query(q => q
-              .findRecords('taxonomy_term--unit')
-              .filter({ attribute: 'name', op: 'equal', value: UNIT_NAME }));
-
-          return results.flatMap(l => l).find(a => a);
-        };
-
-        let harvestUnitTerm = await findUnitTerm(assetLink.entitySource.cache);
 
        if (!harvestUnitTerm) {
-          harvestUnitTerm = await findUnitTerm(assetLink.entitySource);
+          return;
        }
-
-        if (!harvestUnitTerm) {
-          const unitTermToCreate = {
-              type: 'taxonomy_term--unit',
-              id: uuidv4(),
-              attributes: {
-                name: UNIT_NAME,
-              },
-          };
-
-          harvestUnitTerm = await assetLink.entitySource.update(
-              (t) => t.addRecord(unitTermToCreate),
-              {label: `Add '${UNIT_NAME}' unit`});
-        }

         if (!harvestCount || harvestCount <= 0) {
           return;
         }

         let harvestQuantityMeasure = "count";
-        if (UNIT_NAME === "gram" ) {
+        if (harvestUnitTerm.attributes.name === "gram" ) {
           harvestQuantityMeasure = "weight";
         } else {
           harvestQuantityMeasure = "count";
         }

It might be possible to avoid that logic for selecting the measure based on the unit at some point in the future since there’s be some discussion about improving that in core farmOS.

1 Like

It’s probably worth noting also that if the code still needed to save with a unit by name, that could have been simplified by using the $relateByName directive.

From the Asset Link ‘Farm Data Access’ docs;

Which means we could have done something like the following - without even looking up the term in our code;

const harvestQuantity = {
  type: 'quantity--standard',
  id: uuidv4(),
  attributes: {
    measure: harvestQuantityMeasure,
    value: {
      numerator: harvestCount,
      denominator: 1,
      decimal: `${harvestCount}`,
    },
  },
  relationships: {
    units: {
      data: {
        type: 'taxonomy_term--unit',
        id: uuidv4(),
        '$relateByName': {
          name: UNIT_NAME,
        },
      }
    },
  },
};

This has the advantage that the term is not created permanently until the data is saved remotely which means that two users working offline are much less likely to create duplicate terms - in the already fairly unlikely event that they both used this plugin offline before the term had been created.

1 Like

Thank you very much, that works great, thanks also for the links to the docs, I don’t think I have seen those before :slight_smile:

I added now so that it only shows up on plants, I think it is better to not have a harvest button on every asset but rather have one plugin for plants and another for eggs and one for wool. But there it start to be interesting to filter by animal_type and I didn’t find a way to do that now when looking through the object but maybe I just need to look more in the documentation about getting data and somehow could find it that way.

The showIf method of the action slot could be extended to do that as follows;

action.showIf(({ asset }) => {
  if (asset.attributes.status === 'archived') {
    return false;
  }

  // Here we're querying `assetLink.entitySource.cache` which is
  // synchronous and never results in a request to farmOS. That
  // is possible because these relationships will almost always
  // already be loaded on the asset page. It is also necessary
  // since slots cannot have asynchronous `showIf` methods.
  const plantTypes = asset.type === 'asset--plant' ?
    assetLink.entitySource.cache.query((q) =>
    q.findRelatedRecords(
      { type: asset.type, id: asset.id },
      'plant_type')
    ) : [];

  console.log("plantTypes=", plantTypes);
  
  // Obviously, since `plant_type` is a required (N >= 1)
  // attribute, we didn't actually need to load them it would have
  // been sufficient to just check `asset.type === 'asset--plant'`
  if (plantTypes.length) {
    return true;
  }

  const animalType = asset.type === 'asset--animal' ?
    assetLink.entitySource.cache.query((q) =>
    q.findRelatedRecord(
      { type: asset.type, id: asset.id },
      'animal_type')
    ) : [];

  // Note the difference between `findRelatedRecords` and
  // `findRelatedRecord` (above) which return a list of entities and
  // a single entity respectively. They cannot be used
  // interchangeably - usage must match the cardinality of the
  // relationship.
  console.log("animalType=", animalType);

  // Ideally, there'd be a better "machine readable" way to determine
  // if a given animal type can be "harvested" - and maybe provide
  // defaults to the harvest dialog.
  return animalType.attributes.name.toLowerCase().includes('sheep');
});

Looks great I will incorporate that, great also to be able to have it all in one plugin, I will think I also will add the possibility to have the same one for eggs as well to have it all in one plugin.

Yeah, I’m not sure whether it makes sense to do it all in one plugin or not. Probably depends on the complexity of the harvest data you’re trying to capture.

That said, Asset Link is philosophically aligned with having many small plugins and I intend to continue optimizing around the expectation that there will be lots of plugins. (From a performance and tooling perspective.)

Yes I don’t know which way is the best way either but for now I will try to make it as one to start with and see if I can make it work well so that if I want to update it I only need to do it one place but I might split it up in one for plants and another for wool and for eggs you already created a good one :slight_smile:

After thinking a bit more I think splitting it is the right way to go so that users can choose what they want a plugin for.

I have now splitted the plugin and added a harvest wool plugin that shows up if the animal type is sheep so that its easier to modify each harvest plugin for the things that they are used to.

1 Like

this sounds useful to me. In the harvest logs, I currently track the qty harvested as part of my records, but this information is just text in the notes field. In my case the Product is Grain harvested from Plant Asset located in a field location. I already create a Product Asset for the Grain (because that’s the product I sell). I’d like to be able to increment the Grain (product asset) qty as I go through harvest. Ultimately, there would be a production report generated from FarmOS that provided a compilation of the crop season’s production across different fields by Plant Asset, Product Asset, Crop/Variety or other filters.

3 Likes

Cool @graffte!

It might be obvious, but it probably bears stating that Asset Link is just a UI layer and way of providing offline support. It doesn’t provide any new data model so it’s probably worth making sure you know how you want your data modeled in farmOS itself, then use tools like farmOS’ Quick Forms, Asset Link, or Field Kit to provide the convenience mechanisms for entering/accessing the data more easily or offline.

One beautiful thing about that is there’s no lock-in. All those tools just read/write data in farmOS. You could decide at any point to switch between a Quick Form and an Asset Link plugins approach with the only cost being re-implementing your displays/forms on top of the existing data.

1 Like

Im thinking about how to solve the add the quantity to the product in my harvest plugin.
@Symbioquine is there any way to easy get all child assets to the plant in a plugin? Because I’m thinking that I would have the product as a child to the plant which makes it possible to know what product to attach the quantity to, or having a EntitySelect to be able to handle multiple child products.

1 Like

I think you can filter by parent in the API. Something like…

/api/asset/plant?filter[parent.id]={UUID}

I just tested that with animal assets and it worked.

2 Likes

<script setup>
import { computed, ref} from 'vue';

const selectedParent = ref(undefined);
const selectedChild = ref(undefined);

const childrenFilter = computed(() => {
  if (!selectedParent.value) {
    return [];
  }
  return [{
    attribute: 'parent.id',
    value: selectedParent.value.id
  }];
});
</script>

<template alink-route[com.example.farmos_asset_link.routes.v0.example_children_selection_page]="/select-parent-child">
  <q-page padding class="text-left">
    <h4 class="q-mb-md q-mt-xs">Select a parent and child</h4>

    <p><b>Parent:</b> {{ selectedParent?.attributes?.drupal_internal__id }} {{ selectedParent?.attributes?.name }}</p>
    
    <p><b>Child:</b> {{ selectedChild?.attributes?.drupal_internal__id }} {{ selectedChild?.attributes?.name }}</p>

    <entity-select
      label="Parent Asset"
      entity-type="asset"
      v-model="selectedParent"
    ></entity-select>
    
    <entity-select
      label="Child Asset"
      entity-type="asset"
      v-model="selectedChild"
      :additional-filters="childrenFilter"
    ></entity-select>

  </q-page>
</template>

<script>
import { h } from 'vue';
import { QBtn } from 'quasar';

export default {
  onLoad(handle, assetLink) {
    handle.defineSlot('com.example.farmos_asset_link.tb_item.v0.example_children_selection_button', slot => {
      slot.type('toolbar-item');
      slot.weight(20);
      slot.component(() => h(QBtn, { flat: true, dense: true, icon: "mdi-human-male-girl", to: '/select-parent-child' }));
    });
  }
}
</script>

3 Likes

Great thank you :slight_smile: then I probably will add that functionality next week :slight_smile:

1 Like

You can, of course, also use that filtering strategy when directly querying the assets (not using the entity-select/entity-search/etc widgets):

const children = await assetLink.entitySource.query(q => q
  .findRecords(`asset--animal`)
  .filter({ attribute: 'parent.id', value: '00000000-0000-0000-0000-000000000000
' }));
2 Likes

See Asset Link docs Farm Data Access

3 Likes