@etxdev/losys-lib
v1.11.3
Published
This document provides an overview of how to integrate and use the Losys API with Vue 3 components. The components currently use our own CSS framework
Readme
Losys API Integration with Vue 3
This document provides an overview of how to integrate and use the Losys API with Vue 3 components. The components currently use our own CSS framework
Table of Contents
- Changelog
- Overview
- Setup
- Components and Data Sources
- Event Handlers
- Template Integration
- Complete Script Code
- Complete Template Code
Changelog
1.9.0
- Enable SSR for the swiper components
- Fix bug where imageKit url replacement on null object was throwing
1.8.0
- Fix loading & rendering problems for HighlightComponent and ReferenceDetailComponent
- Added loader to HighlightComponent
1.7.0
- Remove unused space in ref detail page
- Align Image Gallery in ReferenceDetailPage to content instead of window
- Introduced v-etx-appearance directive to handle will-appear and did-appear to optimize behavior
1.6.12
- Style attribute in ReferenceDetailPage advantages is now computed, so component updates newly assigned css classes
1.6.11
- Updated highlight component
- Updated data-source to trigger loaded$ subject on manually changing the data
- Introduced setData method to data-source
1.6.10
- Some fixes
1.6.9
- Remove href element from teaser card
1.6.8
- Added attribute style to ReferenceDetail.advantages with values 'bg-secondary' | 'bg-grey-1'
- HighlightComponent allRefsText optional
- HighlightComponent always display stripes
- Pointer cursor on TeaserCards
- Prevent empty rendered containers in ReferenceDetailComponent
- Updated examples
1.6.7
- Added typing for project_categories in Losys Api
- Updated style for Video Container in ReferenceDetailPage
- Updated example
1.6.6
- For FilterComponent added level attribute and css class level-${level} to indicate elements level
1.6.5
- Updated ReferenceDetailComponent input data. Changed mediaBlock type from Media to EmbedCode. We now expect the videos embed code instead of url. If no mediaBlock is passed, nothing will be rendered.
- Updated Losys API project video attributes. Added missing attributes
- Please find all changes in the examples below
1.6.4
- Implemented HighlightComponent. Added new attribute "id" for HighlightEntry, event handler @reference-clicked and text for the all references link.
- Notice: on the etx-container for the FilterComponent, please use css class overflow-visible"
- Introduced loaded$ observable on datasource
- Added handler for window resize to update and recalculate swiper sizes
- Added missing div in ReferenceDetailView for the Gallery
- Please find all listed changes in the examples below
1.6.3
- Introduced additional information for losys api
- Added Pro/cons section to reference detail view
1.6.2
- Introduced new type for attribute project_properties on LosysApiProjectProperty. Pass generic type if wanted.
const projProps = Object.entries(myDataSet[0].project_properties);
const examples = projProps.filter(x => x.value.projectAttributeId === '<<someId>>');
const specificAttributeFound = examples.find(x => (x as LosysApiProjectProperty<boolean >).value === true);Overview
This integration demonstrates how to use the Losys API to fetch and display project-related data in Vue 3 using reactive data sources and components like HighlightComponent and TeaserListComponent.
Setup
API Service Initialization
The LosysApiService is initialized to handle authentication and fetch data from the Losys API.
const apiService: LosysApiService = new LosysApiService({
authEndpoint: 'myauth/oauth/token',
apiHost: 'my-losys-api-host-url',
authStore: {
set: (data: LosysApiAuthResult) => {
localStorage.setItem('losys-auth', JSON.stringify({
access_token: data.access_token,
company: undefined
}));
},
get: (): LosysApiAuthResult | undefined => {
const storedData = localStorage.getItem('losys-auth');
return storedData ? JSON.parse(storedData) : undefined;
}
}
});
apiService.authenticateAndGetBasicInformation().then(() => {
loadData();
});Components and Data Sources
Highlight Module Data Source
The highlight module fetches and maps data for the HighlightComponent.
const highlightModuleDataSource: ReactiveAsyncDataSource<HighlightEntry, LosysApiProjectFilter> =
new ReactiveAsyncDataSource<HighlightEntry, LosysApiProjectFilter>(
(filter: LosysApiProjectFilter): Promise<HighlightEntry[]> => {
return new Promise<HighlightEntry[]>((resolve, _reject) => {
// fetch data from losys api
apiService.getCustomerProjects({
...filter,
languages: defaultLanguageFilter,
expand: ['project_images', 'project_categories', 'project_videos', 'project_properties']
})
.then(restData => {
// map result to highlight module entries
const data: HighlightEntry[] = restData.map(x => {
// use imagekit urls instead of losys api image urls
let imageKitUrl = (x.project_images ? x.project_images[0]?.extralarge_image : '')
.replace('https://api.referenz-verwaltung.ch/static/images/project/', 'https://ik.imagekit.io/equans/losys/');
imageKitUrl = `${imageKitUrl}?tr=w-3000,ar-3-2,fo-auto`;
return {
image: imageKitUrl,
title: {
main: x.title,
sub: x.city
},
id: 'test'
};
});
resolve(data);
});
});
}
);Teaser Module Data Source
The teaser module fetches data for the TeaserListComponent and TeaserSliderComponent.
const teaserModuleDataSource: ReactiveAsyncDataSource<TeaserCard, LosysApiProjectFilter> =
new ReactiveAsyncDataSource<TeaserCard, LosysApiProjectFilter>(
(filter: LosysApiProjectFilter): Promise<TeaserCard[]> => {
return new Promise<TeaserCard[]>((resolve, _reject) => {
// fetch data from api
apiService.getCustomerProjects({
...filter,
languages: defaultLanguageFilter,
expand: [
'project_images',
'project_categories',
'project_videos',
'project_properties'
]
})
.then(restData => {
// map the retrieved data into teaser card data
const data = restData
.filter(x => {
const approvalByMarketingIds = [765, 779, 765, 761, 781];
const approvalByMarketing = Object.values(x.project_properties ?? {}).find(x => approvalByMarketingIds.includes(x.projectAttributeId));
return approvalByMarketing?.value === "True";
})
.map(x => {
let imageUrl = (x.project_images && x.project_images?.length > 0 ? x.project_images[0]?.small_image : '')
.replace('https://api.referenz-verwaltung.ch/static/images/project/', 'https://ik.imagekit.io/equans/losys/');
imageUrl = `${imageUrl}?tr=w-1000,ar-3-2,fo-auto`;
return {
properties: {
id: `${x.id}`,
image: {
url: imageUrl
},
// TODO: map with competences provided through drupal
tags: ['Tag 1', 'Tag 2'],
title: {
small: x.zipcode,
main: x.title,
sub: x.city
}
}
};
});
resolve(data);
});
});
}
);
Event Handlers
Event handlers handle various user interactions.
const handleHighlightAllReferencesClicked = () => {
alert('should route to all references overview page');
};
const handleHighlightReferenceClicked = (entry: HighlightEntry) => {
alert(`should route to reference with id: ${entry.id}`);
};
const handleHighlightToggleVariant = (): void => {
variant.value = variant.value === 'right' ? 'left' : 'right';
};
const handleFiltersChanged = (filters: FilterEntry<(keyof LosysApiProjectFilter)>[]) => {
let apiFilter: LosysApiProjectFilter = { pageIndex: 0, pageSize: pageSize };
filters.forEach(x => {
apiFilter = {
...apiFilter,
[x.id]: x.selected.map(x => x.value)
};
});
teaserModuleDataSource.load(apiFilter, 'overwrite');
};Template Integration
The template includes HighlightComponent, FilterComponent, and TeaserListComponent.
<template>
<main>
<div class="etx-container Container pt-0 pb-25">
<div class="etx-center Center">
<h1>Translation Test: {{ $etxtranslate('global.welcome') }}</h1>
<button @click="$setLanguage('de')">DE</button>
<button @click="$setLanguage('en')">EN</button>
</div>
</div>
<!-- HIGHLIGHT MODULE -->
<div class="etx-container Container pt-0 pb-25">
<div class="etx-center Center">
<button @click="handleHighlightToggleVariant()">Toggle Variant {{ variant }}</button>
<HighlightComponent :background="variant" @all-references-clicked="handleHighlightAllReferencesClicked()"
@reference-clicked="handleHighlightReferenceClicked($event)" :dataSource="highlightModuleDataSource" />
</div>
</div>
<!-- TEASER MODULE AND FILTER -->
<!-- WARNING: USE style overflow unset in order for filter drawer to work -->
<div class="etx-container Container pt-0 pb-25 overflow-visible">
<div class="etx-center Center">
<FilterComponent :entries="filterEntries" @filters-changed="handleFiltersChanged($event)" />
</div>
</div>
<div class="etx-container Container pt-0 pb-0 overflow-visible">
<div class="etx-center Center">
<TeaserListComponent :dataSource="teaserModuleDataSource" @selected="handleTeaserSelected($event)" />
</div>
</div>
<div class="etx-container Container pt-25">
<div class="etx-center Center">
<div class="etx-button Button minimal active" @click="handleLoadTeaserListData()">
<div class="etx-button__text Button__text">
<span>{{ 'Mehr laden' }}</span>
</div>
<div class="etx-button__icon Button__icon-">
<i class="far fa-long-arrow-down"></i>
</div>
</div>
</div>
</div>
<!-- TEASER SWIPER MODULE AND FILTER -->
<div class="etx-container Container pt-0 pb-0 overflow-visible">
<div class="etx-center Center">
<TeaserSliderComponent :dataSource="teaserModuleDataSource" :page-size="pageSize" :id="'references-swiper-1'"
@selected="handleTeaserSelected($event)" />
</div>
</div>
<div class="etx-spacer"></div>
<!-- DETAIL REFERENCE VIEW -->
<ReferenceDetailComponent :data="reactiveTestDetail" @cta-clicked="handleCtaClicked()" />
</main>
</template>Complete Script Code
<script setup lang="ts">
import { COMPANIES } from '@/core/constants';
import {
createReferenceDetail,
FilterComponent,
HighlightComponent,
LosysApiService,
ReactiveAsyncDataSource,
ReferenceDetailComponent,
TeaserListComponent,
TeaserSliderComponent,
type FilterEntry,
type HighlightEntry,
type HighlightLayoutType,
type LosysApiAuthResult,
type LosysApiProjectFilter,
type ReferenceDetail,
type TeaserCard
} from '@etx/losys-lib';
import { reactive, ref } from 'vue';
// property definitions
/**
* init losys api service
* use etrex.dev proxy for now, which injects client secret into request
* Setting the authStore param is not needed. Default behavior: token is stored within service instance.
* If you want to store data elsewhere (as in example) pass a authStore impl.
*/
const apiService: LosysApiService = new LosysApiService({
authEndpoint: 'https://equans.etrex.live/middleware-losys/proxy.php?endpoint=auth&target=https://api.referenz-verwaltung.ch/oauth/token',
apiHost: 'https://api.referenz-verwaltung.ch/api',
authStore: {
set: (data: LosysApiAuthResult) => {
localStorage.setItem('losys-auth', JSON.stringify({
access_token: data.access_token,
company: undefined! // dont need it
}));
},
get: (): LosysApiAuthResult | undefined => {
const storedData = localStorage.getItem('losys-auth');
if (!storedData) return undefined;
return JSON.parse(storedData) as LosysApiAuthResult;
}
}
});
apiService.authenticateAndGetBasicInformation().then(() => {
loadData();
})
const pageSize: number = 5; // default page size for data sources
let variant = ref<HighlightLayoutType>('left');
let showStripes = ref<boolean>(false);
/**
* filter entries for filter component
* Is used to filter data source at a later point
*/
const filterEntries: FilterEntry<keyof LosysApiProjectFilter>[] = [
{
id: 'companyIds',
text: 'Unternehmen',
items: COMPANIES.map(c => {
return {
value: `${c.id}`,
text: c.text
}
}),
selected: [],
multiselect: true
}
];
/**
* Highlight data source
* Data source for the highlight component
*
*/
const highlightModuleDataSource: ReactiveAsyncDataSource<HighlightEntry, LosysApiProjectFilter> =
new ReactiveAsyncDataSource<HighlightEntry, LosysApiProjectFilter>(
(filter: LosysApiProjectFilter): Promise<HighlightEntry[]> => {
return new Promise<HighlightEntry[]>((resolve, _reject) => {
// fetch data from losys api
apiService.getCustomerProjects({
...filter,
languages: defaultLanguageFilter,
expand: ['project_images', 'project_categories', 'project_videos', 'project_properties']
})
.then(restData => {
// map result to highlight module entries
const data: HighlightEntry[] = restData.map(x => {
// use imagekit urls instead of losys api image urls
let imageKitUrl = (x.project_images ? x.project_images[0]?.extralarge_image : '')
.replace('https://api.referenz-verwaltung.ch/static/images/project/', 'https://ik.imagekit.io/equans/losys/');
imageKitUrl = `${imageKitUrl}?tr=w-3000,ar-3-2,fo-auto`;
return {
image: imageKitUrl,
title: {
main: x.title,
sub: x.city
},
id: 'test'
};
});
resolve(data);
});
});
}
);
/**
* Teaser data source
* Data source for the teaser list & slider component
*/
const teaserModuleDataSource: ReactiveAsyncDataSource<TeaserCard, LosysApiProjectFilter> =
new ReactiveAsyncDataSource<TeaserCard, LosysApiProjectFilter>(
(filter: LosysApiProjectFilter): Promise<TeaserCard[]> => {
return new Promise<TeaserCard[]>((resolve, _reject) => {
// fetch data from api
apiService.getCustomerProjects({
...filter,
expand: [
'project_images',
'project_categories'
]
})
.then(restData => {
// map the retrieved data into teaser card data
const data = restData
.filter(x => {
const approvalByMarketingIds = [765, 779, 765, 761, 781];
const approvalByMarketing = Object.values(x.project_properties ?? {}).find(x => approvalByMarketingIds.includes(x.projectAttributeId));
return approvalByMarketing?.value === "True";
})
.map(x => {
let imageUrl = (x.project_images && x.project_images?.length > 0 ? x.project_images[0]?.small_image : '')
.replace('https://api.referenz-verwaltung.ch/static/images/project/', 'https://ik.imagekit.io/equans/losys/');
imageUrl = `${imageUrl}?tr=w-1000,ar-3-2,fo-auto`;
return {
properties: {
id: `${x.id}`,
image: {
url: imageUrl
},
// TODO: map with competences provided through drupal
tags: ['Tag 1', 'Tag 2'],
title: {
small: x.zipcode,
main: x.title,
sub: x.city
}
}
};
});
resolve(data);
});
});
}
);
/**
* Trigged if all references is clicked within highlight module
*/
const handleHighlightAllReferencesClicked = () => {
alert('should route to all references overview page');
};
/**
* Toggles the highlight module variant
* Updates the module to display the block left or right
*/
const handleHighlightToggleVariant = (): void => {
variant.value = variant.value === 'right' ? 'left' : 'right';
}
/**
* Toggles wether to display the stripes image container
* within the highlight module
*/
const handleHighlightToggleStripes = (): void => {
showStripes.value = !showStripes.value;
}
/**
* Triggers if teaser card was clicked within highlight module
* @param card
*/
const handleTeaserSelected = (card: TeaserCard) => {
alert(`Selected teaser card: ${card.properties.title.main}. Should route to detail page`);
console.log('should route to entity detail with id:', card.properties.id);
}
/**
* handle filter component filter changes
* triggers data source load with new api filter which is composed
* based on the selected filter items
* @param filters
*/
const handleFiltersChanged = (filters: FilterEntry<(keyof LosysApiProjectFilter)>[]) => {
let apiFilter: LosysApiProjectFilter = {
pageIndex: 0,
pageSize: pageSize
};
// build up api filter
filters.forEach(x => {
const filterKey = x.id;
apiFilter = {
...apiFilter,
[filterKey]: x.selected.map(x => x.value)
}
});
// trigger data source to load data for given filter, overwrite stored data instead of append
teaserModuleDataSource.load(apiFilter, 'overwrite');
}
/**
* Triggers load on teaser data source
* @param filter
*/
const handleLoadTeaserListData = () => {
// trigger data source to load data for given filter, append result to stored data
teaserModuleDataSource.load({
pageSize: pageSize,
pageIndex: teaserModuleDataSource.activeFilter.pageIndex === 0 ? teaserModuleDataSource.activeFilter.pageIndex : (teaserModuleDataSource.activeFilter.pageIndex * pageSize),
expand: ['project_images', 'project_categories']
}, 'append');
}
/**
* CTA on detail component clicked
*/
const handleCtaClicked = () => {
alert('clicked cta: do something');
}
/**
* Define base data for ReferenceDetail component
*/
const reactiveTestDetail: ReferenceDetail = reactive(createReferenceDetail());
const loadData = () => {
// initial load for highlight module data source
highlightModuleDataSource.load({
pageIndex: 0,
pageSize: 3,
expand: ['project_images']
}, 'overwrite');
// initial load for teaser module data source
teaserModuleDataSource.load({
pageIndex: 0,
pageSize: 5,
expand: ['project_images']
}, 'append');
// initial load for highlight module data source
highlightModuleDataSource.load({
pageIndex: 0,
pageSize: 4,
expand: ['project_images', 'project_videos']
}, 'overwrite');
// initial load for teaser module data source
teaserModuleDataSource.load({
pageIndex: 0,
pageSize: 5,
expand: ['project_images', 'project_videos']
}, 'append');
apiService.getCustomerProjects({
pageIndex: 0,
pageSize: 100,
expand: [
'project_images',
'project_videos',
'project_categories',
'project_address_contact_persons',
'project_participating_companies',
'project_properties',
'project_type_of_buildings',
'project_type_of_constructions',
'project_type_of_works'
],
languages: ['de']
}).then(data => {
const x = data[0];
const props = Object.values(x.project_properties);
const approvalByMarketingIds = [765, 779, 765, 761, 781];
const approvalByMarketing = props.find(x => approvalByMarketingIds.includes(x.projectAttributeId));
const titleSection1 = props.find(x => x.projectAttributeId === 784);
const leadText = props.find(x => x.projectAttributeId === 785);
const description1 = props.find(x => x.projectAttributeId === 786);
const titleSection2 = props.find(x => x.projectAttributeId === 787);
const description2 = props.find(x => x.projectAttributeId === 788);
const quote = props.find(x => x.projectAttributeId === 789);
const quoteLegend = props.find(x => x.projectAttributeId === 790);
const advantageIds = [791, 792, 800, 814, 822, 830, 838, 846, 854, 862, 870, 878, 886];
const advantage: string = props.find(x => advantageIds.includes(x.projectAttributeId))?.value;
const advantages = advantage.split('\r\n');
if (approvalByMarketing?.value === "False") {
alert('This is not yet approved by marketing');
}
// Try to find Main Image otherwise use first image
const images = (x.project_images ?? []).length > 0 ? x.project_images ?? [] : [];
// use imagekit urls instead of losys api image urls
let imageKitUrls = images.map(img => {
let imageKitUrl = img.large_image.replace('https://api.referenz-verwaltung.ch/static/images/project/', 'https://ik.imagekit.io/equans/losys/');
imageKitUrl = `${imageKitUrl}?tr=w-3000,ar-3-2,fo-auto`;
return imageKitUrl;
});
const mainImage = images.find(x => x.isMainImage)?.large_image ??
(images.length > 0 ? images[0].large_image : undefined);
/**
* This should map to the top level competence of each iterated competence
* We want to display a tag for each top level competence of the reference.
* Apply the custom cms - losys mapping
*/
const topLevelTags = x.project_categories?.map(x => {
const tag: Tag = {
text: x.category?.titles?.displayLocale
};
return tag;
}) ?? [];
reactiveTestDetail.hero = {
title: {
main: x.title,
sub: x.city
},
image: {
url: mainImage!
},
// split the page navigation into crumbs
breadcrumbs: [
{
text: 'Parent Page',
url: 'https://parentpage....'
},
{
text: 'Current (Active) Page',
url: 'https://parentpage/currentpage....'
}
],
// top level competences tags corresponding to the reference
tags: topLevelTags
};
reactiveTestDetail.intro = {
title: {
main: titleSection1?.value,
sub: leadText?.value
},
text: description1?.value,
wanted: {
date: {
text: x.yearOfCompletion.toString()
},
location: {
text: `${x.city} - ${x.address}`,
},
title: {
// static text
main: 'Zahlen & Fakten'
}
}
};
reactiveTestDetail.images = imageKitUrls.reverse().map(x => {
return {
url: x
}
});
reactiveTestDetail.textBlock = {
title: {
main: titleSection2?.value
},
content: {
text: description2?.value
}
};
// would map video from losys but no example available RN
if (x.project_videos && x.project_videos.length > 0) {
reactiveTestDetail.mediaBlock = {
code: x.project_videos[0].embbedIframe
};
}
// map quotes coming from drupal?
if (quote && quoteLegend) {
reactiveTestDetail.quotes = [
{
person: quoteLegend?.value,
quote: quote?.value
}
];
}
/**
* For the sake of having an example: Harcoded
* Foreach topLevelTag in topLevelTags list the second level competences in tiles
*/
const subSections: TileTeaserSubSection[] = [
{
text: 'Gebäudetechnik',
url: 'https://www.google.com',
tiles: [
{
text: 'Heizung & Kühlung',
url: 'https://www.google.com'
},
{
text: 'Lüftung & Klima',
url: 'https://www.google.com'
},
{
text: 'Sanitär',
url: 'https://www.google.com'
},
{
text: 'Reinraum',
url: 'https://www.google.com'
}
]
}
];
/**
* Create SubSections for every top level competence mapped to the reference
* Each SubSction url should route to the reference overview page with active filter of the top level competence
* Each Tile url within a subscription should route the the references overview page with active filter of selected competence
*/
reactiveTestDetail.tileTeaser = {
title: {
// static text
main: 'Unsere Leistungen'
},
// static text
text: 'Als Komplettanbieterin für Gebäude, Infrastruktur und Energie gehen wir mit massgeschneiderten Lösungen auf Sie ein.', // static text
sections: [
{
title: {
main: 'Kompetenzen'
},
subSections: subSections
}
]
};
reactiveTestDetail.advantages = {
title: {
// static text
main: 'Ihre Vorteile'
},
// static text
text: 'Unsere Kund:innen haben die verschiedenste Bedürfnisse. Unser Anspruch ist es, für jedes Bedürfnis den besten Service zu bieten – passgenau, effizient und zuverlässig',
advantages: advantages,
style: 'bg-secondary'
};
});
}
</script>Complete Template Code
<template>
<main>
<!-- HIGHLIGHT MODULE -->
<div class="etx-container Container pt-0 pb-100">
<div class="etx-center Center">
<button @click="handleHighlightToggleVariant()">Toggle Variant {{ variant }}</button>
<HighlightComponent :background="variant" @all-references-clicked="handleHighlightAllReferencesClicked()"
@reference-clicked="handleHighlightReferenceClicked($event)" :dataSource="highlightModuleDataSource" />
</div>
</div>
<!-- TEASER MODULE AND FILTER -->
<!-- WARNING: USE style overflow unset in order for filter drawer to work -->
<div class="etx-container Container pt-0 pb-25 overflow-visible">
<div class="etx-center Center">
<FilterComponent :entries="filterEntries" @filters-changed="handleFiltersChanged($event)" />
</div>
</div>
<div class="etx-container Container pt-0 pb-0 overflow-visible">
<div class="etx-center Center">
<TeaserListComponent :dataSource="teaserModuleDataSource" @selected="handleTeaserSelected($event)" />
</div>
</div>
<div class="etx-container Container pt-25">
<div class="etx-center Center">
<div class="etx-button Button minimal active" @click="handleLoadTeaserListData()">
<div class="etx-button__text Button__text">
<span>{{ 'Mehr laden' }}</span>
</div>
<div class="etx-button__icon Button__icon-">
<i class="far fa-long-arrow-down"></i>
</div>
</div>
</div>
</div>
<!-- TEASER SWIPER MODULE AND FILTER -->
<div class="etx-container Container pt-0 pb-0 overflow-visible">
<div class="etx-center Center">
<TeaserSliderComponent :dataSource="teaserModuleDataSource" :page-size="pageSize" :id="'references-swiper-1'"
@selected="handleTeaserSelected($event)" />
</div>
</div>
<div class="etx-spacer"></div>
<!-- DETAIL REFERENCE VIEW -->
<ReferenceDetailComponent :data="reactiveTestDetail" @cta-clicked="handleCtaClicked()" />
</main>
</template>