From 469b84051791355ba358eedf3abc7270825aaa8c Mon Sep 17 00:00:00 2001 From: David Featherston Date: Thu, 24 Aug 2023 16:23:49 +1000 Subject: [PATCH 01/88] feat(@dpc-sdp/ripple-ui-forms): add native date picker --- .../test/features/landingpage/forms.feature | 1 + .../test/fixtures/landingpage/full-form.json | 8 ++++ .../components/RplForm/RplForm.stories.mdx | 10 ++++ .../components/RplFormInput/RplFormInput.vue | 6 +++ .../ripple-ui-forms/src/inputs/datePicker.ts | 48 +++++++++++++++++++ packages/ripple-ui-forms/src/inputs/index.ts | 1 + packages/ripple-ui-forms/src/plugin.ts | 3 ++ 7 files changed, 77 insertions(+) create mode 100644 packages/ripple-ui-forms/src/inputs/datePicker.ts diff --git a/examples/nuxt-app/test/features/landingpage/forms.feature b/examples/nuxt-app/test/features/landingpage/forms.feature index b6b97bcae1..fe6627534f 100644 --- a/examples/nuxt-app/test/features/landingpage/forms.feature +++ b/examples/nuxt-app/test/features/landingpage/forms.feature @@ -18,6 +18,7 @@ Feature: Forms Then a "number" input with the label "Quantity" should exist Then a "url" input with the label "Website" should exist Then a "tel" input with the label "Mobile phone" should exist + Then a "date" input with the label "Date of birth" should exist Then a select field with the label "Favourite colour" should exist | required | | true | diff --git a/examples/nuxt-app/test/fixtures/landingpage/full-form.json b/examples/nuxt-app/test/fixtures/landingpage/full-form.json index 6026b1ffa4..a7c9d850df 100644 --- a/examples/nuxt-app/test/fixtures/landingpage/full-form.json +++ b/examples/nuxt-app/test/fixtures/landingpage/full-form.json @@ -127,6 +127,14 @@ "validation": "", "validationMessages": {} }, + { + "$formkit": "RplFormDatePicker", + "name": "dob", + "label": "Date of birth", + "id": "dob", + "validation": "", + "validationMessages": {} + }, { "$formkit": "RplFormTextarea", "name": "message", diff --git a/packages/ripple-ui-forms/src/components/RplForm/RplForm.stories.mdx b/packages/ripple-ui-forms/src/components/RplForm/RplForm.stories.mdx index a2223fd8eb..24bddd77c4 100644 --- a/packages/ripple-ui-forms/src/components/RplForm/RplForm.stories.mdx +++ b/packages/ripple-ui-forms/src/components/RplForm/RplForm.stories.mdx @@ -319,6 +319,16 @@ export const SlotTemplate = (args) => ({ date_before: 'Must be before 25th December 1999', } }, + { + $formkit: "RplFormDatePicker", + name: "date-picker", + label: "Date Picker", + id: "date-picker", + min: "2022-06-01", + max: "2026-06-01", + help: "Select date", + validation: 'required|date_before:2025-10-22', + }, { $formkit: 'RplFormActions', label: 'Submit form', diff --git a/packages/ripple-ui-forms/src/components/RplFormInput/RplFormInput.vue b/packages/ripple-ui-forms/src/components/RplFormInput/RplFormInput.vue index 04c1e4d6c0..78bf7d26d2 100644 --- a/packages/ripple-ui-forms/src/components/RplFormInput/RplFormInput.vue +++ b/packages/ripple-ui-forms/src/components/RplFormInput/RplFormInput.vue @@ -22,6 +22,8 @@ interface Props { label?: string prefixIcon?: string suffixIcon?: string + min?: number + max?: number minlength?: number maxlength?: number counter?: 'word' | 'character' @@ -44,6 +46,8 @@ const props = withDefaults(defineProps(), { label: undefined, prefixIcon: undefined, suffixIcon: undefined, + min: undefined, + max: undefined, minlength: undefined, maxlength: undefined, counter: undefined, @@ -120,6 +124,8 @@ const handleChange = useDebounceFn(() => { v-bind="$attrs" :name="name" :value="value" + :min="min" + :max="max" :minlength="!isWordCounter ? minlength : null" :maxlength="!isWordCounter ? maxlength : null" @blur="onBlur" diff --git a/packages/ripple-ui-forms/src/inputs/datePicker.ts b/packages/ripple-ui-forms/src/inputs/datePicker.ts new file mode 100644 index 0000000000..dcf95e2ee6 --- /dev/null +++ b/packages/ripple-ui-forms/src/inputs/datePicker.ts @@ -0,0 +1,48 @@ +import { FormKitTypeDefinition } from '@formkit/core' +import { + createRplFormInput, + defaultRplFormInputProps, + inputLibrary, + rplFeatures +} from './input-utils' + +/** + * Input definition for Ripple text input. + * @public + */ +export const datePicker: FormKitTypeDefinition = { + /** + * The actual schema of the input, or a function that returns the schema. + */ + schema: createRplFormInput({ + $cmp: 'RplFormInput', + props: { + ...defaultRplFormInputProps, + min: '$node.props.min', + max: '$node.props.max', + type: 'date' + } + }), + library: inputLibrary, + /** + * The type of node, can be a list, group, or input. + */ + type: 'input', + /** + * The family of inputs this one belongs too. For example "text" and "email" + * are both part of the "text" family. This is primary used for styling. + */ + family: 'text', + /** + * An array of extra props to accept for this input. + */ + props: ['min', 'max', 'validationMeta', 'columnClasses'], + /** + * Forces node.props.type to be this explicit value. + */ + forceTypeProp: 'date', + /** + * Additional features that should be added to your input + */ + features: rplFeatures +} diff --git a/packages/ripple-ui-forms/src/inputs/index.ts b/packages/ripple-ui-forms/src/inputs/index.ts index 356d7ee7f3..7ce041ae90 100644 --- a/packages/ripple-ui-forms/src/inputs/index.ts +++ b/packages/ripple-ui-forms/src/inputs/index.ts @@ -10,6 +10,7 @@ export { checkboxGroup } from './checkboxGroup' export { radioGroup } from './radioGroup' export { optionButtons } from './optionButtons' export { dropdown } from './dropdown' +export { datePicker } from './datePicker' export { content } from './content' export { label } from './label' export { fieldset } from './fieldset' diff --git a/packages/ripple-ui-forms/src/plugin.ts b/packages/ripple-ui-forms/src/plugin.ts index 608acf4c97..cd7673464d 100644 --- a/packages/ripple-ui-forms/src/plugin.ts +++ b/packages/ripple-ui-forms/src/plugin.ts @@ -14,6 +14,7 @@ import { label, divider, date, + datePicker, optionButtons, fieldset } from './inputs/index' @@ -48,6 +49,8 @@ rplFormInputs.library = (node) => { return node.define(dropdown) case 'RplFormDate': return node.define(date) + case 'RplFormDatePicker': + return node.define(datePicker) case 'RplFormContent': return node.define(content) case 'RplFormLabel': From 631f17c40f7fbe1c3164f164dbb76c0e07f3774a Mon Sep 17 00:00:00 2001 From: Jeffrey Dowdle Date: Tue, 24 Oct 2023 11:32:22 +1100 Subject: [PATCH 02/88] feat(@dpc-sdp/ripple-ui-maps): added WIP ripple-ui-maps package --- .vscode/settings.json | 3 +- packages/nuxt-ripple/nuxt.config.ts | 1 + packages/nuxt-ripple/package.json | 1 + .../ripple-storybook/.storybook/preview.ts | 2 + packages/ripple-storybook/package.json | 2 + packages/ripple-ui-maps/README.md | 3 + packages/ripple-ui-maps/cypress.config.ts | 10 + packages/ripple-ui-maps/cypress.d.ts.t | 12 + .../cypress/support/component-index.html | 12 + .../cypress/support/component.ts | 35 + packages/ripple-ui-maps/event.d.ts | 8 + .../ripple-ui-maps/global-css.vite.config.ts | 26 + packages/ripple-ui-maps/index.html | 14 + packages/ripple-ui-maps/package.json | 72 ++ packages/ripple-ui-maps/src/App.vue | 5 + packages/ripple-ui-maps/src/components.ts | 2 + .../src/components/cluster/RplMapCluster.vue | 66 ++ .../feature-pin/RplMapFeaturePin.vue | 23 + .../src/components/feature-pin/icon-pin.svg | 4 + .../src/components/map/RplMap.css | 35 + .../src/components/map/RplMap.cy.ts | 16 + .../src/components/map/RplMap.stories.mdx | 102 +++ .../src/components/map/RplMap.vue | 124 +++ .../components/map/__fixture__/features.json | 82 ++ .../map/providers/RplMapProviderArcVector.vue | 16 + .../map/providers/RplMapProviderEsri.vue | 57 ++ .../map/providers/RplMapProviderGoogle.vue | 8 + .../map/providers/RplMapProviderMapbox.vue | 27 + .../map/providers/RplMapProviderVicMap.vue | 56 ++ .../src/components/popup/RplMapPopUp.css | 25 + .../src/components/popup/RplMapPopUp.vue | 40 + .../src/composables/onMapClick.ts | 87 ++ packages/ripple-ui-maps/src/index.ts | 1 + packages/ripple-ui-maps/src/lib/providers.ts | 0 packages/ripple-ui-maps/src/main.ts | 4 + packages/ripple-ui-maps/src/nuxt/index.ts | 32 + .../ripple-ui-maps/src/nuxt/runtime/plugin.ts | 12 + .../ripple-ui-maps/src/plugins/register.ts | 9 + packages/ripple-ui-maps/src/styles.ts | 1 + packages/ripple-ui-maps/src/styles/global.css | 1 + packages/ripple-ui-maps/src/types.ts | 8 + packages/ripple-ui-maps/src/vite.plugins.ts | 35 + packages/ripple-ui-maps/tsconfig.json | 28 + packages/ripple-ui-maps/vite.config.ts | 45 + pnpm-lock.yaml | 804 +++++++++++++----- 45 files changed, 1760 insertions(+), 196 deletions(-) create mode 100644 packages/ripple-ui-maps/README.md create mode 100644 packages/ripple-ui-maps/cypress.config.ts create mode 100644 packages/ripple-ui-maps/cypress.d.ts.t create mode 100644 packages/ripple-ui-maps/cypress/support/component-index.html create mode 100644 packages/ripple-ui-maps/cypress/support/component.ts create mode 100644 packages/ripple-ui-maps/event.d.ts create mode 100644 packages/ripple-ui-maps/global-css.vite.config.ts create mode 100644 packages/ripple-ui-maps/index.html create mode 100644 packages/ripple-ui-maps/package.json create mode 100644 packages/ripple-ui-maps/src/App.vue create mode 100644 packages/ripple-ui-maps/src/components.ts create mode 100644 packages/ripple-ui-maps/src/components/cluster/RplMapCluster.vue create mode 100644 packages/ripple-ui-maps/src/components/feature-pin/RplMapFeaturePin.vue create mode 100644 packages/ripple-ui-maps/src/components/feature-pin/icon-pin.svg create mode 100644 packages/ripple-ui-maps/src/components/map/RplMap.css create mode 100644 packages/ripple-ui-maps/src/components/map/RplMap.cy.ts create mode 100644 packages/ripple-ui-maps/src/components/map/RplMap.stories.mdx create mode 100644 packages/ripple-ui-maps/src/components/map/RplMap.vue create mode 100644 packages/ripple-ui-maps/src/components/map/__fixture__/features.json create mode 100644 packages/ripple-ui-maps/src/components/map/providers/RplMapProviderArcVector.vue create mode 100644 packages/ripple-ui-maps/src/components/map/providers/RplMapProviderEsri.vue create mode 100644 packages/ripple-ui-maps/src/components/map/providers/RplMapProviderGoogle.vue create mode 100644 packages/ripple-ui-maps/src/components/map/providers/RplMapProviderMapbox.vue create mode 100644 packages/ripple-ui-maps/src/components/map/providers/RplMapProviderVicMap.vue create mode 100644 packages/ripple-ui-maps/src/components/popup/RplMapPopUp.css create mode 100644 packages/ripple-ui-maps/src/components/popup/RplMapPopUp.vue create mode 100644 packages/ripple-ui-maps/src/composables/onMapClick.ts create mode 100644 packages/ripple-ui-maps/src/index.ts create mode 100644 packages/ripple-ui-maps/src/lib/providers.ts create mode 100644 packages/ripple-ui-maps/src/main.ts create mode 100644 packages/ripple-ui-maps/src/nuxt/index.ts create mode 100644 packages/ripple-ui-maps/src/nuxt/runtime/plugin.ts create mode 100644 packages/ripple-ui-maps/src/plugins/register.ts create mode 100644 packages/ripple-ui-maps/src/styles.ts create mode 100644 packages/ripple-ui-maps/src/styles/global.css create mode 100644 packages/ripple-ui-maps/src/types.ts create mode 100644 packages/ripple-ui-maps/src/vite.plugins.ts create mode 100644 packages/ripple-ui-maps/tsconfig.json create mode 100644 packages/ripple-ui-maps/vite.config.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 12f813e584..755da58cbc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -28,7 +28,8 @@ "@dpc-sdp/nuxt-ripple-cli", "@dpc-sdp/ripple-test-utils", "@dpc-sdp/nuxt-ripple-analytics", - "eslint-config-ripple" + "eslint-config-ripple", + "@dpc-sdp/ripple-ui-maps" ], "cSpell.words": [ "colour", diff --git a/packages/nuxt-ripple/nuxt.config.ts b/packages/nuxt-ripple/nuxt.config.ts index 6bb40ad50c..4c37bd4ad4 100644 --- a/packages/nuxt-ripple/nuxt.config.ts +++ b/packages/nuxt-ripple/nuxt.config.ts @@ -46,6 +46,7 @@ export default defineNuxtConfig({ 'nuxt-proxy', '@dpc-sdp/ripple-ui-core/nuxt', '@dpc-sdp/ripple-ui-forms/nuxt', + '@dpc-sdp/ripple-ui-maps/nuxt', '@nuxtjs/robots' ] }) diff --git a/packages/nuxt-ripple/package.json b/packages/nuxt-ripple/package.json index 20de82d4fd..02bbc32aff 100644 --- a/packages/nuxt-ripple/package.json +++ b/packages/nuxt-ripple/package.json @@ -15,6 +15,7 @@ "dependencies": { "@dpc-sdp/ripple-tide-api": "workspace:*", "@dpc-sdp/ripple-ui-core": "workspace:*", + "@dpc-sdp/ripple-ui-maps": "workspace:*", "@dpc-sdp/ripple-ui-forms": "workspace:*", "@nuxtjs/robots": "^3.0.0", "@vueuse/core": "^9.13.0", diff --git a/packages/ripple-storybook/.storybook/preview.ts b/packages/ripple-storybook/.storybook/preview.ts index df0eb5ccd1..9264ceb98b 100644 --- a/packages/ripple-storybook/.storybook/preview.ts +++ b/packages/ripple-storybook/.storybook/preview.ts @@ -2,6 +2,7 @@ import { setup, type Preview } from '@storybook/vue3' // @ts-ignore-next-line: Missing declaration import { registerRplFormPlugin } from '@dpc-sdp/ripple-ui-forms' +import { registerRplMapsPlugin } from '@dpc-sdp/ripple-ui-maps' // Note: rebuild ripple-ui-core after generating sprite to update in storybook // @ts-ignore-next-line: Vue SFC import { RplIconSprite, RplLink, RplImg } from '@dpc-sdp/ripple-ui-core/vue' @@ -22,6 +23,7 @@ window.svgPlaceholder = svgPlaceholder setup((app) => { // Ripple vue plugins registerRplFormPlugin(app) + registerRplMapsPlugin(app) app.component('RplLink', RplLink) app.component('RplImg', RplImg) diff --git a/packages/ripple-storybook/package.json b/packages/ripple-storybook/package.json index 78db272344..3cd43bf99b 100644 --- a/packages/ripple-storybook/package.json +++ b/packages/ripple-storybook/package.json @@ -37,6 +37,8 @@ "wait-on": "^7.0.1" }, "dependencies": { + "@dpc-sdp/ripple-ui-core": "workspace:*", + "@dpc-sdp/ripple-ui-maps": "workspace:*", "concurrently": "^8.2.1", "vite-svg-loader": "^4.0.0" } diff --git a/packages/ripple-ui-maps/README.md b/packages/ripple-ui-maps/README.md new file mode 100644 index 0000000000..b8080235d9 --- /dev/null +++ b/packages/ripple-ui-maps/README.md @@ -0,0 +1,3 @@ +# ripple-ui-maps + +> A UI component library built with Vue.js. [View the Ripple storybook](https://www.ripple.sdp.vic.gov.au/storybook) to start exploring available components. diff --git a/packages/ripple-ui-maps/cypress.config.ts b/packages/ripple-ui-maps/cypress.config.ts new file mode 100644 index 0000000000..a1b19a5a15 --- /dev/null +++ b/packages/ripple-ui-maps/cypress.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'cypress' + +export default defineConfig({ + component: { + devServer: { + framework: 'vue', + bundler: 'vite' + } + } +}) diff --git a/packages/ripple-ui-maps/cypress.d.ts.t b/packages/ripple-ui-maps/cypress.d.ts.t new file mode 100644 index 0000000000..3da0de5934 --- /dev/null +++ b/packages/ripple-ui-maps/cypress.d.ts.t @@ -0,0 +1,12 @@ +import { mount } from 'cypress/vue' + +type MountParams = Parameters +type OptionsParam = MountParams[1] + +declare global { + namespace Cypress { + interface Chainable { + mount: typeof mount + } + } +} diff --git a/packages/ripple-ui-maps/cypress/support/component-index.html b/packages/ripple-ui-maps/cypress/support/component-index.html new file mode 100644 index 0000000000..e39ba42969 --- /dev/null +++ b/packages/ripple-ui-maps/cypress/support/component-index.html @@ -0,0 +1,12 @@ + + + + + + + Components App + + +
+ + diff --git a/packages/ripple-ui-maps/cypress/support/component.ts b/packages/ripple-ui-maps/cypress/support/component.ts new file mode 100644 index 0000000000..b8bdffd910 --- /dev/null +++ b/packages/ripple-ui-maps/cypress/support/component.ts @@ -0,0 +1,35 @@ +// *********************************************************** +// This example support/component.ts is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + + +import { mount } from 'cypress/vue' +import { h } from 'vue' +import { RplIconSprite } from '@dpc-sdp/ripple-ui-core/vue' +// Ensure global styles are loaded +import '@dpc-sdp/ripple-ui-core/style' + +const RplAppWrapper = { + components: { RplIconSprite }, + template: `
+ + +
` +} + +Cypress.Commands.add('mount', (component: any, options = {}) => { + return mount(() => { + return h(RplAppWrapper, null, () => + h(component, { ...options.props }, { ...options.slots }) + ) + }) +}) + +// Example use: +// cy.mount(MyComponent) diff --git a/packages/ripple-ui-maps/event.d.ts b/packages/ripple-ui-maps/event.d.ts new file mode 100644 index 0000000000..636d9c3f6c --- /dev/null +++ b/packages/ripple-ui-maps/event.d.ts @@ -0,0 +1,8 @@ +/// + +declare module '*.vue' { + import { DefineComponent } from 'vue' + // eslint-disable-next-line + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/packages/ripple-ui-maps/global-css.vite.config.ts b/packages/ripple-ui-maps/global-css.vite.config.ts new file mode 100644 index 0000000000..1a9d2010fd --- /dev/null +++ b/packages/ripple-ui-maps/global-css.vite.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from 'vite' +import path from 'path' + +// https://vitejs.dev/config/ +// https://vitejs.dev/guide/build.html#library-mode +export default defineConfig({ + resolve: { + alias: { + '@': path.resolve(__dirname, './src') + } + }, + build: { + emptyOutDir: false, + lib: { + entry: path.resolve(__dirname, 'src/styles/global.css'), + fileName: (f) => `delete.${f}.js`, + formats: ['es'] + }, + rollupOptions: { + output: { + assetFileNames: (assetInfo) => + assetInfo.name === 'style.css' ? 'global.css' : assetInfo.name + } + } + } +}) diff --git a/packages/ripple-ui-maps/index.html b/packages/ripple-ui-maps/index.html new file mode 100644 index 0000000000..7d75371846 --- /dev/null +++ b/packages/ripple-ui-maps/index.html @@ -0,0 +1,14 @@ + + + + + + + + Ripple component test page + + +
+ + + diff --git a/packages/ripple-ui-maps/package.json b/packages/ripple-ui-maps/package.json new file mode 100644 index 0000000000..fdfa9a55d7 --- /dev/null +++ b/packages/ripple-ui-maps/package.json @@ -0,0 +1,72 @@ +{ + "packageManager": "pnpm@8.6.2", + "name": "@dpc-sdp/ripple-ui-maps", + "description": "Ripple UI Core component library", + "version": "2.1.1", + "license": "Apache-2.0", + "repository": "https://github.com/dpc-sdp/ripple-framework", + "files": [ + "dist", + "src", + "./postcssrc.json", + "README.md" + ], + "type": "module", + "main": "./dist/rpl-lib.es.js", + "module": "./dist/rpl-lib.es.js", + "types": "./dist/index.d.ts", + "exports": { + ".": "./dist/rpl-lib.es.js", + "./nuxt": "./dist/nuxt/index.js", + "./style": "./dist/global.css", + "./style/breakpoints": "./src/styles/_breakpoints.css", + "./style/components": "./dist/style.css" + }, + "scripts": { + "build": "pnpm clean && pnpm build:types && pnpm build:styles && pnpm build:lib", + "build:types": "tsc -p tsconfig.json", + "build:lib": "vite build", + "build:vue": "vite build --config vue.vite.config.ts", + "build:styles": "vite build --config global-css.vite.config.ts && rimraf ./dist/delete.es.js", + "watch": "pnpm build:types && vite build --watch", + "clean": "(rimraf dist* && rimraf tsconfig.tsbuildinfo) | true", + "preview": "vite preview", + "storybook": "start-storybook -p 6006", + "storybook:build": "build-storybook", + "test:components": "cypress run --component", + "cy:components": "cypress open --component", + "test:generate-output": "jest --json --outputFile=.jest-test-results.json" + }, + "dependencies": { + "@nuxt/kit": "3.3.2", + "@vueuse/core": "^9.13.0", + "@vueuse/integrations": "^9.13.0", + "ol": "^7.5.1", + "ol-contextmenu": "^5.2.1", + "ol-ext": "^4.0.11", + "postcss-each": "^1.1.0", + "postcss-nested": "^6.0.1", + "postcss-normalize": "^10.0.1", + "postcss-preset-env": "^8.1.0", + "vue": "3.3.4", + "vue3-openlayers": "^1.2.0" + }, + "devDependencies": { + "@dpc-sdp/ripple-tide-api": "workspace:*", + "@vitejs/plugin-vue": "^4.1.0", + "@vue/compiler-sfc": "^3.2.47", + "autoprefixer": "^10.4.14", + "chromatic": "^6.17.2", + "eslint-config-prettier": "^8.8.0", + "eslint-plugin-storybook": "^0.6.13", + "jest-axe": "^7.0.1", + "rimraf": "^4.4.1", + "style-dictionary": "^3.7.2", + "vite": "4.1.5", + "vite-plugin-copy": "^0.1.6", + "vite-plugin-dts": "^2.1.0", + "vite-plugin-svg-icons": "^2.0.1", + "vite-plugin-vue-svg": "^0.1.0", + "vite-svg-loader": "^4.0.0" + } +} diff --git a/packages/ripple-ui-maps/src/App.vue b/packages/ripple-ui-maps/src/App.vue new file mode 100644 index 0000000000..60565e4cc9 --- /dev/null +++ b/packages/ripple-ui-maps/src/App.vue @@ -0,0 +1,5 @@ + + + diff --git a/packages/ripple-ui-maps/src/components.ts b/packages/ripple-ui-maps/src/components.ts new file mode 100644 index 0000000000..661ca10d0e --- /dev/null +++ b/packages/ripple-ui-maps/src/components.ts @@ -0,0 +1,2 @@ +/* Add component exports here */ +export { default as RplDemo } from './components/demo/RplDemo.vue' diff --git a/packages/ripple-ui-maps/src/components/cluster/RplMapCluster.vue b/packages/ripple-ui-maps/src/components/cluster/RplMapCluster.vue new file mode 100644 index 0000000000..1e28e17992 --- /dev/null +++ b/packages/ripple-ui-maps/src/components/cluster/RplMapCluster.vue @@ -0,0 +1,66 @@ + + + diff --git a/packages/ripple-ui-maps/src/components/feature-pin/RplMapFeaturePin.vue b/packages/ripple-ui-maps/src/components/feature-pin/RplMapFeaturePin.vue new file mode 100644 index 0000000000..4f9c38d0fa --- /dev/null +++ b/packages/ripple-ui-maps/src/components/feature-pin/RplMapFeaturePin.vue @@ -0,0 +1,23 @@ + + + + diff --git a/packages/ripple-ui-maps/src/components/feature-pin/icon-pin.svg b/packages/ripple-ui-maps/src/components/feature-pin/icon-pin.svg new file mode 100644 index 0000000000..ebe49241e7 --- /dev/null +++ b/packages/ripple-ui-maps/src/components/feature-pin/icon-pin.svg @@ -0,0 +1,4 @@ + + + diff --git a/packages/ripple-ui-maps/src/components/map/RplMap.css b/packages/ripple-ui-maps/src/components/map/RplMap.css new file mode 100644 index 0000000000..17497c7130 --- /dev/null +++ b/packages/ripple-ui-maps/src/components/map/RplMap.css @@ -0,0 +1,35 @@ +@import '@dpc-sdp/ripple-ui-core/style/breakpoints'; + +.rpl-map { + position: relative; +} + +.rpl-map__control { + position: absolute; + right: var(--rpl-sp-8); + + button { + padding: var(--rpl-sp-2); + border: var(--rpl-border-1) solid var(--rpl-clr-neutral-200); + border-radius: var(--rpl-border-radius-2); + box-shadow: var(--rpl-elevation-1); + background-color: var(--rpl-clr-light); + height: var(--rpl-sp-8); + width: var(--rpl-sp-8); + display: flex; + justify-content: center; + font-size: 1.75rem; + line-height: 1.25rem; + } +} + +.rpl-map__control-zoom { + top: var(--rpl-sp-4); + button { + margin-top: var(--rpl-sp-1); + } +} + +.rpl-map__control-fullscreen { + top: calc(var(--rpl-sp-12) + var(--rpl-sp-8) + var(--rpl-sp-4)); +} diff --git a/packages/ripple-ui-maps/src/components/map/RplMap.cy.ts b/packages/ripple-ui-maps/src/components/map/RplMap.cy.ts new file mode 100644 index 0000000000..8fedfdb1ff --- /dev/null +++ b/packages/ripple-ui-maps/src/components/map/RplMap.cy.ts @@ -0,0 +1,16 @@ +import RplMap from './RplMap.vue' + +const baseProps = { + id: '1234' +} + +describe('RplMap', () => { + it('mounts', () => { + cy.mount(RplMap, { + props: { + ...baseProps + } + }) + cy.get('.rpl-map').should('exist') + }) +}) diff --git a/packages/ripple-ui-maps/src/components/map/RplMap.stories.mdx b/packages/ripple-ui-maps/src/components/map/RplMap.stories.mdx new file mode 100644 index 0000000000..21c14a241d --- /dev/null +++ b/packages/ripple-ui-maps/src/components/map/RplMap.stories.mdx @@ -0,0 +1,102 @@ +import { + Canvas, + Meta, + Story, + ArgsTable +} from '@storybook/addon-docs' +import { RplAccordion } from '@dpc-sdp/ripple-ui-core/vue' +import RplMap from './RplMap.vue' +import RplMapProviderEsri from './providers/RplMapProviderEsri.vue' +import RplMapProviderVicMap from './providers/RplMapProviderVicMap.vue' +import RplMapProviderMapbox from './providers/RplMapProviderMapbox.vue' +import featureData from './__fixture__/features.json' +import '@dpc-sdp/ripple-ui-core/style/components' + +export const Template = (args) => ({ + components: { RplMap, RplMapProviderEsri, RplMapProviderVicMap, RplMapProviderMapbox, RplAccordion }, + setup() { + const getClusteredFeatures = (itms) => { + return itms.map(itm => { + return { + id: itm.id, + title: itm.title, + content: itm.description + } + }) + } + return { + getClusteredFeatures, + args + } + }, + template: ` + + + + + + ` +}) + + + +# Demo + + + + + + {Template.bind()} + + + {Template.bind()} + + + {Template.bind()} + + diff --git a/packages/ripple-ui-maps/src/components/map/RplMap.vue b/packages/ripple-ui-maps/src/components/map/RplMap.vue new file mode 100644 index 0000000000..acaa55ea84 --- /dev/null +++ b/packages/ripple-ui-maps/src/components/map/RplMap.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/packages/ripple-tide-search/components/global/TideSearchAddressLookup.vue b/packages/ripple-tide-search/components/global/TideSearchAddressLookup.vue new file mode 100644 index 0000000000..920a7bf802 --- /dev/null +++ b/packages/ripple-tide-search/components/global/TideSearchAddressLookup.vue @@ -0,0 +1,194 @@ + + + diff --git a/packages/ripple-tide-search/components/global/TideSearchListingResultsMap.vue b/packages/ripple-tide-search/components/global/TideSearchListingResultsMap.vue new file mode 100644 index 0000000000..6ff2aabd12 --- /dev/null +++ b/packages/ripple-tide-search/components/global/TideSearchListingResultsMap.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/packages/ripple-tide-search/composables/useTideSearch.ts b/packages/ripple-tide-search/composables/useTideSearch.ts index bb083fd5de..51a5669f2a 100644 --- a/packages/ripple-tide-search/composables/useTideSearch.ts +++ b/packages/ripple-tide-search/composables/useTideSearch.ts @@ -25,20 +25,28 @@ const escapeJSONString = (raw: string): string => { interface Config { queryConfig: TideSearchListingConfig['queryConfig'] - userFilterConfig: TideSearchListingConfig['userFilters'] + userFilters: TideSearchListingConfig['userFilters'] globalFilters: any[] searchResultsMappingFn: (item: any) => any searchListingConfig: TideSearchListingConfig['searchListingConfig'] - sortOptions: TideSearchListingConfig['sortOptions'] + sortOptions?: TideSearchListingConfig['sortOptions'] + includeMapsRequest?: boolean + mapResultsMappingFn?: (item: any) => any + mapConfig?: any + locationQueryConfig?: any } export default ({ queryConfig, - userFilterConfig, + userFilters: userFilterConfig, globalFilters, searchResultsMappingFn, searchListingConfig, - sortOptions + sortOptions = [], + includeMapsRequest = false, + mapResultsMappingFn = (item: any) => item, + mapConfig = {}, + locationQueryConfig = {} }: Config) => { const { public: config } = useRuntimeConfig() const route: RouteLocation = useRoute() @@ -61,9 +69,15 @@ export default ({ return JSON.parse(JSON.stringify(obj).replace(re, escapedValue)) } + const activeTab: TideSearchListingTabKey = ref( + searchListingConfig?.displayMapTab ? 'map' : null + ) + const isBusy = ref(true) const searchError = ref(null) + const locationQuery = ref(null) + const searchTerm = ref('') const filterForm = ref({}) const page = ref(1) @@ -87,6 +101,9 @@ export default ({ const totalPages = computed(() => { return pageSize.value ? Math.ceil(totalResults.value / pageSize.value) : 0 }) + + const mapResults = ref([]) + const onAggregationUpdateHook = ref() const getQueryClause = () => { @@ -96,7 +113,7 @@ export default ({ return [{ match_all: {} }] } - const getFilterClause = () => { + const getUserFilterClause = () => { const _filters = [] as any[] if (globalFilters && globalFilters.length > 0) { _filters.push(...globalFilters) @@ -107,6 +124,30 @@ export default ({ return _filters } + const getLocationFilterClause = async () => { + const transformFnName = locationQueryConfig?.dslTransformFunction + const fns = appConfig?.ripple?.search?.locationDSLTransformFunctions || {} + + // If no transform function is defined, return an empty array + if (!transformFnName) { + return [] + } + + const transformFn = fns[transformFnName] + + if (typeof transformFn !== 'function') { + throw new Error( + `Search listing: No matching location transform function called "${transformFnName}"` + ) + } + + const transformedDSL = await transformFn(locationQuery.value) + const listingFilters = transformedDSL?.listing?.filter || [] + + // return transformedDSL as an array to match the format of the other filters, transformedDSL might not be an array + return Array.isArray(listingFilters) ? listingFilters : [listingFilters] + } + const getAggregations = () => { if (Array.isArray(userFilterConfig) && userFilterConfig.length > 0) { const aggregations = userFilterConfig.reduce((aggs, currentFilter) => { @@ -247,12 +288,14 @@ export default ({ }) }) - const getQueryDSL = () => { + const getQueryDSL = async () => { + const locationFilters = await getLocationFilterClause() + return { query: { bool: { must: getQueryClause(), - filter: getFilterClause() + filter: [...getUserFilterClause(), ...locationFilters] } }, size: pageSize.value, @@ -276,12 +319,27 @@ export default ({ } } + const getQueryDSLForMaps = async () => { + return { + query: { + bool: { + must: getQueryClause(), + filter: getUserFilterClause() + } + }, + // size: 10000, TODO change back to this, but it's tanking performance when switching tabs + size: 20, + from: 0, + sort: getSortClause() + } + } + const getSearchResults = async (isFirstRun) => { isBusy.value = true searchError.value = null try { - const body = getQueryDSL() + const body = await getQueryDSL() if (process.env.NODE_ENV === 'development') { console.info(JSON.stringify(body, null, 2)) @@ -292,8 +350,9 @@ export default ({ body }) - // Set the aggregations request to a resolved promise, this helps keep the Promise.all logic clean + // Set the aggregations and maps request to a resolved promise by default, this helps keep the Promise.all logic clean let aggsRequest: Promise = Promise.resolve() + let mapsRequest: Promise = Promise.resolve() if (isFirstRun) { // Kick off an 'empty' search in order to get the aggregations (options) for the dropdowns, this @@ -305,9 +364,17 @@ export default ({ }) } - const [searchResponse, aggsResponse] = await Promise.all([ + if (activeTab.value === 'map') { + mapsRequest = $fetch(searchUrl, { + method: 'POST', + body: await getQueryDSLForMaps() + }) + } + + const [searchResponse, aggsResponse, mapsResponse] = await Promise.all([ searchRequest, - aggsRequest + aggsRequest, + mapsRequest ]) totalResults.value = searchResponse?.hits?.total?.value || 0 @@ -328,6 +395,10 @@ export default ({ onAggregationUpdateHook.value(mappedAggs) } + if (mapsResponse) { + mapResults.value = mapsResponse.hits?.hits.map(mapResultsMappingFn) + } + isBusy.value = false } catch (error) { console.error(error) @@ -394,11 +465,24 @@ export default ({ Object.entries(filterForm.value).filter(([key, value]) => value) ) + // flatten locationQuery into an object for adding to the query string + const locationParams = Object.entries(locationQuery.value || {}).reduce( + (obj, [key, value]) => { + return { + ...obj, + [`location[${key}]`]: value + } + }, + {} + ) + await navigateTo({ path: route.path, query: { page: 1, q: searchTerm.value || undefined, + activeTab: activeTab.value, + ...locationParams, ...filterFormValues } }) @@ -417,9 +501,6 @@ export default ({ }) } - /** - * Navigates to a specific page using the search term and filters in the current URL - */ const changeSortOrder = async (newSortId: string) => { await navigateTo({ ...route, @@ -431,6 +512,16 @@ export default ({ }) } + const changeActiveTab = async (newActiveTab: string) => { + await navigateTo({ + ...route, + query: { + ...route.query, + activeTab: newActiveTab + } + }) + } + const getFiltersFromRoute = (newRoute: RouteLocation) => { // Re-construct the filter form values from the URL, we find every query param that matches // a user filter, then construct the filter values based on that. @@ -453,12 +544,32 @@ export default ({ }, {}) } + const getLocationQueryFromRoute = (newRoute: RouteLocation) => { + // parse the location query from the route + const location = Object.keys(newRoute.query) + .filter((key) => key.startsWith('location')) + .reduce((obj, key) => { + return { + ...obj, + [key.replace('location[', '').replace(']', '')]: newRoute.query[key] + } + }, {}) + + console.log('asdasd', location) + + return location + } + /** * The URL is the source of truth for what is shown in the search results. * * When the URL changes, the URL is parsed and the query is transformed into an elastic DSL query. */ const searchFromRoute = (newRoute: RouteLocation, isFirstRun = false) => { + activeTab.value = searchListingConfig?.displayMapTab + ? getSingleQueryStringValue(newRoute.query, 'activeTab') || 'map' + : null + searchTerm.value = getSingleQueryStringValue(newRoute.query, 'q') || '' page.value = parseInt(getSingleQueryStringValue(newRoute.query, 'page'), 10) || 1 @@ -469,6 +580,8 @@ export default ({ filterForm.value = getFiltersFromRoute(newRoute) + locationQuery.value = getLocationQueryFromRoute(newRoute) + getSearchResults(isFirstRun) } @@ -506,6 +619,10 @@ export default ({ pagingStart, pagingEnd, userSelectedSort, - changeSortOrder + changeSortOrder, + mapResults, + activeTab, + changeActiveTab, + locationQuery } } diff --git a/packages/ripple-tide-search/lib/address/constants.ts b/packages/ripple-tide-search/lib/address/constants.ts new file mode 100644 index 0000000000..2864c579d5 --- /dev/null +++ b/packages/ripple-tide-search/lib/address/constants.ts @@ -0,0 +1,15 @@ +// Length of query string before attempting match +export const MIN_QUERY_LENGTH = 3 +// Enables fetching council results for each address suggestion - WARNING this is very slow! +export const COUNCIL_PER_SUGGESTION = false +// Match score, adjust this to fine tune matching +export const DEFAULT_SCORE = 91 +// +export const FILTER_UNINCORPORATED_AREAS = true + +export const UNINCORPORATED_AREAS = [ + 'French Island', + 'Falls Creek', + 'Baw Baw Village', + 'Mount Buller' +] diff --git a/packages/ripple-tide-search/lib/address/index.ts b/packages/ripple-tide-search/lib/address/index.ts new file mode 100644 index 0000000000..49de1d58e6 --- /dev/null +++ b/packages/ripple-tide-search/lib/address/index.ts @@ -0,0 +1,266 @@ +import { + DEFAULT_SCORE, + FILTER_UNINCORPORATED_AREAS, + UNINCORPORATED_AREAS, + MIN_QUERY_LENGTH +} from './constants' + +import type { + IAddressResultResponse, + IMapboxAddressFeature, + IEsriCouncilFeatureResponse, + addressGeoCoderEnum, + lgaDataType, + IEsriAddressSuggestions, + IVicMapAddressSuggestions +} from './types' + +const localityData = [] +const lgaNames = [] + +export const fetchData = (url: string, params = {}): Promise => { + return $fetch(url, { params }) +} + +export const getCouncilByEsriLatLng = ( + latitude: number, + longitude: number +): Promise => { + const baseUrl = + 'https://services6.arcgis.com/GB33F62SbDxJjwEL/arcgis/rest/services/Vicmap_Admin/FeatureServer/9/query' + const params = { + where: '1=1', + geometry: `${longitude},${latitude}`, + geometryType: 'esriGeometryPoint', + inSR: '4326', + spatialRel: 'esriSpatialRelWithin', + resultType: 'none', + distance: '0.0', + units: 'esriSRUnit_Meter', + returnGeodetic: 'false', + outFields: 'LGA_NAME', + returnGeometry: 'false', + returnCentroid: 'false', + featureEncoding: 'esriDefault', + multipatchOption: 'xyFootprint', + applyVCSProjection: 'false', + returnIdsOnly: 'false', + returnUniqueIdsOnly: 'false', + returnCountOnly: 'false', + returnExtentOnly: 'false', + returnQueryGeometry: 'false', + returnDistinctValues: 'false', + cacheHint: 'false', + returnZ: 'false', + returnM: 'false', + returnExceededLimitFeatures: 'true', + sqlFormat: 'none', + f: 'json' + } + return fetchData(baseUrl, params).then( + (data: IEsriCouncilFeatureResponse) => { + if (data && data.features && data.features.length > 0) { + return data.features[0].attributes.LGA_NAME + } + return 'unknown' + } + ) +} + +export const getMapboxAddressSuggestions = ( + address: string, + limit: number, + score: number +): Promise => { + const baseUrl = `https://api.mapbox.com/geocoding/v5/mapbox.places/${address}.json` + const token = + 'pk.eyJ1IjoibXl2aWN0b2lyYSIsImEiOiJjamlvMDgxbnIwNGwwM2t0OWh3ZDJhMGo5In0.w_xKPPd39cwrS1F4_yy39g' + const params = { + country: 'AU', + access_token: token, + types: 'address', + limit, + bbox: '140.961682,-39.2305638,150.0537139,-33.980426' // Victoria BBox boundary + } + return fetchData(baseUrl, params).then((data) => { + if (data.features && data.features.length > 0) { + return data.features + .filter((feature: IMapboxAddressFeature) => { + if ( + feature.context.some((ctx) => ctx.short_code === 'AU-VIC') && + feature.relevance > score / 100 + ) { + if (!FILTER_UNINCORPORATED_AREAS) { + return true + } else if ( + !feature.context.some((ctx) => + UNINCORPORATED_AREAS.includes(ctx.text) + ) + ) { + return true + } + } + return false + }) + .map((feature: IMapboxAddressFeature) => { + return { + name: feature.place_name, + type: feature.place_type[0], + longitude: feature.center[0], + latitude: feature.center[1] + } + }) + } + }) +} + +export const getEsriAddressSuggestions = async ( + address: string, + limit: number, + score: number +): Promise => { + const baseUrl = + 'https://geocode-api.arcgis.com/arcgis/rest/services/World/GeocodeServer/findAddressCandidates' + const token = + 'AAPKb4288179ee4c40c99fedf129bcf74633RxWXGuVkAefFF0Iz0GGNu8wjowjpR3YNV9kzJ5W8AIC3pO4xhbIjLWomfgdFebeS' + const params = { + address, + countryCode: 'AU', + region: 'Victoria', + maxLocations: limit, + geometryPrecision: '6', + category: 'Address', + f: 'json', + token + } + const { candidates } = await fetchData(baseUrl, params) + return candidates + .filter((res: IEsriAddressSuggestions) => { + return res.score > score + }) + .map((res: IEsriAddressSuggestions) => { + return { + name: res.address, + longitude: res.location.x, + latitude: res.location.y + } + }) +} + +export const getVicMapAddressSuggestions = async ( + address: string +): Promise => { + const baseUrl = + 'https://services6.arcgis.com/GB33F62SbDxJjwEL/ArcGIS/rest/services/Vicmap_Address/FeatureServer/0/query' + const params = { + where: `NUM_ROAD_ADDRESS='${address.replace(/ /g, '+').toUpperCase()}'`, + outFields: 'EZI_ADDRESS', + geometryType: 'esriGeometryPoint', + returnGeometry: 'true', + f: 'json' + } + const response = await fetchData(baseUrl, params) + return response.features.map((res: IVicMapAddressSuggestions) => { + return { + name: res.attributes.EZI_ADDRESS, + longitude: res.geometry.x, + latitude: res.geometry.y + } + }) +} + +export const getSuggestionsByAddress = ( + address: string, + geocoder: addressGeoCoderEnum = 'mapbox', + limit: number | false, + score = DEFAULT_SCORE +): Promise => { + switch (geocoder) { + case 'vicmap': + return getVicMapAddressSuggestions(address) + case 'mapbox': + return getMapboxAddressSuggestions(address, limit, score) + case 'esri': + return getEsriAddressSuggestions(address, limit, score) + default: + return getMapboxAddressSuggestions(address, limit, score) + } +} + +export const getLGAByLocalityQuery = async (query: string) => { + if (query.length > MIN_QUERY_LENGTH) { + const q = query.toLowerCase() + const searchUrl = `https://a83890f7a31dea14e1ae83c6f0afacca.sdp3.elastic.sdp.vic.gov.au/sdp_data_pipelines_vic_localities/_search` + const queryDSL = { + query: { + bool: { + must: [ + { + multi_match: { + query: q, + fields: ['name', 'lga_key', 'lga', 'lga_gazetted_name'] + } + } + ], + filter: [{ terms: { type: ['postcode'] } }] + } + } + } + console.log(JSON.stringify(queryDSL)) + + let localityResponse + try { + localityResponse = await $fetch(searchUrl, { + method: 'POST', + body: { + ...queryDSL, + size: 6 + } + }) + } catch (e) { + console.error(e) + } + + if (localityResponse?.hits?.hits?.length > 0) { + const matchedLocalities = localityResponse.hits.hits.map((itm) => { + console.log(itm) + return { + name: itm._source?.name, + council: itm._source?.lga_gazetted_name, + lga_key: itm._source?.lga_key, + type: itm._source?.type + } + }) + return matchedLocalities + } else { + return [] + } + } + return [] +} + +export const titleCase = (str: string) => { + return str + .toLowerCase() + .split(' ') + .map((word: string) => { + return word.replace(word[0], word[0].toUpperCase()) + }) + .join(' ') +} + +export const findLGAByQuery = (query: string) => { + if (!query) { + return [] + } + const q = `${query}`.toLowerCase() + return lgaNames + .filter((lga) => { + return lga && (lga.short === q || lga.name?.toLowerCase() === q) + }) + .map((lga: lgaDataType) => ({ + name: titleCase(lga.short), + council: lga.name, + type: 'LGA' + })) +} diff --git a/packages/ripple-tide-search/lib/address/types.ts b/packages/ripple-tide-search/lib/address/types.ts new file mode 100644 index 0000000000..3c292c2e47 --- /dev/null +++ b/packages/ripple-tide-search/lib/address/types.ts @@ -0,0 +1,55 @@ +export type addressGeoCoderEnum = 'mapbox' | 'vicmap' | 'mapbox' | 'esri' + +export type lgaDataType = { + short: string + name: string +} + +export interface IAddressResultResponse { + name: string + type?: string + longitude: number + latitude: number +} + +export interface IMapboxAddressFeatureContext { + short_code: string + text: string +} + +export interface IMapboxAddressFeature { + context: IMapboxAddressFeatureContext[] + relevance: number + place_name: string + place_type: string[] + center: [number[], number[]] +} + +export interface IEsriCouncilFeature { + attributes: { + LGA_NAME: string + } +} + +export interface IEsriCouncilFeatureResponse { + features: IEsriCouncilFeature[] +} + +export interface IEsriAddressSuggestions { + score: number + address: string + location: { + x: number + y: number + } +} + +export interface IVicMapAddressSuggestions { + attributes: { + EZI_ADDRESS: string + } + geometry: { + x: number + y: number + } +} diff --git a/packages/ripple-tide-search/mapping/components/custom-collection/custom-collection-mapping.ts b/packages/ripple-tide-search/mapping/components/custom-collection/custom-collection-mapping.ts index 3d9b61210f..307d115db5 100644 --- a/packages/ripple-tide-search/mapping/components/custom-collection/custom-collection-mapping.ts +++ b/packages/ripple-tide-search/mapping/components/custom-collection/custom-collection-mapping.ts @@ -4,7 +4,7 @@ export const customCollectionMapping = ( field ): TideDynamicPageComponent => { return { - component: 'TideCustomCollection', + component: 'TideDataDrivenMap', id: field.drupal_internal__id.toString(), props: { id: field.drupal_internal__id.toString(), diff --git a/packages/ripple-tide-search/server/api/services/address.ts b/packages/ripple-tide-search/server/api/services/address.ts new file mode 100644 index 0000000000..3b93f080c2 --- /dev/null +++ b/packages/ripple-tide-search/server/api/services/address.ts @@ -0,0 +1,123 @@ +import { defineEventHandler, H3Event } from 'h3' +// @ts-ignore +import { createHandler } from '@dpc-sdp/ripple-tide-api' +import { + findLGAByQuery, + getLGAByLocalityQuery, + getSuggestionsByAddress, + getCouncilByEsriLatLng +} from './../../../lib/address' +import { + DEFAULT_SCORE, + COUNCIL_PER_SUGGESTION +} from './../../../lib/address/constants' +import { addressGeoCoderEnum } from './../../../lib/address/types' + +interface addressQuery { + q?: string + geocoder?: addressGeoCoderEnum + limit?: string + score?: string + addresses: string + postcodes: string + localities: string + lgas: string +} +const boolVal = (val, defaultVal) => { + if (typeof val !== 'undefined') { + if (val === 'true') { + return true + } else if (val === 'false') { + return false + } + } + return defaultVal +} + +export const createAddressHandler = (event: H3Event) => { + return createHandler(event, 'AddressServiceHandler', async () => { + const queryObj: addressQuery = await getQuery(event) + const query = queryObj.q + const addressSearch = boolVal(queryObj.addresses, true) + const localitiesSearch = boolVal(queryObj.localities, true) + const postcodeSearch = boolVal(queryObj.postcodes, true) + const lgasSearch = boolVal(queryObj.lgas, true) + const results = [] + const geocoder = queryObj.geocoder || 'mapbox' + const resultsLimit = queryObj.limit ? parseInt(queryObj.limit) : 8 + const score = queryObj.score ? parseFloat(queryObj.score) : DEFAULT_SCORE + + if (!query) { + return { + error: 'No query defined' + } + } + + console.log('queryObj.addresses', queryObj) + console.log(addressSearch) + + // Simple string match on LGA name + if (lgasSearch) { + const lgaResults = findLGAByQuery(query) + + if (lgaResults && lgaResults.length > 0) { + results.push(...lgaResults) + } + } + + // match suburb or postcode from budget data + if (localitiesSearch || postcodeSearch) { + const localityResults = await getLGAByLocalityQuery(query) + + console.log('localityResults', localityResults) + + if (localityResults && localityResults.length > 0) { + results.push(...localityResults) + } + } + + // get address suggestions from geocoder + if (addressSearch) { + const suggestions = await getSuggestionsByAddress( + query, + geocoder, + resultsLimit, + score + ) + + if (suggestions && suggestions.length > 0) { + for (let i = 0; i < suggestions.length; i++) { + const suggestion = suggestions[i] + // NOTE: this is very slow + if (COUNCIL_PER_SUGGESTION) { + const council = await getCouncilByEsriLatLng( + suggestion.latitude, + suggestion.longitude + ) + if (council && council !== 'unknown') { + results.push({ + ...suggestion, + council + }) + } + } else { + results.push(suggestion) + } + } + } + } + + const limitResults = (results: unknown[], limit: number) => { + if (limit && typeof limit === 'number' && results.length > limit) { + return results.slice(0, limit) + } + return results + } + + return { query, results: limitResults(results, resultsLimit) } + }) +} + +export default defineEventHandler((event: H3Event) => { + return createAddressHandler(event) +}) diff --git a/packages/ripple-tide-search/server/api/services/lga.ts b/packages/ripple-tide-search/server/api/services/lga.ts new file mode 100644 index 0000000000..b208a9d7af --- /dev/null +++ b/packages/ripple-tide-search/server/api/services/lga.ts @@ -0,0 +1,43 @@ +import { defineEventHandler, H3Event } from 'h3' +// @ts-ignore +import { createHandler } from '@dpc-sdp/ripple-tide-api' + +interface addressQuery { + q?: string +} + +export function getLGAGeometry(query) { + const baseUrl = `https://services6.arcgis.com/GB33F62SbDxJjwEL/arcgis/rest/services/Vicmap_Admin/FeatureServer/9/query` + const params = { + where: `LGA_NAME='${query}'`, + geometryType: 'esriGeometryPolygon', + inSR: '4326', + spatialRel: 'esriSpatialRelWithin', + resultType: 'none', + distance: '0.0', + units: 'esriSRUnit_Meter', + outFields: 'lga_name', + returnGeodetic: 'false', + returnGeometry: 'true', + returnCentroid: 'true', + featureEncoding: 'esriDefault', + multipatchOption: 'xyFootprint', + returnExceededLimitFeatures: 'true', + sqlFormat: 'none', + f: 'json' + } + return $fetch(baseUrl, { params }) +} + +export const createLGAHandler = (event: H3Event) => { + return createHandler(event, 'LGAGeometryHandler', async () => { + const queryObj: addressQuery = await getQuery(event) + const query = queryObj.q + const LGAdata = await getLGAGeometry(query) + return LGAdata + }) +} + +export default defineEventHandler((event: H3Event) => { + return createLGAHandler(event) +}) diff --git a/packages/ripple-tide-search/types.ts b/packages/ripple-tide-search/types.ts index 6f9c62e554..cd69596946 100644 --- a/packages/ripple-tide-search/types.ts +++ b/packages/ripple-tide-search/types.ts @@ -84,6 +84,22 @@ export type TideSearchListingSortOption = { clause: any } +export type TideSearchLocationQueryConfig = { + component?: string + props?: { + [key: string]: unknown + } + dslTransformFn?: (location: any) => any +} + +export type TideSearchListingMapConfig = { + props?: { + [key: string]: unknown + } +} + +export type TideSearchListingTabKey = 'map' | 'listing' + export type TideSearchListingConfig = { /** * @description general configuration for search listing @@ -106,11 +122,17 @@ export type TideSearchListingConfig = { submit: string reset: string placeholder: string + mapTab: string + listingTab: string } /** * @description custom sort clause */ customSort?: Record[] + /** + * @description whether to display map tab and include map search results + */ + displayMapTab?: boolean } /** * @description Elastic Query DSL for query clause @@ -140,6 +162,8 @@ export type TideSearchListingConfig = { } } sortOptions?: TideSearchListingSortOption[] + locationQueryConfig?: TideSearchLocationQueryConfig + mapConfig?: TideSearchListingMapConfig } export interface TideSearchListingPage extends TidePageBase { diff --git a/packages/ripple-ui-core/src/components/search-bar/RplSearchBar.vue b/packages/ripple-ui-core/src/components/search-bar/RplSearchBar.vue index 6eba543ab5..4db68ff70f 100644 --- a/packages/ripple-ui-core/src/components/search-bar/RplSearchBar.vue +++ b/packages/ripple-ui-core/src/components/search-bar/RplSearchBar.vue @@ -25,6 +25,7 @@ interface Props { placeholder?: string globalEvents?: boolean showNoResults?: boolean + getSuggestionVal?: (item: any) => string } const props = withDefaults(defineProps(), { @@ -37,7 +38,8 @@ const props = withDefaults(defineProps(), { suggestions: () => [], maxSuggestionsDisplayed: 10, placeholder: undefined, - globalEvents: true + globalEvents: true, + getSuggestionVal: (item) => item }) const emit = defineEmits<{ @@ -98,7 +100,7 @@ const handleSelectOption = (optionValue, focusBackOnInput) => { inputRef.value.focus() } - internalValue.value = optionValue + internalValue.value = props.getSuggestionVal(optionValue) emit('update:inputValue', optionValue) isOpen.value = false @@ -107,7 +109,7 @@ const handleSelectOption = (optionValue, focusBackOnInput) => { { action: 'search', id: props.id, - text: optionValue, + text: props.getSuggestionVal(optionValue), value: optionValue, type: 'suggestion' }, @@ -207,7 +209,7 @@ const focusOption = (optionId) => { watch( () => props.inputValue, (newModelValue) => { - internalValue.value = newModelValue + internalValue.value = props.getSuggestionVal(newModelValue) }, { immediate: true } ) @@ -264,10 +266,7 @@ watch(activeOptionId, async (newId) => { diff --git a/packages/ripple-tide-search/components/global/TideSearchAddressLookup.vue b/packages/ripple-tide-search/components/global/TideSearchAddressLookup.vue index 6ad19130b1..b57258a837 100644 --- a/packages/ripple-tide-search/components/global/TideSearchAddressLookup.vue +++ b/packages/ripple-tide-search/components/global/TideSearchAddressLookup.vue @@ -40,10 +40,12 @@ import { fromLonLat } from 'ol/proj' interface Props { inputValue?: any + resultsloaded?: boolean } const props = withDefaults(defineProps(), { - inputValue: null + inputValue: null, + resultsloaded: false }) const results = ref([]) @@ -148,14 +150,24 @@ const onUpdate = useDebounceFn(async (q: string): Promise => { } }, 300) -// Center the map on the location when the map is ready -// It can take a while for the map to be ready, so we need to watch for it -// We don't animate the zoom here, because it's the initial load or a tab change +// Center the map on the location when the map instance is ready +// this is for tab switching only watch( () => rplMapRef.value, - (newMap, oldMap) => { - if (!oldMap && newMap) { - centerMapOnLocation(newMap, props.inputValue, false) + (newVal, oldVal) => { + if (!oldVal && newVal && props.resultsloaded) { + // We don't animate the zoom here, because it's the initial load or a tab change + centerMapOnLocation(newVal, props.inputValue, false) + } + } +) +// we also watch for the map search results to be loaded before centering +// this is for first load +watch( + () => props.resultsloaded, + (newVal, oldVal) => { + if (!oldVal && newVal && rplMapRef.value) { + centerMapOnLocation(rplMapRef.value, props.inputValue, false) } } ) diff --git a/packages/ripple-tide-search/components/global/TideSearchListingResultsMap.vue b/packages/ripple-tide-search/components/global/TideSearchListingResultsMap.vue index 986270d67b..fedbbe8e74 100644 --- a/packages/ripple-tide-search/components/global/TideSearchListingResultsMap.vue +++ b/packages/ripple-tide-search/components/global/TideSearchListingResultsMap.vue @@ -10,6 +10,9 @@ :pinStyle="pinStyle" :noresults="noresults" > + From 56ed7ef1482127ad6749de495244c6c6a804f694 Mon Sep 17 00:00:00 2001 From: Dylan Kelly Date: Thu, 4 Jan 2024 14:42:45 +1100 Subject: [PATCH 73/88] test(@dpc-sdp/ripple-ui-maps): :white_check_mark: fix maps tests --- .../global/VSBAProjectAreaLayer.vue | 6 ++++- .../nuxt-app/test/features/maps/vsba.feature | 25 +++++++++++-------- .../fixtures/map-table/vsba/arcgis-nyah.json | 13 ++++++++++ .../step_definitions/components/maps.ts | 24 ++++++++++++++---- .../global/TideSearchAddressLookup.vue | 5 ++-- 5 files changed, 55 insertions(+), 18 deletions(-) create mode 100644 examples/nuxt-app/test/fixtures/map-table/vsba/arcgis-nyah.json diff --git a/examples/nuxt-app/components/global/VSBAProjectAreaLayer.vue b/examples/nuxt-app/components/global/VSBAProjectAreaLayer.vue index d597b7a7a9..c12183a914 100644 --- a/examples/nuxt-app/components/global/VSBAProjectAreaLayer.vue +++ b/examples/nuxt-app/components/global/VSBAProjectAreaLayer.vue @@ -1,5 +1,9 @@