Add files from webapp-discovery with client/web/discovery prefix

This commit is contained in:
Corteza Monorepo Migrator
2022-11-14 09:26:45 +01:00
parent 6564abcf36
commit f28e34b57b
67 changed files with 15175 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
module.exports = {
root: false,
env: {
es6: true,
node: true,
mocha: true,
},
extends: [
'plugin:vue/recommended',
'@vue/standard',
],
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'new-cap': 'off',
'import/no-named-default': 'off',
'vue/component-name-in-template-casing': ['error', 'kebab-case'],
'vue/no-v-html': 'off',
'vue/order-in-components': ['error'],
'comma-dangle': ['error', 'always-multiline'],
},
parserOptions: {
parser: 'babel-eslint',
},
}
+20
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
+37
View File
@@ -0,0 +1,37 @@
# build-stage
FROM node:12.14-alpine as build-stage
ENV PATH /app/node_modules/.bin:$PATH
WORKDIR /app
RUN apk update && apk add --no-cache git
COPY package.json ./
COPY yarn.lock ./
RUN yarn install
COPY . ./
RUN yarn build
# deploy stage
FROM nginx:stable-alpine
WORKDIR /usr/share/nginx/html
COPY --from=build-stage /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
COPY CONTRIBUTING.* DCO LICENSE README.* ./
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 80
HEALTHCHECK --interval=30s --start-period=10s --timeout=30s \
CMD wget --quiet --tries=1 --spider "http://127.0.0.1:80/config.js" || exit 1
ENTRYPOINT ["/entrypoint.sh"]
+39
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 $@
+12
View File
@@ -0,0 +1,12 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset',
],
env: {
test: {
plugins: [
['istanbul', { useInlineSourceMaps: false }],
],
},
},
}
+42
View File
@@ -0,0 +1,42 @@
#!/bin/sh
set -eu
if [ ! -z "${1:-}" ]; then
exec "$@"
else
# check if config.js is present (via volume)
# or if it's missing
if [ ! -f "./config.js" ]; then
# config.js missing, generate it
if [ ! -z "${CONFIGJS:-}" ]; then
# use $CONFIGJS variable that might be passed to the container:
# --env CONFIGJS="$(cat public/config.example.js)"
echo "${CONFIGJS}" > ./config.js
else
# Try to guess where the API is located by using DOMAIN or VIRTUAL_HOST and prefix it with "api."
API_HOST=${API_HOST:-"api.${VIRTUAL_HOST:-"${DOMAIN:-"local.cortezaproject.org"}"}"}
API_BASEURL=${API_FULL_URL:-"//${API_HOST}"}
echo "window.CortezaAPI = '${API_BASEURL}'" > ./config.js
fi
fi
BASE_PATH=${BASE_PATH:-"/"}
if [ $BASE_PATH != "/" ]; then
BASE_PATH_LENGTH=${#BASE_PATH}
BASE_PATH_LAST_CHAR=${BASE_PATH:BASE_PATH_LENGTH-1:1}
if [ $BASE_PATH_LAST_CHAR != "/" ]; then
BASE_PATH="$BASE_PATH/"
fi
fi
sed -i "s|<base href=/ >|<base href=\"$BASE_PATH\">|g" ./index.html
sed -i "s|<base href=\"/\">|<base href=\"$BASE_PATH\">|g" ./index.html
sed -i "s|{{BASE_PATH}}|$BASE_PATH|g" /etc/nginx/nginx.conf
nginx -g "daemon off;"
fi
+46
View File
@@ -0,0 +1,46 @@
user nginx;
worker_processes 1;
error_log /dev/stdout warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format json escape=json
'{'
'"@timestamp":"$time_iso8601",'
'"remote_addr":"$remote_addr",'
'"request":"$request",'
'"status":$status,'
'"body_bytes_sent":$body_bytes_sent,'
'"request_time":$request_time,'
'"http_referrer":"$http_referer",'
'"http_user_agent":"$http_user_agent"'
'}';
access_log /dev/stdout json;
sendfile on;
keepalive_timeout 300;
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
index index.html;
root /usr/share/nginx/html;
location {{BASE_PATH}} {
try_files $uri {{BASE_PATH}}index.html;
}
}
}
+112
View File
@@ -0,0 +1,112 @@
{
"name": "corteza-discovery",
"version": "2022.9.2",
"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 && stylelint '**/*.{vue,scss}' --fix",
"test:unit": "vue-cli-service test:unit",
"test:unit:cc": "nyc vue-cli-service test:unit",
"cdeps": "yarn upgrade @cortezaproject/corteza-js @cortezaproject/corteza-vue"
},
"gitHooks": {
"pre-commit": "yarn lint"
},
"dependencies": {
"@cortezaproject/corteza-js": "^2022.9.2",
"@cortezaproject/corteza-vue": "^2022.9.2",
"@fortawesome/fontawesome-svg-core": "^1.2.21",
"@fortawesome/free-regular-svg-icons": "^5.10.1",
"@fortawesome/free-solid-svg-icons": "^5.10.1",
"@fortawesome/vue-fontawesome": "^0.1.9",
"@panter/vue-i18next": "^0.15.0",
"axios": "^0.24.0",
"bootstrap-vue": "^2.21.2",
"html-parse-stringify": "^3.0.1",
"i18next": "^17.0.9",
"i18next-browser-languagedetector": "^3.0.2",
"leaflet": "^1.7.1",
"portal-vue": "^2.1.7",
"vue": "2.6.14",
"vue-router": "^3.4.9",
"vue-text-highlight": "^2.0.10",
"vue2-leaflet": "^2.7.1",
"vuelayers": "^0.11.37",
"vuex": "^3.1.1"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-plugin-unit-mocha": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"@vue/eslint-config-prettier": "^6.0.0",
"@vue/eslint-config-standard": "^5.1.0",
"@vue/test-utils": "^1.0.0-beta.25",
"babel-eslint": "^10.1.0",
"babel-loader": "^7.0.0",
"babel-plugin-istanbul": "^5.2.0",
"babel-preset-vue": "^2.0.2",
"chai": "^4.2.0",
"eslint": "^6.7.2",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-node": "^11.0.0",
"eslint-plugin-promise": "^4.2.1",
"eslint-plugin-standard": "^4.0.1",
"eslint-plugin-vue": "^6.2.2",
"flush-promises": "^1.0.2",
"null-loader": "^4.0.1",
"nyc": "^14.1.1",
"resolve-url-loader": "^3.1.2",
"sass": "^1.49.9",
"sass-loader": "^10",
"sinon": "^7.3.2",
"stylelint": "^8.0.0",
"stylelint-config-standard": "^18.3.0",
"stylelint-scss": "^3.14.2",
"stylelint-webpack-plugin": "^0.10.5",
"vue-template-compiler": "2.6.14"
},
"stylelint": {
"plugins": [
"stylelint-scss"
],
"extends": "stylelint-config-standard",
"rules": {
"color-hex-case": null,
"color-hex-length": null,
"no-empty-source": null,
"selector-list-comma-newline-after": null
}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
],
"nyc": {
"all": true,
"reporter": [
"lcov",
"text"
],
"include": [
"src/**/*.{js,vue}"
],
"exclude": [
"**/index.js",
"**/*.spec.js"
],
"extension": [
".js",
".vue"
],
"check-coverage": true,
"per-file": true,
"branches": 0,
"lines": 0,
"functions": 0,
"statements": 0
}
}
+1
View File
@@ -0,0 +1 @@
config.js
@@ -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';
Binary file not shown.

After

Width:  |  Height:  |  Size: 702 B

+30
View File
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html 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="<%= BASE_URL %>favicon32x32.png">
<link rel="preload" href="<%= BASE_URL %>config.js" as="script" />
<title><%= FLAVOUR %></title>
</head>
<body>
<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>
</body>
</html>
+59
View File
@@ -0,0 +1,59 @@
<template>
<div id="app">
<div>
<router-view
v-if="loaded && i18nLoaded"
class="h-100"
/>
</div>
</div>
</template>
<script>
export default {
data: () => ({
loaded: false,
i18nLoaded: false,
}),
async created () {
this.$i18n.i18next.on('loaded', () => {
this.i18nLoaded = true
})
this.$auth.handle().then(({ accessTokenFn, user }) => {
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)
}
this.$Settings.init({ api: this.$SystemAPI }).then(() => {
this.loaded = true
// This bit removes code from the query params
//
// Vue router can't be used here because when on any child route there is no
// guarantee that the route has loaded and so it may redirect us to the root page.
//
// @todo dig a bit deeper if there is a better vue-like solution; atm none were ok.
const url = new URL(window.location.href)
if (url.searchParams.get('code')) {
url.searchParams.delete('code')
window.location.replace(url.toString())
}
})
})
.catch((err) => {
if (err instanceof Error && err.message === 'Unauthenticated') {
// user not logged-in,
// start with authentication flow
this.$auth.startAuthenticationFlow()
return
}
throw err
})
},
}
</script>
@@ -0,0 +1,92 @@
<template>
<l-map
:zoom="zoom"
:center="center"
style="height: calc(100vh - 64px);"
@click="clearClickedMarker()"
>
<l-tile-layer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
:attribution="attribution"
/>
<l-marker
v-for="(marker, i) in markers"
:key="i"
:lat-lng="getLatLng(marker.coordinates)"
:opacity="[hoverIndex, clickedMarker].includes(marker.id) ? 1.0 : 0.6"
@click="onMarkerClick(marker.id)"
/>
</l-map>
</template>
<script>
import { latLng } from 'leaflet'
export default {
props: {
markers: {
type: Array,
required: true,
},
hoverIndex: {
type: String,
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,
}
},
watch: {
markers: {
immediate: true,
handler (markers = []) {
if (markers.length) {
const { coordinates = [30, 30] } = markers[0]
this.center = this.getLatLng(coordinates)
}
},
},
hoverIndex: {
handler (hoverIndex) {
if (hoverIndex) {
const { coordinates } = this.markers.find(({ id }) => id === hoverIndex) || {}
if (coordinates) {
this.center = this.getLatLng(coordinates)
}
}
},
},
},
methods: {
getLatLng (coordinates = [0, 0]) {
return latLng(coordinates[0], coordinates[1])
},
onMarkerClick (ID) {
this.clickedMarker = ID
this.$emit('hover', this.clickedMarker)
},
clearClickedMarker () {
this.clickedMarker = undefined
this.$emit('hover', this.clickedMarker)
},
},
}
</script>
<style lang="scss">
.vl-style-text {
color: white;
}
</style>
@@ -0,0 +1,190 @@
<template>
<div>
<div class="my-2">
<h6 class="text-primary mb-2">
{{ this.$t('types.title') }}
</h6>
<b-form-checkbox-group
v-model="types"
name="types"
:disabled="$store.state.processing"
stacked
class="mt-1"
@change="updateTypes()"
>
<b-form-checkbox
v-for="option in options"
:key="option.value"
:value="option.value"
class="ml-2"
>
<div
class="d-flex align-items-center mb-0"
>
<span class="d-inline-block text-truncate mr-3">
{{ option.text }}
</span>
</div>
</b-form-checkbox>
</b-form-checkbox-group>
</div>
<div
v-for="agg in aggregationOptions"
:key="agg.resource"
class="mt-4"
>
<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="dark"
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="$store.state.processing"
@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>
export default {
i18nOptions: {
namespaces: 'filters',
},
data () {
return {
types: [],
groups: {
Module: [],
Namespace: [],
},
}
},
computed: {
options () {
return [
{ text: this.$t('types.namespace'), value: 'compose:namespace' },
{ text: this.$t('types.module'), value: 'compose:module' },
{ text: this.$t('types.record'), value: 'compose:record' },
{ text: this.$t('types.user'), value: 'system:user' },
]
},
aggregationOptions () {
let namespaceOptions = this.$store.state.aggregations.find(({ resource }) => resource === 'compose:namespace') || {}
let moduleOptions = this.$store.state.aggregations.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: {
'$store.state.namespaces': {
immediate: true,
handler (namespace) {
this.groups.Namespace = namespace
},
},
'$store.state.modules': {
immediate: true,
handler (module) {
this.groups.Module = module
},
},
},
methods: {
getResourceDisplayName (type, { name, handle, slug }) {
if (type === 'compose:namespace') {
return name || slug || 'Unnamed namespace'
} else if (type === 'compose:module') {
return handle || name || 'Unnamed module'
}
},
updateTypes () {
this.$store.commit('updateTypes', this.types)
},
updateGroup (name) {
this.$store.commit(`update${name}s`, this.groups[name])
},
clearGroup (name) {
this.groups[name] = []
this.$store.commit(`update${name}s`, [])
},
},
}
</script>
@@ -0,0 +1,101 @@
<template>
<b-overlay>
<b-card-header
header-bg-variant="white"
class="border-bottom"
>
<div class="d-flex align-items-center mb-3 justify-content-between">
<h5
class="text-primary text-capitalize text-truncate mr-2 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
v-b-tooltip.hover
:title="$t('types.module')"
size="sm"
icon="list-ul"
class="align-center bg-light text-dark"
style="z-index: 1;"
/>
</span>
</div>
<div class="d-flex justify-content-between small">
<slot name="header" />
</div>
</b-card-header>
<b-card-body class="pb-0">
<div
v-for="(value, name, i) in limitData()"
:key="i"
class="d-flex flex-column mb-3"
>
<label
class="text-capitalize text-primary mb-0"
>
{{ name }}
</label>
<div class="mt-1">
<text-highlight
:queries="query"
highlight-style="padding: 0 0.05rem;"
>
{{ value }}
</text-highlight>
</div>
</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',
'fields',
'namespace',
'labels',
'module',
]
},
},
}
</script>
@@ -0,0 +1,71 @@
<template>
<b-overlay>
<b-card-header
header-bg-variant="white"
class="border-bottom"
>
<div class="d-flex align-items-center mb-3 justify-content-between">
<h5
class="text-primary text-capitalize text-truncate mr-2 mb-0"
>
{{ hit.value.name || hit.value.slug }}
</h5>
<b-avatar
v-b-tooltip.hover
:title="$t('types.namespace')"
size="sm"
icon="code-square"
class="align-center bg-light text-dark"
style="z-index: 1;"
/>
</div>
<div class="d-flex justify-content-between small">
<slot name="header" />
</div>
</b-card-header>
<b-card-body class="pb-0">
<div
v-for="(item, name, i) in limitData()"
:key="i"
class="d-flex flex-column mb-3"
>
<label
class="text-capitalize text-primary mb-0"
>
{{ name }}
</label>
<div class="mt-1">
<text-highlight
:queries="query"
highlight-style="padding: 0 0.05rem;"
>
{{ item }}
</text-highlight>
</div>
</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>
@@ -0,0 +1,110 @@
<template>
<b-overlay>
<b-card-header
header-bg-variant="white"
class="border-bottom"
>
<div class="d-flex align-items-center mb-3 justify-content-between">
<h5
class="text-primary text-capitalize text-truncate mr-2 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-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
v-b-tooltip.hover
:title="$t('filters:types.record')"
size="sm"
icon="file-earmark-text"
class="align-center bg-light text-dark"
style="z-index: 1;"
/>
</span>
</div>
<div class="d-flex justify-content-between small">
<slot name="header" />
</div>
</b-card-header>
<b-card-body class="pb-0">
<div
v-if="limitData().length"
>
<div
v-for="(item, i) in limitData()"
:key="i"
class="d-flex flex-column mb-3"
>
<label
class="text-capitalize text-primary mb-0"
>
{{ item.label || item.name }}
</label>
<p class="multiline mt-1 mb-0">
<text-highlight
:queries="query"
highlight-style="padding: 0 0.05rem;"
>
{{ item.value }}
</text-highlight>
</p>
</div>
</div>
<p
v-else
>
{{ $t('general:no-values') }}
</p>
</b-card-body>
</b-overlay>
</template>
<script>
import base from './base'
export default {
extends: base,
methods: {
limitData () {
const { values = [] } = this.hit.value
return (values || []).map(({ name, label, value = [] }) => {
if (value) {
value = value.map(v => {
return v.toString().includes('{"coordinates":[') ? ((JSON.parse(v || '{}') || {}).coordinates || []).join(', ') : v
}).join('\n')
}
return { name, label, value }
})
},
},
}
</script>
@@ -0,0 +1,74 @@
<template>
<b-overlay>
<b-card-header
header-bg-variant="white"
class="border-bottom"
>
<div class="d-flex align-items-center mb-3 justify-content-between">
<h5
class="text-primary text-capitalize text-truncate mr-2 mb-0"
>
{{ $t('types.user') }}
</h5>
<b-avatar
v-b-tooltip.hover
:title="$t('types.user')"
size="sm"
class="align-center bg-light text-dark"
style="z-index: 1;"
/>
</div>
<div class="d-flex justify-content-between small">
<slot name="header" />
</div>
</b-card-header>
<b-card-body class="pb-0">
<div
v-for="(value, name, i) in limitData()"
:key="i"
class="d-flex flex-column mb-3"
>
<label
class="text-capitalize text-primary mb-0"
>
{{ name }}
</label>
<div class="mt-1">
<text-highlight
:queries="[$route.query.query]"
highlight-style="padding: 0 0.05rem;"
>
{{ value }}
</text-highlight>
</div>
</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',
'fields',
'namespace',
'labels',
]
},
},
}
</script>
@@ -0,0 +1,62 @@
<script>
import TextHighlight from 'vue-text-highlight'
export default {
components: {
TextHighlight,
},
props: {
index: {
type: Number,
required: true,
},
hit: {
type: Object,
required: true,
},
showMap: {
type: Boolean,
required: true,
},
},
data () {
return {
defaultBlacklistedFields: ['deleted', 'created', 'updated', 'security'],
}
},
computed: {
blacklistedFields () {
return this.defaultBlacklistedFields
},
query () {
return this.$route.query.query || ''
},
},
methods: {
limitData () {
const out = {}
const limit = 5
if (this.hit.value) {
let counter = 0
for (const key in this.hit.value) {
const value = this.hit.value[key]
if (counter < limit && !!value && this.blacklistedFields.indexOf(key) < 0) {
out[key] = value
counter++
}
}
}
return out
},
},
}
</script>
@@ -0,0 +1,99 @@
<template>
<b-card
no-body
class="shadow-sm h-100"
:class="{ 'hover-effect': hit.value.url }"
@mouseover="$emit('hover', hit.value.recordID)"
@mouseleave="$emit('hover', undefined)"
>
<a
v-if="hit.value.url"
:href="hit.value.url"
target="_blank"
rel="noopener noreferrer"
class="stretched-link"
/>
<component
:is="component"
v-bind="$props"
>
<template #header>
<span
v-if="createdBy"
class="text-truncate text-nowrap mr-1"
>
<b-icon-person />
{{ createdBy }}
</span>
<span
v-if="createdAt"
class="text-nowrap mr-1"
>
<b-icon-calendar />
{{ createdAt }}
</span>
<span
v-if="updatedAt"
class="text-nowrap"
>
<b-icon-pencil-square />
{{ updatedAt }}
</span>
</template>
</component>
</b-card>
</template>
<script>
import base from './base'
import * as Results from './loader'
export default {
extends: base,
props: {
hit: {
type: Object,
required: true,
},
},
computed: {
component () {
let { type } = this.hit
type = type.split(':')[1]
const keys = Object.keys(Results)
const i = keys.map(c => c.toLocaleLowerCase()).findIndex(c => c === type)
return Results[keys[i]]
},
createdBy () {
const { by } = this.hit.value.created || {}
return by
},
createdAt () {
const { at } = this.hit.value.created || {}
return at ? new Date(at).toLocaleDateString() : at
},
updatedAt () {
const { at } = this.hit.value.updated || {}
return at ? new Date(at).toLocaleDateString() : at
},
},
}
</script>
<style lang="scss" scoped>
.hover-effect {
&:hover {
transition: all 0.2s ease;
box-shadow: 0 4px 8px rgba(38, 38, 38, 0.2) !important;
top: -2px;
}
}
</style>
@@ -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'
@@ -0,0 +1,396 @@
<template>
<b-container
fluid
class="h-100 mh-100 p-0"
>
<b-row
no-gutters
>
<b-col
md="12"
:lg="map.show ? '7' : '12'"
:xl="map.show ? '8' : '12'"
class="results-container"
:class="{ 'with-map': map.show }"
>
<b-form
@submit.prevent="onQuerySubmit()"
>
<b-form-group class="px-3">
<c-input-search
v-model="query"
:placeholder="$t('input-placeholder')"
:autocomplete="'off'"
submittable
@search="onQuerySubmit()"
/>
<div
class="d-flex align-items-center justify-content-between px-1 mt-1 text-muted"
>
<span
:class="{ 'discovering': $store.state.processing }"
>
{{ searchDescription }}
</span>
<span>
Use <samp>"text"</samp> for exact match
</span>
</div>
</b-form-group>
</b-form>
<b-row
class="results w-100 m-0 mh-100 overflow-auto"
>
<div
v-if="$store.state.processing || !total.actual"
class="position-absolute d-flex align-items-center justify-content-center w-100 h-100"
style="opacity: 0.8; z-index: 1; background-color: #F3F3F5;"
>
<h5
class="mb-5"
>
<b-spinner
v-if="$store.state.processing"
variant="primary"
class="p-4"
/>
<span
v-else-if="!total.actual"
>
{{ $t('no-results') }}
</span>
</h5>
</div>
<b-col
v-for="(hit, i) in filteredHits"
:key="i"
sm="12"
md="6"
:lg="map.show ? '6': '4'"
class="py-3"
>
<result
:id="hit.value.recordID || hit.value.moduleID"
:index="i"
:hit="hit"
:show-map="map.show"
:class="{ 'border-primary border shadow': map.clickedMarker && [hit.value.recordID, hit.value.moduleID].includes(map.clickedMarker) }"
@hover="map.hoverIndex = $event"
/>
</b-col>
</b-row>
<div
class="position-fixed map-button"
>
<b-button
variant="warning"
class="rounded-circle p-3"
:title="$t('tooltip.map')"
@click="toggleMap"
>
<font-awesome-icon
:icon="['fas', 'map-marked-alt']"
class="h3 mb-0"
/>
</b-button>
</div>
</b-col>
<b-col
v-if="map.show"
md="12"
lg="5"
xl="4"
>
<discovery-map
:markers="map.markers"
:hover-index="map.hoverIndex"
@hover="markerHovered"
/>
</b-col>
</b-row>
</b-container>
</template>
<script>
import Result from './Results'
import DiscoveryMap from './DiscoveryMap.vue'
import { components } from '@cortezaproject/corteza-vue'
const { CInputSearch } = components
export default {
i18nOptions: {
namespaces: 'search',
},
components: {
Result,
DiscoveryMap,
CInputSearch,
},
data () {
return {
query: '',
hits: [],
filteredHits: [],
pagination: {
limit: 50,
from: 0,
size: 0,
},
total: {
all: 0,
actual: 0,
},
initial: false,
map: {
show: false,
markers: [],
clickedMarker: undefined,
hoverIndex: undefined,
},
}
},
computed: {
searchDescription () {
if (this.$store.state.processing) {
return this.$t('discovering')
}
if (this.total.all > 0) {
return this.$t('range', { actual: this.total.actual, all: this.total.all })
}
return ''
},
},
watch: {
'$store.state.types': {
handler: function () {
this.getFilteredData()
},
},
'$store.state.modules': {
handler () {
if (this.initial) return
this.pagination.size = this.pagination.limit
this.getSearchData(this.query)
},
},
'$store.state.namespaces': {
handler () {
if (this.initial) return
this.pagination.size = this.pagination.limit
this.getSearchData(this.query)
},
},
},
created () {
this.initial = true
const { query = '', modules = [], namespaces = [], size = 0 } = this.$route.query
this.query = query
this.pagination.size = size
this.$store.commit('updateModules', Array.isArray(modules) ? modules : [modules])
this.$store.commit('updateNamespaces', Array.isArray(namespaces) ? namespaces : [namespaces])
this.getSearchData(this.query)
setTimeout(() => {
this.initial = false
}, 1000)
},
mounted () {
const listElm = document.querySelector('.results')
listElm.addEventListener('scroll', e => {
if (listElm.scrollTop + listElm.clientHeight >= listElm.scrollHeight - 10) {
if (!this.$store.state.processing && this.total.actual < this.total.all) {
this.getSearchData(this.query, true)
}
}
})
},
methods: {
getSearchData (query = '', append = false) {
this.$store.commit('updateProcessing', true)
if (!append) {
this.map.markers = []
}
// Filters
const modules = this.$store.state.modules
const namespaces = this.$store.state.namespaces
// Pagination
if (append) {
this.pagination.size += this.pagination.limit
}
const { size } = this.pagination
this.updateRouteQuery({ query, modules, namespaces, size })
this.$DiscoveryAPI.query({ query, modules, namespaces, size })
.then((response = {}) => {
if (response) {
this.hits = (response.hits || [])
this.total.all = response.total_results || 0
this.getFilteredData()
this.pagination = {
...this.pagination,
from: response.from || 0,
size: response.size || 0,
}
this.$store.commit('updateAggregations', response.aggregations)
this.getMarkers()
}
}).catch(e => {
this.toastErrorHandler(this.$t('notification:search.failed'))(e)
this.hits = []
this.filteredHits.splice(0, this.filteredHits.length)
})
.finally(() => {
this.$store.commit('updateProcessing', false)
})
},
getFilteredData () {
let filteredHits = this.hits
if (this.$store.state.types.length > 0 && this.hits.length) {
filteredHits = this.hits.filter(hit => this.$store.state.types.includes(hit.type))
}
this.filteredHits.splice(0, this.filteredHits.length, ...filteredHits)
this.total.actual = this.filteredHits.length
},
onQuerySubmit () {
if (!this.$store.state.processing) {
this.pagination.size = this.pagination.limit
this.getSearchData(this.query)
}
},
getMarkers () {
const markers = []
this.filteredHits.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.toString().includes('{"coordinates":[')
})
if (isGeometry) {
value.forEach(coordinates => {
coordinates = JSON.parse(coordinates || '{}').coordinates
if (coordinates) {
markers.push({ id, coordinates })
}
})
}
})
}
})
this.map.markers = markers
},
markerHovered (ID) {
if (ID) {
document.getElementById(ID).scrollIntoView({
behavior: 'smooth',
block: 'center',
})
}
this.map.clickedMarker = ID
},
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 })) {
this.$router.push({ query: { query: query || undefined, modules, namespaces, size } })
}
},
},
}
</script>
<style lang="scss" scoped>
.results-container {
display: flex;
flex-direction: column;
height: calc(100vh - 64px);
}
.results-container.with-map {
height: calc(60vh - 64px);
}
@media (min-width: 992px) {
.results-container {
height: calc(100vh - 64px) !important;
}
}
.results {
flex: 1 1 auto;
}
.clear-query {
z-index: 3 !important;
right: 52px;
top: 2px;
}
.map-button {
bottom: 1rem;
right: 1rem;
z-index: 99999;
}
// 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: ''; }
}
</style>
@@ -0,0 +1,16 @@
import { library } from '@fortawesome/fontawesome-svg-core'
import { faGripHorizontal, faThumbtack, faBars, faChevronUp, faChevronDown, faSearch, faMapMarkedAlt, faTimes } from '@fortawesome/free-solid-svg-icons'
import { faQuestionCircle, faUser } from '@fortawesome/free-regular-svg-icons'
library.add(
faUser,
faQuestionCircle,
faGripHorizontal,
faThumbtack,
faBars,
faChevronDown,
faChevronUp,
faSearch,
faMapMarkedAlt,
faTimes,
)
@@ -0,0 +1,40 @@
import Vue from 'vue'
import { BootstrapVue, BootstrapVueIcons } from 'bootstrap-vue'
import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome'
import PortalVue from 'portal-vue'
import './faIcons'
import { components } from '@cortezaproject/corteza-vue'
import { LMap, LTileLayer, LMarker } from 'vue2-leaflet'
import 'leaflet/dist/leaflet.css'
import { Icon } from 'leaflet'
Vue.use(PortalVue)
Vue.component('l-map', LMap)
Vue.component('l-tile-layer', LTileLayer)
Vue.component('l-marker', LMarker)
delete Icon.Default.prototype._getIconUrl
Icon.Default.mergeOptions({
iconRetinaUrl: require('leaflet/dist/images/marker-icon-2x.png'),
iconUrl: require('leaflet/dist/images/marker-icon.png'),
shadowUrl: require('leaflet/dist/images/marker-shadow.png'),
})
Vue.use(BootstrapVue, {
BToast: {
// see https://bootstrap-vue.org/docs/components/toast#comp-ref-b-toast-props
autoHideDelay: 7000,
toaster: 'b-toaster-bottom-right',
},
})
Vue.use(BootstrapVueIcons)
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)
+8
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.`)
}
})
@@ -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)
+26
View File
@@ -0,0 +1,26 @@
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import { i18n } from '@cortezaproject/corteza-vue'
import './themes'
import './config-check'
import './console-splash'
import './plugins'
import './mixins'
import './components'
new Vue({
router,
store,
i18n: i18n(Vue,
{ app: 'corteza-webapp-discovery' },
'general',
'notification',
'navigation',
'filters',
'search',
),
render: h => h(App),
}).$mount('#app')
+5
View File
@@ -0,0 +1,5 @@
import Vue from 'vue'
import toast from './toast'
Vue.mixin(toast)
+46
View File
@@ -0,0 +1,46 @@
export default {
methods: {
toastSuccess (message, title = undefined) {
if (title === undefined) {
title = this.$t('notification:general:success')
}
this.toast(message, { title, variant: 'success' })
},
toastWarning (message, title = undefined) {
if (title === undefined) {
title = this.$t('notification:general:warning')
}
this.toast(message, { title, variant: 'warning' })
},
toastDanger (message, title = undefined) {
if (title === undefined) {
title = this.$t('notification:general:error')
}
this.toast(message, { title, variant: 'danger' })
},
toast (msg, opt = { variant: 'success' }) {
this.$root.$bvToast.toast(msg, opt)
},
toastErrorHandler (opt) {
if (typeof opt === 'string') {
opt = { prefix: opt }
}
const { prefix, title } = opt
return (err = {}) => {
/* eslint-disable no-console */
console.error(err)
const msg = err.message ? (prefix + ': ' + this.$t(err.message)) : prefix
this.toastDanger(msg, title)
}
},
},
}
+10
View File
@@ -0,0 +1,10 @@
import Vue from 'vue'
import Router from 'vue-router'
import { plugins } from '@cortezaproject/corteza-vue'
import DiscoveryAPI from './searcher.js'
Vue.use(Router)
Vue.use(plugins.Auth(), { app: 'discovery' })
Vue.use(plugins.CortezaAPI('system'))
Vue.use(plugins.Settings, { api: Vue.prototype.$SystemAPI })
Vue.use(DiscoveryAPI())
@@ -0,0 +1,128 @@
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,
} = 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))
// 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}`
}
}
+7
View File
@@ -0,0 +1,7 @@
import Router from 'vue-router'
import routes from './views/routes'
export default new Router({
mode: 'history',
routes,
})
+20
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
+36
View File
@@ -0,0 +1,36 @@
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
processing: false,
types: [],
aggregations: [],
modules: [],
namespaces: [],
},
mutations: {
updateProcessing (state, value = false) {
state.processing = value
},
updateTypes (state, types) {
state.types = types
},
updateAggregations (state, aggs) {
state.aggregations = aggs
},
updateModules (state, value) {
state.modules = value
},
updateNamespaces (state, value) {
state.namespaces = value
},
},
actions: {
},
modules: {
},
})
@@ -0,0 +1,242 @@
/* stylelint-disable no-descending-specificity */
html {
height: 100vh;
width: 100vw;
overflow: hidden;
}
body {
height: 100%;
}
:focus {
outline-color: $primary;
}
button:disabled {
cursor: not-allowed;
pointer-events: all !important;
}
.btn-link {
&:focus {
text-decoration: none;
}
}
.pointer {
cursor: pointer;
}
.grab {
cursor: grab;
}
thead th,
legend,
label,
.btn {
font-family: $font-medium;
}
strong,
b,
.font-weight-bold {
font-family: $font-semibold;
}
th {
white-space: nowrap;
}
.vue-grid-layout {
transition: none !important;
}
.vue-grid-item {
transition: none !important;
overflow-x: hidden;
overflow-y: auto;
}
.v-select {
min-width: 250px;
.vs__search {
margin: 0;
}
}
.vs__dropdown-toggle {
padding: 0.375rem;
border-width: 2px;
border-color: $light;
.vs__selected {
margin: 0;
}
}
.vs__clear,
.vs__open-indicator {
fill: $gray-900;
display: inline-flex;
}
.ProseMirror.ProseMirror-focused {
outline: none;
}
// Pagination
.page-item {
&.disabled {
cursor: not-allowed;
}
}
// Rich text content styling
.rt-content {
&.editor {
border-radius: 4px;
border: 2px solid $light;
}
.editor__content {
min-height: 200px;
}
.btn {
width: 60px;
}
p {
margin: 0;
padding: 0;
}
// blockquote
blockquote {
border-left: 3px solid rgba($black, 0.1);
padding-left: 0.8rem;
font-style: italic;
}
// Code
pre {
background-color: #23241f;
color: #f8f8f2;
overflow: visible;
white-space: pre-wrap;
margin-bottom: 5px;
margin-top: 5px;
padding: 5px 10px;
border-radius: 3px;
}
// todo list - https://github.com/scrumpy/tiptap/blob/master/examples/Components/Routes/TodoList/index.vue
li[data-type="todo_item"] {
display: flex;
flex-direction: row;
}
.todo-checkbox {
border: 2px solid $black;
height: 0.9em;
width: 0.9em;
box-sizing: border-box;
margin-right: 10px;
margin-top: 0.3rem;
user-select: none;
cursor: pointer;
border-radius: 0.2em;
background-color: transparent;
transition: 0.4s background;
}
.todo-content {
flex: 1;
> p:last-of-type {
margin-bottom: 0;
}
> ul[data-type="todo_list"] {
margin: 0.5rem 0;
}
}
li[data-done="true"] {
> .todo-content {
> p {
text-decoration: line-through;
}
}
> .todo-checkbox {
background-color: $black;
}
}
li[data-done="false"] {
text-decoration: none;
}
}
fieldset.required {
&.error {
legend::before {
color: $danger;
}
}
label::after {
content: "*";
display: inline-block;
color: $danger;
width: 10px;
height: 18px;
overflow: hidden;
}
}
.alert-holder {
position: absolute;
top: 0;
width: 100%;
.alert {
z-index: 1040;
box-shadow: 0 0 2px 0 rgba($secondary, 0.75);
}
}
// Over-ride Bootstrap clear search input icon
input[type="search"]::-webkit-search-cancel-button {
-webkit-appearance: none;
height: 13px;
width: 13px;
background: url("data:image/svg+xml;charset=UTF-8,%3csvg viewPort='0 0 12 12' version='1.1' xmlns='http://www.w3.org/2000/svg'%3e%3cline x1='1' y1='11' x2='11' y2='1' stroke='black' stroke-width='2'/%3e%3cline x1='1' y1='1' x2='11' y2='11' stroke='black' stroke-width='2'/%3e%3c/svg%3e");
}
.b-toaster.b-toaster-bottom-right {
bottom: 75px;
}
.vs__spinner, .vs__spinner::after {
width: 4em;
height: 4em;
}
.wrap-with-vertical-gutters {
margin-top: -0.25rem;
> * {
margin-top: 0.25rem;
}
}
.custom-control-label {
width: 100%;
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
@@ -0,0 +1 @@
import './index.scss'
@@ -0,0 +1,6 @@
@import "./variables";
@import "~bootstrap/scss/bootstrap";
@import '~bootstrap-vue/src/index.scss';
@import "./custom";
@import "./poppins.scss";
@import "~vuelayers/lib/style.css";
@@ -0,0 +1,143 @@
@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;
}
@@ -0,0 +1,114 @@
$wideminwidth:767px !default;
$confortableminwidth:1020px !default;
// Paths
$fonts_dir : './fonts/' !default;
// Typography
$font-light: 'Poppins-Light' !default;
$font-regular: 'Poppins-Regular' !default;
$font-medium: 'Poppins-Medium' !default;
$font-semibold: 'Poppins-Semibold' !default;
$font-bold: 'Poppins-Bold' !default;
$font-size-base: 0.9rem !default;
$font-family-base: $font-regular !default;
$headings-font-family: $font-semibold !default;
// Color system
$white: #FFF !default;
$black: #000 !default;
$primary: #0b344e !default;
$secondary: #758D9B !default;
$success: #43AA8B !default;
$warning: #F5D380 !default;
$danger: #E24646 !default;
$light: #E4E9EF !default;
$dark: #162425 !default;
$gray-200: #F3F3F5 !default;
// Grid - maybe not the smartest idea to change this but let's see
$grid-breakpoints: (
xs: 0,
sm: 576px,
md: 768px,
lg: 992px,
xl: 1500px
) !default;
// Options
$enable-rounded: true !default;
$enable-gradients: false !default;
$enable-responsive-font-sizes: true !default;
// Body
$body-bg: $gray-200 !default;
// Card
$card-border-radius: 1rem !default;
$card-border-width: 0 !default;
$card-cap-bg: $gray-200 !default;
// Tables
$table-cell-padding-sm: 0.25rem !default;
$table-hover-bg: $gray-200 !default;
// Buttons
$btn-font-size: $font-size-base !default;
$btn-padding-y: 0.25em !default;
$btn-padding-x: 1em !default;
$btn-focus-width: 0.2rem !default;
$btn-focus-box-shadow: none !default;
$btn-active-box-shadow: none !default;
$btn-font-size-lg: 16px !default;
$btn-font-family: $font-semibold !default;
$topbar-height: 64px !default;
// Navbar
$navbar-bg: $white !default;
$navbar-color: $black !default;
$navbar-border: $primary !default;
$navbar-active-bg: $white !default;
$navbar-active-color: $primary !default;
$navbar-active-border: $primary !default;
$navbar-hover-bg: $white !default;
$navbar-hover-color: $primary !default;
$navbar-hover-border: $primary !default;
$navbar-height: 50px !default;
$navbar-padding-y: 0 !default;
$navbar-padding-x: 0 !default;
$navbar-toggler-font-size: 24px !default;
$sidebar-width: 320px !default;
$sidebar-bg: #F4F7FA;
// Pagination
$pagination-color: $dark !default;
$pagination-disabled-bg: transparent !default;
$pagination-bg: transparent !default;
$pagination-active-bg: $dark !default;
$pagination-border-width: 0 !default;
// Forms
$input-font-size: $font-size-base !default;
$input-border-color: $light !default;
$input-border-width: 2px !default;
$input-btn-focus-width: 0 !default;
$input-focus-box-shadow: none !default;
$input-focus-border-color: $primary !default;
/* stylelint-disable */
// This is used within corteza-webapp-compose/src/lib/block/Calendar/feedLoader/
// to determine default event colors
:export {
primary: $primary;
danger: $danger;
secondary: $secondary;
}
/* stylelint-enable */
+2
View File
@@ -0,0 +1,2 @@
// eslint-disable-next-line no-unused-expressions
import(/* webpackChunkName: 'corteza-base' */ './corteza-base')
+144
View File
@@ -0,0 +1,144 @@
<template>
<div class="d-flex flex-column w-100 vh-100">
<header>
<c-topbar
:sidebar-pinned="pinned"
:settings="$Settings.get('ui.topbar', {})"
:labels="{
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'),
}"
>
<template #title>
{{ $t('discovery') }}
</template>
</c-topbar>
</header>
<aside>
<c-sidebar
:expanded.sync="expanded"
:pinned.sync="pinned"
:icon="icon"
:logo="logo"
:disabled-routes="['report.list', 'report.create', 'report.edit']"
expand-on-hover
>
<template #body-expanded>
<filters />
</template>
</c-sidebar>
</aside>
<main class="d-inline-flex h-100">
<!--
Content spacer
Large and xl screens should push in content when the nav is expanded
-->
<template>
<div
class="spacer"
:class="{
'expanded': expanded && pinned,
}"
/>
</template>
<search />
</main>
</div>
</template>
<script>
import Search from '../components/Search.vue'
import Filters from '../components/Filters.vue'
import { components } from '@cortezaproject/corteza-vue'
const { CTopbar, CSidebar } = components
export default {
i18nOptions: {
namespaces: 'navigation',
},
components: {
CTopbar,
CSidebar,
Search,
Filters,
},
data () {
return {
expanded: undefined,
pinned: undefined,
}
},
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')
},
},
watch: {
icon: {
immediate: true,
handler (icon) {
if (icon) {
const favicon = document.getElementById('favicon')
favicon.href = icon
}
},
},
},
created () {
this.$root.$on('alert', this.displayToast)
},
methods: {
displayToast ({ title, message, variant, countdown }) {
this.$bvToast.toast(message, {
title,
variant,
solid: true,
autoHideDelay: countdown,
toaster: 'b-toaster-bottom-right',
})
},
},
}
</script>
<style lang="scss" scoped>
.spacer {
min-width: 0;
-webkit-transition: min-width 0.2s ease-in-out;
-moz-transition: min-width 0.2s ease-in-out;
-o-transition: min-width 0.2s ease-in-out;
transition: min-width 0.2s ease-in-out;
&.expanded {
min-width: $sidebar-width;
-webkit-transition: min-width 0.2s ease-in-out;
-moz-transition: min-width 0.2s ease-in-out;
-o-transition: min-width 0.2s ease-in-out;
transition: min-width 0.2s ease-in-out;
}
}
</style>
+10
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' } },
]
+63
View File
@@ -0,0 +1,63 @@
import Vue from 'vue'
import { createLocalVue, shallowMount as sm, mount as rm } from '@vue/test-utils'
import sinon from 'sinon'
import BootstrapVue from 'bootstrap-vue'
import PortalVue from 'portal-vue'
// Components
Vue.config.ignoredElements = [
'font-awesome-icon',
]
Vue.use(BootstrapVue)
Vue.use(PortalVue)
export const writeableWindowLocation = ({ path: value = '/' } = {}) => Object.defineProperty(window, 'location', { writable: true, value })
export const mount = (component, params = {}) => shallowMount(component, { ...params })
export const stdReject = () => sinon.stub().rejects({ message: 'err' })
export const stdResolve = (rtr) => sinon.stub().resolves(rtr)
export const networkReject = () => sinon.stub().rejects({ message: 'Network Error' })
export const stdAuth = (mocks = {}) => ({
is: sinon.stub().returns(true),
check: stdResolve(),
goto: (u) => u,
open: (u) => u,
...mocks,
})
const mounter = (component, { localVue = createLocalVue(), $auth = {}, mocks = {}, stubs = [], ...options } = {}, mount) => {
return mount(component, {
localVue,
stubs: [
'router-view',
'router-link',
...stubs,
],
mocks: {
$t: (e) => e,
$i18n: {
i18next: {
language: 'en',
},
},
$SystemAPI: {},
$route: { query: { fullPath: '', token: undefined } },
$auth,
...mocks,
},
...options,
})
}
export const shallowMount = (...e) => {
return mounter(...e, sm)
}
export const fullMount = (...e) => {
return mounter(...e, rm)
}
export default shallowMount
@@ -0,0 +1,7 @@
module.exports = {
env: {
es6: true,
node: true,
mocha: true,
},
}
@@ -0,0 +1,40 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-unused-vars */
import { expect } from 'chai'
import sinon from 'sinon'
import Layout from '../../../src/views/Layout'
import { shallowMount } from '../../lib/helpers'
import fp from 'flush-promises'
describe('/views/Layout.vue', () => {
afterEach(() => {
sinon.restore()
})
let $auth, $Settings
beforeEach(() => {
$auth = {
user: {},
}
$Settings = {
get: () => ({}),
attachment: () => '',
}
})
const mountLayout = (opt) => shallowMount(Layout, {
mocks: { $auth, $Settings },
...opt,
})
describe('Init', () => {
it('It renders', async () => {
const wrapper = mountLayout()
await fp()
expect(wrapper.find('div')).to.exist
})
})
})
+127
View File
@@ -0,0 +1,127 @@
const webpack = require('webpack')
const exec = require('child_process').execSync
const path = require('path')
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 || isDevelopment) {
const Vue = require('vue')
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: {
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
},
},
},
}
return {
publicPath: './',
lintOnSave: true,
runtimeCompiler: true,
configureWebpack: {
// other webpack options to merge in ...
resolve: { symlinks: 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,
},
chainWebpack: config => {
// Do not copy config files (deployment procedure will do that)
config.plugin('copy').tap(options => {
options[0][0].ignore.push('config*js')
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,
}))
// Load CSS assets according to their location
scssNormal.use('resolve-url-loader')
.loader('resolve-url-loader').options({
keepQuery: true,
root: path.join(root, 'src/themes', theme),
})
.before('sass-loader')
},
devServer: {
host: '127.0.0.1',
hot: true,
disableHostCheck: true,
watchOptions: {
ignored: [
// Do not watch for changes under node_modules
// (exception is node_modules/@cortezaproject)
/node_modules([\\]+|\/)+(?!@cortezaproject)/,
],
aggregateTimeout: 200,
poll: 1000,
},
},
css: {
sourceMap: isDevelopment,
loaderOptions: {
sass: {
// @todo cleanup all components and remove this global import
additionalData: `@import "./src/themes/${theme}/variables.scss";`,
},
},
},
}
}
+9
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',
})
File diff suppressed because it is too large Load Diff