Skip to content

Commit

Permalink
Add farmOS-timeline library #862
Browse files Browse the repository at this point in the history
  • Loading branch information
mstenta committed Sep 19, 2024
2 parents b956a19 + a240c0f commit 7232d5f
Show file tree
Hide file tree
Showing 13 changed files with 626 additions and 1 deletion.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- [Allow modules to alter dashboard panes #868](https://github.com/farmOS/farmOS/pull/868)
- [Add geometry/location fields to CSV importers #815](https://github.com/farmOS/farmOS/pull/815)
- [Add an asset.logs service for retrieving logs that reference an asset #850](https://github.com/farmOS/farmOS/pull/850)
- [Add farmOS-timeline library (experimental) #862](https://github.com/farmOS/farmOS/pull/862)

### Changed

Expand Down
18 changes: 17 additions & 1 deletion composer.libraries.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"require": {
"farmos/farmos-map": "*"
"farmos/farmos-map": "*",
"farmos/farmos-timeline": "*"
},
"repositories": {
"farmos-map": {
Expand All @@ -17,6 +18,21 @@
"installer-name": "farmOS-map"
}
}
},
"farmos-timeline": {
"type": "package",
"package": {
"name": "farmos/farmos-timeline",
"version": "0.0.2",
"type": "drupal-library",
"dist": {
"url": "https://github.com/farmOS/farmOS-timeline/releases/download/v0.0.2/v0.0.2-dist.zip",
"type": "zip"
},
"extra": {
"installer-name": "farmOS-timeline"
}
}
}
}
}
105 changes: 105 additions & 0 deletions modules/core/timeline/css/farm_timeline.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/* Set the width to 100% instead of using flex. This prevents auto-expanding width of timeline. */
.layout__region--top {
width: 100%;
}

.sg-table-header-cell,
.column-header-cell {
font: var(--gin-font) !important;
}
.sg-table-header-cell {
font-size: var(--gin-font-size-m) !important;
font-weight: var(--gin-font-weight-bold) !important;
}
.column-header-cell {
font-size: var(--gin-font-size-s) !important;
font-weight: var(--gin-font-weight-normal) !important;
}

.sg-task {
--task-bg-color: #74bfff;
box-shadow: var(--gin-shadow-l1);
background-color: var(--task-bg-color) !important;
border-left: 2px solid #666666;
}
.sg-task:hover {
background-color: var(--task-bg-color) !important;
}
.sg-task-selected {
box-shadow: inset 0 0 0 1px var(--gin-color-focus-border), inset 0 0 0 4px var(--gin-color-focus), var(--gin-shadow-l2);
}

.sg-task .sg-task-content {
color: var(--gin-color-text-light);
}

.sg-task.last-location,
.sg-task.last-location:hover {
background-color: unset !important;
background: linear-gradient(90deg, var(--task-bg-color), rgba(255,255,255,0));
}

.sg-task.log {
margin-top: 4px;
margin-left: -5px;
width: 10px !important;
height: 10px !important;
background-color: var(--task-bg-color);
border: 2px solid var(--task-bg-color);
border-radius: 50% !important;
}
.sg-task.log.sg-task-selected {
--task-bg-color: var(--gin-color-focus);
background-color: var(--task-bg-color);
border: none;
}

.sg-task.log--status-pending {
background-color: transparent !important;
}

.sg-task.log--activity {
--task-bg-color: #f1c40f;
}
.sg-task.log--harvest {
--task-bg-color: #e67e22;
}
.sg-task.log--input {
--task-bg-color: #9b59b6;
}
.sg-task.log--observation {
--task-bg-color: #2980b9;
}
.sg-task.log--seeding {
--task-bg-color: #2ecc71;
}
.sg-task.log--transplanting {
--task-bg-color: #2ecc71;
}

.sg-popup {
z-index: 1000;
background-color: var(--gin-bg-layer);
border: 1px solid var(--gin-border-color-layer);
box-shadow: var(--gin-shadow-l2);
border-radius: var(--gin-border-s);
padding: var(--gin-spacing-xs);
font-size: var(--gin-font-size-s);
}

.sg-tree-expander {
/*@TODO do this properly.*/
mask-image: url('/themes/gin/dist/media/sprite.svg#handle-view');
-webkit-mask-image: url('/themes/gin/dist/media/sprite.svg#handle-view');
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
mask-position: center center;
-webkit-mask-position: center center;
margin-right: 0.5em;
background-color: var(--gin-color-text);
transform: rotate(90deg);
transition: transform var(--details-transform-transition-duration) ease-in 0s;
}
.sg-row-expanded .sg-tree-expander {
transform: rotate(270deg);
}
6 changes: 6 additions & 0 deletions modules/core/timeline/farm_timeline.info.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
name: farmOS Timeline
description: Provides farmOS timeline features.
type: module
package: farmOS (Experimental)
lifecycle: experimental
core_version_requirement: ^10
20 changes: 20 additions & 0 deletions modules/core/timeline/farm_timeline.libraries.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
farmOS-timeline:
remote: https://github.com/farmOS/farmOS-timeline
license:
name: MIT
url: https://github.com/farmOS/farmOS-timeline/blob/main/LICENSE
gpl-compatible: true
js:
/libraries/farmOS-timeline/farmOS-timeline.js:
minified: true
farm_timeline:
js:
js/farm_timeline.js: { }
css:
theme:
css/farm_timeline.css: { }
dependencies:
- core/drupal.dialog
- core/drupal.dialog.ajax
- core/once
- farm_timeline/farmOS-timeline
19 changes: 19 additions & 0 deletions modules/core/timeline/farm_timeline.module
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

/**
* @file
* The farm_timeline module.
*/

/**
* Implements hook_theme().
*/
function farm_timeline_theme($existing, $type, $theme, $path) {
return [
'farm_timeline' => [
'variables' => [
'attributes' => [],
],
],
];
}
190 changes: 190 additions & 0 deletions modules/core/timeline/js/farm_timeline.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
(function (Drupal, drupalSettings, once, farmOS) {
Drupal.behaviors.farm_timeline = {
attach: function (context, settings) {
once('timelineGantt', '.farm-timeline', context).forEach(async function (element) {
const opts = {
props: {
taskElementHook: (node, task) => {
let popup;

function onHover() {
popup = createPopup(task, node);
}

function onLeave() {
if (popup) {
popup.remove();
}
}

node.addEventListener('mouseenter', onHover);
node.addEventListener('mouseleave', onLeave);
return {
destroy() {
node.removeEventListener('mouseenter', onHover);
node.removeEventListener('mouseleave', onLeave);
}
}
}
},
};

// Create the timeline instance.
const timeline = farmOS.timeline.create(element, opts);

// Helper function to process a single row and its children recursively.
const processRow = async function(row) {

// Handle URL string to fetch row data dynamically.
if (typeof row === "string") {

// Fetch and process the array of returned rows.
const data = await fetch(row)
.then(res => res.json())
.then(data => data?.rows ?? []);
const awaitedRows = await Promise.all(data.map(processRow));

// Aggregate all rows and tasks from processed rows.
return awaitedRows.reduce((accumulator, current) => {
return {
rows: [...accumulator.rows, ...current.rows],
tasks: [...accumulator.tasks, ...current.tasks],
};
}, {
rows: [],
tasks: []
});
} else if (!row) {
// Handle potential null/undefined rows
return { rows: [], tasks: [] };
}

// Begin processing a single parent row.
// First process all child rows.
const awaitedChildren = await Promise.all((row.children ?? []).map(processRow));

// Aggregate child rows and tasks.
const aggregatedChildren = awaitedChildren.reduce((accumulator, current) => {
return {
rows: [...accumulator.rows, ...current.rows],
tasks: [...accumulator.tasks, ...current.tasks],
};
}, {
rows: [],
tasks: []
});

// Map the parent row to a row object and include children rows.
row.children = aggregatedChildren.rows;
const mappedRow = Drupal.behaviors.farm_timeline.mapRow(row);

// Collect all tasks from the parent row and add all child tasks.
const tasks = [
...(row.tasks?.map(Drupal.behaviors.farm_timeline.mapTask) ?? []),
...aggregatedChildren.tasks,
];

// Return the final processed parent row and all tasks.
return {
rows: [mappedRow],
tasks: tasks,
};
};

// Helper function to add row to timeline after processing.
const addRow = async function(row) {
return processRow(row)
.then(data => {
timeline.addRows(data.rows);
timeline.addTasks(data.tasks);
})
}

// Add all provided timeline rows to the timeline.
const timelineRows = JSON.parse(element.dataset?.timelineRows) ?? [];
await Promise.all(timelineRows.map(addRow));

function createPopup(task, node) {
const rect = node.getBoundingClientRect();
const div = document.createElement('div');
div.className = 'sg-popup';
div.innerHTML = `
<div class="sg-popup-title">${task.label}</div>
<div class="sg-popup-item">From: ${new Date(task.from).toLocaleDateString()}</div>
<div class="sg-popup-item">To: ${new Date(task.to).toLocaleDateString()}</div>
`;
div.style.position = 'absolute';
div.style.top = `${rect.bottom + window.scrollY + 5}px`;
div.style.left = `${rect.left + rect.width / 2}px`;

if (task?.meta?.entity_type === 'log') {
div.innerHTML = `
<div class="sg-popup-title">Log: ${task.meta.label}</div>
<div class="sg-popup-item">Type: ${task.meta.entity_bundle}</div>
<div class="sg-popup-item">Timestamp: ${new Date(task.from).toLocaleDateString()}</div>
`;
}

if (task?.meta?.stage) {
div.innerHTML = `
<div class="sg-popup-title">Stage: ${task.meta.stage}</div>
<div class="sg-popup-item">From: ${new Date(task.from).toLocaleDateString()}</div>
<div class="sg-popup-item">To: ${new Date(task.to).toLocaleDateString()}</div>
`;
}

document.body.appendChild(div);
return div;
}

// Open entity page on click.
timeline.timeline.api.tasks.on.select((task) => {
task = task[0];
if (task.model?.editUrl) {
var ajaxSettings = {
url: task.model.editUrl,
dialogType: 'dialog',
dialogRenderer: 'off_canvas',
};
var myAjaxObject = Drupal.ajax(ajaxSettings);
myAjaxObject.execute();
} else {
let dialog = document.getElementById('drupal-off-canvas');
if (dialog) {
Drupal.dialog(dialog, {}).close();
}
}
});
});
},
// Helper function to map row properties.
mapRow: function(row) {
return {
id: row.id,
label: row.label,
headerHtml: row.link,
expanded: row.expanded ?? false,
draggable: row.draggable ?? false,
resizable: row.resizable ?? false,
// Only provide a children array if there are children
// otherwise an expanded icon will appear for rows without children.
children: row.children.length ? row.children : null,
};
},
// Helper function to map task properties.
mapTask: function(task) {
return {
id: task.id,
resourceId: task.resource_id,
from: task.start,
to: task.end,
label: task.label ?? '',
editUrl: task.edit_url,
draggable: task.draggable ?? false,
resizable: task.resizable ?? false,
meta: task?.meta,
classes: task.classes,
};
},
};
}(Drupal, drupalSettings, once, farmOS));
Loading

0 comments on commit 7232d5f

Please sign in to comment.