snapshot of https://github.com/cortezaproject/corteza.git (2024.9.x, 2026-09-18; blobs > 131072B omitted)
Some checks failed
SonarQube / SonarQube (push) Failing after 5m28s
E2e test on main branch push / server and client test (map[name:admin port:8080]) (push) Failing after 3m33s
Server and lib tests / Library test (js) (push) Failing after 1m36s
Server and lib tests / Library test (vue) (push) Successful in 2m24s
Server and lib tests / Web client test (admin) (push) Successful in 2m52s
Server and lib tests / Web client test (compose) (push) Successful in 3m23s
Server and lib tests / Web client test (discovery) (push) Successful in 2m49s
Server and lib tests / Web client test (one) (push) Successful in 2m52s
Server and lib tests / Web client test (privacy) (push) Successful in 2m44s
Server and lib tests / Web client test (reporter) (push) Successful in 2m56s
Server and lib tests / Web client test (workflow) (push) Successful in 3m0s
Server and lib tests / Server tests (push) Failing after 4m55s
E2e test on main branch push / notify-failure (push) Failing after 9s
Server and lib tests / Send matrix testing notification (push) Successful in 2s
Mark stale issues and pull requests / stale (push) Successful in 40s

This commit is contained in:
upstream-mirror
2026-09-18 01:22:23 +05:00
commit 7e385fc126
8391 changed files with 1766236 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
> 1%
last 2 versions
not dead

View File

@@ -0,0 +1,16 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
# Tab indentation (no size specified)
[Makefile]
indent_style = tab

View File

@@ -0,0 +1,10 @@
module.exports = {
extends: ['corteza-client'],
parser: 'vue-eslint-parser',
parserOptions: {
parser: '@babel/eslint-parser',
requireConfigFile: false,
ecmaVersion: 2020,
sourceType: 'module',
},
}

20
client/web/discovery/.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
.DS_Store
node_modules
dist
*.log
# Editor directories and files
.idea
.vscode
*.iml
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw*
.nyc_output
coverage
/.act*
/workflow

View File

@@ -0,0 +1,39 @@
.PHONY: dep test build release upload
YARN_FLAGS ?= --non-interactive --no-progress --silent --emoji false
YARN = yarn $(YARN_FLAGS)
REPO_NAME ?= corteza-webapp-discovery
BUILD_FLAVOUR ?= corteza
BUILD_FLAGS ?= --production
BUILD_DEST_DIR = dist
BUILD_TIME ?= $(shell date +%FT%T%z)
BUILD_VERSION ?= $(shell git describe --tags --abbrev=0)
BUILD_NAME = $(REPO_NAME)-$(BUILD_VERSION)
RELEASE_NAME = $(BUILD_NAME).tar.gz
RELEASE_EXTRA_FILES ?= README.md LICENSE CONTRIBUTING.md DCO
RELEASE_PKEY ?= .upload-rsa
dep:
$(YARN) install
test:
$(YARN) lint
$(YARN) test:unit
build:
export BUILD_VERSION=${BUILD_VERSION} && $(YARN) build $(BUILD_FLAGS)
release:
@ cp $(RELEASE_EXTRA_FILES) $(BUILD_DEST_DIR)
@ tar -C $(BUILD_DEST_DIR) -czf $(RELEASE_NAME) $(dir $(BUILD_DEST_DIR))
upload: $(RELEASE_PKEY)
@ echo "put *.tar.gz" | sftp -q -o "StrictHostKeyChecking no" -i $(RELEASE_PKEY) $(RELEASE_SFTP_URI)
@ rm -f $(RELEASE_PKEY)
$(RELEASE_PKEY):
@ echo $(RELEASE_SFTP_KEY) | base64 -d > $(RELEASE_PKEY)
@ chmod 0400 $@

View File

@@ -0,0 +1,5 @@
module.exports = {
presets: [
['@vue/app', { useBuiltIns: 'entry' }],
],
}

View File

@@ -0,0 +1,36 @@
/** @type {import('jest').Config} */
module.exports = {
preset: '@vue/cli-plugin-unit-jest',
testMatch: [
'**/tests/unit/**/*.spec.js',
],
moduleNameMapper: {
'^corteza-webapp-discovery/(.*)$': '<rootDir>/$1',
},
moduleFileExtensions: [
'js',
'jsx',
'json',
'vue',
],
transform: {
'^.+\\.vue$': '@vue/vue2-jest',
'.+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub',
'^.+\\.jsx?$': 'babel-jest',
},
transformIgnorePatterns: [
'/node_modules/(?!(chai|sinon)/)',
],
snapshotSerializers: [
'jest-serializer-vue',
],
testEnvironment: 'jsdom',
testURL: 'http://localhost/',
watchPlugins: [
'jest-watch-typeahead/filename',
'jest-watch-typeahead/testname',
],
setupFiles: [
'<rootDir>/tests/setup.js',
],
}

View File

@@ -0,0 +1,87 @@
{
"name": "corteza-discovery",
"version": "2024.9.10",
"license": "Apache-2.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"dev-env": "vue-cli-service serve src/dev-env.js",
"build": "vue-cli-service build --mode production",
"lint": "vue-cli-service lint --fix",
"cdeps": "yarn link @cortezaproject/corteza-js @cortezaproject/corteza-vue",
"test:unit": "vue-cli-service test:unit",
"test": "yarn test:unit"
},
"gitHooks": {
"pre-commit": "yarn lint"
},
"resolutions": {
"cross-spawn": "^7.0.6",
"postcss": "^8.5.3"
},
"dependencies": {
"@cortezaproject/corteza-js": "^2024.9.10",
"@cortezaproject/corteza-vue": "2024.9.10",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/vue-fontawesome": "^2.0.10",
"@panter/vue-i18next": "^0.15.0",
"axios": "^1.8.3",
"bootstrap-vue": "2.23.1",
"i18next": "^23.16.8",
"i18next-browser-languagedetector": "^3.0.2",
"lodash": "^4.17.21",
"portal-vue": "^2.1.7",
"vue": "2.7.16",
"vue-native-websocket": "^2.0.15",
"vue-router": "^3.4.9",
"vue-simple-markdown": "^1.1.5",
"vue-split-panel": "^1.0.4",
"vuex": "^3.6.2"
},
"devDependencies": {
"@babel/core": "^7.26.7",
"@babel/eslint-parser": "^7.26.5",
"@vue/cli-plugin-babel": "5.0.8",
"@vue/cli-plugin-eslint": "5.0.8",
"@vue/cli-plugin-unit-jest": "^5.0.8",
"@vue/cli-service": "5.0.8",
"@vue/eslint-config-standard": "^8.0.1",
"@vue/test-utils": "^1.3.6",
"@vue/vue2-jest": "^27.0.0",
"babel-jest": "^27.1.0",
"chai": "^4.3.7",
"chai-as-promised": "^7.1.1",
"eslint": "^8.7.2",
"eslint-config-corteza-client": "file:../../../lib/eslint-client",
"eslint-plugin-vue": "^9.32.0",
"jest": "^27.1.0",
"jest-serializer-vue": "^2.0.2",
"jest-transform-stub": "^2.0.0",
"jest-watch-typeahead": "^1.0.0",
"null-loader": "^4.0.1",
"resolve-url-loader": "^5.0.0",
"rtlcss": "^4.3.0",
"sass": "^1.85.1",
"sass-loader": "^10",
"sinon": "^9.2.4",
"stylelint": "16.14.1",
"stylelint-config-standard": "^37.0.0",
"stylelint-scss": "^6.11.0",
"stylelint-webpack-plugin": "^5.0.1",
"vue-eslint-parser": "^9.4.3",
"vue-template-compiler": "2.7.16",
"webpack": "^5.97.1"
},
"stylelint": {
"plugins": [
"stylelint-scss"
],
"extends": "stylelint-config-standard",
"rules": {
"color-hex-length": null,
"no-empty-source": null
}
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
autoprefixer: {},
rtlcss: {},
},
}

View File

@@ -0,0 +1 @@
config.js

View File

@@ -0,0 +1,13 @@
// Corteza API location
window.CortezaAPI = 'https://api.cortezaproject.your-domain.tld'
window.CortezaDiscoveryAPI = 'https://api.searcher.your-domain.tld'
// CortezaAuth can be autoconfigured by replacing /api with /auth in CortezaAPI
// or by appending /auth to the end of CortezaAPI string
// When this is not possible and your configuration is more exotic you can set it
// explicitly:
// window.CortezaAuth = 'https://api.cortezaproject.your-domain.tld/auth';
// Configure CortezaWebapp when your web applications are not placed on the root.
// This is autoconfigured from the value of <base> tag href attribute in most cases.
// window.CortezaWebapp = 'https://cortezaproject.your-domain.tld';

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-0.0075 -0.0058 36.9075 38.5121" width="32px" height="32px">
<defs>
<style type="text/css">
.st0{fill:#95B4AF;}
</style>
</defs>
<path class="st0" d="M2.5,9.4c1.6-2.9,4-5.3,6.9-6.9c3-1.7,6.3-2.5,9.8-2.5c3.9-0.1,7.8,1.1,11.1,3.2c3.2,2.2,5.5,5.3,6.6,9h-8.7 c-0.8-1.7-2-3.1-3.6-4.1c-1.7-0.9-3.5-1.4-5.4-1.3c-2.1,0-4.1,0.5-5.9,1.5c-1.7,1-3.1,2.6-4,4.4c-1,2.1-1.5,4.3-1.4,6.6 c0,2.3,0.4,4.5,1.4,6.6c0.9,1.8,2.3,3.3,4,4.4c1.8,1,3.8,1.6,5.9,1.5c1.9,0,3.8-0.4,5.4-1.4c1.6-1,2.9-2.4,3.6-4.1h8.7 c-1.1,3.7-3.5,6.8-6.6,9c-3.3,2.2-7.2,3.3-11.1,3.2c-3.4,0-6.8-0.8-9.8-2.5c-2.9-1.6-5.2-4-6.9-6.8c-1.7-3-2.6-6.5-2.5-10 C-0.1,15.9,0.8,12.4,2.5,9.4z" id="object-0" style="fill: rgb(255, 150, 97);"/>
</svg>

After

Width:  |  Height:  |  Size: 792 B

View File

@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html data-color-mode="light" lang="en">
<head>
<!--
base location is root by default for all webapps
href value should be modified when app is placed under subdir
-->
<base href="/" />
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" id="favicon" href="">
<link rel="stylesheet" type="text/css" href="<%= BASE_URL %>custom.css">
<link rel="preload" href="<%= BASE_URL %>config.js" as="script" />
<title><%= FLAVOUR %></title>
</head>
<body id="discovery">
<noscript>
<strong>
JavaScript is disabled in your browser!
</strong>
<p>
We're sorry but Corteza doesn't work properly without JavaScript enabled.
Please enable it to continue.
</p>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
<script src="<%= BASE_URL %>config.js" type="text/javascript"></script>
<!-- contains cdns that will be injected at the head of the page -->
<script src="<%= BASE_URL %>code-snippets.js" type="text/javascript"></script>
</body>
</html>

View File

@@ -0,0 +1,142 @@
import Vue from 'vue'
import './config-check'
import './console-splash'
import './plugins'
import './mixins'
import './components'
import './filters'
import store from './store'
import { i18n, websocket } from '@cortezaproject/corteza-vue'
import router from './router'
export default (options = {}) => {
options = {
el: '#app',
name: 'discovery',
template: '<router-view v-if="loaded && i18nLoaded" />',
data: () => ({
loaded: false,
i18nLoaded: false,
}),
async created () {
this.$i18n.i18next.on('initialized', () => {
this.i18nLoaded = true
})
this.websocket()
return this.$auth.vue(this).handle().then(async ({ user }) => {
// switch the favicon based on the settings
await this.$Settings.init({ api: this.$SystemAPI }).then(() => {
const icon = this.$Settings.attachment('ui.iconLogo') || '/icon.svg'
const favicon = document.getElementById('favicon')
if (favicon) {
favicon.href = icon
}
})
if (user.meta.preferredLanguage) {
// After user is authenticated, get his preferred language
// and instruct i18next to change it
this.$i18n.i18next.changeLanguage(user.meta.preferredLanguage)
}
// switch the webapp theme based on user preference
if (user.meta.theme) {
document.getElementsByTagName('html')[0].setAttribute('data-color-mode', user.meta.theme)
}
// Initialize notifications
this.$store.dispatch('notifications/fetchNotifications')
this.loaded = true
})
.catch((err) => {
if (err instanceof Error && err.message === 'Unauthenticated') {
// user not logged-in,
// start with authentication flow
this.$auth.startAuthenticationFlow()
return
}
throw err
})
},
methods: {
/**
* Registers event listener for websocket messages and
* routes them depending on their type
*/
websocket () {
// cross-link auth & websocket so that ws can use the right access token
websocket.init(this)
// register event listener for messages
this.$on('websocket-message', ({ data }) => {
const msg = JSON.parse(data)
switch (msg['@type']) {
case 'notification':
this.$store.dispatch('notifications/addNotification', msg['@value'])
break
case 'notification.read':
this.$store.dispatch('notifications/updateReadNotification', msg['@value'])
break
case 'notification.unread':
this.$store.dispatch('notifications/updateUnreadNotification', msg['@value'])
break
case 'notification.read.all':
this.$store.dispatch('notifications/updateAllReadNotifications', msg['@value'])
break
case 'notification.unread.all':
this.$store.dispatch('notifications/updateAllUnreadNotifications', msg['@value'])
break
case 'notification.delete':
this.$store.dispatch('notifications/removeNotification', msg['@value'])
break
case 'error':
this.toastDanger('Websocket message with error', msg['@value'])
}
})
},
},
router,
store,
i18n: i18n(Vue,
{ app: 'corteza-webapp-discovery' },
'general',
'notification',
'notifications',
'navigation',
'filters',
'search',
),
// Any additional options we want to merge
...options,
}
const app = new Vue(options)
// Simple HMR acceptance
if (module.hot) {
module.hot.accept()
}
return app
}

View File

@@ -0,0 +1,108 @@
<template>
<c-map
style="height: calc(100vh - 64px);"
:map="{
zoom,
center,
}"
:markers="makerValues"
@on-marker-click="onMarkerClick"
@on-map-click="clearClickedMarker"
/>
</template>
<script>
import { components } from '@cortezaproject/corteza-vue'
const { CMap } = components
export default {
components: {
CMap,
},
props: {
markers: {
type: Array,
required: true,
},
hoverIndex: {
type: Number,
required: false,
default: undefined,
},
},
data () {
return {
zoom: 3,
center: [30, 30],
rotation: 0,
attribution: '&copy; <a target="_blank" rel="noopener noreferrer" href="http://osm.org/copyright">OpenStreetMap</a>',
clickedMarker: undefined,
}
},
computed: {
makerValues () {
return this.markers.map((marker, i) => {
return {
id: i,
value: marker.coordinates,
opacity: [this.hoverIndex, this.clickedMarker].includes(i) ? 1.0 : 0.6,
color: 'var(--primary)',
}
})
},
},
watch: {
markers: {
immediate: true,
handler (markers = []) {
if (markers.length) {
const { coordinates = [30, 30] } = markers[0]
this.center = coordinates
}
},
},
hoverIndex: {
handler (hoverIndex) {
if (hoverIndex) {
const { coordinates } = this.markers.find(({ id }) => id === hoverIndex) || {}
if (coordinates) {
this.center = coordinates
}
}
this.clickedMarker = undefined
},
},
},
methods: {
onMarkerClick ({ index }) {
if (index === this.clickedMarker) {
this.clickedMarker = undefined
} else {
this.clickedMarker = index
}
this.$emit('marker-clicked', this.clickedMarker)
},
clearClickedMarker () {
this.clickedMarker = undefined
this.$emit('marker-clicked', this.clickedMarker)
},
},
}
</script>
<style lang="scss">
.vl-style-text {
color: var(--white);
}
</style>

View File

@@ -0,0 +1,197 @@
<template>
<div>
<!-- <div class="my-2">
<h6 class="text-primary mb-2">
{{ $t('types.title') }}
</h6>
<b-form-checkbox-group
:checked="storeResourceTypes"
name="type"
:disabled="storeProcessing"
stacked
class="mt-1"
@change="updateResourceTypes"
>
<b-form-checkbox
v-for="option in options"
:key="option.value"
:value="option.value"
class="pointer ml-2"
>
<b-avatar
:icon="option.icon"
size="sm"
class="align-center bg-light text-dark"
/>
{{ option.text }}
</b-form-checkbox>
</b-form-checkbox-group>
</div> -->
<div
v-for="agg in aggregationOptions"
:key="agg.resource"
class="my-2"
>
<div
v-if="agg.items.length"
class="d-flex justify-content-between align-items-center"
style="min-height: 25px;"
>
<h6
class="text-primary d-flex mb-0"
>
{{ agg.name }}
<b-badge
v-if="groups[agg.name].length"
variant="light"
pill
class="ml-1 align-self-center"
>
{{ groups[agg.name].length }}
</b-badge>
</h6>
<b-button
v-if="groups[agg.name].length"
variant="link"
class="text-muted p-0 m-0"
size="sm"
@click="clearGroup(agg.name)"
>
{{ $t('reset') }}
</b-button>
</div>
<b-form-checkbox-group
v-model="groups[agg.name]"
stacked
class="mt-1 ml-2"
:disabled="storeProcessing"
@change="updateGroup(agg.name)"
>
<b-form-checkbox
v-for="(resource, i) in agg.items"
:key="i"
:value="resource.name"
class="mb-1"
>
<div class="d-flex align-items-center">
<span class="d-inline-block text-truncate">
{{ getResourceDisplayName(agg.resource, resource) }}
</span>
<!-- <span
class="pl-3 ml-auto text-muted"
>
{{ resource.hits }}
</span> -->
</div>
</b-form-checkbox>
</b-form-checkbox-group>
</div>
</div>
</template>
<script>
import { mapGetters, mapActions } from 'vuex'
export default {
i18nOptions: {
namespaces: 'filters',
},
data () {
return {
groups: {
Module: [],
Namespace: [],
},
}
},
computed: {
...mapGetters({
storeResourceTypes: 'discovery/resourceTypes',
storeAggregations: 'discovery/aggregations',
storeModules: 'discovery/modules',
storeNamespaces: 'discovery/namespaces',
storeProcessing: 'discovery/processing',
}),
options () {
return [
{ text: this.$t('types.namespace'), value: 'compose:namespace', icon: 'code-square' },
{ text: this.$t('types.module'), value: 'compose:module', icon: 'list-ul' },
{ text: this.$t('types.record'), value: 'compose:record', icon: 'file-earmark-text' },
]
},
aggregationOptions () {
let namespaceOptions = this.storeAggregations.find(({ resource }) => resource === 'compose:namespace') || {}
let moduleOptions = this.storeAggregations.find(({ resource }) => resource === 'compose:module') || {}
namespaceOptions = {
resource: 'compose:namespace',
name: 'Namespace',
hits: namespaceOptions.hits || 0,
items: namespaceOptions.resource_name || [],
}
// Get all modules that are missing from store aggregations but are in filter
const missingModuleOptions = this.groups.Module.filter(name => !(moduleOptions.resource_name || []).some(o => o.name === name))
.map(name => ({ name }))
moduleOptions = {
resource: 'compose:module',
name: 'Module',
hits: moduleOptions.hits || 0,
items: [
...missingModuleOptions,
...(moduleOptions.resource_name || []),
],
}
return [namespaceOptions, moduleOptions]
},
},
watch: {
storeNamespaces: {
immediate: true,
handler (namespace) {
this.groups.Namespace = namespace
},
},
storeModules: {
immediate: true,
handler (module) {
this.groups.Module = module
},
},
},
methods: {
...mapActions({
updateResourceTypes: 'discovery/updateResourceTypes',
}),
getResourceDisplayName (type, { name, handle, slug }) {
if (type === 'compose:namespace') {
return name || slug || 'Unnamed namespace'
} else if (type === 'compose:module') {
return handle || name || 'Unnamed module'
}
},
updateGroup (name) {
this.$store.dispatch(`discovery/update${name}s`, this.groups[name])
},
clearGroup (name) {
this.groups[name] = []
this.updateGroup(name)
},
},
}
</script>

View File

@@ -0,0 +1,90 @@
<template>
<b-overlay>
<b-card-header class="border-bottom">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2">
<h5
class="text-primary text-capitalize text-truncate mb-0"
>
<span
v-if="hit.value.namespace.name || hit.value.namespace.handle"
>
{{ hit.value.namespace.name || hit.value.namespace.handle }}
</span>
<span
v-if="hit.value.namespace.name || hit.value.namespace.handle"
class="mx-2"
>
<b-icon
icon="arrow-right"
font-scale="1"
/>
</span>
<span>
{{ hit.value.name || hit.value.handle }}
</span>
</h5>
<span class="text-nowrap">
<b-badge
v-if="Object.keys(hit.value.labels || { }).includes('federation')"
variant="light"
class="mr-1 h5 p-2 mb-0"
>
{{ $t('general:federated') }}
</b-badge>
<b-avatar
size="sm"
icon="list-ul"
class="align-center bg-light text-dark"
/>
{{ $t('types.module') }}
</span>
</div>
<div class="d-flex justify-content-between small">
<slot name="header" />
</div>
</b-card-header>
<b-card-body
class="d-flex flex-wrap"
style="gap: 2rem;"
>
<b-form-group
v-for="(value, name, i) in limitData()"
:key="i"
:label="name"
label-class="text-capitalize text-primary"
class="mb-0"
style="min-width: 20rem; max-width: 100%;"
>
{{ value }}
</b-form-group>
</b-card-body>
</b-overlay>
</template>
<script>
import base from './base'
export default {
i18nOptions: {
namespaces: 'filters',
},
extends: base,
computed: {
blacklistedFields () {
return [
...this.defaultBlacklistedFields,
'meta',
'fields',
'namespace',
'labels',
'module',
]
},
},
}
</script>

View File

@@ -0,0 +1,65 @@
<template>
<b-overlay>
<b-card-header class="border-bottom">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2">
<h5
class="text-primary text-capitalize text-truncate mb-0"
>
{{ hit.value.name || hit.value.slug }}
</h5>
<span class="text-nowrap">
<b-avatar
size="sm"
icon="code-square"
class="align-center bg-light text-dark"
/>
{{ $t('types.namespace') }}
</span>
</div>
<div class="d-flex justify-content-between small">
<slot name="header" />
</div>
</b-card-header>
<b-card-body>
<div
class="d-flex flex-wrap"
style="gap: 2rem;"
>
<b-form-group
v-for="(item, name, i) in limitData()"
:key="i"
:label="name"
label-class="text-capitalize text-primary"
class="mb-0"
style="min-width: 20rem; max-width: 100%;"
>
{{ item }}
</b-form-group>
</div>
</b-card-body>
</b-overlay>
</template>
<script>
import base from './base'
export default {
i18nOptions: {
namespaces: 'filters',
},
extends: base,
computed: {
blacklistedFields () {
return [
...this.defaultBlacklistedFields,
'meta',
'namespace',
]
},
},
}
</script>

View File

@@ -0,0 +1,176 @@
<template>
<b-overlay>
<b-card-header class="border-bottom">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2">
<h5 class="text-primary text-capitalize text-truncate mb-0">
<span
v-if="hit.value.namespace.name || hit.value.namespace.handle"
>
{{ hit.value.namespace.name || hit.value.namespace.handle }}
</span>
<span
v-if="hit.value.namespace.name || hit.value.namespace.handle"
class="mx-2"
>
<b-icon
icon="arrow-right"
font-scale="1"
/>
</span>
<span>
{{ hit.value.module.name || hit.value.module.handle }}
</span>
</h5>
<span class="text-nowrap">
<b-avatar
size="sm"
icon="file-earmark-text"
class="align-center bg-light text-dark"
/>
{{ $t('types.record') }}
</span>
</div>
</b-card-header>
<b-card-body class="d-flex flex-column flex-wrap gap-2">
<div v-if="Object.keys(hit.value.labels || { }).includes('federation')">
<b-badge
variant="light"
class="h6 mb-0"
>
{{ $t('general:federated') }}
</b-badge>
</div>
<div
v-if="recordValues.length"
class="d-flex flex-wrap gap-2 flex-grow-1"
>
<b-form-group
v-for="(item, i) in recordValues"
:key="i"
:label="item.label || item.name"
label-class="text-capitalize text-primary"
class="mb-0"
style="min-width: 20rem; max-width: 100%; white-space: pre-line;"
>
{{ item.value }}
</b-form-group>
</div>
<div
v-if="systemValues.length"
class="d-flex flex-wrap gap-2 flex-grow-1"
>
<b-form-group
v-for="item in systemValues"
:key="item.name"
:label="item.label"
label-class="text-capitalize text-primary"
class="mb-0"
style="min-width: 20rem; max-width: 100%;"
>
<c-user-label
v-if="item.name === 'createdBy'"
:user="createdByUser"
/>
<template v-else>
{{ item.value }}
</template>
</b-form-group>
</div>
</b-card-body>
</b-overlay>
</template>
<script>
import { components } from '@cortezaproject/corteza-vue'
import base from './base'
const { CUserLabel } = components
const userCache = new Map()
const inflight = new Map()
export default {
i18nOptions: {
namespaces: 'filters',
},
components: {
CUserLabel,
},
extends: base,
data () {
return {
enrichedUser: null,
}
},
computed: {
recordID () {
return this.hit.value.recordID
},
recordValues () {
const { values = [] } = this.hit.value
return (values || []).map(({ name, label, value = [] }) => {
if (value) {
value = value.map(v => {
return (v !== null ? v : '').toString().includes('{"coordinates":[') ? ((JSON.parse(v || '{}') || {}).coordinates || []).join(', ') : v
}).join('\n')
}
return { name, label, value }
})
},
createdByUser () {
return this.enrichedUser || this.createdBy || {}
},
systemValues () {
return [
{ name: 'recordID', label: this.$t('general:recordID'), value: this.recordID },
{ name: 'createdBy', label: this.$t('general:createdBy'), value: this.createdBy },
{ name: 'createdAt', label: this.$t('general:createdAt'), value: this.createdAt },
{ name: 'updatedAt', label: this.$t('general:updatedAt'), value: this.updatedAt },
].filter(v => v.value)
},
},
watch: {
'createdBy.userID': {
immediate: true,
handler (userID) {
if (!userID) {
this.enrichedUser = null
return
}
if (userCache.has(userID)) {
this.enrichedUser = userCache.get(userID)
return
}
const pending = inflight.get(userID) || this.$SystemAPI.userRead({ userID })
.then(user => {
userCache.set(userID, user)
return user
})
.finally(() => {
inflight.delete(userID)
})
inflight.set(userID, pending)
pending.then(user => {
this.enrichedUser = user
}).catch(() => {
// keep snapshot fallback
})
},
},
},
}
</script>

View File

@@ -0,0 +1,60 @@
<template>
<b-overlay>
<b-card-header class="border-bottom">
<div class="d-flex align-items-center justify-content-between">
<h5
class="text-primary text-capitalize text-truncate mr-2 mb-0"
>
{{ $t('types.user') }}
</h5>
<b-avatar
v-b-tooltip.noninteractive.hover="{ title: $t('types.user'), boundary: 'body' }"
size="sm"
class="align-center bg-light text-dark"
/>
</div>
<div class="d-flex justify-content-between small">
<slot name="header" />
</div>
</b-card-header>
<b-card-body class="pb-0">
<b-form-group
v-for="(value, name, i) in limitData()"
:key="i"
:label="name"
label-class="text-capitalize text-primary"
class="mb-0"
style="min-width: 200px; max-width: 100%;"
>
{{ value }}
</b-form-group>
</b-card-body>
</b-overlay>
</template>
<script>
import base from './base'
export default {
i18nOptions: {
namespaces: 'filters',
},
extends: base,
computed: {
blacklistedFields () {
return [
...this.defaultBlacklistedFields,
'meta',
'fields',
'namespace',
'labels',
]
},
},
}
</script>

View File

@@ -0,0 +1,79 @@
<script>
export default {
props: {
index: {
type: Number,
required: true,
},
hit: {
type: Object,
required: true,
},
showMap: {
type: Boolean,
required: true,
},
},
data () {
return {
defaultBlacklistedFields: ['deleted', 'created', 'updated', 'security', 'catch_all'],
}
},
computed: {
blacklistedFields () {
return this.defaultBlacklistedFields
},
query () {
return [this.$route.query.query || '']
},
createdBy () {
const { by } = this.hit.value.created || {}
return by
},
createdAt () {
const { at } = this.hit.value.created || {}
return at ? this.$options.filters.locFullDateTime(at) : at
},
updatedAt () {
const { at } = this.hit.value.updated || {}
return at ? this.$options.filters.locFullDateTime(at) : at
},
},
methods: {
limitData () {
const out = {}
if (this.hit.value) {
for (const key in this.hit.value) {
const value = this.hit.value[key]
if (!!value && this.blacklistedFields.indexOf(key) < 0) {
out[key] = value
}
}
}
if (this.createdBy) {
out.createdBy = this.createdBy
}
if (this.createdAt) {
out.createdAt = this.createdAt
}
if (this.updatedAt) {
out.updatedAt = this.updatedAt
}
return out
},
},
}
</script>

View File

@@ -0,0 +1,74 @@
<template>
<b-card
no-body
class="result shadow-sm h-100"
:class="{ 'shadow': hovered }"
@mouseover="onHover"
@mouseleave="onLeave"
>
<a
v-if="hit.value.url"
:href="hit.value.url"
target="_blank"
rel="noopener noreferrer"
class="stretched-link"
/>
<component
:is="component"
v-bind="$props"
/>
</b-card>
</template>
<script>
import base from './base'
import * as Results from './loader'
export default {
extends: base,
props: {
hit: {
type: Object,
required: true,
},
},
data () {
return {
hovered: false,
}
},
computed: {
component () {
const { type } = this.hit
const resourceType = type.split(':')[1]
const keys = Object.keys(Results)
const i = keys.map(c => c.toLocaleLowerCase()).findIndex(c => c === resourceType)
return Results[keys[i]]
},
},
methods: {
onHover () {
this.hovered = true
this.$emit('hover', this.hit.value.recordID)
},
onLeave () {
this.hovered = false
this.$emit('hover', undefined)
},
},
}
</script>
<style lang="scss" scoped>
.result {
transition: all 0.3s ease-in;
}
</style>

View File

@@ -0,0 +1,4 @@
export { default as Module } from './Module'
export { default as Namespace } from './Namespace'
export { default as Record } from './Record'
export { default as User } from './User'

View File

@@ -0,0 +1,445 @@
<template>
<b-container
fluid
class="h-100 mh-100 p-0 d-flex flex-column"
>
<split
direction="horizontal"
:gutter-size="12"
class="h-100 overflow-hidden"
>
<split-area
:size="map.show ? 70 : 100"
:min-size="300"
class="d-flex flex-column"
>
<div class="px-3 flex-shrink-0">
<b-form-group class="mb-0">
<c-input-search
:value="query"
:placeholder="$t('input-placeholder')"
:autocomplete="'off'"
:disabled="storeProcessing"
submittable
@search="onQuerySubmit"
/>
</b-form-group>
<div
class="d-flex align-items-center px-1 mt-1 mb-2 text-muted"
>
<div class="d-flex align-items-center">
<span
:class="{ 'discovering': storeProcessing }"
class="mt-1"
>
{{ searchDescription }}
</span>
</div>
<div class="d-flex align-items-center ml-auto gap-2">
<b-button
variant="extra-light"
size="sm"
class="d-flex align-items-center gap-1 mt-2"
@click="toggleMap"
>
<font-awesome-icon
:icon="['fas', 'map-marked-alt']"
/>
{{ !map.show ? $t('search:show-map') : $t('search:hide-map') }}
</b-button>
<div class="d-flex align-items-center">
<font-awesome-icon
:icon="['fas', 'grip-lines']"
class="mt-2 mr-1 pointer"
:class="{ 'text-primary': viewMode === 'list' }"
@click="viewMode = 'list'"
/>
<b-form-checkbox
v-model="viewMode"
:value="'grid'"
:unchecked-value="'list'"
switch
class="pointer ml-2"
/>
<font-awesome-icon
:icon="['fas', 'grip-horizontal']"
class="mt-2 ml-1 pointer"
:class="{ 'text-primary': viewMode === 'grid' }"
@click="viewMode = 'grid'"
/>
</div>
</div>
</div>
</div>
<div class="d-flex flex-column flex-fill overflow-hidden">
<div
v-if="(storeProcessing || !total.actual) && !loadingMore"
class="d-flex align-items-center justify-content-center w-100 my-5"
>
<h5 class="mb-0">
<b-spinner
v-if="storeProcessing"
variant="primary"
class="p-4"
/>
<span
v-else-if="!total.actual"
>
{{ $t('no-results') }}
</span>
</h5>
</div>
<div
v-else
class="results d-flex flex-wrap gap-3 px-4 py-3 overflow-auto"
:class="{ 'list-view': viewMode === 'list' }"
>
<div
v-for="(hit, i) in hits"
:key="i"
class="result-item w-100"
:class="{ 'grid-view': viewMode === 'grid' }"
>
<result
:id="`result-${i}`"
:index="i"
:hit="hit"
:show-map="map.show"
:class="{ 'border-primary border shadow': map.clickedMarker && map.clickedMarker === i }"
class="border"
@hover="onResultHover(i)"
/>
</div>
<div
v-if="total.actual > 0 && total.actual < total.all"
class="d-flex align-items-center justify-content-center py-3 w-100"
>
<b-button
variant="primary"
:disabled="loadingMore"
class="d-flex align-items-center justify-content-center gap-1"
@click="getSearchData({ append: true })"
>
<b-spinner
v-if="loadingMore"
small
/>
{{ loadingMore ? $t('search:loading-more') : $t('search:show-more') }}
</b-button>
</div>
</div>
</div>
</split-area>
<split-area
:size="map.show ? 30 : 0"
:min-size="300"
>
<discovery-map
v-if="map.show"
:markers="map.markers"
:hover-index="map.hoverIndex"
class="pl-2"
@marker-clicked="markerClicked"
/>
</split-area>
</split>
</b-container>
</template>
<script>
import { mapGetters, mapActions } from 'vuex'
import Result from './Results'
import DiscoveryMap from './DiscoveryMap.vue'
import { components } from '@cortezaproject/corteza-vue'
import { Split, SplitArea } from 'vue-split-panel'
const { CInputSearch } = components
export default {
i18nOptions: {
namespaces: 'search',
},
components: {
Result,
DiscoveryMap,
CInputSearch,
Split,
SplitArea,
},
data () {
return {
loadingMore: false,
query: '',
hits: [],
pagination: {
limit: 50,
from: 0,
size: 50,
},
total: {
all: 0,
actual: 0,
},
initial: false,
map: {
show: false,
markers: [],
clickedMarker: undefined,
hoverIndex: undefined,
},
viewMode: 'list',
}
},
computed: {
...mapGetters({
storeProcessing: 'discovery/processing',
storeResourceTypes: 'discovery/resourceTypes',
storeModules: 'discovery/modules',
storeNamespaces: 'discovery/namespaces',
}),
searchDescription () {
if (this.storeProcessing) {
return this.$t('discovering')
}
if (this.total.all > 0) {
return this.$t('range', { actual: this.total.actual, all: this.total.all })
}
return ''
},
},
watch: {
storeResourceTypes: {
handler () {
if (this.initial) return
this.pagination.size = this.pagination.limit
this.getSearchData()
},
},
storeModules: {
handler () {
if (this.initial) return
this.pagination.size = this.pagination.limit
this.getSearchData()
},
},
storeNamespaces: {
handler () {
if (this.initial) return
this.pagination.size = this.pagination.limit
this.getSearchData()
},
},
},
mounted () {
this.initial = true
const { query = '', modules, namespaces, resourceTypes, size = 50 } = this.$route.query
this.query = query
this.pagination.size = size
if (namespaces) {
this.updateNamespaces(Array.isArray(namespaces) ? namespaces : [namespaces])
}
if (modules) {
this.updateModules(Array.isArray(modules) ? modules : [modules])
}
if (resourceTypes) {
this.updateResourceTypes(Array.isArray(resourceTypes) ? resourceTypes : [resourceTypes])
}
this.getSearchData()
setTimeout(() => {
this.initial = false
}, 1000)
},
methods: {
...mapActions({
fetchData: 'discovery/fetchData',
updateModules: 'discovery/updateModules',
updateNamespaces: 'discovery/updateNamespaces',
updateResourceTypes: 'discovery/updateResourceTypes',
}),
getSearchData ({ query = this.query, append = false } = {}) {
if (append) {
this.pagination.size += this.pagination.limit
this.loadingMore = true
} else {
this.map.markers = []
this.hits = []
this.map.hoverIndex = undefined
}
const modules = this.storeModules
const namespaces = this.storeNamespaces
const { size } = this.pagination
this.updateRouteQuery({ query, modules, namespaces, size })
this.fetchData({ query, modules, namespaces, size }).then((response = {}) => {
if (response) {
if (append) {
this.hits = [...this.hits, ...(response.hits || [])]
} else {
this.hits = response.hits || []
}
this.total.all = response.total_results || 0
this.total.actual = this.hits.length
this.pagination = {
...this.pagination,
from: response.from || 0,
size: response.size || 0,
}
this.getMarkers()
}
}).catch(e => {
this.toastErrorHandler(this.$t('notification:search.failed'))(e)
this.hits = []
}).finally(() => {
this.loadingMore = false
})
},
onQuerySubmit (query) {
if (!this.storeProcessing) {
this.query = query
this.pagination.size = 50
this.getSearchData()
}
},
getMarkers () {
const markers = []
this.hits.forEach(({ type, value }) => {
if (type === 'compose:record' && Array.isArray(value.values)) {
const id = value.recordID
value.values.forEach(({ value = [] }) => {
const isGeometry = value && value.find(v => {
return (v !== null ? v : '').toString().includes('{"coordinates":[')
})
if (isGeometry) {
value.forEach(coordinates => {
coordinates = JSON.parse(coordinates || '{}').coordinates
if (coordinates && coordinates.length) {
markers.push({ id, coordinates })
}
})
}
})
}
})
this.map.markers = markers
},
markerClicked (ID) {
if (ID) {
const result = document.getElementById(`result-${ID}`)
if (result) {
result.scrollIntoView({
behavior: 'smooth',
block: 'center',
})
}
}
this.map.clickedMarker = ID
this.map.hoverIndex = undefined
},
onResultHover (index) {
if (this.map.show) {
this.map.hoverIndex = index
}
},
toggleMap () {
this.map.show = !this.map.show
},
updateRouteQuery ({ query = undefined, modules = [], namespaces = [], size = 0 }) {
if (JSON.stringify(this.$route.query) !== JSON.stringify({ query, modules, namespaces, size })) {
this.$router.push({ query: { query: query || undefined, modules, namespaces, size } })
}
},
},
}
</script>
<style lang="scss">
.split .gutter {
background-color: transparent;
}
</style>
<style lang="scss" scoped>
// https://stackoverflow.com/a/40991531/17926309
.discovering::after {
display: inline-block;
animation: discovering steps(1, end) 1s infinite;
content: '';
}
@keyframes discovering {
0% { content: ''; }
25% { content: '.'; }
50% { content: '..'; }
75% { content: '...'; }
100% { content: ''; }
}
.result-item {
&.grid-view {
min-width: 30rem;
flex: 1;
}
}
.list-view {
.result-item {
max-width: 100%;
}
}
.results {
flex: 1;
min-height: 0;
position: relative;
}
</style>

View File

@@ -0,0 +1,56 @@
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faGripHorizontal,
faGripLines,
faThumbtack,
faBars,
faChevronUp,
faChevronDown,
faSearch,
faMapMarkedAlt,
faTimes,
faLocationArrow,
faCheckDouble,
faBellSlash,
faBell as faBellSolid,
faEllipsisV,
faCheck,
faLocationCrosshairs,
faPaperPlane,
faRobot,
} from '@fortawesome/free-solid-svg-icons'
import {
faQuestionCircle,
faUser,
faBell,
faTrashAlt,
faEnvelope,
faEnvelopeOpen,
} from '@fortawesome/free-regular-svg-icons'
library.add(
faUser,
faQuestionCircle,
faGripHorizontal,
faThumbtack,
faBars,
faChevronDown,
faChevronUp,
faSearch,
faMapMarkedAlt,
faTimes,
faLocationArrow,
faCheckDouble,
faBell,
faBellSolid,
faBellSlash,
faEllipsisV,
faTrashAlt,
faGripLines,
faCheck,
faLocationCrosshairs,
faEnvelope,
faEnvelopeOpen,
faPaperPlane,
faRobot,
)

View File

@@ -0,0 +1,15 @@
import Vue from 'vue'
import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome'
import PortalVue from 'portal-vue'
import './faIcons'
import { components } from '@cortezaproject/corteza-vue'
Vue.use(PortalVue)
Vue.component('font-awesome-icon', FontAwesomeIcon)
Vue.component('font-awesome-layers', FontAwesomeLayers)
Vue.component('c-permissions-button', components.CPermissionsButton)
Vue.component('c-input-confirm', components.CInputConfirm)
Vue.component('c-input-processing', components.CInputProcessing)
Vue.component('c-input-search', components.CInputSearch)

View File

@@ -0,0 +1,8 @@
[
'CortezaAPI',
].forEach((cfg) => {
if (window[cfg] === undefined) {
throw new Error(`Missing or invalid configuration.
Make sure there is a public/config.js configuration file with window.${cfg} entry.`)
}
})

View File

@@ -0,0 +1,6 @@
/* eslint-disable no-undef */
const text = `%c${WEBAPP || 'Corteza Webapp'}, version: ${VERSION}, build time: ${BUILD_TIME}`
const style = 'background-color: #1397CB; color: white; padding: 3px 10px; border: 1px solid black; font: Courier'
/* eslint-disable no-console */
console.log(text, style)

View File

@@ -0,0 +1,6 @@
import Vue from 'vue'
import { filters } from '@cortezaproject/corteza-vue'
for (const n in filters) {
Vue.filter(n, filters[n])
}

View File

@@ -0,0 +1,5 @@
import './themes'
import app from './app'
// Mount the app
app()

View File

@@ -0,0 +1,5 @@
import Vue from 'vue'
import { mixins } from '@cortezaproject/corteza-vue'
Vue.mixin(mixins.toast)

View File

@@ -0,0 +1,30 @@
import Vue from 'vue'
import Vuex from 'vuex'
import Router from 'vue-router'
import { BootstrapVue, BootstrapVueIcons } from 'bootstrap-vue'
import VueNativeSock from 'vue-native-websocket'
import VueSimpleMarkdown from 'vue-simple-markdown'
import 'vue-simple-markdown/dist/vue-simple-markdown.css'
import { plugins, websocket } from '@cortezaproject/corteza-vue'
import DiscoveryAPI from './searcher.js'
Vue.use(Vuex)
Vue.use(Router)
Vue.use(BootstrapVue, {
BToast: {
// see https://bootstrap-vue.org/docs/components/toast#comp-ref-b-toast-props
autoHideDelay: 7000,
toaster: 'b-toaster-bottom-right',
},
BModal: {
noEnforceFocus: true,
},
})
Vue.use(BootstrapVueIcons)
Vue.use(plugins.Auth(), { app: 'discovery' })
Vue.use(plugins.CortezaAPI('system'))
Vue.use(plugins.CortezaAPI('compose'))
Vue.use(plugins.Settings, { api: Vue.prototype.$SystemAPI })
Vue.use(DiscoveryAPI())
Vue.use(VueNativeSock, websocket.endpoint(), websocket.config)
Vue.use(VueSimpleMarkdown)

View File

@@ -0,0 +1,130 @@
import axios from 'axios'
export default function (service, opt = {}) {
if (!opt.baseURL) {
// @ts-ignore
if (!window.CortezaDiscoveryAPI) {
throw new Error('config.js missing or window.CortezaDiscoveryAPI not set')
}
// @ts-ignore
opt.baseURL = `${window.CortezaDiscoveryAPI}/`
}
return function (Vue) {
if (!opt.accessTokenFn) {
/**
* Checking if auth plugin was initialized before and
* hooking on to it's accessTokenFn
*/
opt.accessTokenFn = Vue.prototype.$auth.accessTokenFn
}
// @ts-ignore
// makes Vue.$<service>API (Vue.$SystemAPI, Vue.$ComposeAPI, Vue.$FederationAPI, Vue.$AutomationAPI) available
Vue.prototype.$DiscoveryAPI = new Searcher(opt)
}
}
function stdResolve (response) {
if (response.data.error) {
return Promise.reject(response.data.error)
} else {
return response.data.response
}
}
class Searcher {
baseURL
accessTokenFn
headers = {}
constructor ({ baseURL, headers, accessTokenFn }) {
this.baseURL = baseURL
this.accessTokenFn = accessTokenFn
this.headers = {
/**
* All we send is JSON
*/
'Content-Type': 'application/json',
}
this.setHeaders(headers)
}
setAccessTokenFn (fn) {
this.accessTokenFn = fn
return this
}
setHeaders (headers) {
if (typeof headers === 'object') {
this.headers = headers
}
return this
}
setHeader (name, value) {
if (value === undefined) {
delete this.headers[name]
} else {
this.headers[name] = value
}
return this
}
api () {
const headers = { ...this.headers }
const accessToken = this.accessTokenFn ? this.accessTokenFn() : undefined
if (accessToken) {
headers.Authorization = 'Bearer ' + accessToken
}
return axios.create({
withCredentials: true,
baseURL: this.baseURL,
headers,
})
}
// List namespaces
async query (a, extra = {}) {
const {
modules,
namespaces,
from,
size,
resourceTypes,
} = a || {}
const params = new URLSearchParams()
// Filter
if (modules?.length > 0) modules.forEach(m => params.append('moduleAggs', m))
if (namespaces?.length > 0) namespaces.forEach(n => params.append('namespaceAggs', n))
if (resourceTypes?.length > 0) resourceTypes.forEach(t => params.append('resourceTypes', t))
// Pagination
if (from) params.append('from', from)
if (size) params.append('size', size)
const cfg = {
...extra,
method: 'get',
url: this.queryEndpoint(a),
params,
}
return this.api().request(cfg).then(result => stdResolve(result))
}
queryEndpoint (a) {
const {
query = '',
} = a || {}
return `/?q=${query}`
}
}

View File

@@ -0,0 +1,7 @@
import Router from 'vue-router'
import routes from './views/routes'
export default new Router({
mode: 'history',
routes,
})

View File

@@ -0,0 +1,20 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
const routes = [
{
name: 'root',
path: '/',
component: () => import('../views/Layout'),
},
{ path: '*', redirect: { name: 'root' } },
]
const router = new VueRouter({
mode: 'history',
routes,
})
export default router

View File

@@ -0,0 +1,81 @@
const storeTypes = {
processing: 'processing',
resourceTypes: 'resourceTypes',
aggregations: 'aggregations',
modules: 'modules',
namespaces: 'namespaces',
}
export default function (DiscoveryAPI) {
return {
namespaced: true,
state: {
processing: false,
resourceTypes: [
'compose:record',
],
aggregations: [],
modules: [],
namespaces: [],
},
getters: {
processing: state => state.processing,
resourceTypes: state => state.resourceTypes,
aggregations: state => state.aggregations,
modules: state => state.modules,
namespaces: state => state.namespaces,
},
actions: {
async fetchData ({ commit, state }, { query, modules = state.modules, namespaces = state.namespaces, size }) {
commit(storeTypes.processing, true)
return DiscoveryAPI.query({ query, modules, namespaces, size, resourceTypes: state.resourceTypes }).then((response = {}) => {
if (response) {
commit(storeTypes.aggregations, response.aggregations)
}
return response
}).finally(() => {
commit(storeTypes.processing, false)
})
},
updateResourceTypes ({ commit }, resourceTypes) {
commit(storeTypes.resourceTypes, resourceTypes)
},
updateModules ({ commit }, modules) {
commit(storeTypes.modules, modules)
},
updateNamespaces ({ commit }, namespaces) {
commit(storeTypes.namespaces, namespaces)
},
},
mutations: {
[storeTypes.processing] (state, value) {
state.processing = value
},
[storeTypes.resourceTypes] (state, resourceTypes) {
state.resourceTypes = resourceTypes
},
[storeTypes.aggregations] (state, aggregations) {
state.aggregations = aggregations
},
[storeTypes.modules] (state, modules) {
state.modules = modules
},
[storeTypes.namespaces] (state, namespaces) {
state.namespaces = namespaces
},
},
}
}

View File

@@ -0,0 +1,21 @@
import Vue from 'vue'
import Vuex from 'vuex'
import discovery from './discovery'
import { store as cvStore } from '@cortezaproject/corteza-vue'
Vue.use(Vuex)
export default new Vuex.Store({
strict: process.env.NODE_ENV !== 'production',
modules: {
discovery: discovery(Vue.prototype.$DiscoveryAPI),
notifications: {
namespaced: true,
...cvStore.notifications({
api: Vue.prototype.$SystemAPI,
}),
},
},
})

View File

@@ -0,0 +1,5 @@
.custom-control-label {
width: 100%;
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}

View File

@@ -0,0 +1 @@
import './index.scss'

View File

@@ -0,0 +1,2 @@
@import "./custom";
@import "./poppins.scss";

View File

@@ -0,0 +1,146 @@
// Fonts Path
$fonts_dir : './fonts/' !default;
@font-face {
font-family: 'Poppins-Bold';
src: url($fonts_dir + 'poppins/Poppins-Bold.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins-BlackItalic';
src: url($fonts_dir + 'poppins/Poppins-BlackItalic.ttf') format('truetype');
font-weight: 100 900;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Poppins-Italic';
src: url($fonts_dir + 'poppins/Poppins-Italic.ttf') format('truetype');
font-weight: 100 900;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Poppins-BoldItalic';
src: url($fonts_dir + 'poppins/Poppins-BoldItalic.ttf') format('truetype');
font-weight: 100 900;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Poppins-ExtraBoldItalic';
src: url($fonts_dir + 'poppins/Poppins-ExtraBoldItalic.ttf') format('truetype');
font-weight: 100 900;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Poppins-ExtraLightItalic';
src: url($fonts_dir + 'poppins/Poppins-ExtraLightItalic.ttf') format('truetype');
font-weight: 100 900;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Poppins-Black';
src: url($fonts_dir + 'poppins/Poppins-Black.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins-ExtraBold';
src: url($fonts_dir + 'poppins/Poppins-ExtraBold.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins-ExtraLight';
src: url($fonts_dir + 'poppins/Poppins-ExtraLight.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins-MediumItalic';
src: url($fonts_dir + 'poppins/Poppins-MediumItalic.ttf') format('truetype');
font-weight: 100 900;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Poppins-SemiBoldItalic';
src: url($fonts_dir + 'poppins/Poppins-SemiBoldItalic.ttf') format('truetype');
font-weight: 100 900;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Poppins-SemiBold';
src: url($fonts_dir + 'poppins/Poppins-SemiBold.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins-LightItalic';
src: url($fonts_dir + 'poppins/Poppins-LightItalic.ttf') format('truetype');
font-weight: 100 900;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Poppins-Regular';
src: url($fonts_dir + 'poppins/Poppins-Regular.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins-Medium';
src: url($fonts_dir + 'poppins/Poppins-Medium.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins-Light';
src: url($fonts_dir + 'poppins/Poppins-Light.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins-ThinItalic';
src: url($fonts_dir + 'poppins/Poppins-ThinItalic.ttf') format('truetype');
font-weight: 100 900;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Poppins-Thin';
src: url($fonts_dir + 'poppins/Poppins-Thin.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}

View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line no-unused-expressions
import(/* webpackChunkName: 'corteza-base' */ './corteza-base')

View File

@@ -0,0 +1,126 @@
<template>
<div class="d-flex flex-column w-100 h-viewport overflow-hidden">
<header>
<c-topbar
:expanded="expanded"
:settings="$Settings.get('ui.topbar', {})"
:labels="{
appMenu: $t('appMenu'),
helpForum: $t('help.forum'),
helpDocumentation: $t('help.documentation'),
helpFeedback: $t('help.feedback'),
helpVersion: $t('help.version'),
userSettingsLoggedInAs: $t('userSettings.loggedInAs', { user }),
userSettingsProfile: $t('userSettings.profile'),
userSettingsChangePassword: $t('userSettings.changePassword'),
userSettingsLogout: $t('userSettings.logout'),
userSettingsTheme: $t('userSettings.theme'),
lightTheme: $t('general:themes.labels.light'),
darkTheme: $t('general:themes.labels.dark'),
}"
>
<template #title>
{{ $t("discovery") }}
</template>
</c-topbar>
</header>
<aside>
<c-sidebar
:expanded.sync="expanded"
:icon="icon"
:logo="logo"
expand-on-click
>
<template #body-expanded>
<filters />
</template>
</c-sidebar>
</aside>
<main class="d-inline-flex overflow-hidden">
<!--
Content spacer
Large and xl screens should push in content when the nav is expanded
-->
<template>
<div
class="sidebar-spacer d-print-none"
:class="{
expanded: expanded,
}"
/>
</template>
<div class="flex-grow-1 overflow-hidden">
<search />
</div>
</main>
<c-extend-session
v-if="isAutoLogoutEnabled"
:timeout="$Settings.get('auth.autoLogout.timeout')"
:labels="{
extend: $t('general:extendSession.labels.extend'),
warning: (countdownTime) =>
$t('general:extendSession.labels.warning', { countdownTime }),
}"
/>
<c-notification-sidebar
v-if="!$Settings.get('ui.topbar', {}).hideNotifications"
/>
</div>
</template>
<script>
import Search from '../components/Search.vue'
import Filters from '../components/Filters.vue'
import { components } from '@cortezaproject/corteza-vue'
const { CTopbar, CSidebar, CExtendSession, CNotificationSidebar } = components
export default {
i18nOptions: {
namespaces: 'navigation',
},
components: {
CTopbar,
CSidebar,
Search,
Filters,
CExtendSession,
CNotificationSidebar,
},
data () {
return {
expanded: false,
}
},
computed: {
user () {
const { user } = this.$auth
return user.name || user.handle || user.email || ''
},
icon () {
return this.$Settings.attachment('ui.iconLogo')
},
logo () {
return this.$Settings.attachment('ui.mainLogo')
},
isAutoLogoutEnabled () {
return this.$Settings.get('auth.autoLogout.enabled')
},
},
}
</script>
<style scoped>
.h-viewport {
height: 100vh;
height: 100dvh;
}
</style>

View File

@@ -0,0 +1,10 @@
export default [
{
name: 'root',
path: '',
component: () => import('./Layout.vue'),
},
// When everything else fails, go to root
{ path: '*', redirect: { name: 'root' } },
]

View File

@@ -0,0 +1,31 @@
/* global jest */
jest.mock('@cortezaproject/corteza-js', () => ({}), { virtual: true })
jest.mock('@cortezaproject/corteza-vue', () => ({
components: {
CToaster: jest.fn(),
CPrompts: {
name: 'c-prompts',
render: () => {},
},
CPermissionsModal: {
name: 'c-permissions-modal',
render: () => {},
},
CTopbar: {
name: 'c-topbar',
render: () => {},
},
CSidebar: {
name: 'c-sidebar',
render: () => {},
},
},
mixins: {
corredor: {
methods: {
triggerScript: jest.fn(),
},
},
},
}), { virtual: true })

View File

@@ -0,0 +1,7 @@
module.exports = {
env: {
es6: true,
node: true,
mocha: true,
},
}

View File

@@ -0,0 +1,78 @@
/* eslint-disable no-unused-expressions */
/* global jest */
import { expect } from 'chai'
import { shallowMount, createLocalVue } from '@vue/test-utils'
import Layout from '../../../src/views/Layout'
import BootstrapVue from 'bootstrap-vue'
import PortalVue from 'portal-vue'
import Vuex from 'vuex'
import sinon from 'sinon'
describe('Layout.vue', () => {
let localVue
let store
let rbacModule
beforeEach(() => {
localVue = createLocalVue()
localVue.use(BootstrapVue)
localVue.use(PortalVue)
localVue.use(Vuex)
// Mock the Vuex store
rbacModule = {
namespaced: true,
getters: {
can: () => () => true,
},
}
store = new Vuex.Store({
modules: {
rbac: rbacModule,
},
})
})
afterEach(() => {
sinon.restore()
})
it('renders', () => {
const wrapper = shallowMount(Layout, {
localVue,
store,
mocks: {
$auth: {
user: {},
},
$Settings: {
get: () => ({}),
attachment: () => '',
},
$t: (key) => key,
textDirectionality: () => 'ltr',
$root: {
$on: jest.fn(),
},
},
stubs: [
'router-view',
'portal-target',
'c-topbar',
'c-sidebar',
'c-prompts',
'c-permissions-modal',
],
})
expect(wrapper.find('div').exists()).to.be.true
expect(wrapper.find('div').classes()).to.include.members([
'd-flex',
'flex-column',
'w-100',
'h-viewport',
'overflow-hidden',
])
})
})

View File

@@ -0,0 +1,239 @@
const webpack = require('webpack')
const exec = require('child_process').execSync
const path = require('path')
const Vue = require('vue')
module.exports = ({ appFlavour, appLabel, version = process.env.BUILD_VERSION, theme, packageAlias, root = path.resolve('.'), env = process.env.NODE_ENV }) => {
const isDevelopment = (env === 'development')
const isTest = (env === 'test')
if (isTest) {
Vue.config.devtools = false
Vue.config.productionTip = false
}
if (isDevelopment) {
Vue.config.devtools = true
Vue.config.performance = true
}
const optimization = isTest
? {}
: {
usedExports: true,
runtimeChunk: 'single',
splitChunks: {
chunks: 'all',
minSize: 20000,
maxSize: 244000,
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
priority: -10,
},
common: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true,
},
},
},
}
return {
publicPath: isDevelopment ? '/' : './',
lintOnSave: true,
runtimeCompiler: true,
configureWebpack: {
// other webpack options to merge in ...
// Webpack 5 specific configuration
resolve: {
symlinks: true,
fallback: {
path: false,
fs: false,
crypto: false,
stream: false,
util: false,
},
},
plugins: [
new webpack.DefinePlugin({
FLAVOUR: JSON.stringify(appFlavour),
WEBAPP: JSON.stringify(appLabel),
VERSION: JSON.stringify(version || ('' + exec('git describe --always --tags')).trim()),
BUILD_TIME: JSON.stringify((new Date()).toISOString()),
}),
],
optimization: {
...optimization,
moduleIds: 'deterministic',
chunkIds: 'named',
emitOnErrors: false,
},
stats: {
warnings: false,
errors: false,
},
performance: {
hints: isDevelopment ? false : 'warning',
maxEntrypointSize: 512000,
maxAssetSize: 512000,
},
},
chainWebpack: config => {
// Remove css extraction issues
// https://github.com/vuejs/vue-cli/issues/3771#issuecomment-526228100
config.plugin('friendly-errors').tap(args => {
const vueCli3Transformer = args[0].additionalTransformers[0]
args[0].additionalTransformers = [
vueCli3Transformer,
error => {
return /\[mini-css-extract-plugin\]/.test(error.message) ? {} : error
},
]
return args
})
// Update copy-webpack-plugin configuration for v8+
config.plugins.has('copy') && config.plugin('copy').tap(options => {
options[0].patterns = [{
from: 'public',
globOptions: {
ignore: ['**/config*js', '**/*gitignore', '**/index.html'],
},
noErrorOnMissing: true,
}]
return options
})
// Aliasing full package name instead of '@' so we do
// not break imports on apps that import this code
config.resolve.alias.delete('@')
if (packageAlias) {
config.resolve.alias.set(packageAlias, root)
}
if (isTest) {
const scssRule = config.module.rule('scss')
scssRule.uses.clear()
scssRule
.use('null-loader')
.loader('null-loader')
}
const scssNormal = config.module.rule('scss').oneOf('normal')
scssNormal.use('sass-loader')
.loader('sass-loader')
.tap(options => ({
...options,
sourceMap: true,
sassOptions: {
outputStyle: isDevelopment ? 'expanded' : 'compressed',
},
}))
// Load CSS assets according to their location
scssNormal.use('resolve-url-loader')
.loader('resolve-url-loader').options({
keepQuery: true,
removeCR: true,
root: path.join(root, 'src/themes', theme),
})
.before('sass-loader')
// Keep this to ensure we don't have multiple HTML plugins
},
devServer: {
host: '127.0.0.1',
port: 8080,
hot: true,
allowedHosts: 'all',
webSocketServer: 'ws',
liveReload: false,
headers: {
'Access-Control-Allow-Origin': '*',
},
proxy: {
'^/custom.css': {
target: fetchBaseUrl(),
},
'^/code-snippets.js': {
target: fetchBaseUrl(),
},
},
watchFiles: {
paths: [
'**/*',
'!**/node_modules/!(@cortezaproject)/**',
],
options: {
usePolling: true,
aggregateTimeout: 200,
poll: 1000,
},
},
client: {
overlay: false,
progress: true,
},
},
css: {
sourceMap: isDevelopment,
extract: !isTest,
loaderOptions: {
sass: {
sassOptions: {
outputStyle: isDevelopment ? 'expanded' : 'compressed',
},
},
postcss: {
postcssOptions: {
plugins: [
require('autoprefixer'),
require('rtlcss'),
],
},
},
},
},
}
}
function fetchBaseUrl () {
const fs = require('fs')
const window = {}
const fileContents =
fs.existsSync('public/config.js')
? fs.readFileSync('public/config.js', 'utf-8')
: ''
try {
// eslint-disable-next-line no-eval
eval(fileContents)
const u = window.CortezaAPI || ''
const ur = new URL(u.startsWith('//') ? `http:${u}` : u)
return `${ur.protocol}//${ur.host}/`
} catch (e) {
return '/'
}
}

View File

@@ -0,0 +1,9 @@
const buildVueConfig = require('./vue.config-builder')
module.exports = buildVueConfig({
appFlavour: 'Discovery',
appName: 'discovery',
appLabel: 'Corteza Discovery Discovery',
theme: 'corteza-base',
packageAlias: 'corteza-webapp-discovery',
})