feat: Frontend 2.0 MVP

This commit is contained in:
Kristaps Fabians Geikins
2023-05-08 10:47:01 +03:00
committed by GitHub
parent 3238427488
commit b02a07e2b6
622 changed files with 74926 additions and 2061 deletions
+114 -2
View File
@@ -23,6 +23,17 @@ workflows:
# run tests for any commit on any branch, including any tags
only: /.*/
- test-frontend-2:
filters: &filters-frontend-2
tags:
# frontend-2 prefix only
only: /^frontend\-2.*/
- chromatic:
context:
- chromatic
filters: *filters-frontend-2
- get-version:
filters: *filters-allow-all
@@ -45,6 +56,11 @@ workflows:
requires:
- get-version
- docker-build-frontend-2:
filters: *filters-build
requires:
- get-version
- docker-build-webhooks:
context: *build-context
filters: *filters-build
@@ -83,7 +99,9 @@ workflows:
type: approval
filters: &filters-ignore-main-branch-or-all-tags
branches:
ignore: main
ignore:
- main
- frontend-2
tags:
ignore: /.*/
@@ -110,6 +128,16 @@ workflows:
- docker-build-frontend
- pre-commit
- docker-publish-frontend-2:
context: *docker-hub-context
filters: *filters-publish
requires:
- get-version
- publish-approval
- docker-build-frontend-2
- pre-commit
- test-frontend-2
- docker-publish-webhooks:
context: *docker-hub-context
filters: *filters-publish
@@ -161,6 +189,7 @@ workflows:
only:
- main
- hotfix*
- frontend-2
tags:
only: &filters-tag /^[0-9]+\.[0-9]+\.[0-9]+$/
requires:
@@ -168,6 +197,7 @@ workflows:
- publish-approval
- docker-publish-server
- docker-publish-frontend
- docker-publish-frontend-2
- docker-publish-webhooks
- docker-publish-file-imports
- docker-publish-previews
@@ -249,6 +279,9 @@ jobs:
paths:
- .yarn/cache
- .yarn/unplugged
- run:
name: Build public packages
command: yarn build:public
- run:
name: Run pre-commit
command: ./.husky/pre-commit
@@ -294,7 +327,6 @@ jobs:
- run:
name: Install Dependencies
command: yarn
# working_directory: 'packages/server'
- save_cache:
name: Save Yarn Package Cache
@@ -335,6 +367,75 @@ jobs:
# path: packages/server/coverage/lcov-report
# destination: package/server/coverage
test-frontend-2:
docker:
- image: cimg/node:16.15-browsers
resource_class: xlarge
steps:
- checkout
- restore_cache:
name: Restore Yarn Package Cache
keys:
- yarn-packages-server-{{ checksum "yarn.lock" }}
- run:
name: Install Dependencies
command: yarn
- save_cache:
name: Save Yarn Package Cache
key: yarn-packages-server-{{ checksum "yarn.lock" }}
paths:
- .yarn/cache
- .yarn/unplugged
- run:
name: Build public packages
command: yarn build:public
- run:
name: Lint everything
command: yarn lint
working_directory: 'packages/frontend-2'
- run:
name: Install Playwright
command: cd ~ && npx playwright install --with-deps
- run:
name: Test via Storybook
command: yarn storybook:test:ci
working_directory: 'packages/frontend-2'
chromatic:
resource_class: medium+
docker:
- image: cimg/node:16.15
steps:
- checkout
- restore_cache:
name: Restore Yarn Package Cache
keys:
- yarn-packages-server-{{ checksum "yarn.lock" }}
- run:
name: Install Dependencies
command: yarn
- save_cache:
name: Save Yarn Package Cache
key: yarn-packages-server-{{ checksum "yarn.lock" }}
paths:
- .yarn/cache
- .yarn/unplugged
- run:
name: Build shared packages
command: yarn build:public
- run:
name: Run chromatic
command: yarn chromatic
working_directory: 'packages/frontend-2'
vulnerability-scan:
# snyk can undertake most types of scans through GitHub integration
# which does not require integration with the CI
@@ -384,6 +485,7 @@ jobs:
command: |
[[ "${CIRCLE_TAG}" ]] && echo "proceed because tag is set" && exit 0
[[ "${CIRCLE_BRANCH}" == "main" ]] && echo "proceed because main branch" && exit 0
[[ "${CIRCLE_BRANCH}" == "frontend-2" ]] && echo "proceed because frontend-2 branch" && exit 0
[[ "${IS_DRAFT_PR}" == "TRUE" || -z "${CIRCLE_PULL_REQUEST}" ]] && echo "Should not build because either Draft PR or branch without PR, stopping" && exit 1
echo "proceeding"
- setup_remote_docker:
@@ -409,6 +511,11 @@ jobs:
environment:
SPECKLE_SERVER_PACKAGE: frontend
docker-build-frontend-2:
<<: *build-job
environment:
SPECKLE_SERVER_PACKAGE: frontend-2
docker-build-previews:
<<: *build-job
environment:
@@ -465,6 +572,11 @@ jobs:
environment:
SPECKLE_SERVER_PACKAGE: frontend
docker-publish-frontend-2:
<<: *publish-job
environment:
SPECKLE_SERVER_PACKAGE: frontend-2
docker-publish-previews:
<<: *publish-job
environment:
+1 -1
View File
@@ -26,7 +26,7 @@ if [[ "${IMAGE_VERSION_TAG}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-alpha\.[0-9]+)?$ ]]; th
docker push "${DOCKER_IMAGE_TAG}:2"
fi
else
BRANCH_TAG="${NEXT_RELEASE}-branch.${BRANCH_NAME_TRUNCATED}"
BRANCH_TAG="branch.${BRANCH_NAME_TRUNCATED}"
echo "🏷 Tagging and pushing image as '${DOCKER_IMAGE_TAG}:${BRANCH_TAG}'"
docker tag "${DOCKER_IMAGE_TAG}:${IMAGE_VERSION_TAG}" "${DOCKER_IMAGE_TAG}:${BRANCH_TAG}"
docker push "${DOCKER_IMAGE_TAG}:${BRANCH_TAG}"
+35 -23
View File
@@ -1,39 +1,51 @@
#!/bin/bash
#!/usr/bin/env bash
set -e
set -eo pipefail
RELEASE_VERSION=${IMAGE_VERSION_TAG}
GIT_REPO=$( pwd )
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
# shellcheck disable=SC1090,SC1091
source "${SCRIPT_DIR}/common.sh"
echo "Releasing Helm Chart version $RELEASE_VERSION"
RELEASE_VERSION="${IMAGE_VERSION_TAG}"
HELM_STABLE_BRANCH="${HELM_STABLE_BRANCH:-"main"}"
echo "Releasing Helm Chart version ${RELEASE_VERSION}"
git config --global user.email "devops+circleci@speckle.systems"
git config --global user.name "CI"
git clone git@github.com:specklesystems/helm.git ~/helm
# before overwriting the chart with the build version, check if the current chart version
# is not newer than the currently build one
git clone git@github.com:specklesystems/helm.git "${HOME}/helm"
CURRENT_VERSION="$(grep ^version ~/helm/charts/speckle-server/Chart.yaml | grep -o '2\..*')"
echo "${CURRENT_VERSION}"
yq e -i ".version = \"${RELEASE_VERSION}\"" "${GIT_REPO}/utils/helm/speckle-server/Chart.yaml"
yq e -i ".appVersion = \"${RELEASE_VERSION}\"" "${GIT_REPO}/utils/helm/speckle-server/Chart.yaml"
yq e -i ".docker_image_tag = \"${RELEASE_VERSION}\"" "${GIT_REPO}/utils/helm/speckle-server/values.yaml"
.circleci/check_version.py "${CURRENT_VERSION}" "${RELEASE_VERSION}"
if [ $? -eq 1 ]
then
echo "The current helm chart version is newer than the currently built. Exiting"
exit 1
if [[ -n "${CIRCLE_TAG}" || "${CIRCLE_BRANCH}" == "${HELM_STABLE_BRANCH}" ]]; then
# before overwriting the chart with the build version, check if the current chart version
# is not newer than the currently build one
CURRENT_VERSION="$(grep ^version "${HOME}/helm/charts/speckle-server/Chart.yaml" | grep -o '2\..*')"
echo "${CURRENT_VERSION}"
.circleci/check_version.py "${CURRENT_VERSION}" "${RELEASE_VERSION}"
if [ $? -eq 1 ]
then
echo "The current helm chart version '${CURRENT_VERSION}' is newer than the version '${RELEASE_VERSION}' we are attempting to publish. Exiting"
exit 1
fi
rm -rf "${HOME}/helm/charts/speckle-server"
cp -r "${GIT_REPO}/utils/helm/speckle-server" "${HOME}/helm/charts/speckle-server"
else
# overwrite the name of the chart
yq e -i ".name = \"speckle-server-branch-${BRANCH_NAME_TRUNCATED}\"" "${GIT_REPO}/utils/helm/speckle-server/Chart.yaml"
rm -rf "${HOME}/helm/charts/speckle-server-branch-${BRANCH_NAME_TRUNCATED}"
cp -r "${GIT_REPO}/utils/helm/speckle-server" "${HOME}/helm/charts/speckle-server-branch-${BRANCH_NAME_TRUNCATED}"
fi
rm -rf ~/helm/charts/speckle-server
cp -r utils/helm/speckle-server ~/helm/charts/speckle-server
sed -i 's/version: [^\s]*/version: '"${RELEASE_VERSION}"'/g' ~/helm/charts/speckle-server/Chart.yaml
sed -i 's/appVersion: [^\s]*/appVersion: '\""${RELEASE_VERSION}"\"'/g' ~/helm/charts/speckle-server/Chart.yaml
sed -i 's/docker_image_tag: [^\s]*/docker_image_tag: '"${RELEASE_VERSION}"'/g' ~/helm/charts/speckle-server/values.yaml
cd ~/helm
git add .
git commit -m "CircleCI commit"
git commit -m "CircleCI commit for version '${RELEASE_VERSION}'"
git push
+4 -1
View File
@@ -23,10 +23,13 @@ const config = {
ignorePatterns: [
'node_modules',
'dist',
'dist-*',
'public',
'events.json',
'.*.{ts,js,vue,tsx,jsx}',
'generated/**/*'
'generated/**/*',
'.nuxt',
'.output'
]
}
+2
View File
@@ -58,3 +58,5 @@ packages/server/.vscode/*.log
# GitGuardian
.cache_ggshield
storybook-static
+4
View File
@@ -1,4 +1,8 @@
schema: 'http://localhost:3000/graphql'
extensions:
languageService:
# Cause it's busted
enableValidation: false
require:
- ts-node/register
- tsconfig-paths/register
+11 -1
View File
@@ -2,11 +2,19 @@ node_modules
build
dist
dist2
dist-*
coverage
.nyc_output
packages/server/reports*
packages/preview-service/public/render/**/*
packages/objectloader/examples/browser/objectloader.web.js
packages/viewer/example/speckleviewer.web.js
packages/frontend-2/.output
packages/frontend-2/.nuxt
packages/frontend-2/lib/core/nuxt-modules/**/templates/*.js
packages/frontend-2/lib/common/generated/**/*
package-lock.json
yarn.lock
.yarn
@@ -24,4 +32,6 @@ venv
.*.{ts,js,vue,tsx,jsx}
**/generated/**/*
**/generated/graphql.ts
**/generated/graphql.ts
storybook-static
+2 -1
View File
@@ -8,7 +8,8 @@
"esbenp.prettier-vscode",
"hbenl.vscode-mocha-test-adapter",
"ryanluker.vscode-coverage-gutters",
"Vue.volar"
"Vue.volar",
"bradlc.vscode-tailwindcss"
],
// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
"unwantedRecommendations": ["octref.vetur", "vscode.typescript-language-features"]
+3 -1
View File
@@ -9,7 +9,9 @@ module.exports = {
// Filter out generated folder files
`**/generated/**/*`,
// Filter out types in object loader
'**/packages/objectloader/types/**/*'
'**/packages/objectloader/types/**/*',
// Filter out nuxt plugin templates
'**/templates/plugin.js'
])
return 'eslint --cache --max-warnings=0 ' + finalFiles.join(' ')
+9 -2
View File
@@ -22,9 +22,10 @@
"dev": "yarn workspaces foreach -piv -j unlimited run dev",
"dev:no-server": "yarn workspaces foreach --exclude @speckle/server -piv -j unlimited run dev",
"dev:minimal": "yarn workspaces foreach -piv -j unlimited --include '{@speckle/server,@speckle/frontend,@speckle/shared}' run dev",
"gqlgen": "yarn workspaces foreach -piv -j unlimited --include '{@speckle/server,@speckle/frontend}' run gqlgen",
"gqlgen": "yarn workspaces foreach -piv -j unlimited --include '{@speckle/server,@speckle/frontend,@speckle/frontend-2}' run gqlgen",
"dev:server": "yarn workspace @speckle/server dev",
"dev:frontend": "yarn workspace @speckle/frontend dev",
"dev:frontend-2": "yarn workspace @speckle/frontend-2 dev",
"dev:shared": "yarn workspace @speckle/shared dev",
"prepare": "husky install",
"postinstall": "husky install",
@@ -48,7 +49,13 @@
"tslib": "^2.3.1",
"core-js": "3.22.4",
"graphql": "^15.3.0",
"typescript": "^4.8.4"
"typescript": "^5.0.4",
"prettier": "^2.8.7",
"@typescript-eslint/eslint-plugin": "^5.59.0",
"@typescript-eslint/parser": "^5.59.0",
"eslint": "^8.11.0",
"eslint-config-prettier": "^8.5.0",
"@types/react": "file:./packages/frontend-2/type-augmentations/stubs/types__react"
},
"config": {
"commitizen": {
+24
View File
@@ -57,12 +57,16 @@ async function doTask(task) {
let fileSizeForMetric = 0
const metricDurationEnd = metricDuration.startTimer()
let newBranchCreated = false
let branchMetadata = { streamId: null, branchName: null }
try {
taskLogger.info('Doing task.')
const info = await FileUploads().where({ id: task.id }).first()
if (!info) {
throw new Error('Internal error: DB inconsistent')
}
fileTypeForMetric = info.fileType || 'missing_info'
fileSizeForMetric = Number(info.fileSize) || 0
taskLogger = taskLogger.child({
@@ -77,6 +81,19 @@ async function doTask(task) {
fs.mkdirSync(TMP_INPUT_DIR, { recursive: true })
serverApi = new ServerAPI({ streamId: info.streamId })
branchMetadata = {
branchName: info.branchName,
streamId: info.streamId
}
const existingBranch = await serverApi.getBranchByNameAndStreamId({
streamId: info.streamId,
name: info.branchName
})
if (!existingBranch) {
newBranchCreated = true
}
const { token } = await serverApi.createToken({
userId: info.userId,
name: 'temp upload token',
@@ -189,6 +206,13 @@ async function doTask(task) {
[err.toString(), task.id]
)
metricOperationErrors.labels(fileTypeForMetric).inc()
} finally {
const { streamId, branchName } = branchMetadata
await knex.raw(
`NOTIFY file_import_update, '${task.id}:::${streamId}:::${branchName}:::${
newBranchCreated ? 1 : 0
}'`
)
}
metricDurationEnd({ op: fileTypeForMetric })
metricInputFileSize.labels(fileTypeForMetric).observe(fileSizeForMetric)
+7
View File
@@ -0,0 +1,7 @@
HOST=0.0.0.0
PORT=8081
NUXT_PUBLIC_API_ORIGIN=http://localhost:3000
NUXT_PUBLIC_MIXPANEL_TOKEN_ID=acd87c5a50b56df91a795e999812a3a4
NUXT_PUBLIC_MIXPANEL_API_HOST=https://analytics.speckle.systems
+129
View File
@@ -0,0 +1,129 @@
const mainExtends = [
'plugin:nuxt/recommended',
'plugin:vue/vue3-recommended',
'plugin:storybook/recommended',
'prettier'
]
/** @type {import('eslint').Linter.Config} */
const config = {
env: {
node: true
},
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
parser: '@typescript-eslint/parser',
tsconfigRootDir: __dirname,
project: ['./tsconfig.eslint.json'],
extraFileExtensions: ['.vue']
},
extends: [...mainExtends],
plugins: ['@typescript-eslint'],
ignorePatterns: [
'**/templates/*',
'coverage',
'lib/common/generated/**/*',
'storybook-static',
'!.storybook',
'.nuxt',
'.output'
],
rules: {
camelcase: [
'error',
{
properties: 'always',
allow: ['^[\\w]+_[\\w]+Fragment$']
}
],
'no-alert': 'error',
eqeqeq: ['error', 'always', { null: 'always' }],
'no-console': 'off',
'no-var': 'error'
},
overrides: [
{
files: '*.test.{ts,js}',
env: {
jest: true
}
},
{
files: './{components|pages|store|lib}/*.{js,ts,vue}',
env: {
node: false,
browser: true
}
},
{
files: '*.{ts,tsx,vue}',
extends: ['plugin:@typescript-eslint/recommended', ...mainExtends],
rules: {
'@typescript-eslint/no-explicit-any': ['error'],
'@typescript-eslint/no-unsafe-argument': ['error'],
'@typescript-eslint/no-unsafe-assignment': 'error',
'@typescript-eslint/no-unsafe-call': 'error',
'@typescript-eslint/no-unsafe-member-access': 'error',
'@typescript-eslint/no-unsafe-return': 'error',
'@typescript-eslint/no-for-in-array': ['error'],
'@typescript-eslint/restrict-template-expressions': ['error'],
'@typescript-eslint/restrict-plus-operands': ['error'],
'@typescript-eslint/await-thenable': ['warn'],
'@typescript-eslint/ban-types': ['warn'],
'require-await': 'off',
'@typescript-eslint/require-await': 'error',
'no-undef': 'off'
}
},
{
files: '*.vue',
plugins: ['vuejs-accessibility'],
extends: [
'plugin:@typescript-eslint/recommended',
...mainExtends,
'plugin:vuejs-accessibility/recommended'
],
rules: {
'vue/component-tags-order': [
'error',
{ order: ['docs', 'template', 'script', 'style'] }
],
'vue/require-default-prop': 'off',
'vue/multi-word-component-names': 'off',
'vue/component-name-in-template-casing': [
'error',
'PascalCase',
{ registeredComponentsOnly: false }
],
'vuejs-accessibility/label-has-for': [
'error',
{
required: {
some: ['nesting', 'id']
}
}
]
}
},
{
files: '*.d.ts',
rules: {
'no-var': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/ban-types': 'off'
}
},
{
files: '*.stories.{js,ts}',
rules: {
// this one feels busted, tells me to await synchronous calls
'storybook/await-interactions': 'off',
// storybook types suck and can't be augmented
'@typescript-eslint/no-unsafe-call': 'off'
}
}
]
}
module.exports = config
+8
View File
@@ -0,0 +1,8 @@
node_modules
*.log*
.nuxt
.nitro
.cache
.output
.env
dist
+76
View File
@@ -0,0 +1,76 @@
import dotenv from 'dotenv'
import Unimport from 'unimport/unplugin'
import { flatten } from 'lodash-es'
import type { StorybookConfig } from '@storybook/vue3-vite'
import { mergeConfig, InlineConfig } from 'vite'
import jiti from 'jiti'
// used in nuxt.config.ts
process.env.IS_STORYBOOK_BUILD = 'true'
// make nuxt env vars available here
dotenv.config()
// having to use jiti cause of weird transpilation stuff going on during the storybook build
const jitiImport = jiti(__filename, {
cache: false,
esmResolve: true
})
const nuxtViteConfigUtil = jitiImport(
'../lib/fake-nuxt-env/utils/nuxtViteConfig.mjs'
) as typeof import('~~/lib/fake-nuxt-env/utils/nuxtViteConfig.mjs')
const storyPaths = ['stories', 'components', 'pages', 'lib', 'layouts']
const storiesPairs = storyPaths.map((p) => [
`../${p}/**/*.stories.mdx`,
`../${p}/**/*.stories.@(js|ts)`
])
const stories = flatten(storiesPairs)
/**
* STORYBOOK CONFIG STARTS HERE
*/
const config: StorybookConfig = {
stories,
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-a11y'
],
framework: {
name: '@storybook/vue3-vite',
options: {}
},
features: {
storyStoreV7: true
},
async viteFinal(config) {
const now = performance.now()
console.log('Integrating Nuxt into Storybook...')
const { resolvedViteConfig, nuxt } =
await nuxtViteConfigUtil.integrateNuxtIntoStorybook()
const { unimportOptions } = await nuxtViteConfigUtil.getNuxtModuleConfigs(nuxt)
console.log(`...done [${Math.ceil(performance.now() - now)}ms]`)
const customConfig: InlineConfig = {
plugins: [
// Auto-imports managed by unimport
// TODO: Is this already handled through nuxtViteConfig? Global functions seem to work without this
Unimport.vite(unimportOptions)
],
define: {
'process.server': false,
'process.client': true
},
build: {
sourcemap: false
}
}
let final = mergeConfig(config, resolvedViteConfig)
final = mergeConfig(final, customConfig)
return final
},
docs: {
autodocs: true
}
}
export default config
@@ -0,0 +1,3 @@
<script>
window.global = window
</script>
+209
View File
@@ -0,0 +1,209 @@
import '~~/assets/css/tailwind.css'
import { setupVueApp } from '~~/lib/fake-nuxt-env/utils/nuxtAppBootstrapper'
import { MockedApolloProvider } from '~~/lib/fake-nuxt-env/components/MockedApolloProvider'
import { setup } from '@storybook/vue3'
import SingletonManagers from '~~/components/singleton/Managers.vue'
import { useArgs, useGlobals } from '@storybook/client-api'
import { provide, watch } from 'vue'
import { AppTheme, useTheme } from '~~/lib/core/composables/theme'
setup((app) => {
setupVueApp(app)
})
export const parameters = {
// Main storybook params
viewport: {
viewports: {
mobile1: {
name: 'Small mobile (320px)',
styles: { width: '320px', height: '568px' }, // ratio 0.56
type: 'mobile'
},
mobile2: {
name: 'Large mobile (414px)',
styles: { width: '414px', height: '896px' }, // ratio 0.46
type: 'mobile'
},
SM: {
name: 'SM (640px)',
styles: { width: '640px', height: '1024px' },
type: 'mobile'
},
MD: {
name: 'MD (768px)',
styles: { width: '768px', height: '1024px' },
type: 'tablet'
},
LG: {
name: 'LG (1024px)',
styles: { width: '1024px', height: '768px' },
type: 'desktop'
},
XL: {
name: 'XL (1280px)',
styles: { width: '1280px', height: '768px' },
type: 'desktop'
},
'2XL': {
name: '2XL (1536px)',
styles: { width: '1536px', height: '1024px' },
type: 'desktop'
}
}
},
backgrounds: {
// Using tailwind theme bg values
default: 'foundation-page',
values: [
{
name: 'foundation-page',
value: 'var(--foundation-page)'
},
{
name: 'foundation',
value: 'var(--foundation)'
},
{
name: 'foundation-2',
value: 'var(--foundation-2)'
},
{
name: 'foundation-3',
value: 'var(--foundation-3)'
},
{
name: 'foundation-4',
value: 'var(--foundation-4)'
},
{
name: 'foundation-5',
value: 'var(--foundation-5)'
},
{
name: 'foundation-focus',
value: 'var(--foundation-focus)'
},
{
name: 'foundation-disabled',
value: 'var(--foundation-disabled)'
}
]
},
// Custom params
apolloClient: {
MockedApolloProvider
}
}
/** @type {import('@storybook/csf').DecoratorFunction[]} */
export const decorators = [
// Feed in updateArgs() into stories
(story, ctx) => {
const [, updateArgs] = useArgs()
return story({ ...ctx, updateArgs })
},
/**
* - Global CSS class setup
* - Theme support
* - Global singletons
*/
(story, ctx) => {
const [, updateGlobals] = useGlobals()
const theme = ctx.globals.theme
const isDarkMode = theme === 'dark'
const {
parameters: { manualLayout }
} = ctx
if (isDarkMode) {
document.querySelector('html').classList.add('dark')
} else {
document.querySelector('html').classList.remove('dark')
}
return {
components: {
Story: story(),
SingletonManagers
},
setup: () => {
const { isDarkTheme, setTheme } = useTheme()
setTheme(isDarkMode ? AppTheme.Dark : AppTheme.Light)
watch(isDarkTheme, (isDark, oldIsDark) => {
if (isDark === oldIsDark) return
updateGlobals({
theme: isDark ? 'dark' : 'light'
})
})
},
inheritAttrs: false,
template: `
<div class="text-foreground">
<Story v-bind="$attrs" />
${manualLayout ? '' : '<SingletonManagers />'}
</div>
`
}
},
// Apollo Mocked Provider decorator
(story, ctx) => {
const {
parameters: {
apolloClient: { MockedApolloProvider, ...providerProps }
}
} = ctx
if (!MockedApolloProvider) {
console.error(
'Apollo MockedApolloProvider missing from parameters in preview.js!'
)
return { template: `<Story/>`, components: { Story: story() } }
}
return {
data: () => ({ providerProps }),
components: { MockedApolloProvider, Story: story() },
template: `
<MockedApolloProvider :options="providerProps || {}"><Story/></MockedApolloProvider>
`
}
},
// Mocked router
(story, ctx) => {
const {
parameters: { vueRouter: { route } = { route: undefined } }
} = ctx
return {
components: { Story: story() },
setup: () => {
if (route) {
provide('_route', route)
}
},
template: `<Story/>`
}
}
]
export const globalTypes = {
theme: {
name: 'Theme',
description: 'Global theme for components',
defaultValue: 'light',
toolbar: {
icon: 'circlehollow',
// Array of plain string values or MenuItem shape (see below)
items: ['light', 'dark'],
// Property that specifies if the name of the item will be displayed
title: 'Theme',
// Change title based on selected value
dynamicTitle: true
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"css.validate": false,
"less.validate": false,
"scss.validate": false,
"stylelint.validate": ["css", "scss", "vue", "postcss"],
"stylelint.enable": true,
"stylelint.configFile": "${workspaceFolder}/stylelint.config.js",
"volar.completion.preferredTagNameCase": "pascal",
"javascript.suggest.autoImports": true,
"typescript.suggest.autoImports": true,
"typescript.preferences.importModuleSpecifier": "non-relative",
"javascript.preferences.importModuleSpecifier": "non-relative"
}
+47
View File
@@ -0,0 +1,47 @@
FROM node:16.18.0-bullseye-slim as build-stage
ARG NODE_ENV=production
ARG SPECKLE_SERVER_VERSION=custom
WORKDIR /speckle-server
COPY .yarnrc.yml .
COPY .yarn ./.yarn
COPY package.json yarn.lock ./
COPY packages/viewer/package.json ./packages/viewer/
COPY packages/objectloader/package.json ./packages/objectloader/
COPY packages/shared/package.json ./packages/shared/
COPY packages/frontend-2/package.json ./packages/frontend-2/
COPY packages/frontend-2/type-augmentations ./packages/frontend-2/
COPY packages/objectloader ./packages/objectloader/
COPY packages/viewer ./packages/viewer/
COPY packages/shared ./packages/shared/
COPY packages/frontend-2 ./packages/frontend-2/
RUN yarn workspaces focus -A
# hadolint ignore=DL3059
RUN yarn workspaces foreach run build
FROM node:16.18.0-bullseye-slim as production-stage
ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV}
ENV TINI_VERSION v0.19.0
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
RUN chmod +x /tini
ENTRYPOINT ["/tini", "--"]
USER node
ENV PORT=8080
ENV NUXT_PUBLIC_MIXPANEL_TOKEN_ID=acd87c5a50b56df91a795e999812a3a4
ENV NUXT_PUBLIC_MIXPANEL_API_HOST=https://analytics.speckle.systems
WORKDIR /speckle-server
COPY --from=build-stage /speckle-server/packages/frontend-2/.output .
EXPOSE ${PORT}
CMD ["node", "./server/index.mjs"]
+43
View File
@@ -0,0 +1,43 @@
<template>
<div id="speckle" class="bg-foundation-page text-foreground">
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<SingletonManagers />
</div>
</template>
<script setup lang="ts">
import { useTheme } from '~~/lib/core/composables/theme'
import { useAuthManager } from '~~/lib/auth/composables/auth'
import { useMixpanelInitialization } from '~~/lib/core/composables/mp'
const { isDarkTheme } = useTheme()
useHead({
// Title suffix
titleTemplate: (titleChunk) => (titleChunk ? `${titleChunk} - Speckle` : 'Speckle'),
htmlAttrs: {
class: computed(() => (isDarkTheme.value ? `dark` : ``)),
lang: 'en'
},
bodyAttrs: {
class: 'simple-scrollbar bg-foundation-page text-foreground'
}
})
const { watchAuthQueryString } = useAuthManager()
watchAuthQueryString()
// Awaiting to block the app from continuing until mixpanel tracking is fully initialized
await useMixpanelInitialization()
</script>
<style>
.page-enter-active,
.page-leave-active {
transition: all 0.1s;
}
.page-enter-from,
.page-leave-to {
opacity: 0;
}
</style>
@@ -0,0 +1,124 @@
/* stylelint-disable custom-property-empty-line-before */
/* stylelint-disable comment-empty-line-before */
/*
Currently not possible to define different color values for the same color name just through
the tailwind config, so using CSS variables instead.
Palette reference: https://tailwindcss.com/docs/customizing-colors
*/
:root {
/* used only as the page background */
--foundation-page: #f1f5f9;
/* used as the background for any elements that sit on the page */
--foundation: #fcfcfc;
--foundation-2: #fcfcfc;
--foundation-3: #fcfcfc;
--foundation-4: #fcfcfc;
--foundation-5: #fcfcfc;
/* for hover/focus states */
--foundation-focus: #dbeafe;
/* for disabled backgrounds */
--foundation-disabled: #e5e5e5;
/* default foreground color */
--foreground: #334155;
/* dimmer foreground color, e.g. caption text */
--foreground-2: #94a3b8;
/* disabled foreground color */
--foreground-disabled: #a3a3a3;
/* primary color when used for text directly on top of foundation-page */
--foreground-primary: #3b82f6;
/* foreground color when put on top of a primary colored background */
--foreground-on-primary: #fff;
/* primary color */
--primary: #3b82f6;
/* focused primary color */
--primary-focus: #2563eb;
/* muted primary color */
--primary-muted: #3b82f60d;
/* outline variations */
--outline-1: #3b82f6;
--outline-2: #93c5fd;
--outline-3: #cbd5e1;
/* success variations */
--success: #34d399;
--success-lighter: #d1fae5;
--success-darker: #064e3b;
/* warning variations */
--warning: #fbbf24;
--warning-lighter: #fef3c7;
--warning-darker: #78350f;
/* info variations */
--info: #38bdf8;
--info-lighter: #e0f2fe;
--info-darker: #0c4a6e;
/* danger variations */
--danger: #f87171;
--danger-lighter: #fee2e2;
--danger-darker: #7f1d1d;
}
:root.dark {
/* used only as the page background */
--foundation-page: #18181b;
/* used as the background for any element that sits on the page */
--foundation: #27272a;
--foundation-2: #303034;
--foundation-3: #52525b;
--foundation-4: #71717a;
--foundation-5: #a1a1aa;
/* for hover/focus states */
--foundation-focus: #52525b;
/* for disabled backgrounds */
--foundation-disabled: #3c3c3d;
/* default foreground color */
--foreground: #f4f4f5;
/* dimmer foreground color, e.g. caption text */
--foreground-2: #71717a;
/* disabled foreground color */
--foreground-disabled: #5a5a5f;
/* primary color when used for text directly on top of foundation-page */
--foreground-primary: #bfdbfe;
/* foreground color when put on top of a primary colored background */
--foreground-on-primary: #fafafa;
/* primary color */
--primary: #3b82f6;
/* focused primary color */
--primary-focus: #60a5fa;
/* muted primary color */
--primary-muted: #71717a0d;
/* outline variations */
--outline-1: #a1a1aa;
--outline-2: #52525b;
--outline-3: #3f3f46;
/* success variations */
--success: #34d399;
--success-lighter: #a7f3d0;
--success-darker: #064e3b;
/* warning variations */
--warning: #facc15;
--warning-lighter: #fef08a;
--warning-darker: #78350f;
/* info variations */
--info: #38bdf8;
--info-lighter: #bae6fd;
--info-darker: #0c4a6e;
/* danger variations */
--danger: #f87171;
--danger-lighter: #fecaca;
--danger-darker: #7f1d1d;
}
@@ -0,0 +1,15 @@
/**
* Tippy mentions theme
*/
.tippy-box[data-theme='mention'] {
background: none;
pointer-events: none;
.tippy-arrow {
display: none;
}
.tippy-content > * {
pointer-events: auto;
}
}
+155
View File
@@ -0,0 +1,155 @@
/* stylelint-disable selector-id-pattern */
@import './tailwind-setup/semantic-colors.css';
@import './tailwind-setup/tippy.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
/**
* Don't pollute this - it's going to be bundled in all pages!
*/
@layer components {
/**
* Heading weights & line heights can differ, which is why we only set the font size
*/
.h1 {
@apply text-5xl leading-10;
}
.h2 {
@apply text-4xl leading-10;
}
.h3 {
@apply text-3xl leading-9;
}
.h4 {
@apply text-2xl leading-8;
}
.h5 {
@apply text-xl leading-7;
}
.h6 {
@apply text-lg leading-6;
}
.label {
@apply text-sm font-medium leading-5;
&--light {
@apply font-normal;
}
}
.label-light {
@apply label label--light;
}
.normal {
@apply text-base font-normal;
}
.caption {
@apply text-xs;
}
.text-tiny {
font-size: 0.6rem;
line-height: 1rem;
}
/**
* Grid/Layout container that limits max width to expected sizes that we use in our designs
* (see Figma - Design System - Foundations - Grid & Layout)
*/
.layout-container {
@apply mx-auto;
/* base/mobile - fluid, no max width, just padding */
@apply px-4;
/* sm+ - also fluid, increased padding */
@media (min-width: theme('screens.sm')) {
@apply px-8;
}
/* lg+ (from this point on, no padding just limited max width) */
@media (min-width: theme('screens.lg')) {
/* @apply px-0 max-w-[928px]; */
@apply px-6 max-w-full;
}
/* xl+ */
@media (min-width: theme('screens.xl')) {
@apply max-w-[1216px];
}
/* 2xl+ */
@media (min-width: theme('screens.2xl')) {
@apply max-w-[1312px];
}
}
/* Simple scrollbar (OSX-like) to use instead of the ugly browser one */
.simple-scrollbar {
scrollbar-width: var(--simple-scrollbar-width);
scrollbar-color: var(--foreground-2);
&::-webkit-scrollbar {
width: var(--simple-scrollbar-width);
height: 6px;
}
&::-webkit-scrollbar-track {
border-radius: 15px;
background: var(--foundation-disabled);
}
&::-webkit-scrollbar-thumb {
border-radius: 15px;
background: var(--foreground-2);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--foreground-2);
}
&::-webkit-scrollbar-thumb:active {
background: rgba(90 90 90 10100%);
}
}
}
:root {
--simple-scrollbar-width: 4px;
}
/**
* Making sure page is always stretched to the bottom of the screen even if there's nothing in it
*/
html,
body,
div#__nuxt,
div#__nuxt > div {
min-height: 100%;
}
html,
body,
div#__nuxt {
height: 100%;
}
body {
@apply font-sans;
}
[type='checkbox']:focus,
[type='radio']:focus {
@apply ring-offset-foundation;
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>

After

Width:  |  Height:  |  Size: 814 B

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg viewBox="0 0 18 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
<!-- Generator: Sketch 3.3.3 (12081) - http://www.bohemiancoding.com/sketch -->
<title>btn_google_light_normal_ios</title>
<desc>Created with Sketch.</desc>
<defs>
<filter x="-50%" y="-50%" width="200%" height="200%" filterUnits="objectBoundingBox" id="filter-1">
<feOffset dx="0" dy="1" in="SourceAlpha" result="shadowOffsetOuter1"></feOffset>
<feGaussianBlur stdDeviation="0.5" in="shadowOffsetOuter1" result="shadowBlurOuter1"></feGaussianBlur>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.168 0" in="shadowBlurOuter1" type="matrix"
result="shadowMatrixOuter1"></feColorMatrix>
<feOffset dx="0" dy="0" in="SourceAlpha" result="shadowOffsetOuter2"></feOffset>
<feGaussianBlur stdDeviation="0.5" in="shadowOffsetOuter2" result="shadowBlurOuter2"></feGaussianBlur>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.084 0" in="shadowBlurOuter2" type="matrix"
result="shadowMatrixOuter2"></feColorMatrix>
<feMerge>
<feMergeNode in="shadowMatrixOuter1"></feMergeNode>
<feMergeNode in="shadowMatrixOuter2"></feMergeNode>
<feMergeNode in="SourceGraphic"></feMergeNode>
</feMerge>
</filter>
<rect id="path-2" x="0" y="0" width="40" height="40" rx="2"></rect>
</defs>
<g id="Google-Button" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
<g id="btn_google_light_normal" sketch:type="MSArtboardGroup" transform="translate(000000, 000000)">
<g id="logo_googleg_48dp" sketch:type="MSLayerGroup" transform="translate(0,-0.)">
<path
d="M17.64,9.20454545 C17.64,8.56636364 17.5827273,7.95272727 17.4763636,7.36363636 L9,7.36363636 L9,10.845 L13.8436364,10.845 C13.635,11.97 13.0009091,12.9231818 12.0477273,13.5613636 L12.0477273,15.8195455 L14.9563636,15.8195455 C16.6581818,14.2527273 17.64,11.9454545 17.64,9.20454545 L17.64,9.20454545 Z"
id="Shape" fill="#4285F4" sketch:type="MSShapeGroup"></path>
<path
d="M9,18 C11.43,18 13.4672727,17.1940909 14.9563636,15.8195455 L12.0477273,13.5613636 C11.2418182,14.1013636 10.2109091,14.4204545 9,14.4204545 C6.65590909,14.4204545 4.67181818,12.8372727 3.96409091,10.71 L0.957272727,10.71 L0.957272727,13.0418182 C2.43818182,15.9831818 5.48181818,18 9,18 L9,18 Z"
id="Shape" fill="#34A853" sketch:type="MSShapeGroup"></path>
<path
d="M3.96409091,10.71 C3.78409091,10.17 3.68181818,9.59318182 3.68181818,9 C3.68181818,8.40681818 3.78409091,7.83 3.96409091,7.29 L3.96409091,4.95818182 L0.957272727,4.95818182 C0.347727273,6.17318182 0,7.54772727 0,9 C0,10.4522727 0.347727273,11.8268182 0.957272727,13.0418182 L3.96409091,10.71 L3.96409091,10.71 Z"
id="Shape" fill="#FBBC05" sketch:type="MSShapeGroup"></path>
<path
d="M9,3.57954545 C10.3213636,3.57954545 11.5077273,4.03363636 12.4404545,4.92545455 L15.0218182,2.34409091 C13.4631818,0.891818182 11.4259091,0 9,0 C5.48181818,0 2.43818182,2.01681818 0.957272727,4.95818182 L3.96409091,7.29 C4.67181818,5.16272727 6.65590909,3.57954545 9,3.57954545 L9,3.57954545 Z"
id="Shape" fill="#EA4335" sketch:type="MSShapeGroup"></path>
<path d="M0,0 L18,0 L18,18 L0,18 L0,0 Z" id="Shape" sketch:type="MSShapeGroup"></path>
</g>
<g id="handles_square" sketch:type="MSLayerGroup"></g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="46px" height="46px" viewBox="0 0 46 46" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
<!-- Generator: Sketch 3.3.3 (12081) - http://www.bohemiancoding.com/sketch -->
<title>btn_google_light_normal_ios</title>
<desc>Created with Sketch.</desc>
<defs>
<filter x="-50%" y="-50%" width="200%" height="200%" filterUnits="objectBoundingBox" id="filter-1">
<feOffset dx="0" dy="1" in="SourceAlpha" result="shadowOffsetOuter1"></feOffset>
<feGaussianBlur stdDeviation="0.5" in="shadowOffsetOuter1" result="shadowBlurOuter1"></feGaussianBlur>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.168 0" in="shadowBlurOuter1" type="matrix" result="shadowMatrixOuter1"></feColorMatrix>
<feOffset dx="0" dy="0" in="SourceAlpha" result="shadowOffsetOuter2"></feOffset>
<feGaussianBlur stdDeviation="0.5" in="shadowOffsetOuter2" result="shadowBlurOuter2"></feGaussianBlur>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.084 0" in="shadowBlurOuter2" type="matrix" result="shadowMatrixOuter2"></feColorMatrix>
<feMerge>
<feMergeNode in="shadowMatrixOuter1"></feMergeNode>
<feMergeNode in="shadowMatrixOuter2"></feMergeNode>
<feMergeNode in="SourceGraphic"></feMergeNode>
</feMerge>
</filter>
<rect id="path-2" x="0" y="0" width="40" height="40" rx="2"></rect>
</defs>
<g id="Google-Button" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
<g id="9-PATCH" sketch:type="MSArtboardGroup" transform="translate(-608.000000, -160.000000)"></g>
<g id="btn_google_light_normal" sketch:type="MSArtboardGroup" transform="translate(-1.000000, -1.000000)">
<g id="logo_googleg_48dp" sketch:type="MSLayerGroup" transform="translate(15.000000, 15.000000)">
<path d="M17.64,9.20454545 C17.64,8.56636364 17.5827273,7.95272727 17.4763636,7.36363636 L9,7.36363636 L9,10.845 L13.8436364,10.845 C13.635,11.97 13.0009091,12.9231818 12.0477273,13.5613636 L12.0477273,15.8195455 L14.9563636,15.8195455 C16.6581818,14.2527273 17.64,11.9454545 17.64,9.20454545 L17.64,9.20454545 Z" id="Shape" fill="#4285F4" sketch:type="MSShapeGroup"></path>
<path d="M9,18 C11.43,18 13.4672727,17.1940909 14.9563636,15.8195455 L12.0477273,13.5613636 C11.2418182,14.1013636 10.2109091,14.4204545 9,14.4204545 C6.65590909,14.4204545 4.67181818,12.8372727 3.96409091,10.71 L0.957272727,10.71 L0.957272727,13.0418182 C2.43818182,15.9831818 5.48181818,18 9,18 L9,18 Z" id="Shape" fill="#34A853" sketch:type="MSShapeGroup"></path>
<path d="M3.96409091,10.71 C3.78409091,10.17 3.68181818,9.59318182 3.68181818,9 C3.68181818,8.40681818 3.78409091,7.83 3.96409091,7.29 L3.96409091,4.95818182 L0.957272727,4.95818182 C0.347727273,6.17318182 0,7.54772727 0,9 C0,10.4522727 0.347727273,11.8268182 0.957272727,13.0418182 L3.96409091,10.71 L3.96409091,10.71 Z" id="Shape" fill="#FBBC05" sketch:type="MSShapeGroup"></path>
<path d="M9,3.57954545 C10.3213636,3.57954545 11.5077273,4.03363636 12.4404545,4.92545455 L15.0218182,2.34409091 C13.4631818,0.891818182 11.4259091,0 9,0 C5.48181818,0 2.43818182,2.01681818 0.957272727,4.95818182 L3.96409091,7.29 C4.67181818,5.16272727 6.65590909,3.57954545 9,3.57954545 L9,3.57954545 Z" id="Shape" fill="#EA4335" sketch:type="MSShapeGroup"></path>
<path d="M0,0 L18,0 L18,18 L0,18 L0,0 Z" id="Shape" sketch:type="MSShapeGroup"></path>
</g>
<g id="handles_square" sketch:type="MSLayerGroup"></g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="21" height="21" viewBox="0 0 21 21"><title>MS-SymbolLockup</title><rect x="1" y="1" width="9" height="9" fill="#f25022"/><rect x="1" y="11" width="9" height="9" fill="#00a4ef"/><rect x="11" y="1" width="9" height="9" fill="#7fba00"/><rect x="11" y="11" width="9" height="9" fill="#ffb900"/></svg>

After

Width:  |  Height:  |  Size: 343 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 545 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

@@ -0,0 +1,20 @@
<svg width="300" height="84" viewBox="0 0 300 84" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_38_2213)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M299.98 0.631348L0 17.2189V83.2951L299.98 66.7077V0.631348V0.631348Z" fill="#3B82F6"/>
<path d="M76.631 65.7472C73.7571 65.9062 71.2156 65.5319 69.0063 64.6244C66.7969 63.7169 65.0636 62.3225 63.8064 60.4412C62.549 58.5598 61.9204 56.2102 61.9204 53.3923V51.9835L68.3327 51.6289V53.0378C68.3327 55.5306 69.0871 57.3494 70.5958 58.4944C72.1047 59.6392 74.1164 60.1421 76.631 60.0031C79.1817 59.862 81.1035 59.2319 82.3968 58.1127C83.6899 56.9936 84.3366 55.6029 84.3366 53.9412C84.3366 52.8213 84.0313 51.9259 83.4206 51.2552C82.8098 50.5844 81.9207 50.0647 80.7532 49.6956C79.5857 49.3267 78.1758 48.9892 76.5232 48.6832L74.9067 48.4474C72.428 48.0065 70.2815 47.3936 68.4674 46.6088C66.6534 45.8239 65.2613 44.7539 64.2913 43.3986C63.3213 42.0432 62.8364 40.2277 62.8364 37.9516C62.8364 35.6756 63.3753 33.695 64.4531 32.0096C65.5307 30.3242 67.0574 28.9934 69.0332 28.0172C71.0091 27.0409 73.3262 26.4793 75.9845 26.3323C78.6428 26.1852 81.0137 26.5057 83.0972 27.2938C85.1807 28.0816 86.8243 29.3455 88.0277 31.0854C89.2311 32.8252 89.8328 35.0499 89.8328 37.7594V39.5477L83.4206 39.9023V38.114C83.4206 36.5605 83.1153 35.3309 82.5045 34.4255C81.8938 33.5199 81.0316 32.8812 79.9181 32.5093C78.8044 32.1373 77.4932 31.993 75.9845 32.0765C73.7571 32.1997 72.0688 32.7266 70.9192 33.657C69.7697 34.5877 69.1949 35.8479 69.1949 37.4375C69.1949 38.449 69.4554 39.2927 69.9762 39.9682C70.4971 40.644 71.2694 41.1793 72.2933 41.5743C73.3171 41.9692 74.6014 42.2866 76.1461 42.5263L77.7627 42.8163C80.3492 43.2513 82.6123 43.8577 84.5521 44.6356C86.4921 45.4133 88.0098 46.4945 89.1055 47.8791C90.2011 49.2636 90.749 51.1119 90.749 53.4241C90.749 55.7 90.1652 57.7373 88.9977 59.536C87.8302 61.3346 86.1957 62.7798 84.0941 63.8715C81.9926 64.9631 79.5049 65.5883 76.631 65.7472Z" fill="white"/>
<path d="M96.4067 63.8953V25.9618L111.656 25.1186C114.027 24.9875 116.111 25.3419 117.907 26.1819C119.703 27.0219 121.122 28.271 122.164 29.9295C123.206 31.588 123.726 33.5912 123.726 35.9394V36.6982C123.726 39.0103 123.197 41.0628 122.137 42.8554C121.077 44.6481 119.64 46.0643 117.826 47.104C116.012 48.1435 113.955 48.7268 111.656 48.8541L102.927 49.3368V63.5346L96.4067 63.8953ZM102.927 43.4299L111.01 42.983C112.878 42.8797 114.378 42.2911 115.509 41.2169C116.641 40.1427 117.206 38.7206 117.206 36.9503V36.4085C117.206 34.6382 116.65 33.2782 115.536 32.3281C114.422 31.3782 112.914 30.9558 111.01 31.061L102.927 31.508V43.4299Z" fill="white"/>
<path d="M128.9 62.0985V24.1651L152.933 22.8362V28.7429L135.42 29.7113V39.6824L151.424 38.7975V44.7043L135.42 45.5892V55.8312L153.202 54.848V60.7547L128.9 62.0985Z" fill="white"/>
<path d="M173.031 60.4167C168.433 60.671 164.769 59.564 162.039 57.0957C159.309 54.6274 157.943 50.9368 157.943 46.0234V37.0278C157.943 32.1145 159.309 28.2727 162.039 25.5026C164.769 22.7324 168.433 21.2201 173.031 20.9659C177.63 20.7116 181.177 21.78 183.674 24.1708C186.17 26.5617 187.419 29.9607 187.419 34.3683V34.6934L181.06 35.045V34.5573C181.06 32.173 180.404 30.2492 179.093 28.7864C177.782 27.3236 175.761 26.6676 173.031 26.8184C170.373 26.9655 168.28 27.903 166.754 29.6313C165.227 31.3595 164.464 33.6687 164.464 36.5589V45.7713C164.464 48.6253 165.227 50.841 166.754 52.4184C168.28 53.9959 170.373 54.7111 173.031 54.5641C175.761 54.4131 177.782 53.5337 179.093 51.9257C180.404 50.318 181.06 48.3217 181.06 45.9374V45.0161L187.419 44.6645V45.4232C187.419 49.8307 186.17 53.3679 183.674 56.0349C181.177 58.7018 177.63 60.1624 173.031 60.4167Z" fill="white"/>
<path d="M193.508 58.5259V20.5924L200.028 20.2319V35.6763L200.945 35.6256L214.092 19.4543L222.445 18.9924L206.01 38.5969L222.984 56.896L214.416 57.3699L200.945 42.3453L200.028 42.3959V58.1654L193.508 58.5259Z" fill="white"/>
<path d="M227.025 56.6726V18.7392L233.545 18.3787V50.4054L251.219 49.428V55.3348L227.025 56.6726Z" fill="white"/>
<path d="M256.285 55.0546V17.1212L280.317 15.7922V21.6991L262.805 22.6674V32.6384L278.809 31.7535V37.6604L262.805 38.5453V48.7873L280.587 47.8041V53.7107L256.285 55.0546Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M54.5232 35.0176L22.5767 36.7715V68.6316L54.5232 66.8775V35.0176Z" fill="#FAFCFD"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M48.1467 29.1033L16.2002 30.8574L22.8257 36.7715L54.7722 35.0176L48.1467 29.1033Z" fill="#CEDDF0"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M22.8257 36.7716L16.2002 30.8574V62.7175L22.8257 68.6316V36.7716Z" fill="#7399D4"/>
</g>
<defs>
<clipPath id="clip0_38_2213">
<rect width="300" height="84" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

+28
View File
@@ -0,0 +1,28 @@
import type { CodegenConfig } from '@graphql-codegen/cli'
const config: CodegenConfig = {
schema: 'http://127.0.0.1:3000/graphql',
documents: ['{lib,components,layouts,pages,middleware}/**/*.{vue,js,ts}'],
ignoreNoDocuments: true, // for better experience with the watcher
generates: {
'./lib/common/generated/gql/': {
preset: 'client',
config: {
useTypeImports: true,
fragmentMasking: false,
dedupeFragments: true,
scalars: {
JSONObject: '{}',
DateTime: 'string'
}
},
presetConfig: {
fragmentMasking: false,
dedupeFragments: true
},
plugins: []
}
}
}
export default config
@@ -0,0 +1,27 @@
<template>
<div>
Active user test:
<div>
{{ activeUser ? activeUser.id : 'none' }}
</div>
<div>
{{ data ? data.activeUser?.id : 'none' }}
</div>
</div>
</template>
<script setup lang="ts">
import { ApolloClient } from '@apollo/client/core'
import { activeUserQuery, useActiveUser } from '~~/lib/auth/composables/activeUser'
import { convertThrowIntoFetchResult } from '~~/lib/common/helpers/graphql'
const { activeUser } = useActiveUser()
const { $apollo } = useNuxtApp()
const client = ($apollo as { default: ApolloClient<unknown> }).default
const { data } = await client
.query({
query: activeUserQuery
})
.catch(convertThrowIntoFetchResult)
</script>
@@ -0,0 +1,22 @@
import DemoNuxtFunctionality from '~~/components/DemoNuxtFunctionality.vue'
import { StoryObj, Meta } from '@storybook/vue3'
export default {
title: 'Appendix/Test/Demo Nuxt Functionality (test)',
component: DemoNuxtFunctionality,
parameters: {
docs: {
description: {
component:
'This invokes various Nuxt globals (funcs & Vue components) as a test to ensure the Nuxt-Storybook integration works'
}
}
}
} as Meta
export const Default: StoryObj = {
render: () => ({
components: { DemoNuxtFunctionality },
template: `<demo-nuxt-functionality/>`
})
}
@@ -0,0 +1,20 @@
<template>
<div class="bg-foundation text-foreground p-12">
<NuxtLink to="http://example.com">Link to exmaple.com</NuxtLink>
<FormCheckbox name="Checkbox" value="test-checkbox" />
</div>
</template>
<script setup lang="ts">
/* eslint-disable @typescript-eslint/no-unused-vars */
// Tests that show this functionality works in Storybook
const counter = useState('counter', () => 100)
const runtimeConfig = useRuntimeConfig()
const nuxt = useNuxtApp()
// onMounted(() => {
// console.log(counter.value)
// console.log(runtimeConfig)
// console.log(nuxt)
// })
</script>
@@ -0,0 +1,18 @@
import { Meta, StoryObj } from '@storybook/vue3'
import DesignSystem from '~~/components/DesignSystem.vue'
export default {
title: 'Overview/Styling/Design System Examples',
component: DesignSystem
} as Meta
export const Default: StoryObj = {
render: () => ({
components: { DesignSystem },
template: `
<div class="p-8 bg-foundation-page">
<DesignSystem/>
</div>
`
})
}
@@ -0,0 +1,436 @@
<template>
<div class="">
<header class="my-6">
<div class="text-foreground">
<h1 class="h3 mb-2 px-2">Design System Demo</h1>
</div>
</header>
<div class="space-y-4">
<LayoutPanel class="background text-foreground">
<template #header>
<span class="h3">Typograhpy</span>
</template>
<template #default>
<div class="my-10"></div>
<p>Classic headings:</p>
<div class="mt-4 flex flex-col gap-y-4">
<div class="h1">Heading 1</div>
<div class="h2">Heading 2</div>
<div class="h3">Heading 3</div>
<div class="h4">Heading 4</div>
<div class="h5">Heading 5</div>
<div class="normal">Normal text</div>
<div class="label">Label text</div>
<div class="label label--light">Label text (light)</div>
<div class="caption">Caption text</div>
</div>
<div class="bg-foundation-focus mt-4 rounded-lg px-4 py-12 shadow-inner">
<b>Font faces?</b>
system ui only - using github's stack. Lighter, better performance, no more
google fonts trakcing and a more native look.
</div>
<div class="mt-4">
<div class="font-sans">Font weights:</div>
<ul>
<li class="font-thin">Thin</li>
<li class="font-extralight">Exta light</li>
<li class="font-light">Light</li>
<li class="font-normal">Normal</li>
<li class="font-medium">Medium</li>
<li class="font-semibold">SemiBold</li>
<li class="font-bold">Bold</li>
<li class="font-extrabold">ExtraBold</li>
<li class="font-black">Black</li>
</ul>
</div>
</template>
</LayoutPanel>
<LayoutPanel class="background text-foreground">
<template #header>
<span class="h3">Colors</span>
</template>
<template #default>
<div>Here are the available foreground colors:</div>
<div class="mt-4">
<div class="bg-foundation-focus mt-4 rounded-lg px-4 py-12 shadow-inner">
<b class="text-primary">Text colors? &nbsp;</b>
<span class="text-foreground">
<b>foreground</b>
for text you want to stand out.
</span>
<span class="text-foreground-2">
<b>foreground-2</b>
for text you don't want to stand out.
</span>
</div>
</div>
<div class="mt-4">
<ul class="font-bold">
<li class="text-foreground">text-foreground</li>
<li class="text-foreground-2">text-foreground-2</li>
</ul>
</div>
</template>
</LayoutPanel>
<LayoutPanel class="background text-foreground">
<template #header>
<span class="h3">Buttons</span>
</template>
<template #default>
<div class="my-8 flex flex-col gap-y-4 overflow-x-scroll">
<div v-for="size in sizes" :key="size" class="grid grid-cols-6">
<span class="caption col-span-4 text-center md:col-span-1">
{{ size }}
</span>
<div><FormButton :size="size">Button</FormButton></div>
<div><FormButton :size="size" disabled>Button</FormButton></div>
<!-- <div><FormButton :size="size" rounded>Button</FormButton></div>
<div><FormButton :size="size" rounded disabled>Button</FormButton></div> -->
<div><FormButton :size="size" outlined>Button</FormButton></div>
<div><FormButton :size="size" outlined disabled>Button</FormButton></div>
<!-- <div><FormButton :size="size" rounded outlined>Button</FormButton></div> -->
<div>
<!-- <FormButton :size="size" rounded outlined disabled>Button</FormButton> -->
</div>
</div>
<div v-for="size in sizes" :key="size" class="grid grid-cols-6">
<span class="caption col-span-4 text-center md:col-span-1">
{{ size }}
</span>
<div><FormButton :size="size" rounded>Button</FormButton></div>
<div><FormButton :size="size" rounded disabled>Button</FormButton></div>
<!-- <div><FormButton :size="size" rounded>Button</FormButton></div>
<div><FormButton :size="size" rounded disabled>Button</FormButton></div> -->
<div><FormButton :size="size" rounded outlined>Button</FormButton></div>
<div>
<FormButton :size="size" rounded outlined disabled>Button</FormButton>
</div>
<!-- <div><FormButton :size="size" rounded outlined>Button</FormButton></div> -->
<div>
<!-- <FormButton :size="size" rounded outlined disabled>Button</FormButton> -->
</div>
</div>
<div v-for="size in sizes" :key="size" class="grid grid-cols-6">
<span class="caption col-span-4 text-center md:col-span-1">
{{ size }}
</span>
<div>
<FormButton :size="size" :icon-left="XMarkIcon">Button</FormButton>
</div>
<div>
<FormButton :size="size" disabled :icon-right="ArrowRightCircleIcon">
Button
</FormButton>
</div>
<div>
<FormButton :size="size" outlined :icon-right="XMarkIcon">
Button
</FormButton>
</div>
<div>
<FormButton :size="size" outlined disabled :icon-right="XMarkIcon">
Button
</FormButton>
</div>
<!-- <div>
<FormButton :size="size" rounded outlined :icon-right="XMarkIcon">
Button
</FormButton>
</div>
<div>
<FormButton :size="size" rounded outlined disabled :icon-right="XMarkIcon">
Button
</FormButton>
</div> -->
</div>
<div
v-for="type in types"
:key="type"
class="flex flex-wrap space-x-4 py-6"
>
<span class="caption col-span-4 text-center md:col-span-1">
{{ type }}
</span>
<div><FormButton :color="type">Delete</FormButton></div>
<div><FormButton :color="type" disabled>Delete</FormButton></div>
<div><FormButton :color="type" rounded>Delete</FormButton></div>
<div><FormButton :color="type" rounded disabled>Delete</FormButton></div>
<div><FormButton :color="type" outlined>Delete</FormButton></div>
<div><FormButton :color="type" outlined disabled>Delete</FormButton></div>
<div><FormButton :color="type" rounded outlined>Delete</FormButton></div>
<div>
<FormButton :color="type" rounded outlined disabled>Delete</FormButton>
</div>
</div>
<div class="flex flex-wrap items-center justify-around rounded-md py-6">
<FormButton text size="xs">Text</FormButton>
<FormButton text size="sm">Text</FormButton>
<FormButton text size="base">Text</FormButton>
<FormButton text size="lg">Text</FormButton>
<FormButton text size="xl">Text</FormButton>
<FormButton text disabled>Text</FormButton>
<FormButton text disabled>Text</FormButton>
<FormButton text disabled>Text</FormButton>
</div>
<div class="bg-primary rounded-md">
<div class="flex flex-wrap items-center justify-around py-6">
<FormButton color="invert" text size="xs">Text</FormButton>
<FormButton color="invert" text size="sm">Text</FormButton>
<FormButton color="invert" text size="base">Text</FormButton>
<FormButton color="invert" text size="lg">Text</FormButton>
<FormButton color="invert" text size="xl">Text</FormButton>
<FormButton color="invert" text disabled>Text</FormButton>
<FormButton color="invert" text disabled>Text</FormButton>
<FormButton color="invert" text disabled>Text</FormButton>
</div>
<div class="flex flex-wrap items-center justify-around py-6">
<FormButton color="invert" invert size="xs" :icon-right="XMarkIcon">
Button
</FormButton>
<FormButton color="invert" invert size="sm" :icon-right="XMarkIcon">
Button
</FormButton>
<FormButton color="invert" invert size="base" :icon-right="XMarkIcon">
Button
</FormButton>
<FormButton color="invert" invert size="lg" :icon-right="XMarkIcon">
Button
</FormButton>
<FormButton color="invert" invert size="xl" :icon-right="XMarkIcon">
Button
</FormButton>
<FormButton color="invert" invert rounded :icon-right="XMarkIcon">
Button
</FormButton>
<FormButton color="invert" invert outlined rounded>Button</FormButton>
<FormButton color="invert" invert outlined :icon-right="XMarkIcon">
Button
</FormButton>
</div>
<div class="flex flex-wrap items-center justify-around py-6">
<FormButton color="invert" disabled size="xs" :icon-right="XMarkIcon">
Button
</FormButton>
<FormButton color="invert" disabled size="sm" :icon-right="XMarkIcon">
Button
</FormButton>
<FormButton color="invert" disabled size="base" :icon-right="XMarkIcon">
Button
</FormButton>
<FormButton color="invert" disabled size="lg" :icon-right="XMarkIcon">
Button
</FormButton>
<FormButton color="invert" disabled size="xl" :icon-right="XMarkIcon">
Button
</FormButton>
<FormButton color="invert" disabled rounded :icon-right="XMarkIcon">
Button
</FormButton>
<FormButton color="invert" disabled outlined rounded>Button</FormButton>
<FormButton color="invert" disabled outlined :icon-right="XMarkIcon">
Button
</FormButton>
</div>
</div>
</div>
</template>
</LayoutPanel>
<LayoutPanel class="background text-foreground">
<template #header>
<span class="h3">Avatars</span>
</template>
<template #default>
<div class="flex justify-around">
<div class="flex items-center space-x-2">
<UserAvatar hover-effect size="xl" />
<UserAvatar hover-effect size="lg" />
<UserAvatar hover-effect />
<UserAvatar hover-effect size="sm" />
<UserAvatar hover-effect size="xs" />
</div>
<div class="flex items-center space-x-2">
<UserAvatar
:user="{ name: 'Dimitrie Stefanescu', id: 'ds' }"
hover-effect
size="xl"
/>
<UserAvatar
:user="{ name: 'Andrei Stefanescu', id: 'ds' }"
hover-effect
size="lg"
/>
<UserAvatar
:user="{ name: 'Dimitrie Berbinski', id: 'ds' }"
hover-effect
/>
<UserAvatar
:user="{ name: 'Zorkan Stefanescu', id: 'ds' }"
hover-effect
size="sm"
/>
<UserAvatar
:user="{ name: 'Gergo Cant Type Your Last Name', id: 'ds' }"
hover-effect
size="xs"
/>
</div>
<div class="flex items-center space-x-2">
<UserAvatar
:user="{ name: 'Dimitrie Stefanescu', id: 'ds', avatar: img }"
hover-effect
size="xl"
/>
<UserAvatar
:user="{ name: 'Andrei Stefanescu', id: 'ds', avatar: img }"
hover-effect
size="lg"
/>
<UserAvatar
:user="{ name: 'Dimitrie Berbinski', id: 'ds', avatar: img }"
hover-effect
/>
<UserAvatar
:user="{ name: 'Zorkan Stefanescu', id: 'ds', avatar: img }"
hover-effect
size="sm"
/>
<UserAvatar
:user="{
name: 'Gergo Cant Type Your Last Name',
id: 'ds',
avatar: img
}"
hover-effect
size="xs"
/>
</div>
</div>
</template>
</LayoutPanel>
<LayoutPanel class="background text-foreground">
<template #header>
<span class="h3">Links & form elements</span>
</template>
<template #default>
<div class="my-8">
<div>Link:</div>
<div>
<CommonTextLink>Basic Link</CommonTextLink>
|
<CommonTextLink disabled>Disabled Link</CommonTextLink>
</div>
</div>
<div class="my-8">
<Form class="flex flex-col space-y-4" @submit="onSubmit">
<div>Example form with various elements (we use vee-validate v4):</div>
<div>
<FormTextInput name="Basic" placeholder="Basic text input w/ label" />
</div>
<div>
<FormTextInput
name="Error"
placeholder="Error input"
:rules="
(val) =>
val === 'valid' ? true : 'Type in `valid` to make this valid!'
"
validate-on-mount
/>
</div>
<div>
<FormTextInput
name="input1"
label="Custom label"
placeholder="Input w/ help text and a custom label"
help="Here's a tip for ya!"
/>
</div>
<div>
<FormTextInput
name="Required"
placeholder="This field is required"
:rules="(val) => (val ? true : 'Required')"
show-required
/>
</div>
<div>
<FormTextInput
name="Disabled"
placeholder="This field is disabled"
disabled
/>
</div>
<div>
<FormTextInput name="Email" placeholder="test@text.com" type="email" />
</div>
<div>
<FormTextInput
name="Password"
placeholder="Enter PW here"
type="password"
/>
</div>
<div>
<FormCheckbox name="Checkbox1" />
</div>
<div>
<FormCheckbox
name="Checkboxreq"
label="Required checkbox"
show-required
:rules="(val) => (val ? true : 'Required!')"
/>
</div>
<div>
<FormCheckbox
name="Checkbox2"
label="Custom label"
description="Here's a description!"
/>
</div>
<div>
<FormCheckbox
name="Checkbox3"
description="Here's an inline description!"
inline-description
/>
</div>
<div>
<FormCheckbox
name="errorcheckbox1"
label="Checkbox with error"
description="Hello world!"
:rules="
(val) => (val ? true : 'Check this in to get rid of the error!')
"
validate-on-mount
/>
</div>
<div><FormButton submit full-width>Submit</FormButton></div>
</Form>
</div>
</template>
</LayoutPanel>
<LayoutPanel class="background text-foreground">
<template #header>
<span class="h3">Components</span>
</template>
<template #default>TODO</template>
</LayoutPanel>
</div>
</div>
</template>
<script setup lang="ts">
import { Form } from 'vee-validate'
import { XMarkIcon, ArrowRightCircleIcon } from '@heroicons/vue/24/solid'
const onSubmit = (values: unknown) => console.log(values)
type FormButtonSize = 'xs' | 'sm' | 'base' | 'lg' | 'xl'
type FormButtonColor = 'default' | 'invert' | 'danger' | 'warning'
const sizes: FormButtonSize[] = ['xs', 'sm', 'base', 'lg', 'xl']
const types: FormButtonColor[] = ['danger', 'warning']
const img =
'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/4QAuRXhpZgAATU0AKgAAAAgAAkAAAAMAAAABAD4AAEABAAEAAAABAAAAAAAAAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAA+AGQDASIAAhEBAxEB/8QAGwABAAIDAQEAAAAAAAAAAAAABQQGAAIDBwH/xAAjEAACAQUAAgIDAQAAAAAAAAAAAQQCAxEhMUFRBRITFGGh/8QAGgEAAwADAQAAAAAAAAAAAAAAAwQFAQIGB//EAB4RAAIDAQEBAAMAAAAAAAAAAAADAQIRBBITFCEx/9oADAMBAAIRAxEAPwBqmw2zqo2fBPtR98JtqLlcN6sOl6o/QDXEfo4VwqmWtQsrhrVB/gzRpzXVJT6oTzw0cN+mW2qB/DjXCx4/wPVhzvVaYKrVFa8HC5HaLPeiY8EG/HxnQettIrG5JW7thkK9ZLDfsh0i0M1k1o2dAL9rTQfft9HZFHQ2TQMV/g8q4Q7ezCU6dmG436Peo9lOrgrHjrHCFFa+w5ESwjirbB6N020+URVjhs4a9CVmhYO341gzS8wQOiugNcNeiHfiJZ0WW5aXogyLS3oZowg9KtKtJj48BMqzjJaJdtbA5lHR9VtITkTpXZNGM6CpNO2OS6eg8pFCgGqp0HkLoVJ4xaVrIPJfRqsDyqTBDfWYc3VtmBMGsPdYchfbo/DkLWygRJmKujkSfjGzlbJ09LeuS9R5Cwtkum8n5KjHn82I2ZyaWwMpmCU1Q9VWmQpDTyRlLTXTS5IT8mK1mCa7n0jS/IFNXRaTdTT2DzK1scVJJbyAsxdBJmhmZWtgcytbKipFPxsCJj6CS6haZX0CmV7Y7SQlU4RXVtmEZ1rLMC6E+Z6DYltPolHnYxsqtq48ku3dq9kbxB6s5Bco/wAjjyI2PkubKNav1eydZkVryYlUSSXJL1Z+Qz5O/wC9ldKbYk1+ydbkVtdF7KwnsUPXZSa6GS5HdkWu9VjpAlXqsMxSuSINTBrMvrewOZf7s6Tb1WwOZdq2PrErpiDnMvrewSZd6dpV17CJVb2NVkF8z47u2YQ8ttmBPRnwf//Z'
</script>
@@ -0,0 +1,50 @@
<template>
<LayoutPanel fancy-glow class="max-w-lg mx-auto w-full">
<div class="space-y-4">
<div class="flex flex-col items-center space-y-2">
<h1
class="text-center h3 font-bold bg-gradient-to-r from-blue-500 via-blue-400 to-blue-600 inline-block py-1 text-transparent bg-clip-text"
>
Speckle Login
</h1>
<h2 class="text-center text-foreground-2">
Interoperability, Collaboration and Automation for 3D
</h2>
</div>
<AuthThirdPartyLoginBlock
v-if="hasThirdPartyStrategies && serverInfo"
:server-info="serverInfo"
:challenge="challenge"
:app-id="appId"
/>
<div>
<div class="text-center label text-foreground-2 mb-3 text-xs font-normal">
{{
hasThirdPartyStrategies
? 'Or login with your email'
: 'Login with your email'
}}
</div>
<AuthLoginWithEmailBlock v-if="hasLocalStrategy" :challenge="challenge" />
</div>
</div>
</LayoutPanel>
</template>
<script setup lang="ts">
import { useQuery } from '@vue/apollo-composable'
import { AuthStrategy } from '~~/lib/auth/helpers/strategies'
import { useLoginOrRegisterUtils } from '~~/lib/auth/composables/auth'
import { loginServerInfoQuery } from '~~/lib/auth/graphql/queries'
const { result } = useQuery(loginServerInfoQuery)
const { appId, challenge } = useLoginOrRegisterUtils()
const serverInfo = computed(() => result.value?.serverInfo)
const hasLocalStrategy = computed(() =>
(serverInfo.value?.authStrategies || []).some((s) => s.id === AuthStrategy.Local)
)
const hasThirdPartyStrategies = computed(() =>
serverInfo.value?.authStrategies.some((s) => s.id !== AuthStrategy.Local)
)
</script>
@@ -0,0 +1,87 @@
<template>
<form @submit="onSubmit">
<div class="flex flex-col space-y-2">
<FormTextInput
type="email"
name="email"
label="E-mail"
placeholder="Enter your email"
size="xl"
:rules="emailRules"
show-label
:disabled="loading"
auto-focus
/>
<FormTextInput
type="password"
name="password"
label="Password"
placeholder="Enter your password"
size="xl"
:rules="passwordRules"
show-label
:disabled="loading"
/>
</div>
<div class="mt-1">
<CommonTextLink :to="forgottenPasswordRoute" size="sm">
Forgot your password?
</CommonTextLink>
</div>
<FormButton submit full-width class="my-8" :disabled="loading">Log in</FormButton>
<div class="text-center">
<span class="mr-2">Don't have an account?</span>
<CommonTextLink :to="finalRegisterRoute" :icon-right="ArrowRightIcon">
Register
</CommonTextLink>
</div>
</form>
</template>
<script setup lang="ts">
import { useForm } from 'vee-validate'
import { isEmail, isRequired } from '~~/lib/common/helpers/validation'
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
import { ensureError } from '@speckle/shared'
import { useAuthManager } from '~~/lib/auth/composables/auth'
import { forgottenPasswordRoute, registerRoute } from '~~/lib/common/helpers/route'
import { ArrowRightIcon } from '@heroicons/vue/20/solid'
type FormValues = { email: string; password: string }
const props = defineProps<{
challenge: string
}>()
const { handleSubmit } = useForm<FormValues>()
const loading = ref(false)
const emailRules = [isEmail]
const passwordRules = [isRequired]
const { loginWithEmail, inviteToken } = useAuthManager()
const { triggerNotification } = useGlobalToast()
const router = useRouter()
const finalRegisterRoute = computed(() => {
const result = router.resolve({
path: registerRoute,
query: inviteToken.value ? { token: inviteToken.value } : {}
})
return result.fullPath
})
const onSubmit = handleSubmit(async ({ email, password }) => {
try {
loading.value = true
await loginWithEmail({ email, password, challenge: props.challenge })
} catch (e) {
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Login failed',
description: `${ensureError(e).message}`
})
} finally {
loading.value = false
}
})
</script>
@@ -0,0 +1,54 @@
<template>
<div>
<div class="grid grid-cols-2 text-xs text-foreground-2 justify-between px-1">
<div class="flex items-center space-x-2">
<CheckIcon v-if="ruleFits(passwordLongEnough)" class="w-4 h-4 text-success" />
<MinusSmallIcon v-else class="w-4 h-4 text-foreground-2" />
<div>8+ characters long</div>
</div>
<div class="flex items-center space-x-2">
<CheckIcon
v-if="ruleFits(passwordHasAtLeastOneNumber)"
class="w-4 h-4 text-success"
/>
<MinusSmallIcon v-else class="w-4 h-4 text-foreground-2" />
<div>One number</div>
</div>
<div class="flex items-center space-x-2">
<CheckIcon
v-if="ruleFits(passwordHasAtLeastOneLowercaseLetter)"
class="w-4 h-4 text-success"
/>
<MinusSmallIcon v-else class="w-4 h-4 text-foreground-2" />
<div>One lowercase letter</div>
</div>
<div class="flex items-center space-x-2">
<CheckIcon
v-if="ruleFits(passwordHasAtLeastOneUppercaseLetter)"
class="w-4 h-4 text-success"
/>
<MinusSmallIcon v-else class="w-4 h-4 text-foreground-2" />
<div>One uppercase letter</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { GenericValidateFunction } from 'vee-validate'
import {
passwordLongEnough,
passwordHasAtLeastOneNumber,
passwordHasAtLeastOneLowercaseLetter,
passwordHasAtLeastOneUppercaseLetter
} from '~~/lib/auth/helpers/validation'
import { CheckIcon, MinusSmallIcon } from '@heroicons/vue/24/solid'
const props = defineProps<{
password: string
}>()
const ruleFits = (rule: GenericValidateFunction<string>) =>
rule(props.password, { field: '', form: {}, value: props.password }) === true
</script>
@@ -0,0 +1,57 @@
<template>
<LayoutPanel form class="mx-auto max-w-screen-md" @submit="onSubmit">
<template #header>
<span class="h5 font-medium leading-7">
One step closer to resetting your password.
</span>
</template>
<template #default>
<div class="flex flex-col space-y-8">
<FormTextInput
type="password"
name="password"
label="Password"
:rules="passwordRules"
show-label
show-required
/>
<FormTextInput
type="password"
name="password-repeat"
label="Password (repeat)"
:rules="passwordRepeatRules"
show-label
show-required
/>
</div>
</template>
<template #footer>
<FormButton submit full-width :disabled="loading">Save new password</FormButton>
</template>
</LayoutPanel>
</template>
<script setup lang="ts">
import { useForm } from 'vee-validate'
import { usePasswordReset } from '~~/lib/auth/composables/passwordReset'
import { isRequired, isSameAs } from '~~/lib/common/helpers/validation'
type FormValues = {
password: string
repeatPassword: string
}
const props = defineProps<{
token: string
}>()
const { handleSubmit } = useForm<FormValues>()
const { finalize } = usePasswordReset()
const passwordRules = [isRequired]
const passwordRepeatRules = [...passwordRules, isSameAs('password')]
const loading = ref(false)
const onSubmit = handleSubmit(
async ({ password }) => await finalize(password, props.token)
)
</script>
@@ -0,0 +1,43 @@
<template>
<LayoutPanel form class="mx-auto max-w-screen-md" @submit="onSubmit">
<template #header>
<span class="h5 font-medium leading-7">Reset your account password</span>
</template>
<template #default>
<div class="flex flex-col space-y-8">
<div>
Type in the email address you used, so we can verify your account. We will
send you instructions on how to reset your password.
</div>
<div>
<FormTextInput
name="resetEmail"
type="email"
placeholder="email@example.com"
:rules="emailRules"
/>
</div>
</div>
</template>
<template #footer>
<FormButton submit full-width :disabled="loading">Send reset e-mail</FormButton>
</template>
</LayoutPanel>
</template>
<script setup lang="ts">
import { useForm } from 'vee-validate'
import { usePasswordReset } from '~~/lib/auth/composables/passwordReset'
import { isEmail, isRequired } from '~~/lib/common/helpers/validation'
type FormValues = { resetEmail: string }
const { handleSubmit } = useForm<FormValues>()
const { sendResetEmail } = usePasswordReset()
const emailRules = [isEmail, isRequired]
const loading = ref(false)
const onSubmit = handleSubmit(
async ({ resetEmail }) => await sendResetEmail(resetEmail)
)
</script>
@@ -0,0 +1,79 @@
<template>
<LayoutPanel fancy-glow class="max-w-lg mx-auto w-full">
<div class="space-y-4">
<div class="flex flex-col items-center space-y-2">
<h1
class="h3 text-center font-bold bg-gradient-to-r py-1 from-blue-500 via-blue-400 to-blue-600 inline-block text-transparent bg-clip-text"
>
Create your Speckle Account
</h1>
<h2 class="text-center text-foreground-2">
Interoperability, Collaboration and Automation for 3D
</h2>
</div>
<template v-if="isInviteOnly && !inviteToken">
<div class="flex space-x-2 items-center">
<ExclamationTriangleIcon class="h-8 w-8 text-warning" />
<div>
This server is invite only. If you have received an invitation email, please
follow the instructions in it.
</div>
</div>
<div class="flex space-x-2 items-center justify-center">
<span>Already have an account?</span>
<CommonTextLink :to="loginRoute">Log in</CommonTextLink>
</div>
</template>
<template v-else>
<AuthThirdPartyLoginBlock
v-if="serverInfo && hasThirdPartyStrategies"
:server-info="serverInfo"
:challenge="challenge"
:app-id="appId"
/>
<div>
<div
v-if="hasThirdPartyStrategies"
class="text-center label text-foreground-2 mb-3 text-xs font-normal"
>
Or sign up with your email
</div>
<AuthRegisterWithEmailBlock
v-if="serverInfo && hasLocalStrategy"
:challenge="challenge"
:server-info="serverInfo"
/>
</div>
</template>
</div>
</LayoutPanel>
</template>
<script setup lang="ts">
import { useQuery } from '@vue/apollo-composable'
import { AuthStrategy } from '~~/lib/auth/helpers/strategies'
import { useLoginOrRegisterUtils } from '~~/lib/auth/composables/auth'
import { loginServerInfoQuery } from '~~/lib/auth/graphql/queries'
import { graphql } from '~~/lib/common/generated/gql'
import { ExclamationTriangleIcon } from '@heroicons/vue/24/outline'
import { loginRoute } from '~~/lib/common/helpers/route'
graphql(`
fragment AuthRegisterPanelServerInfo on ServerInfo {
inviteOnly
}
`)
const { result } = useQuery(loginServerInfoQuery)
const { appId, challenge, inviteToken } = useLoginOrRegisterUtils()
const serverInfo = computed(() => result.value?.serverInfo)
const hasLocalStrategy = computed(() =>
(serverInfo.value?.authStrategies || []).some((s) => s.id === AuthStrategy.Local)
)
const hasThirdPartyStrategies = computed(() =>
(serverInfo.value?.authStrategies || []).some((s) => s.id !== AuthStrategy.Local)
)
const isInviteOnly = computed(() => !!serverInfo.value?.inviteOnly)
</script>
@@ -0,0 +1,136 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<form @submit="onSubmit">
<div class="flex flex-col space-y-2">
<FormTextInput
type="text"
name="name"
label="Full Name"
placeholder="John Doe"
size="xl"
:rules="nameRules"
:custom-icon="UserIcon"
show-label
:disabled="loading"
auto-focus
/>
<FormTextInput
type="email"
name="email"
label="Email"
placeholder="example@email.com"
size="xl"
:rules="emailRules"
show-label
:disabled="loading"
/>
<FormTextInput
v-model="password"
type="password"
name="password"
label="Password"
placeholder="Type a strong password"
size="xl"
:rules="passwordRules"
show-label
:disabled="loading"
@focusin="pwdFocused = true"
@focusout="pwdFocused = false"
/>
</div>
<AuthPasswordChecks
:password="password"
:class="`mt-2 overflow-hidden ${pwdFocused ? 'h-8' : 'h-0'} transition-[height]`"
/>
<FormButton submit full-width class="mt-4" :disabled="loading">Sign up</FormButton>
<div
v-if="serverInfo.termsOfService"
class="mt-2 text-xs text-foreground-2 text-center linkify-tos"
v-html="serverInfo.termsOfService"
></div>
<div class="mt-8 text-center">
<span class="mr-2">Already have an account?</span>
<CommonTextLink :to="finalLoginRoute" :icon-right="ArrowRightIcon">
Log in
</CommonTextLink>
</div>
</form>
</template>
<script setup lang="ts">
import { useForm } from 'vee-validate'
import { isEmail, isRequired } from '~~/lib/common/helpers/validation'
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
import { ensureError } from '@speckle/shared'
import { useAuthManager } from '~~/lib/auth/composables/auth'
import { loginRoute } from '~~/lib/common/helpers/route'
import { passwordRules } from '~~/lib/auth/helpers/validation'
import { graphql } from '~~/lib/common/generated/gql'
import { ServerTermsOfServicePrivacyPolicyFragmentFragment } from '~~/lib/common/generated/gql/graphql'
import { UserIcon, ArrowRightIcon } from '@heroicons/vue/20/solid'
/**
* TODO:
* - (BE) Password strength check? Do we want to use it anymore?
* - Dim's answer: no, `passwordRules` are legit enough for now.
*/
graphql(`
fragment ServerTermsOfServicePrivacyPolicyFragment on ServerInfo {
termsOfService
}
`)
type FormValues = { email: string; password: string; name: string; company?: string }
const props = defineProps<{
challenge: string
serverInfo: ServerTermsOfServicePrivacyPolicyFragmentFragment
}>()
const { handleSubmit } = useForm<FormValues>()
const router = useRouter()
const loading = ref(false)
const password = ref('')
const emailRules = [isEmail]
const nameRules = [isRequired]
const { signUpWithEmail, inviteToken } = useAuthManager()
const { triggerNotification } = useGlobalToast()
const pwdFocused = ref(false)
const finalLoginRoute = computed(() => {
const result = router.resolve({
path: loginRoute,
query: inviteToken.value ? { token: inviteToken.value } : {}
})
return result.fullPath
})
const onSubmit = handleSubmit(async (fullUser) => {
try {
loading.value = true
const user = fullUser
await signUpWithEmail({
user,
challenge: props.challenge,
inviteToken: inviteToken.value
})
} catch (e) {
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Registration failed',
description: `${ensureError(e).message}`
})
} finally {
loading.value = false
}
})
</script>
<style>
.linkify-tos a {
text-decoration: underline;
}
</style>
@@ -0,0 +1,119 @@
<template>
<div>
<LayoutPanel v-if="shouldShowBanner">
<div class="flex justify-between items-center">
<div>{{ verifyBannerText }}</div>
<div class="flex items-center space-x-4">
<FormButton
type="outline"
size="sm"
:disabled="loading"
@click="requestVerification"
>
{{ verifyBannerCtaText }}
</FormButton>
<CommonTextLink @click="dismiss">
<XMarkIcon class="h-6 w-6" />
</CommonTextLink>
</div>
</div>
</LayoutPanel>
</div>
</template>
<script setup lang="ts">
import { graphql } from '~~/lib/common/generated/gql'
import { useApolloClient, useQuery } from '@vue/apollo-composable'
import { XMarkIcon } from '@heroicons/vue/24/solid'
import {
convertThrowIntoFetchResult,
getFirstErrorMessage
} from '~~/lib/common/helpers/graphql'
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
const reminderStateQuery = graphql(`
query EmailVerificationBannerState {
activeUser {
id
email
verified
hasPendingVerification
}
}
`)
const requestVerificationMutation = graphql(`
mutation RequestVerification {
requestVerification
}
`)
const apollo = useApolloClient().client
const { triggerNotification } = useGlobalToast()
const { result } = useQuery(reminderStateQuery)
const user = computed(() => result.value?.activeUser || null)
const dismissed = ref(false)
const loading = ref(false)
const shouldShowBanner = computed(() => {
if (!user.value) return false
if (user.value.verified) return false
if (dismissed.value) return false
return true
})
const hasPendingVerification = computed(() => !!user.value?.hasPendingVerification)
const verifyBannerText = computed(() => {
if (!user.value?.email) return ''
return hasPendingVerification.value
? `Please check your inbox (${user.value.email}) for the verification e-mail`
: `Please verify your e-mail address (${user.value.email})`
})
const verifyBannerCtaText = computed(() =>
hasPendingVerification.value ? `Re-send verification` : `Send verification`
)
const dismiss = () => (dismissed.value = true)
const requestVerification = async () => {
const userData = user.value
if (!userData) return
loading.value = true
const { data, errors } = await apollo
.mutate({
mutation: requestVerificationMutation,
update: (cache, { data }) => {
const isSuccess = !!data?.requestVerification
if (!isSuccess) return
// Switch hasPendingVerification to true
cache.modify({
id: cache.identify(userData),
fields: {
hasPendingVerification: () => true
}
})
}
})
.catch(convertThrowIntoFetchResult)
.finally(() => (loading.value = false))
if (!data?.requestVerification) {
const errMsg = getFirstErrorMessage(errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Resend failed',
description: errMsg
})
} else {
triggerNotification({
type: ToastNotificationType.Info,
title: 'Verification e-mail sent, please check your inbox'
})
dismiss()
}
}
</script>
@@ -0,0 +1,118 @@
<template>
<div
v-if="shouldShowBanner"
class="flex flex-col mx-2 mt-1 mb-2 p-2 bg-warning text-warning-darker rounded-md"
>
<div class="text-sm">{{ verifyBannerText }}</div>
<div class="">
<FormButton
size="sm"
:disabled="loading"
link
class="font-bold text-danger-darker"
@click="requestVerification"
>
{{ verifyBannerCtaText }}
</FormButton>
<!-- <CommonTextLink @click="dismiss">
<XMarkIcon class="h-6 w-6" />
</CommonTextLink> -->
</div>
</div>
</template>
<script setup lang="ts">
import { graphql } from '~~/lib/common/generated/gql'
import { useApolloClient, useQuery } from '@vue/apollo-composable'
import {
convertThrowIntoFetchResult,
getFirstErrorMessage
} from '~~/lib/common/helpers/graphql'
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
const reminderStateQuery = graphql(`
query EmailVerificationBannerState {
activeUser {
id
email
verified
hasPendingVerification
}
}
`)
const requestVerificationMutation = graphql(`
mutation RequestVerification {
requestVerification
}
`)
const apollo = useApolloClient().client
const { triggerNotification } = useGlobalToast()
const { result } = useQuery(reminderStateQuery)
const user = computed(() => result.value?.activeUser || null)
const dismissed = ref(false)
const loading = ref(false)
const shouldShowBanner = computed(() => {
if (!user.value) return false
if (user.value.verified) return false
if (dismissed.value) return false
return true
})
const hasPendingVerification = computed(() => !!user.value?.hasPendingVerification)
const verifyBannerText = computed(() => {
if (!user.value?.email) return ''
return hasPendingVerification.value
? `Please check your inbox (${user.value.email}) for the verification e-mail`
: `Please verify your e-mail address.`
})
const verifyBannerCtaText = computed(() =>
hasPendingVerification.value ? `Re-send verification` : `Send verification`
)
const dismiss = () => (dismissed.value = true)
const requestVerification = async () => {
const userData = user.value
if (!userData) return
loading.value = true
const { data, errors } = await apollo
.mutate({
mutation: requestVerificationMutation,
update: (cache, { data }) => {
const isSuccess = !!data?.requestVerification
if (!isSuccess) return
// Switch hasPendingVerification to true
cache.modify({
id: cache.identify(userData),
fields: {
hasPendingVerification: () => true
}
})
}
})
.catch(convertThrowIntoFetchResult)
.finally(() => (loading.value = false))
if (!data?.requestVerification) {
const errMsg = getFirstErrorMessage(errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Resend failed',
description: errMsg
})
} else {
triggerNotification({
type: ToastNotificationType.Info,
title: 'Verification e-mail sent, please check your inbox'
})
dismiss()
}
}
</script>
@@ -0,0 +1,95 @@
<template>
<div>
<div class="flex flex-col space-y-4 sm:flex-row sm:space-y-0 sm:space-x-4">
<Component
:is="getButtonComponent(strat)"
v-for="strat in thirdPartyStrategies"
:key="strat.id"
:to="buildAuthUrl(strat)"
@click="() => onClick(strat)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { Get } from 'type-fest'
import { useAuthManager } from '~~/lib/auth/composables/auth'
import { AuthStrategy } from '~~/lib/auth/helpers/strategies'
import { graphql } from '~~/lib/common/generated/gql'
import { AuthStategiesServerInfoFragmentFragment } from '~~/lib/common/generated/gql/graphql'
import { useMixpanel } from '~~/lib/core/composables/mp'
/**
* TODO:
* - Invite token
*/
type StrategyType = NonNullable<
Get<AuthStategiesServerInfoFragmentFragment, 'authStrategies.0'>
>
graphql(`
fragment AuthStategiesServerInfoFragment on ServerInfo {
authStrategies {
id
name
url
}
}
`)
const props = defineProps<{
serverInfo: AuthStategiesServerInfoFragmentFragment
challenge: string
appId: string
}>()
const {
public: { apiOrigin }
} = useRuntimeConfig()
const mixpanel = useMixpanel()
const { inviteToken } = useAuthManager()
const NuxtLink = resolveComponent('NuxtLink')
const GoogleButton = resolveComponent('AuthThirdPartyLoginButtonGoogle')
const MicrosoftButton = resolveComponent('AuthThirdPartyLoginButtonMicrosoft')
const GithubButton = resolveComponent('AuthThirdPartyLoginButtonGithub')
const thirdPartyStrategies = computed(() =>
props.serverInfo.authStrategies.filter((s) => s.id !== AuthStrategy.Local)
)
const buildAuthUrl = (strat: StrategyType) => {
const url = new URL(strat.url, apiOrigin)
url.searchParams.set('appId', props.appId)
url.searchParams.set('challenge', props.challenge)
if (inviteToken.value) {
url.searchParams.set('token', inviteToken.value)
}
return url.toString()
}
const getButtonComponent = (strat: StrategyType) => {
const stratId = strat.id as AuthStrategy
switch (stratId) {
case AuthStrategy.Google:
return GoogleButton
case AuthStrategy.Github:
return GithubButton
case AuthStrategy.AzureAD:
return MicrosoftButton
}
return NuxtLink
}
const onClick = (strat: StrategyType) => {
mixpanel.track('Log In', {
isInvite: !!inviteToken.value,
type: 'action',
provider: strat.name
})
}
</script>
@@ -0,0 +1,21 @@
<template>
<NuxtLink
:to="to"
:class="[
'transition grow px-3 inline-flex justify-center items-center outline-none h6 font-medium leading-7',
'rounded shadow hover:shadow-lg bg-foundation-2 text-foreground dark:text-foreground-on-primary',
'ring-outline-2 focus:ring-2 hover:ring-2',
noVerticalPadding ? '' : 'py-2'
]"
>
<div class="flex items-center justify-center">
<slot />
</div>
</NuxtLink>
</template>
<script setup lang="ts">
defineProps<{
to: string
noVerticalPadding?: boolean
}>()
</script>
@@ -0,0 +1,15 @@
<template>
<AuthThirdPartyLoginButtonBase :to="to" class="dark:border dark:border-[#475569]">
<img
src="~/assets/images/auth/github_icon.svg"
alt="GitHub Sign In"
class="w-4 dark:invert"
/>
<div>Github</div>
</AuthThirdPartyLoginButtonBase>
</template>
<script setup lang="ts">
defineProps<{
to: string
}>()
</script>
@@ -0,0 +1,15 @@
<template>
<AuthThirdPartyLoginButtonBase :to="to" no-vertical-padding>
<img
src="~/assets/images/auth/google_icon_w_bg.svg"
alt="Google Sign In"
class="w-11"
/>
<div>Google</div>
</AuthThirdPartyLoginButtonBase>
</template>
<script setup lang="ts">
defineProps<{
to: string
}>()
</script>
@@ -0,0 +1,11 @@
<template>
<AuthThirdPartyLoginButtonBase :to="to" class="dark:bg-[#2F2F2F]">
<img src="~/assets/images/auth/ms_icon.svg" alt="Microsoft Sign In" class="w-4" />
<div>Microsoft</div>
</AuthThirdPartyLoginButtonBase>
</template>
<script setup lang="ts">
defineProps<{
to: string
}>()
</script>
@@ -0,0 +1,84 @@
import { Meta, StoryObj } from '@storybook/vue3'
import CommonBadge from '~~/components/common/Badge.vue'
import { XMarkIcon } from '@heroicons/vue/20/solid'
import { FaceSmileIcon } from '@heroicons/vue/24/outline'
export default {
component: CommonBadge,
argTypes: {
default: {
type: 'string',
description: 'Default slot holds badge text'
},
iconLeft: {
type: 'function'
},
size: {
options: ['base', 'lg'],
control: { type: 'select' }
},
clickIcon: {
action: 'click',
type: 'function'
}
}
} as Meta
export const Default: StoryObj = {
render: (args) => ({
components: { CommonBadge },
setup: () => ({ args }),
template: `<CommonBadge v-bind="args" @click-icon="args.clickIcon">{{ args.default || 'Badge' }}</CommonBadge>`
}),
args: {
size: 'base',
dot: false,
rounded: false,
clickableIcon: false
}
}
export const Large: StoryObj = {
...Default,
args: {
size: 'lg'
}
}
export const Rounded: StoryObj = {
...Default,
args: {
rounded: true
}
}
export const WithDot: StoryObj = {
...Default,
args: {
dot: true
}
}
export const WithIcon: StoryObj = {
...Default,
args: {
iconLeft: FaceSmileIcon
}
}
export const WithClickableIcon: StoryObj = {
...Default,
args: {
clickableIcon: true,
iconLeft: XMarkIcon
}
}
export const WithCustomColors: StoryObj = {
...Default,
args: {
iconLeft: FaceSmileIcon,
dot: true,
colorClasses: 'text-info bg-warning',
dotIconColorClasses: 'text-success'
}
}
@@ -0,0 +1,115 @@
<template>
<span :class="badgeClasses">
<svg v-if="dot" :class="dotClasses" fill="currentColor" viewBox="0 0 8 8">
<circle cx="4" cy="4" r="3" />
</svg>
<slot>Badge</slot>
<NuxtLink v-if="iconLeft" :class="iconClasses" @click="onIconClick($event)">
<Component :is="iconLeft" :class="['h-4 w-4', badgeDotIconColorClasses]" />
</NuxtLink>
</span>
</template>
<script setup lang="ts">
import { ConcreteComponent } from 'vue'
type BadgeSize = 'base' | 'lg'
const emit = defineEmits<{
(e: 'click-icon', v: MouseEvent): void
}>()
const props = defineProps<{
size?: BadgeSize
/**
* Set text & bg color. Defaults to primary variation.
*/
colorClasses?: string
/**
* Show dot to the right
*/
dot?: boolean
/**
* Set dot/icon bg color. Defaults to primary variation.
*/
dotIconColorClasses?: string
/**
* Optionally show icon to the left of the text
*/
iconLeft?: ConcreteComponent
/**
* A more square, but still rounded look
*/
rounded?: boolean
/**
* Track icon clicks
*/
clickableIcon?: boolean
}>()
const badgeColorClasses = computed(
() => props.colorClasses || 'bg-blue-100 text-blue-800'
)
const badgeDotIconColorClasses = computed(
() => props.dotIconColorClasses || 'text-blue-400'
)
const badgeClasses = computed(() => {
const classParts: string[] = [
'inline-flex items-center',
badgeColorClasses.value,
props.size === 'lg' ? 'px-3 py-0.5 label' : 'px-2.5 py-0.5 caption font-medium'
]
if (props.rounded) {
classParts.push('rounded')
classParts.push(
props.size === 'lg' ? 'px-2 py-0.5 label' : 'px-2.5 py-0.5 caption font-medium'
)
} else {
classParts.push('rounded-full')
classParts.push(
props.size === 'lg' ? 'px-2.5 py-0.5 label' : 'px-2.5 py-0.5 caption font-medium'
)
}
return classParts.join(' ')
})
const iconClasses = computed(() => {
const classParts: string[] = [
'mt-0.5 ml-0.5 inline-flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full focus:outline-none'
]
if (props.clickableIcon) {
classParts.push('cursor-pointer')
}
return classParts.join(' ')
})
const dotClasses = computed(() => {
const classParts: string[] = [
'-ml-0.5 mr-1.5 h-2 w-2',
badgeDotIconColorClasses.value
]
return classParts.join(' ')
})
const onIconClick = (e: MouseEvent) => {
if (!props.clickableIcon) {
e.stopPropagation()
e.stopImmediatePropagation()
e.preventDefault()
return
}
emit('click-icon', e)
}
</script>
@@ -0,0 +1,16 @@
<template>
<div class="flex flex-col space-y-2 items-center justify-center p-4">
<img
src="~~/assets/images/boxes/empty.png"
alt="No search results found image"
class="h-32"
/>
<div class="text-sm">
<slot>No items matching your search query found!</slot>
</div>
<FormButton size="sm" @click="() => $emit('clear-search')">Clear Search</FormButton>
</div>
</template>
<script setup lang="ts">
defineEmits(['clear-search'])
</script>
@@ -0,0 +1,35 @@
<template>
<div
:class="[
'relative w-full h-1 bg-blue-500/30 text-xs text-foreground-on-primary overflow-hidden rounded-xl',
loading ? 'opacity-100' : 'opacity-0'
]"
>
<div class="swoosher relative top-0 bg-blue-500/50"></div>
</div>
</template>
<script setup lang="ts">
defineProps<{ loading: boolean }>()
</script>
<style scoped>
.swoosher {
width: 100%;
height: 100%;
animation: swoosh 1s infinite linear;
transform-origin: 0% 30%;
}
@keyframes swoosh {
0% {
transform: translateX(0) scaleX(0);
}
40% {
transform: translateX(0) scaleX(0.4);
}
100% {
transform: translateX(100%) scaleX(0.5);
}
}
</style>
@@ -0,0 +1,146 @@
<template>
<FormSelectBase
v-model="selectedValue"
:items="items"
:name="name || 'models'"
:label="label || 'Models'"
:show-label="showLabel"
:multiple="multiple"
:disabled="!items.length"
:allow-unset="allowUnset"
by="id"
>
<template #nothing-selected>
<div class="label label--light">
{{ multiple ? 'Select models' : 'Select a model' }}
</div>
</template>
<template #something-selected="{ value }">
<template v-if="isMultiItemArrayValue(value)">
<div ref="elementToWatchForChanges" class="flex items-center space-x-0.5">
<div ref="itemContainer" class="flex flex-wrap overflow-hidden space-x-0.5">
<span
v-for="branch in value"
:key="branch.id"
class="text-foreground normal"
>
{{ branch.name }}
</span>
</div>
<div v-if="hiddenSelectedItemCount > 0" class="text-foreground-2 normal">
+{{ hiddenSelectedItemCount }}
</div>
</div>
</template>
<template v-else>
<div class="flex items-center">
<span class="truncate label label--light">
{{ (isArrayValue(value) ? value[0] : value).name }}
</span>
</div>
</template>
</template>
<template #option="{ item }">
<div class="flex items-center">
<span class="truncate">{{ item.name }}</span>
</div>
</template>
</FormSelectBase>
</template>
<script setup lang="ts">
import { useQuery } from '@vue/apollo-composable'
import { Nullable, Optional } from '@speckle/shared'
import { projectModelsSelectorValuesQuery } from '~~/lib/common/graphql/queries'
import { CommonModelSelectorModelFragment } from '~~/lib/common/generated/gql/graphql'
import { useFormSelectChildInternals } from '~~/lib/form/composables/select'
import { graphql } from '~~/lib/common/generated/gql'
graphql(`
fragment CommonModelSelectorModel on Model {
id
name
}
`)
type BranchItem = CommonModelSelectorModelFragment
type ValueType = BranchItem | BranchItem[] | undefined
const emit = defineEmits<{
(e: 'update:modelValue', v: ValueType): void
}>()
const props = defineProps({
projectId: {
type: String,
required: true
},
multiple: {
type: Boolean,
default: false
},
modelValue: {
type: [Object, Array] as PropType<ValueType>,
default: undefined
},
label: {
type: String,
default: undefined
},
showLabel: {
type: Boolean,
default: false
},
name: {
type: String,
default: undefined
},
excludedIds: {
type: Array as PropType<Optional<string[]>>,
default: undefined
},
allowUnset: {
type: Boolean,
default: false
}
})
const { result, onResult, fetchMore } = useQuery(
projectModelsSelectorValuesQuery,
() => ({
projectId: props.projectId,
cursor: null as Nullable<string>
})
)
const elementToWatchForChanges = ref(null as Nullable<HTMLElement>)
const itemContainer = ref(null as Nullable<HTMLElement>)
const { selectedValue, isMultiItemArrayValue, isArrayValue, hiddenSelectedItemCount } =
useFormSelectChildInternals({
props: toRefs(props),
emit,
dynamicVisibility: { elementToWatchForChanges, itemContainer }
})
const items = computed(() => {
const queryItems = result.value?.project?.models.items || []
if (!props.excludedIds?.length) return queryItems
return queryItems.filter((i) => !(props.excludedIds || []).includes(i.id))
})
onResult((res) => {
if (!res.data?.project) return
if (res.data.project?.models.totalCount <= res.data.project?.models.items.length)
return
if (!res.data.project.models.cursor) return
// Load more
const cursor = res.data.project.models.cursor
fetchMore({
variables: {
cursor
}
})
})
</script>
@@ -0,0 +1,107 @@
import { action } from '@storybook/addon-actions'
import { Meta, StoryObj } from '@storybook/vue3'
import CommonStepsBullet from '~~/components/common/steps/Bullet.vue'
import { BulletStepType } from '~~/lib/common/helpers/components'
import { mergeStories } from '~~/lib/common/helpers/storybook'
import { TailwindBreakpoints } from '~~/lib/common/helpers/tailwind'
type StoryType = StoryObj<
Record<string, unknown> & {
'update:modelValue': (val: boolean) => void
}
>
const testSteps: BulletStepType[] = [
{
name: 'First step',
onClick: action('step-clicked')
},
{
name: 'Second step',
onClick: action('step-clicked')
},
{
name: 'Third step',
onClick: action('step-clicked')
}
]
export default {
component: CommonStepsBullet,
argTypes: {
orientation: {
options: ['horizontal', 'vertical'],
control: { type: 'select' }
},
'update:modelValue': {
type: 'function',
action: 'v-model'
}
},
parameters: {
docs: {
description: {
component: 'Bullet-based steps component'
}
}
}
} as Meta
export const Default: StoryType = {
render: (args, ctx) => ({
components: { CommonStepsBullet },
setup: () => ({ args }),
template: `<CommonStepsBullet v-bind="args" @update:modelValue="onModelUpdate"/>`,
methods: {
onModelUpdate(val: boolean) {
args['update:modelValue'](val)
ctx.updateArgs({ ...args, modelValue: val })
}
}
}),
args: {
ariaLabel: 'Steps ARIA title!',
basic: false,
orientation: 'horizontal',
steps: testSteps,
modelValue: 1,
nonInteractive: false
}
}
export const Vertical: StoryType = mergeStories(Default, {
args: {
orientation: 'vertical'
}
})
export const VersionBasic: StoryType = mergeStories(Default, {
args: {
basic: true
}
})
export const StartOnNegativeStep: StoryType = mergeStories(Default, {
args: {
modelValue: -1
},
parameters: {
docs: {
description: {
story: 'Start on -1 step (on neither of the steps)'
}
}
}
})
export const GoVerticalBelowBreakpoint: StoryType = mergeStories(Default, {
args: {
goVerticalBelow: TailwindBreakpoints.md
}
})
export const NonInteractive: StoryType = mergeStories(Default, {
args: {
nonInteractive: true
}
})
@@ -0,0 +1,107 @@
<template>
<nav class="flex justify-center" :aria-label="ariaLabel || 'Progress steps'">
<ol :class="[listClasses, basic ? 'basic' : '']">
<li v-for="(step, i) in steps" :key="step.name">
<a
v-if="isFinishedStep(i)"
:href="step.href"
:class="linkClasses"
@click="(e) => switchStep(i, e)"
>
<span class="relative flex h-5 w-5 flex-shrink-0 items-center justify-center">
<span v-if="basic" class="h-3 w-3 rounded-full bg-foreground-2" />
<CheckCircleIcon
v-else
class="h-full w-full text-primary"
aria-hidden="true"
/>
</span>
<span :class="['text-foreground', labelClasses]">
{{ step.name }}
</span>
</a>
<a
v-else-if="isCurrentStep(i)"
:href="step.href"
:class="linkClasses"
aria-current="step"
@click="(e) => switchStep(i, e)"
>
<span
class="relative flex h-5 w-5 flex-shrink-0 items-center justify-center"
aria-hidden="true"
>
<template v-if="basic">
<span class="h-3 w-3 rounded-full bg-foreground" />
</template>
<template v-else>
<span class="absolute h-4 w-4 rounded-full bg-outline-2" />
<span class="relative block h-2 w-2 rounded-full bg-primary-focus" />
</template>
</span>
<span :class="['text-primary-focus', labelClasses]">
{{ step.name }}
</span>
</a>
<a
v-else
:href="step.href"
:class="linkClasses"
@click="(e) => switchStep(i, e)"
>
<div
class="relative flex h-5 w-5 flex-shrink-0 items-center justify-center"
aria-hidden="true"
>
<span v-if="basic" class="h-3 w-3 rounded-full bg-foreground-2" />
<div v-else class="h-4 w-4 rounded-full bg-foreground-disabled" />
</div>
<p :class="['text-foreground-disabled', labelClasses]">
{{ step.name }}
</p>
</a>
</li>
</ol>
</nav>
</template>
<script setup lang="ts">
import { CheckCircleIcon } from '@heroicons/vue/20/solid'
import { useStepsInternals } from '~~/lib/common/composables/steps'
import { BulletStepType, HorizontalOrVertical } from '~~/lib/common/helpers/components'
import { TailwindBreakpoints } from '~~/lib/common/helpers/tailwind'
const emit = defineEmits<{
(e: 'update:modelValue', val: number): void
}>()
const props = defineProps<{
ariaLabel?: string
basic?: boolean
orientation?: HorizontalOrVertical
steps: BulletStepType[]
modelValue?: number
goVerticalBelow?: TailwindBreakpoints
nonInteractive?: boolean
}>()
const { isCurrentStep, isFinishedStep, switchStep, listClasses, linkClasses } =
useStepsInternals({
props: toRefs(props),
emit
})
const labelClasses = computed(() => {
const classParts: string[] = ['ml-3 h6 font-medium leading-7']
if (props.basic) {
classParts.push('sr-only')
}
return classParts.join(' ')
})
</script>
<style scoped>
.basic {
@apply space-x-4 !important;
}
</style>
@@ -0,0 +1,119 @@
import { action } from '@storybook/addon-actions'
import { Meta, StoryObj } from '@storybook/vue3'
import CommonStepsNumber from '~~/components/common/steps/Number.vue'
import { NumberStepType } from '~~/lib/common/helpers/components'
import { mergeStories } from '~~/lib/common/helpers/storybook'
import { TailwindBreakpoints } from '~~/lib/common/helpers/tailwind'
type StoryType = StoryObj<
Record<string, unknown> & {
'update:modelValue': (val: boolean) => void
}
>
const testStepsWithDescription: NumberStepType[] = [
{
name: 'First step',
onClick: action('step-clicked'),
description: 'Some example text'
},
{
name: 'Second step',
onClick: action('step-clicked'),
description: 'More example text'
},
{
name: 'Third step',
onClick: action('step-clicked'),
description: 'Final example text'
}
]
const testStepsWithoutDescription: NumberStepType[] = [
{
name: 'First step',
onClick: action('step-clicked')
},
{
name: 'Second step',
onClick: action('step-clicked')
},
{
name: 'Third step',
onClick: action('step-clicked')
}
]
export default {
component: CommonStepsNumber,
argTypes: {
orientation: {
options: ['horizontal', 'vertical'],
control: { type: 'select' }
},
'update:modelValue': {
type: 'function',
action: 'v-model'
}
},
parameters: {
docs: {
description: {
component: 'Number-based steps component. Also supports optional description.'
}
}
}
} as Meta
export const Default: StoryType = {
render: (args, ctx) => ({
components: { CommonStepsNumber },
setup: () => ({ args }),
template: `<CommonStepsNumber v-bind="args" @update:modelValue="onModelUpdate"/>`,
methods: {
onModelUpdate(val: boolean) {
args['update:modelValue'](val)
ctx.updateArgs({ ...args, modelValue: val })
}
}
}),
args: {
ariaLabel: 'Steps ARIA title!',
orientation: 'horizontal',
steps: testStepsWithDescription,
modelValue: 1
}
}
export const Vertical: StoryType = mergeStories(Default, {
args: {
orientation: 'vertical'
}
})
export const StartOnNegativeStep: StoryType = mergeStories(Default, {
args: {
modelValue: -1
},
parameters: {
docs: {
description: {
story: 'Start on -1 step (on neither of the steps)'
}
}
}
})
export const NoDescription: StoryType = {
...Default,
args: {
...Default.args,
steps: testStepsWithoutDescription
}
}
export const GoVerticalBelowBreakpoint: StoryType = mergeStories(Default, {
args: {
goVerticalBelow: TailwindBreakpoints.md
}
})
@@ -0,0 +1,106 @@
<template>
<nav class="flex justify-center" :aria-label="ariaLabel || 'Progress steps'">
<ol :class="listClasses">
<li v-for="(step, i) in steps" :key="step.name">
<a
v-if="isFinishedStep(i)"
:href="step.href"
:class="linkClasses"
@click="(e) => switchStep(i, e)"
>
<div
class="flex space-x-3 items-center text-primary-focus normal font-medium leading-5"
>
<div
class="shrink-0 h-8 w-8 rounded-full bg-primary-focus text-foreground-on-primary inline-flex items-center justify-center"
>
<CheckIcon class="w-5 h-5" />
</div>
<div class="flex flex-col">
<div>{{ step.name }}</div>
<div v-if="step.description" class="label label--light text-foreground">
{{ step.description }}
</div>
</div>
</div>
</a>
<a
v-else-if="isCurrentStep(i)"
:href="step.href"
:class="linkClasses"
aria-current="step"
@click="(e) => switchStep(i, e)"
>
<div
class="flex space-x-3 items-center text-primary-focus normal font-medium leading-5"
>
<div
class="shrink-0 h-8 w-8 rounded-full border-2 border-primary-focus inline-flex items-center justify-center"
>
{{ getStepDisplayValue(i) }}
</div>
<div class="flex flex-col">
<div>{{ step.name }}</div>
<div v-if="step.description" class="label label--light text-foreground">
{{ step.description }}
</div>
</div>
</div>
</a>
<a
v-else
:href="step.href"
:class="linkClasses"
@click="(e) => switchStep(i, e)"
>
<div
class="flex space-x-3 items-center text-foreground-disabled normal font-medium leading-5"
>
<div
class="shrink-0 h-8 w-8 rounded-full border-2 border-foreground-disabled inline-flex items-center justify-center"
>
{{ getStepDisplayValue(i) }}
</div>
<div class="flex flex-col">
<div>{{ step.name }}</div>
<div v-if="step.description" class="label label--light">
{{ step.description }}
</div>
</div>
</div>
</a>
</li>
</ol>
</nav>
</template>
<script setup lang="ts">
import { CheckIcon } from '@heroicons/vue/20/solid'
import { useStepsInternals } from '~~/lib/common/composables/steps'
import { HorizontalOrVertical, NumberStepType } from '~~/lib/common/helpers/components'
import { TailwindBreakpoints } from '~~/lib/common/helpers/tailwind'
const emit = defineEmits<{
(e: 'update:modelValue', val: number): void
}>()
const props = defineProps<{
ariaLabel?: string
orientation?: HorizontalOrVertical
steps: NumberStepType[]
modelValue?: number
goVerticalBelow?: TailwindBreakpoints
nonInteractive?: boolean
}>()
const {
isCurrentStep,
isFinishedStep,
switchStep,
getStepDisplayValue,
listClasses,
linkClasses
} = useStepsInternals({
props: toRefs(props),
emit
})
</script>
@@ -0,0 +1,139 @@
import { userEvent, within } from '@storybook/testing-library'
import { Meta, StoryObj } from '@storybook/vue3'
import { wait } from '@speckle/shared'
import CommonTextLink from '~~/components/common/text/Link.vue'
import { mergeStories, VuePlayFunction } from '~~/lib/common/helpers/storybook'
export default {
component: CommonTextLink,
parameters: {
docs: {
description: {
component: 'Basically just a wrapper over FormButton w/ type link'
}
}
},
argTypes: {
to: {
type: 'string'
},
default: {
type: 'string',
description: 'Default slot holds button contents'
},
click: {
action: 'click',
type: 'function'
},
external: {
type: 'boolean'
},
disabled: {
type: 'boolean'
},
size: {
options: ['xs', 'sm', 'base', 'lg', 'xl'],
control: { type: 'select' }
}
}
} as Meta
const clickPlayBuilder: (rightClick: boolean) => VuePlayFunction =
(rightClick) =>
async ({ canvasElement }) => {
const canvas = within(canvasElement)
const button = canvas.getByRole('link')
userEvent.click(button, rightClick ? { button: 2 } : undefined)
await wait(1000)
userEvent.tab()
}
const rightClickPlay = clickPlayBuilder(true)
const leftClickPlay = clickPlayBuilder(false)
export const Default: StoryObj = {
render: (args) => ({
components: { CommonTextLink },
setup() {
return { args }
},
template: `<CommonTextLink v-bind="args" @click="args.click">{{ args.default || 'Link' }}</CommonTextLink>`
}),
play: rightClickPlay,
args: {
to: 'https://google.com',
disabled: false,
default: 'Click me!',
size: 'base'
},
parameters: {
docs: {
source: {
code: '<CommonTextLink to="/">Hello World!</CommonTextLink>'
}
}
}
}
export const Small: StoryObj = mergeStories(Default, {
args: {
size: 'sm'
}
})
export const ExtraSmall: StoryObj = mergeStories(Default, {
args: {
size: 'xs'
}
})
export const Large: StoryObj = mergeStories(Default, {
args: {
size: 'lg'
}
})
export const ExtraLarge: StoryObj = mergeStories(Default, {
args: {
size: 'xl'
}
})
export const Disabled: StoryObj = mergeStories(Default, {
play: leftClickPlay,
args: {
disabled: true
},
parameters: {
docs: {
description: {
story: 'Button is disabled and no mouse events fire'
}
}
}
})
export const NoTarget: StoryObj = mergeStories(Default, {
play: leftClickPlay,
args: {
to: null,
default: 'No URL, only for tracking click events'
}
})
export const External: StoryObj = mergeStories(Default, {
args: {
external: true,
to: '/',
default: 'External link'
},
parameters: {
docs: {
description: {
story: 'Forces target to be treated as an external link'
}
}
}
})
@@ -0,0 +1,82 @@
<template>
<FormButton
link
:to="to"
:external="external"
:disabled="disabled"
:size="size"
:foreground-link="foregroundLink"
:icon-left="iconLeft"
:icon-right="iconRight"
:hide-text="hideText"
role="link"
@click.capture="onClick"
>
<slot>Link</slot>
</FormButton>
</template>
<script setup lang="ts">
import { ConcreteComponent, PropType } from 'vue'
import { Nullable, Optional } from '@speckle/shared'
type LinkSize = 'xs' | 'sm' | 'base' | 'lg' | 'xl'
const emit = defineEmits<{ (e: 'click', val: MouseEvent): void }>()
const props = defineProps({
to: {
type: String as PropType<Optional<string>>,
required: false,
default: undefined
},
external: {
type: Boolean as PropType<Optional<boolean>>,
required: false,
default: undefined
},
disabled: {
type: Boolean as PropType<Optional<boolean>>,
required: false,
default: undefined
},
size: {
type: String as PropType<LinkSize>,
default: 'base'
},
foregroundLink: {
type: Boolean,
default: false
},
/**
* Add icon to the left from the text
*/
iconLeft: {
type: [Object, Function] as PropType<Nullable<ConcreteComponent>>,
default: null
},
/**
* Add icon to the right from the text
*/
iconRight: {
type: [Object, Function] as PropType<Nullable<ConcreteComponent>>,
default: null
},
/**
* Hide default slot (when you want to show icons only)
*/
hideText: {
type: Boolean,
default: false
}
})
const onClick = (e: MouseEvent) => {
if (props.disabled) {
e.preventDefault()
e.stopPropagation()
e.stopImmediatePropagation()
return
}
emit('click', e)
}
</script>
@@ -0,0 +1,55 @@
<template>
<div class="bg-foundation text-foreground rounded shadow-md p-2">
<CommonTiptapMentionListItem
v-if="existingUser"
:item="existingUser"
is-selected
@click="enterHandler"
/>
<FormButton v-else size="sm" @click="enterHandler">Invite {{ query }}</FormButton>
</div>
</template>
<script setup lang="ts">
import {
SuggestionCommandProps,
SuggestionKeyDownProps
} from '~~/lib/core/tiptap/email-mention/suggestion'
import { SuggestionOptionsItem } from '~~/lib/core/tiptap/mentionExtension'
const props = defineProps<{
query: string
items: SuggestionOptionsItem[]
command: (mention: SuggestionCommandProps) => void
}>()
const existingUser = computed(() => props.items[0] || null)
const enterHandler = () => {
if (existingUser.value) {
// Create a mention of the existing user
props.command({
mention: { id: existingUser.value.id, label: existingUser.value.name },
email: null
})
} else {
// Trigger invite and close popup
props.command({
email: props.query,
mention: null
})
}
}
const onKeyDown = (params: SuggestionKeyDownProps) => {
const { event } = params
if (event.key === 'Enter') {
enterHandler()
return true
}
return false
}
defineExpose({
onKeyDown
})
</script>
@@ -0,0 +1,88 @@
<template>
<div
v-show="(query?.length || 0) >= 3"
class="bg-foundation text-foreground rounded shadow-md p-2"
>
<ul>
<template v-if="items.length">
<li v-for="(item, i) in items" :key="item.id">
<CommonTiptapMentionListItem
:item="item"
:is-selected="i === selectedIndex"
@click="() => selectItem(i)"
/>
</li>
</template>
<template v-else>
<li>Couldn't find anything 🤷</li>
</template>
</ul>
</div>
</template>
<script setup lang="ts">
import { SuggestionKeyDownProps } from '@tiptap/suggestion'
import { MentionData, SuggestionOptionsItem } from '~~/lib/core/tiptap/mentionExtension'
const props = defineProps<{
query?: string
items?: SuggestionOptionsItem[]
command: (mention: MentionData) => void
}>()
const selectedIndex = ref(0)
const items = computed(() => props.items || [])
const upHandler = () => {
if (!items.value) return
selectedIndex.value =
(selectedIndex.value + items.value.length - 1) % items.value.length
}
const downHandler = () => {
if (!items.value) return
selectedIndex.value = (selectedIndex.value + 1) % items.value.length
}
const selectItem = (index: number) => {
if (!items.value) return
const item = items.value[index]
if (item) {
props.command({ id: item.id, label: item.name })
}
}
const enterHandler = () => {
if (!items.value) return
selectItem(selectedIndex.value)
}
const onKeyDown = (params: SuggestionKeyDownProps) => {
const { event } = params
if (event.key === 'ArrowUp') {
upHandler()
return true
}
if (event.key === 'ArrowDown') {
downHandler()
return true
}
if (event.key === 'Enter') {
enterHandler()
return true
}
return false
}
watch(
() => props.items,
() => (selectedIndex.value = 0)
)
defineExpose({
onKeyDown
})
</script>
@@ -0,0 +1,27 @@
<!-- eslint-disable vuejs-accessibility/click-events-have-key-events -->
<template>
<a
:class="[
'flex flex-col p-2 cursor-pointer',
isSelected ? 'bg-foundation-2' : 'hover:bg-foundation-3'
]"
@click="($event) => $emit('click', $event)"
>
<span class="normal font-bold truncate">{{ item.name }}</span>
<span v-if="item.company" class="label label--light truncate">
{{ item.company }}
</span>
</a>
</template>
<script setup lang="ts">
import { SuggestionOptionsItem } from '~~/lib/core/tiptap/mentionExtension'
defineEmits<{
(e: 'click', val: MouseEvent): void
}>()
defineProps<{
item: SuggestionOptionsItem
isSelected?: boolean
}>()
</script>
@@ -0,0 +1,188 @@
<template>
<div
:class="['text-editor flex flex-col', !!readonly ? 'text-editor--read-only' : '']"
>
<EditorContent
ref="editorContentRef"
class="simple-scrollbar"
:editor="editor"
:style="maxHeight ? `max-height: ${maxHeight}; overflow-y: auto;` : ''"
@click="onEditorContentClick"
/>
<div v-if="$slots.actions && !readonly">
<slot name="actions" />
</div>
</div>
</template>
<script setup lang="ts">
import { EditorContent, Editor } from '@tiptap/vue-3'
import { JSONContent } from '@tiptap/core'
import {
EnterKeypressTrackerExtensionStorage,
getEditorExtensions,
TiptapEditorSchemaOptions
} from '~~/lib/common/helpers/tiptap'
import { Nullable } from '@speckle/shared'
import { userProfileRoute } from '~~/lib/common/helpers/route'
import { onKeyDown } from '@vueuse/core'
const emit = defineEmits<{
(e: 'update:modelValue', val: JSONContent): void
(e: 'submit', val: { data: JSONContent }): void
(e: 'created'): void
}>()
const props = defineProps<{
modelValue?: JSONContent | null
schemaOptions?: TiptapEditorSchemaOptions
maxHeight?: string
autofocus?: boolean
disabled?: boolean
placeholder?: string
readonly?: boolean
/**
* Used to invite users to project when their emails are mentioned
*/
projectId?: string
/**
* Disable invitation CTA, e.g. if user doesn't have the required accesses
*/
disableInvitationCta?: boolean
}>()
const editorContentRef = ref(null as Nullable<HTMLElement>)
const isMultiLine = computed(() => !!props.schemaOptions?.multiLine)
const isEditable = computed(() => !props.disabled && !props.readonly)
const hasEnterTracking = computed(() => !props.readonly && !isMultiLine.value)
const editor = new Editor({
content: props.modelValue,
autofocus: props.autofocus,
editable: isEditable.value,
extensions: getEditorExtensions(props.schemaOptions, {
placeholder: props.placeholder,
projectId:
props.projectId && !props.disableInvitationCta ? props.projectId : undefined
}),
onUpdate: () => {
const data = getData()
if (!data || Object.keys(data).length < 1) return
emit('update:modelValue', data)
},
onCreate: () => {
emit('created')
}
})
const enterKeypressTracker = editor.storage
.enterKeypressTracker as EnterKeypressTrackerExtensionStorage
const getData = (): JSONContent => editor.getJSON()
const onEnter = () => {
if (isMultiLine.value || props.readonly) return
emit('submit', { data: getData() })
}
const onEditorContentClick = (e: MouseEvent) => {
const closestSelectorTarget = (e.target as HTMLElement).closest(
'.editor-mention'
) as Nullable<HTMLElement>
if (!closestSelectorTarget) return
onMentionClick(closestSelectorTarget.dataset.id as string, e)
e.stopPropagation()
}
const onMentionClick = (userId: string, e: MouseEvent) => {
if (!props.readonly) return
const path = userProfileRoute(userId)
const isMetaKey = e.metaKey || e.ctrlKey
if (isMetaKey) {
window.open(path, '_blank')
} else {
window.location.href = path
}
}
onKeyDown(
'Escape',
(e) => {
// TipTap handles Escape, we don't want this to bubble up and close the thread
e.stopImmediatePropagation()
e.stopPropagation()
},
{ target: editorContentRef }
)
watch(
() => hasEnterTracking.value,
(hasEnterTracking) => {
if (hasEnterTracking) {
enterKeypressTracker.subscribe(editor, onEnter)
} else {
enterKeypressTracker.unsubscribe(editor, onEnter)
}
},
{ immediate: true }
)
watch(
() => props.modelValue,
(newVal) => {
const isSame = JSON.stringify(newVal) === JSON.stringify(getData())
if (isSame) return
editor.commands.setContent(newVal || '')
}
)
watch(
() => isEditable.value,
(isEditable) => {
editor.setEditable(isEditable)
}
)
onBeforeUnmount(() => {
editor.destroy()
enterKeypressTracker.unsubscribe(editor, onEnter)
})
</script>
<style lang="postcss">
/* stylelint-disable selector-class-pattern */
.ProseMirror-focused {
outline: none;
}
.ProseMirror {
& p:last-of-type {
margin-bottom: 0;
}
& p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
pointer-events: none;
height: 0;
@apply text-foreground-disabled;
}
& .editor-mention {
box-decoration-break: clone;
@apply border-foreground border;
@apply label label--light rounded inline-block px-1 py-[0.5px];
}
}
.text-editor {
&--read-only {
word-break: break-word;
background-color: unset !important;
box-shadow: unset !important;
.editor-mention {
cursor: pointer;
}
}
}
</style>
@@ -0,0 +1,74 @@
<template>
<div class="bg-foundation p-4 rounded-lg group shadow hover:shadow-md transition">
<div class="flex flex-col justify-between space-y-4">
<div class="text-xs flex items-center space-x-2">
<ShieldCheckIcon
v-if="!tag.isCommunity"
v-tippy="'Made by Speckle'"
class="w-4 h-4 text-primary"
/>
<GlobeEuropeAfricaIcon
v-else
v-tippy="`Contributed by ${tag.communityProvider}`"
class="w-4 h-4 text-foreground-2"
/>
<span
v-if="lastUpdated"
class="bg-primary-muted text-primary rounded-full px-2 py-1 -ml-1"
>
updated {{ lastUpdated }}
</span>
</div>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center space-x-1">
<div class="font-bold truncate text-foreground">{{ tag.name }}</div>
<span
v-if="lastUpdated"
class="text-xs bg-primary-muted text-primary rounded-full px-2 py-1 -ml-1 truncate"
>
{{ tag.stable ? tag.stable : tag.versions[0].Number }}
</span>
</div>
</div>
<div>
<img :src="tag.feature_image" alt="featured image" class="w-12" />
</div>
</div>
<div class="flex space-x-2 items-center justify-end">
<div
class="xxx-opacity-0 group-hover:opacity-100 transition flex items-center justify-between w-full"
>
<FormButton
v-if="tag.directDownload"
size="xs"
text
@click="dialogOpen = true"
>
Downloads
</FormButton>
<ConnectorsVersionsDownloadDialog v-model:open="dialogOpen" :tag="tag" />
<FormButton size="sm" :to="tag.url" target="_blank">Tutorials</FormButton>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import dayjs from 'dayjs'
import { ShieldCheckIcon, GlobeEuropeAfricaIcon } from '@heroicons/vue/24/solid'
import { ConnectorTag } from '~~/lib/connectors'
const props = defineProps<{
tag: ConnectorTag
}>()
const dialogOpen = ref(false)
const lastUpdated = computed(() =>
props.tag.versions?.length > 0
? dayjs(props.tag.versions[0].Date).from(dayjs())
: undefined
)
</script>
@@ -0,0 +1,122 @@
<template>
<LayoutDialog v-model:open="isOpen" max-width="md">
<div class="flex flex-col space-y-2">
<div class="h4 font-bold flex items-center space-x-2">
<img :src="tag.feature_image" alt="featured image" class="w-12" />
<span>{{ tag.name }}</span>
</div>
<div class="text-foreground-2">
{{ tag.description }}
</div>
<div class="flex flex-col space-y-2">
<FormButton
v-if="latestStableVersions.win"
full-width
@click="downloadVersion(latestStableVersions.win as ConnectorVersion)"
>
Download Latest Stable ({{ latestStableVersions.win.Number }}) Windows
</FormButton>
<FormButton
v-if="latestStableVersions.mac"
full-width
text
@click="downloadVersion(latestStableVersions.mac as ConnectorVersion)"
>
Download Latest Stable ({{ latestStableVersions.mac.Number }}) Mac OS
</FormButton>
</div>
<div class="flex items-center py-2 space-x-2">
<div class="h6 font-bold">All releases</div>
<div class="grow">
<FormTextInput
v-model="searchString"
name="search"
:custom-icon="MagnifyingGlassIcon"
class="w-full"
search
:show-clear="!!searchString"
placeholder="Search for a version"
/>
</div>
</div>
<div class="h-40 simple-scrollbar overflow-y-scroll space-y-2">
<div
v-for="version in searchedVersions"
:key="version.Number"
class="flex justify-between text-sm"
>
<div class="space-x-2">
<span>{{ version.Number }}</span>
<span class="text-foreground-2">
{{ dayjs(version.Date).from(dayjs()) }}
</span>
</div>
<div class="px-4">
<FormButton size="sm" text @click="downloadVersion(version)">
<span class="text-xs font-bold">
{{ version.Os === 0 ? 'Windows' : 'MacOS' }}
</span>
<CloudArrowDownIcon class="w-4 h-4" />
</FormButton>
</div>
</div>
<div v-if="searchedVersions.length === 0">No versions found.</div>
</div>
</div>
</LayoutDialog>
</template>
<script setup lang="ts">
import dayjs from 'dayjs'
import { CloudArrowDownIcon, MagnifyingGlassIcon } from '@heroicons/vue/24/solid'
import { ConnectorTag, ConnectorVersion } from '~~/lib/connectors'
const emit = defineEmits<{
(e: 'update:open', v: boolean): void
}>()
const props = defineProps<{
open: boolean
tag: ConnectorTag
}>()
const searchString = ref<string>()
const versions = computed(() => props.tag.versions)
const searchedVersions = computed(() =>
searchString.value
? versions.value.filter((v) =>
v.Number.toLowerCase().includes((searchString.value as string).toLowerCase())
)
: versions.value
)
const latestStableVersions = computed(() => {
const latest = versions.value.find((v) => !v.Prerelease)
const allLatest = versions.value.filter((v) => v.Number === latest?.Number)
return {
win: allLatest.find((v) => v.Os === 0),
mac: allLatest.find((v) => v.Os === 1)
}
})
const isOpen = computed({
get: () => props.open,
set: (newVal) => emit('update:open', newVal)
})
const downloadVersion = (version: ConnectorVersion) => {
// TODO
const splittedName = version.Url.split('/')
const a = document.createElement('a')
document.body.appendChild(a)
a.style.display = 'none'
a.href = version.Url
a.download = splittedName[splittedName.length - 1]
a.click()
document.body.removeChild(a)
// TODO: mixpanel
}
</script>
@@ -0,0 +1,43 @@
<template>
<NuxtErrorBoundary @error="onError">
<ProjectsInviteBanner
v-if="invite"
:invite="invite"
:show-stream-name="false"
@processed="onProcessed"
/>
</NuxtErrorBoundary>
</template>
<script setup lang="ts">
import { Optional } from '@speckle/shared'
import { useQuery } from '@vue/apollo-composable'
import { useNavigateToProject } from '~~/lib/common/helpers/route'
import { projectInviteQuery } from '~~/lib/projects/graphql/queries'
const route = useRoute()
const goToProject = useNavigateToProject()
const token = computed(() => route.query.token as Optional<string>)
const projectId = computed(() => route.params.id as Optional<string>)
const { result } = useQuery(
projectInviteQuery,
() => ({
projectId: projectId.value || '',
token: token.value
}),
() => ({ enabled: !!projectId.value })
)
const invite = computed(() => result.value?.projectInvite)
const onError = (err: unknown) => console.error(err)
const onProcessed = (val: { accepted: boolean }) => {
const { accepted } = val
if (accepted && projectId.value && process.client) {
goToProject({ id: projectId.value })
}
}
</script>
@@ -0,0 +1,265 @@
import { userEvent, within } from '@storybook/testing-library'
import FormButton from '~~/components/form/Button.vue'
import { StoryObj, Meta } from '@storybook/vue3'
import { wait } from '@speckle/shared'
import { VuePlayFunction, mergeStories } from '~~/lib/common/helpers/storybook'
import { XMarkIcon } from '@heroicons/vue/24/solid'
export default {
component: FormButton,
argTypes: {
color: {
options: ['default', 'invert', 'danger', 'warning', 'secondary'],
control: { type: 'select' }
},
outlined: {
type: 'boolean'
},
rounded: {
type: 'boolean'
},
to: {
type: 'string'
},
default: {
type: 'string',
description: 'Default slot holds button contents'
},
click: {
action: 'click',
type: 'function'
},
size: {
options: ['xs', 'sm', 'base', 'lg', 'xl'],
control: { type: 'select' }
},
fullWidth: {
type: 'boolean'
},
external: {
type: 'boolean'
},
disabled: {
type: 'boolean'
},
submit: {
type: 'boolean'
},
iconLeft: {
type: 'function'
},
iconRight: {
type: 'function'
}
},
parameters: {
docs: {
description: {
component: 'A standard button to be used anywhere you need any kind of button'
}
}
}
} as Meta
const clickPlayBuilder: (rightClick: boolean) => VuePlayFunction =
(rightClick) =>
async ({ canvasElement }) => {
const canvas = within(canvasElement)
const button = canvas.getByRole('button')
userEvent.click(button, rightClick ? { button: 2 } : undefined)
await wait(1000)
userEvent.tab()
}
const rightClickPlay = clickPlayBuilder(true)
const leftClickPlay = clickPlayBuilder(false)
export const Default: StoryObj = {
render: (args) => ({
components: { FormButton },
setup() {
return { args }
},
template: `<form-button v-bind="args" @click="args.click">{{ args.default || 'Submit' }}</form-button>`
}),
play: rightClickPlay,
args: {
target: '_blank',
to: 'https://google.com',
default: 'Button text',
size: 'base',
type: 'standard',
fullWidth: false,
outlined: false,
rounded: false,
text: false,
link: false,
color: 'default',
disabled: false,
submit: false,
hideText: false
},
parameters: {
docs: {
source: {
code: '<FormButton to="/">Hello World!</FormButton>'
}
}
}
}
export const Rounded: StoryObj = mergeStories(Default, {
args: {
rounded: true
}
})
export const WarningButton: StoryObj = mergeStories(Default, {
args: {
color: 'warning'
}
})
export const RoundedOutlined: StoryObj = mergeStories(Default, {
args: {
rounded: true,
outlined: true
}
})
export const Outline: StoryObj = mergeStories(Default, {
args: {
outlined: true
}
})
export const Link: StoryObj = mergeStories(Default, {
args: {
link: true
},
parameters: {
docs: {
description: {
story: 'Basically just a link (CommonTextLink is an alias for this)'
}
}
}
})
export const Text: StoryObj = mergeStories(Default, {
args: {
text: true
}
})
export const Small: StoryObj = mergeStories(Default, {
args: {
size: 'sm'
}
})
export const ExtraSmall: StoryObj = mergeStories(Default, {
args: {
size: 'xs'
}
})
export const Large: StoryObj = mergeStories(Default, {
args: {
size: 'lg'
}
})
export const ExtraLarge: StoryObj = mergeStories(Default, {
args: {
size: 'xl'
}
})
export const Disabled: StoryObj = mergeStories(Default, {
play: leftClickPlay,
args: {
disabled: true
},
parameters: {
docs: {
description: {
story: 'Button is disabled and no mouse events fire'
}
}
}
})
export const NoTarget: StoryObj = mergeStories(Default, {
play: leftClickPlay,
args: {
to: null,
default: 'No URL, only for tracking click events'
}
})
export const FullWidth: StoryObj = mergeStories(Default, {
args: {
fullWidth: true,
default: 'Full width button'
}
})
export const External: StoryObj = mergeStories(Default, {
args: {
external: true,
to: '/',
default: 'External link'
},
parameters: {
docs: {
description: {
story: 'Forces target to be treated as an external link'
}
}
}
})
export const Submit: StoryObj = mergeStories(Default, {
play: leftClickPlay,
args: {
to: null,
submit: true,
default: 'Submit button'
},
parameters: {
docs: {
description: {
story: 'Rendered as button w/ type=submit, which will submit any parent forms'
}
}
}
})
export const LeftIcon: StoryObj = mergeStories(Default, {
args: {
iconLeft: XMarkIcon
}
})
export const RightIcon: StoryObj = mergeStories(Default, {
args: {
iconRight: XMarkIcon
}
})
export const IconOnBothSides: StoryObj = mergeStories(Default, {
args: {
iconRight: XMarkIcon,
iconLeft: XMarkIcon
}
})
export const IconOnly: StoryObj = mergeStories(Default, {
args: {
iconLeft: XMarkIcon,
hideText: true
}
})
@@ -0,0 +1,441 @@
<template>
<Component
:is="to ? NuxtLink : 'button'"
:to="to"
:type="buttonType"
:external="external"
:class="buttonClasses"
:disabled="disabled"
role="button"
@click="onClick"
>
<Component
:is="iconLeft"
v-if="iconLeft"
:class="`${iconClasses} ${hideText ? '' : 'mr-2'}`"
/>
<slot v-if="!hideText">Button</slot>
<div v-else style="margin: 0 !important; width: 0.01px">
&nbsp;
<!-- The point of this is to ensure text & no-text buttons have the same height -->
</div>
<Component
:is="iconRight"
v-if="iconRight"
:class="`${iconClasses} ${hideText ? '' : 'ml-2'}`"
/>
</Component>
</template>
<script setup lang="ts">
import { ConcreteComponent, PropType } from 'vue'
import { Nullable, Optional } from '@speckle/shared'
type FormButtonSize = 'xs' | 'sm' | 'base' | 'lg' | 'xl'
type FormButtonColor =
| 'default'
| 'invert'
| 'danger'
| 'warning'
| 'success'
| 'card'
| 'secondary'
const emit = defineEmits<{
/**
* Emit MouseEvent on click
*/
(e: 'click', val: MouseEvent): void
}>()
const props = defineProps({
/**
* URL to which to navigate - can be a relative (app) path or an absolute link for an external URL
*/
to: {
type: String as PropType<Optional<string>>,
required: false,
default: undefined
},
/**
* Choose from one of many button sizes
*/
size: {
type: String as PropType<FormButtonSize>,
default: 'base'
},
/**
* If set, will make the button take up all available space horizontally
*/
fullWidth: {
type: Boolean,
default: false
},
/**
* Will outline the button.
*/
outlined: {
type: Boolean,
default: false
},
/**
* Will apply a rounded class.
*/
rounded: {
type: Boolean,
default: false
},
/**
* Similar to "link", but without an underline and possibly in different colors
*/
text: {
type: Boolean,
default: false
},
/**
* Will remove paddings and background. Use for links.
*/
link: {
type: Boolean,
default: false
},
/**
* Colors:
* default: the default primary blue.
* invert: for when you want to use this button on a primary background.
* danger: for dangerous actions (e.g. deletions).
* warning: for less dangerous actions (e.g. archival).
*/
color: {
type: String as PropType<FormButtonColor>,
default: 'default'
},
/**
* Whether the target location should be forcefully treated as an external URL
* (for relative paths this will likely cause a redirect)
*/
external: {
type: Boolean as PropType<Optional<boolean>>,
required: false,
default: undefined
},
/**
* Whether to disable the button so that it can't be pressed
*/
disabled: {
type: Boolean as PropType<Optional<boolean>>,
required: false,
default: undefined
},
/**
* If set, will have type set to "submit" to enable it to submit any parent forms
*/
submit: {
type: Boolean,
default: false
},
/**
* Add icon to the left from the text
*/
iconLeft: {
type: [Object, Function] as PropType<Nullable<ConcreteComponent>>,
default: null
},
/**
* Add icon to the right from the text
*/
iconRight: {
type: [Object, Function] as PropType<Nullable<ConcreteComponent>>,
default: null
},
/**
* Hide default slot (when you want to show icons only)
*/
hideText: {
type: Boolean,
default: false
}
})
const NuxtLink = resolveComponent('NuxtLink')
const buttonType = computed(() => {
if (props.to) return undefined
if (props.submit) return 'submit'
return 'button'
})
const bgAndBorderClasses = computed(() => {
const classParts: string[] = []
classParts.push('border-2')
if (props.disabled) {
classParts.push(
props.outlined
? 'border-foreground-disabled'
: 'bg-foundation-disabled border-transparent'
)
} else {
switch (props.color) {
case 'invert':
classParts.push(
props.outlined
? 'border-foundation dark:border-foreground'
: 'bg-foundation dark:bg-foreground border-transparent'
)
break
case 'card':
classParts.push(
props.outlined
? 'border-foundation-2 shadow'
: 'bg-foundation-2 dark:bg-foundation-2 border-foundation shadow'
)
break
case 'danger':
classParts.push(props.outlined ? 'border-danger' : 'bg-danger border-danger')
break
case 'secondary':
classParts.push(
props.outlined ? 'border-foundation' : 'bg-foundation border-foundation-2'
)
break
case 'warning':
classParts.push(props.outlined ? 'border-warning' : 'bg-warning border-warning')
break
case 'success':
classParts.push(props.outlined ? 'border-success' : 'bg-success border-success')
break
case 'default':
default:
classParts.push(
props.outlined
? 'border-primary hover:border-primary-focus'
: 'bg-primary hover:bg-primary-focus border-transparent'
)
break
}
}
return classParts.join(' ')
})
const foregroundClasses = computed(() => {
const classParts: string[] = []
if (!props.text && !props.link) {
if (props.disabled) {
classParts.push(
props.outlined ? 'text-foreground-disabled' : 'text-foreground-disabled'
)
} else {
switch (props.color) {
case 'invert':
classParts.push(
props.outlined ? 'text-foundation dark:text-foreground' : 'text-primary'
)
break
case 'card':
classParts.push(props.outlined ? 'text-foreground' : 'text-foreground')
break
case 'danger':
classParts.push(
props.outlined ? 'text-danger' : 'text-foundation dark:text-foreground'
)
break
case 'warning':
classParts.push(
props.outlined ? 'text-warning' : 'text-foundation dark:text-foreground'
)
break
case 'success':
classParts.push(
props.outlined ? 'text-success' : 'text-foundation dark:text-foreground'
)
break
case 'secondary':
classParts.push(
props.outlined
? 'text-foreground hover:text-primary'
: 'text-foreground hover:text-primary'
)
break
case 'default':
default:
classParts.push(
props.outlined
? 'text-primary hover:text-primary-focus'
: 'text-foundation dark:text-foreground'
)
break
}
}
} else {
if (props.disabled) {
classParts.push('text-foreground-disabled')
} else {
if (props.color === 'invert') {
classParts.push(
'text-foundation hover:text-foundation-2 dark:text-foreground dark:hover:text-foreground'
)
} else if (props.color === 'secondary') {
classParts.push('text-foreground-2 hover:text-primary-focus')
} else if (props.color === 'success') {
classParts.push('text-success')
} else if (props.color === 'warning') {
classParts.push('text-warning')
} else if (props.color === 'danger') {
classParts.push('text-danger')
} else {
classParts.push('text-primary hover:text-primary-focus')
}
}
}
return classParts.join(' ')
})
const roundedClasses = computed(() => {
const classParts: string[] = []
classParts.push(props.rounded ? 'rounded-full' : 'rounded-md')
return classParts.join(' ')
})
const ringClasses = computed(() => {
const classParts: string[] = []
if (!props.disabled) {
switch (props.color) {
case 'invert':
classParts.push('hover:ring-4 ring-white/50')
break
case 'danger':
classParts.push('hover:ring-4 ring-danger-lighter dark:ring-danger-darker')
break
case 'warning':
classParts.push('hover:ring-4 ring-warning-lighter dark:ring-warning-darker')
break
case 'success':
classParts.push('hover:ring-4 ring-success-lighter dark:ring-success-darker')
break
case 'default':
default:
classParts.push('hover:ring-2')
break
}
}
return classParts.join(' ')
})
const sizeClasses = computed(() => {
switch (props.size) {
case 'xs':
return 'h-5 text-xs font-medium xxx-tracking-wide'
case 'sm':
return 'h-6 text-sm font-medium xxx-tracking-wide'
case 'lg':
return 'h-10 text-lg font-semibold xxx-tracking-wide'
case 'xl':
return 'h-14 text-xl font-bold xxx-tracking-wide'
default:
case 'base':
return 'h-8 text-base font-medium xxx-tracking-wide'
}
})
const paddingClasses = computed(() => {
switch (props.size) {
case 'xs':
return 'px-1'
case 'sm':
return 'px-2'
case 'lg':
return 'px-4'
case 'xl':
return 'px-5'
default:
case 'base':
return 'px-3'
}
})
const generalClasses = computed(() => {
const classParts: string[] = []
if (props.fullWidth) {
classParts.push('w-full')
}
if (props.disabled) {
classParts.push('cursor-not-allowed')
}
return classParts.join(' ')
})
const decoratorClasses = computed(() => {
const classParts: string[] = []
if (!props.disabled && !props.link && !props.text) {
classParts.push('active:scale-[0.97]')
}
if (!props.disabled && props.link) {
classParts.push(
'underline decoration-transparent decoration-2 underline-offset-4 hover:decoration-inherit'
)
}
return classParts.join(' ')
})
const buttonClasses = computed(() => {
const isLinkOrText = props.link || props.text
return [
'transition inline-flex justify-center items-center space-x-2 outline-none select-none',
generalClasses.value,
sizeClasses.value,
foregroundClasses.value,
isLinkOrText ? '' : bgAndBorderClasses.value,
isLinkOrText ? '' : roundedClasses.value,
isLinkOrText ? '' : ringClasses.value,
props.link ? '' : paddingClasses.value,
decoratorClasses.value
].join(' ')
})
const iconClasses = computed(() => {
const classParts: string[] = ['']
switch (props.size) {
case 'xs':
classParts.push('h-3 w-3')
break
case 'sm':
classParts.push('h-4 w-4')
break
case 'lg':
classParts.push('h-6 w-6')
break
case 'xl':
classParts.push('h-8 w-8')
break
case 'base':
default:
classParts.push('h-5 w-5')
break
}
return classParts.join(' ')
})
const onClick = (e: MouseEvent) => {
if (props.disabled) {
e.preventDefault()
e.stopPropagation()
e.stopImmediatePropagation()
return
}
emit('click', e)
}
</script>
<style scoped>
.icon-slot:empty {
display: none;
}
</style>
@@ -0,0 +1,87 @@
import { userEvent, within } from '@storybook/testing-library'
import { Meta, StoryObj } from '@storybook/vue3'
import { wait } from '@speckle/shared'
import FormCardButton from '~~/components/form/CardButton.vue'
import { VuePlayFunction } from '~~/lib/common/helpers/storybook'
type StoryType = StoryObj<
Record<string, unknown> & {
'update:modelValue': (val: boolean) => void
}
>
export default {
component: FormCardButton,
argTypes: {
default: {
type: 'string',
description: 'Default slot holds button contents'
},
click: {
action: 'click',
type: 'function'
},
'update:modelValue': {
action: 'update:modelValue',
type: 'function'
}
},
parameters: {
docs: {
description: {
component: 'A card button that supports a toggled/selected state'
}
}
}
} as Meta
const clickPlayBuilder: (rightClick?: boolean) => VuePlayFunction =
(rightClick) =>
async ({ canvasElement }) => {
const canvas = within(canvasElement)
userEvent.click(canvas.getByRole('button'), rightClick ? { button: 2 } : undefined)
await wait(500)
userEvent.click(canvas.getByRole('button'), rightClick ? { button: 2 } : undefined)
userEvent.tab()
}
export const Default: StoryType = {
render: (args, ctx) => ({
components: { FormCardButton },
setup() {
return { args }
},
template: `<FormCardButton v-bind="args" @click="args.click" @update:modelValue="onModelUpdate">{{ args.default || 'Text' }}</FormCardButton>`,
methods: {
onModelUpdate(val: boolean) {
args['update:modelValue'](val)
ctx.updateArgs({ ...args, modelValue: val })
}
}
}),
play: clickPlayBuilder(),
args: {
default: 'Architecture',
disabled: false,
modelValue: false
}
}
export const Disabled: StoryType = {
...Default,
args: {
disabled: true
}
}
export const Selected: StoryType = {
...Default,
play: clickPlayBuilder(true),
args: {
modelValue: true
}
}
@@ -0,0 +1,48 @@
<template>
<button :class="computedClasses" :disabled="disabled" @click="onClick">
<slot>Text</slot>
</button>
</template>
<script setup lang="ts">
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
(e: 'click', v: MouseEvent): void
}>()
const props = defineProps<{
disabled?: boolean
modelValue?: boolean
}>()
const computedClasses = computed(() => {
const classParts: string[] = [
'h-20 bg-foundation-2 inline-flex justify-center items-center outline-none',
'normal px-16 py-5 shadow rounded transition active:scale-95'
]
if (props.disabled) {
classParts.push('bg-foundation-disabled text-foreground-2 cursor-not-allowed')
} else {
classParts.push(
props.modelValue
? 'bg-primary-focus text-foreground-on-primary'
: 'bg-foundation text-foreground'
)
classParts.push('ring-outline-2 hover:ring-4')
}
return classParts.join(' ')
})
const onClick = (e: MouseEvent) => {
if (props.disabled) {
e.preventDefault()
e.stopPropagation()
e.stopImmediatePropagation()
return
}
emit('update:modelValue', !props.modelValue)
emit('click', e)
}
</script>
@@ -0,0 +1,176 @@
import FormCheckbox from '~~/components/form/Checkbox.vue'
import FormButton from '~~/components/form/Button.vue'
import { Meta, StoryObj } from '@storybook/vue3'
import { action } from '@storybook/addon-actions'
import { Form, SubmissionHandler } from 'vee-validate'
import { VuePlayFunction } from '~~/lib/common/helpers/storybook'
import { userEvent, within } from '@storybook/testing-library'
import { wait } from '@speckle/shared'
import { expect } from '@storybook/jest'
export default {
component: FormCheckbox,
parameters: {
docs: {
description: {
component:
'A checkbox, integrated with vee-validate for validation. Feed in rules through the `rules` prop. A checkbox can exist on its own or as part of a group. Checkboxes are grouped if they have the same name and have a parent form. The value structure differs between grouped and ungrouped checkboxes. If a checkbox is grouped, its v-model value will be an array of all values of all checked checkboxes in the group. Otherwise, its v-model value will either be its value if its checked or undefined if it isnt.'
}
}
},
argTypes: {
value: {
type: 'string'
},
rules: {
type: 'function'
},
'update:modelValue': {
type: 'function',
action: 'v-model'
},
id: {
type: 'string'
}
}
} as Meta
const toggleCheckboxPlayFunction: VuePlayFunction = async (params) => {
const canvas = within(params.canvasElement)
const checkbox = canvas.getByRole('checkbox')
userEvent.click(checkbox)
await wait(1000)
userEvent.click(checkbox)
}
export const Default: StoryObj = {
play: toggleCheckboxPlayFunction,
render: (args) => ({
components: { FormCheckbox },
setup() {
const vModelAction = action('v-model')
return { args, vModelAction }
},
template: `<FormCheckbox v-bind="args" @update:modelValue="vModelAction"/>`
}),
parameters: {
docs: {
source: {
code: `<FormCheckbox name="group-name" value="checkbox-id" v-model="val" />`
}
}
},
args: {
name: 'test1',
label: 'Example Checkbox',
description: 'Some help description here',
showRequired: false,
validateOnMount: false,
inlineDescription: false,
value: 'test1',
disabled: false
}
}
export const Group: StoryObj = {
render: (args) => ({
components: { FormCheckbox, Form, FormButton },
setup() {
const fooModelValueHandler = action('foo@update:modelValue')
const barModelValueHandler = action('bar@update:modelValue')
const onSubmit: SubmissionHandler = (values) => action('onSubmit')(values)
return { fooModelValueHandler, barModelValueHandler, onSubmit, args }
},
template: `
<Form @submit="onSubmit">
<FormCheckbox name="group1" value="foo" label="foo" @update:modelValue="fooModelValueHandler" alt="foo"/>
<FormCheckbox name="group1" value="bar" label="bar" @update:modelValue="barModelValueHandler" alt="bar"/>
<FormButton submit>Submit</FormButton>
</Form>
`
}),
play: async (params) => {
const smallDelay = 500
const bigDelay = 1000
const canvas = within(params.canvasElement)
const fooCheckbox = canvas.getByAltText('foo')
const barCheckbox = canvas.getByAltText('bar')
const button = canvas.getByRole('button')
userEvent.click(fooCheckbox)
await wait(smallDelay)
userEvent.click(button)
await wait(bigDelay)
userEvent.click(barCheckbox)
await wait(smallDelay)
userEvent.click(button)
await wait(bigDelay)
userEvent.click(fooCheckbox)
await wait(smallDelay)
userEvent.click(barCheckbox)
await wait(smallDelay)
userEvent.click(button)
},
parameters: {
docs: {
description: {
story:
'Checkboxes with the same name are part of a group and on form submit the value will be an array of all selected values. Check actions of this story for an example!'
}
}
}
}
export const InlineDescription: StoryObj = {
...Default,
args: {
name: 'inline1',
value: 'inline1-value',
inlineDescription: true,
label: 'Example checkbox',
description: 'This is an inline description'
}
}
export const Disabled: StoryObj = {
...Default,
play: (params) => {
const canvas = within(params.canvasElement)
const checkbox = canvas.getByRole('checkbox')
const isChecked = (checkbox as HTMLInputElement).checked
// click and assert that checkbox checked state hasn't changed
userEvent.click(checkbox)
const newIsChecked = (checkbox as HTMLInputElement).checked
expect(isChecked).toBe(newIsChecked)
},
args: {
name: 'disabled1',
value: 'disabled1-value',
label: 'Disabled checkbox',
disabled: true
}
}
export const Required: StoryObj = {
...Default,
args: {
name: 'required1',
value: 'required1-value',
label: 'Required checkbox',
showRequired: true,
rules: [(val: string | string[]) => (val ? true : 'This field is required')],
validateOnMount: true
}
}
@@ -0,0 +1,194 @@
<template>
<div class="relative flex items-start">
<div class="flex h-6 items-center">
<!-- eslint-disable-next-line vuejs-accessibility/form-control-has-label -->
<input
:id="finalId"
:checked="finalChecked"
:aria-describedby="descriptionId"
:name="name"
:value="checkboxValue"
:disabled="disabled"
type="checkbox"
class="h-4 w-4 rounded text-primary focus:ring-primary bg-foundation disabled:cursor-not-allowed disabled:bg-disabled disabled:text-disabled-2"
:class="computedClasses"
v-bind="$attrs"
@change="onChange"
/>
</div>
<div class="ml-2 text-sm" style="padding-top: 2px">
<label
:for="finalId"
class="font-medium text-foreground"
:class="{ 'sr-only': hideLabel }"
>
<span>{{ title }}</span>
<span v-if="showRequired" class="text-danger ml-1">*</span>
</label>
<p v-if="descriptionText" :id="descriptionId" :class="descriptionClasses">
{{ descriptionText }}
</p>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { nanoid } from 'nanoid'
export default defineComponent({
inheritAttrs: false
})
</script>
<script setup lang="ts">
import { RuleExpression, useField } from 'vee-validate'
import { PropType } from 'vue'
import { Optional } from '@speckle/shared'
/**
* Troubleshooting:
* - If clicking on the checkbox doesn't do anything, check if any of its ancestor elements
* have a @click.prevent on them anywhere.
* - If you're not using the checkbox in a group, it's suggested that you set :value="true",
* so that a v-model attached to the checkbox will be either 'true' or 'undefined' depending on the
* checked state
*/
type ValueType = Optional<string | true> | string[]
const props = defineProps({
/**
* Input name/id. In a checkbox group, all checkboxes must have the same name and different values.
*/
name: {
type: String,
required: true
},
/**
* Whether the input is disabled
*/
disabled: {
type: Boolean,
default: false
},
/**
* Set label text
*/
label: {
type: String as PropType<Optional<string>>,
default: undefined
},
/**
* Help text
*/
description: {
type: String as PropType<Optional<string>>,
default: undefined
},
/**
* Whether to inline the help description
*/
inlineDescription: {
type: Boolean,
default: false
},
/**
* vee-validate validation rules
*/
rules: {
type: [String, Object, Function, Array] as PropType<RuleExpression<ValueType>>,
default: undefined
},
/**
* vee-validate validation() on component mount
*/
validateOnMount: {
type: Boolean,
default: false
},
/**
* Whether to show the red "required" asterisk
*/
showRequired: {
type: Boolean,
default: false
},
/**
* Checkbox group's value
*/
modelValue: {
type: [String, Boolean] as PropType<ValueType | false>,
default: undefined
},
/**
* Checkbox's own value. If it is checked, modelValue will include this value (amongst any other checked values from the same group).
* If not set will default to 'name' value.
*/
value: {
type: [String, Boolean] as PropType<Optional<string | true>>,
default: true
},
/**
* HTML ID to use, must be globally unique. If not specified, a random ID will be generated. One is necessary to properly associate the label and checkbox.
*/
id: {
type: String as PropType<Optional<string>>,
default: undefined
},
hideLabel: {
type: Boolean,
default: false
}
})
const generateRandomId = (prefix: string) => `${prefix}-${nanoid()}`
defineEmits<{
(e: 'update:modelValue', val: ValueType): void
}>()
const checkboxValue = computed(() => props.value || props.name)
const {
checked: finalChecked,
errorMessage,
handleChange
} = useField<ValueType>(props.name, props.rules, {
validateOnMount: props.validateOnMount,
type: 'checkbox',
checkedValue: checkboxValue,
initialValue: props.modelValue || undefined
})
const onChange = (e: unknown) => {
if (props.disabled) return
handleChange(e)
}
const title = computed(() => props.label || props.name)
const computedClasses = computed((): string => {
return errorMessage.value ? 'border-danger-lighter' : 'border-foreground-4 '
})
const descriptionText = computed(() => props.description || errorMessage.value)
const descriptionId = computed(() => `${props.name}-description`)
const descriptionClasses = computed((): string => {
const classParts: string[] = []
if (props.inlineDescription) {
classParts.push('inline ml-2')
} else {
classParts.push('block')
}
if (errorMessage.value) {
classParts.push('text-danger')
} else {
classParts.push('text-foreground-2')
}
return classParts.join(' ')
})
const implicitId = ref<Optional<string>>(generateRandomId('checkbox'))
const finalId = computed(() => props.id || implicitId.value)
</script>
@@ -0,0 +1,115 @@
import { userEvent, within } from '@storybook/testing-library'
import FormTextArea from '~~/components/form/TextArea.vue'
import { StoryObj, Meta } from '@storybook/vue3'
import { VuePlayFunction, mergeStories } from '~~/lib/common/helpers/storybook'
import { wait } from '@speckle/shared'
export default {
component: FormTextArea,
parameters: {
docs: {
description: {
component:
'A textarea box, integrated with vee-validate for validation. Feed in rules through the `rules` prop.'
}
}
},
argTypes: {
rules: {
type: 'function'
},
'update:modelValue': {
type: 'function',
action: 'v-model'
}
}
} as Meta
const generateRandomName = (prefix: string) => `${prefix}-${Math.random() * 100000}`
const buildTextWriterPlayFunction =
(text: string): VuePlayFunction =>
async (params) => {
const { canvasElement, viewMode } = params
const canvas = within(canvasElement)
const input = canvas.getByRole('textbox')
// https://github.com/storybookjs/storybook/pull/19659
await userEvent.type(input, text, { delay: viewMode === 'story' ? 100 : 0 })
userEvent.tab()
}
export const Default: StoryObj = {
render: (args) => ({
components: { FormTextArea },
setup() {
return { args }
},
template: `<div class="bg-foundation p-5">
<form-text-area v-bind="args" @update:modelValue="args['update:modelValue']"/>
</div>`
}),
play: buildTextWriterPlayFunction('Hello world!'),
args: {
modelValue: '',
name: generateRandomName('default'),
help: 'Some help text',
placeholder: 'Placeholder text!',
label: 'Textarea w/ Label:',
showRequired: false,
showLabel: true,
disabled: false,
validateOnMount: false
},
parameters: {
docs: {
source: {
code: `<FormTextArea name="unique-id" v-model="model" :rules="(val) => val ? true : 'Value is required!'"/>`
}
}
}
}
export const Required = mergeStories(Default, {
play: async (params) => {
const { canvasElement } = params
await buildTextWriterPlayFunction('some text')(params)
await wait(1000)
const canvas = within(canvasElement)
const input = canvas.getByRole('textbox')
userEvent.clear(input)
await wait(1000)
userEvent.tab()
},
args: {
name: generateRandomName('required'),
label: 'This one is required!',
showRequired: true,
rules: [(val: string) => (val ? true : 'This field is required')],
validateOnMount: true
}
})
export const Disabled = mergeStories(Default, {
play: buildTextWriterPlayFunction('12345'),
args: {
name: generateRandomName('disabled'),
label: 'Disabled input',
disabled: true
}
})
export const WithClear = mergeStories(Default, {
play: buildTextWriterPlayFunction('12345'),
args: {
name: generateRandomName('withclear'),
label: 'Click on cross to clear',
showClear: true
}
})
@@ -0,0 +1,129 @@
<template>
<div :class="[fullWidth ? 'w-full' : '']">
<label :for="name" :class="labelClasses">
<span>{{ title }}</span>
</label>
<div class="relative">
<textarea
:id="name"
ref="inputElement"
v-model="value"
:name="name"
:class="[coreClasses, iconClasses, 'min-h-[4rem]']"
:placeholder="placeholder"
:disabled="disabled"
:aria-invalid="errorMessage ? 'true' : 'false'"
:aria-describedby="helpTipId"
v-bind="$attrs"
@change="$emit('change', { event: $event, value })"
@input="$emit('input', { event: $event, value })"
/>
<a
v-if="showClear"
title="Clear input"
class="absolute top-2 right-0 flex items-center pr-2 cursor-pointer"
@click="clear"
@keydown="clear"
>
<span class="text-xs sr-only">Clear input</span>
<XMarkIcon class="h-5 w-5 text-foreground" aria-hidden="true" />
</a>
<div
v-if="errorMessage"
:class="[
'pointer-events-none absolute inset-y-0 right-0 flex items-center',
showClear ? 'pr-8' : 'pr-2'
]"
>
<ExclamationCircleIcon class="h-4 w-4 text-danger" aria-hidden="true" />
</div>
<div
v-if="showRequired && !errorMessage"
class="pointer-events-none absolute inset-y-0 mt-3 text-4xl right-0 flex items-center pr-2 text-danger opacity-50"
>
*
</div>
</div>
<p
v-if="helpTipId"
:id="helpTipId"
class="mt-2 ml-3 text-sm"
:class="helpTipClasses"
>
{{ helpTip }}
</p>
</div>
</template>
<script setup lang="ts">
import { ExclamationCircleIcon, XMarkIcon } from '@heroicons/vue/20/solid'
import { Nullable } from '@speckle/shared'
import { RuleExpression } from 'vee-validate'
import { useTextInputCore } from '~~/lib/form/composables/textInput'
const emit = defineEmits<{
(e: 'update:modelValue', val: string): void
(e: 'change', val: { event?: Event; value: string }): void
(e: 'input', val: { event?: Event; value: string }): void
(e: 'clear'): void
}>()
const props = withDefaults(
defineProps<{
/**
* Unique ID for the input (must be unique page-wide)
*/
name: string
showLabel?: boolean
help?: string
placeholder?: string
label?: string
disabled?: boolean
rules?: RuleExpression<string>
validateOnMount?: boolean
validateOnValueUpdate?: boolean
useLabelInErrors?: boolean
autoFocus?: boolean
modelValue?: string
showClear?: boolean
fullWidth?: boolean
showRequired?: boolean
}>(),
{
useLabelInErrors: true,
modelValue: ''
}
)
const inputElement = ref(null as Nullable<HTMLTextAreaElement>)
const {
coreClasses,
title,
value,
helpTipId,
helpTipClasses,
helpTip,
errorMessage,
labelClasses,
clear,
focus
} = useTextInputCore({
props: toRefs(props),
emit,
inputEl: inputElement
})
const iconClasses = computed(() => {
const classParts: string[] = []
if (props.showClear && errorMessage.value) {
classParts.push('pr-12')
} else if (props.showClear || errorMessage.value) {
classParts.push('pr-8')
}
return classParts.join(' ')
})
defineExpose({ focus })
</script>
@@ -0,0 +1,164 @@
import { userEvent, within } from '@storybook/testing-library'
import FormTextInput from '~~/components/form/TextInput.vue'
import FormButton from '~~/components/form/Button.vue'
import { StoryObj, Meta } from '@storybook/vue3'
import { VuePlayFunction, mergeStories } from '~~/lib/common/helpers/storybook'
import { wait } from '@speckle/shared'
export default {
component: FormTextInput,
parameters: {
docs: {
description: {
component:
'A text input box, integrated with vee-validate for validation. Feed in rules through the `rules` prop.'
}
}
},
argTypes: {
type: {
options: ['text', 'email', 'password', 'url', 'search'],
control: { type: 'select' }
},
rules: {
type: 'function'
},
'update:modelValue': {
type: 'function',
action: 'v-model'
}
}
} as Meta
const generateRandomName = (prefix: string) => `${prefix}-${Math.random() * 100000}`
const buildTextWriterPlayFunction =
(text: string): VuePlayFunction =>
async (params) => {
const { canvasElement, viewMode } = params
const canvas = within(canvasElement)
const input = canvas.getByRole('textbox')
// https://github.com/storybookjs/storybook/pull/19659
await userEvent.type(input, text, { delay: viewMode === 'story' ? 100 : 0 })
userEvent.tab()
}
export const Default: StoryObj = {
render: (args) => ({
components: { FormTextInput },
setup() {
return { args }
},
template: `<div class="bg-foundation p-5">
<form-text-input v-bind="args" @update:modelValue="args['update:modelValue']"/>
</div>`
}),
play: buildTextWriterPlayFunction('Hello world!'),
args: {
modelValue: '',
type: 'text',
name: generateRandomName('default'),
help: 'Some help text',
placeholder: 'Placeholder text!',
label: 'Text input w/ Label:',
showRequired: false,
showLabel: true,
disabled: false,
validateOnMount: false,
inputClasses: ''
},
parameters: {
docs: {
source: {
code: `<FormTextInput name="unique-id" v-model="model" :rules="(val) => val ? true : 'Value is required!'"/>`
}
}
}
}
export const Email: StoryObj = mergeStories(Default, {
play: buildTextWriterPlayFunction('admin@example.com'),
args: {
type: 'email',
name: generateRandomName('email'),
label: 'E-mail'
}
})
export const Password = mergeStories(Default, {
play: buildTextWriterPlayFunction('qwerty'),
args: {
type: 'password',
name: generateRandomName('password'),
label: 'Password'
}
})
export const Required = mergeStories(Default, {
play: async (params) => {
const { canvasElement } = params
await buildTextWriterPlayFunction('some text')(params)
await wait(1000)
const canvas = within(canvasElement)
const input = canvas.getByRole('textbox')
userEvent.clear(input)
await wait(1000)
userEvent.tab()
},
args: {
name: generateRandomName('required'),
label: 'This one is required!',
showRequired: true,
rules: [(val: string) => (val ? true : 'This field is required')],
validateOnMount: true
}
})
export const Disabled = mergeStories(Default, {
play: buildTextWriterPlayFunction('12345'),
args: {
name: generateRandomName('disabled'),
label: 'Disabled input',
disabled: true
}
})
export const WithClear = mergeStories(Default, {
play: buildTextWriterPlayFunction('12345'),
args: {
name: generateRandomName('withclear'),
label: 'Click on cross to clear',
showClear: true
}
})
export const WithCustomRightSlot = mergeStories(Default, {
render: (args) => ({
components: { FormTextInput, FormButton },
setup() {
return { args }
},
template: `<div class="bg-foundation p-5">
<form-text-input v-bind="args" @update:modelValue="args['update:modelValue']">
<template #input-right>
<div class="absolute inset-y-0 right-0 flex items-center pr-2">
<form-button size="xs">Click me</form-button>
</div>
</template>
</form-text-input>
</div>`
}),
play: buildTextWriterPlayFunction('12345'),
args: {
name: generateRandomName('withcustomrightslot'),
label: 'Right side is customized with a button!',
inputClasses: 'pr-20'
}
})
@@ -0,0 +1,319 @@
<template>
<div :class="[fullWidth ? 'w-full' : '', wrapperClasses]">
<label :for="name" :class="labelClasses">
<span>{{ title }}</span>
</label>
<div class="relative">
<div
v-if="hasLeadingIcon"
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-2"
>
<Component
:is="customIcon"
v-if="customIcon"
:class="leadingIconClasses"
aria-hidden="true"
/>
<EnvelopeIcon
v-else-if="type === 'email'"
:class="leadingIconClasses"
aria-hidden="true"
/>
<KeyIcon
v-else-if="type === 'password'"
:class="leadingIconClasses"
aria-hidden="true"
/>
</div>
<input
:id="name"
ref="inputElement"
v-model="value"
:type="type"
:name="name"
:class="[coreClasses, iconClasses, sizeClasses, inputClasses || '']"
:placeholder="placeholder"
:disabled="disabled"
:aria-invalid="errorMessage ? 'true' : 'false'"
:aria-describedby="helpTipId"
role="textbox"
v-bind="$attrs"
@focusin=";(textInputGlobalFocus = true), $emit('focusin')"
@focusout=";(textInputGlobalFocus = false), $emit('focusout')"
@change="$emit('change', { event: $event, value })"
@input="$emit('input', { event: $event, value })"
/>
<slot name="input-right">
<a
v-if="showClear"
title="Clear input"
class="absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer"
@click="clear"
@keydown="clear"
>
<span class="text-xs sr-only">Clear input</span>
<XMarkIcon class="h-5 w-5 text-foreground" aria-hidden="true" />
</a>
<div
v-if="errorMessage"
:class="[
'pointer-events-none absolute inset-y-0 right-0 flex items-center',
showClear ? 'pr-8' : 'pr-2'
]"
>
<ExclamationCircleIcon class="h-4 w-4 text-danger" aria-hidden="true" />
</div>
<div
v-if="showRequired && !errorMessage"
class="pointer-events-none absolute inset-y-0 mt-3 text-4xl right-0 flex items-center pr-2 text-danger opacity-50"
>
*
</div>
</slot>
</div>
<p
v-if="helpTipId && !hideHelpTip"
:id="helpTipId"
class="mt-2 ml-3 text-sm"
:class="helpTipClasses"
>
{{ helpTip }}
</p>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { useTextInputCore } from '~~/lib/form/composables/textInput'
export default defineComponent({
inheritAttrs: false
})
</script>
<script setup lang="ts">
import { RuleExpression } from 'vee-validate'
import {
ExclamationCircleIcon,
EnvelopeIcon,
KeyIcon,
XMarkIcon
} from '@heroicons/vue/20/solid'
import { ConcreteComponent, PropType } from 'vue'
import { Nullable, Optional } from '@speckle/shared'
import { useTextInputGlobalFocus } from '~~/composables/states'
type InputType = 'text' | 'email' | 'password' | 'url' | 'search'
type InputSize = 'sm' | 'base' | 'lg' | 'xl'
const props = defineProps({
/**
* Input "type" value (changes behaviour & look)
*/
type: {
type: String as PropType<InputType>,
default: 'text'
},
/**
* Unique ID for the input (must be unique page-wide)
*/
name: {
type: String,
required: true
},
/**
* Whether to show label (label will always be shown to screen readers)
*/
showLabel: {
type: Boolean,
required: false
},
/**
* Optional help text
*/
help: {
type: String as PropType<Optional<string>>,
default: undefined
},
/**
* Placeholder text
*/
placeholder: {
type: String as PropType<Optional<string>>,
default: undefined
},
/**
* Set label text explicitly
*/
label: {
type: String as PropType<Optional<string>>,
default: undefined
},
/**
* Whether to show the red "required" asterisk
*/
showRequired: {
type: Boolean,
default: false
},
/**
* Whether to disable the component, blocking it from user input
*/
disabled: {
type: Boolean,
default: false
},
/**
* vee-validate validation rules
*/
rules: {
type: [String, Object, Function, Array] as PropType<RuleExpression<string>>,
default: undefined
},
/**
* vee-validate validation() on component mount
*/
validateOnMount: {
type: Boolean,
default: false
},
/**
* Whether to trigger validation whenever the value changes
*/
validateOnValueUpdate: {
type: Boolean,
default: false
},
/**
* Will replace the generic "Value" text with the name of the input in error messages
*/
useLabelInErrors: {
type: Boolean,
default: true
},
/**
* Set a custom icon to use inside the input
*/
customIcon: {
type: [Object, Function] as PropType<Optional<ConcreteComponent>>,
default: undefined
},
/**
* Whether to focus on the input when component is mounted
*/
autoFocus: {
type: Boolean,
default: false
},
modelValue: {
type: String,
default: ''
},
size: {
type: String as PropType<InputSize>,
default: 'base'
},
showClear: {
type: Boolean,
default: false
},
fullWidth: {
type: Boolean,
default: false
},
inputClasses: {
type: String,
default: null
},
hideErrorMessage: {
type: Boolean,
default: false
},
wrapperClasses: {
type: String,
default: () => ''
}
})
const emit = defineEmits<{
(e: 'update:modelValue', val: string): void
(e: 'change', val: { event?: Event; value: string }): void
(e: 'input', val: { event?: Event; value: string }): void
(e: 'clear'): void
(e: 'focusin'): void
(e: 'focusout'): void
}>()
const slots = useSlots()
const inputElement = ref(null as Nullable<HTMLInputElement>)
const {
coreClasses,
title,
value,
helpTipId,
helpTipClasses,
helpTip,
hideHelpTip,
errorMessage,
clear,
focus,
labelClasses
} = useTextInputCore({
props: toRefs(props),
emit,
inputEl: inputElement
})
const textInputGlobalFocus = useTextInputGlobalFocus()
const leadingIconClasses = computed(() => {
const classParts: string[] = ['h-5 w-5']
if (errorMessage.value) {
classParts.push('text-danger')
} else {
classParts.push('text-foreground-2')
}
return classParts.join(' ')
})
const hasLeadingIcon = computed(
() => ['email', 'password'].includes(props.type) || props.customIcon
)
const iconClasses = computed((): string => {
const classParts: string[] = []
if (hasLeadingIcon.value) {
classParts.push('pl-8')
}
if (!slots['input-right']) {
if (errorMessage.value || props.showClear) {
if (errorMessage.value && props.showClear) {
classParts.push('pr-12')
} else {
classParts.push('pr-8')
}
}
}
return classParts.join(' ')
})
const sizeClasses = computed((): string => {
switch (props.size) {
case 'sm':
return 'h-6'
case 'lg':
return 'h-10'
case 'xl':
return 'h-14'
case 'base':
default:
return 'h-8'
}
})
defineExpose({ focus })
</script>
@@ -0,0 +1,23 @@
<template>
<div class="flex flex-col space-y-1">
<FormFileUploadProgressRow
v-for="item in items"
:key="item.id"
:item="item"
:disabled="disabled"
@delete="$emit('delete', { id: item.id })"
/>
</div>
</template>
<script setup lang="ts">
import { UploadFileItem } from '~~/lib/form/composables/fileUpload'
defineEmits<{
(e: 'delete', v: { id: string }): void
}>()
defineProps<{
items: UploadFileItem[]
disabled?: boolean
}>()
</script>
@@ -0,0 +1,76 @@
<template>
<div class="bg-foundation rounded-4xl px-4 py-3 w-full max-w-full relative">
<div class="flex space-x-1 items-center">
<span class="truncate text-sm flex-shrink">{{ item.file.name }}</span>
<span class="text-tiny flex-grow text-foreground-2">
{{ prettyFileSize(item.file.size) }}
</span>
<FormButton
color="danger"
size="xs"
rounded
hide-text
:icon-left="XMarkIcon"
@click="onDelete"
></FormButton>
</div>
<div
v-if="item.progress > 0"
:class="[' w-full mt-2', progressBarClasses]"
:style="progressBarStyle"
/>
<div v-if="false" class="flex flex-col flex-grow">
<div class="text-foreground space-x-1 inline-flex max-w-full truncate">
<span class="normal truncate">{{ item.file.name }}</span>
<span class="label label--light text-foreground-2 truncate">
{{ prettyFileSize(item.file.size) }}
</span>
</div>
<div v-if="errorMessage" class="label label--light text-danger truncate">
{{ errorMessage }}
</div>
<div
v-if="item.progress > 0"
:class="progressBarClasses"
:style="progressBarStyle"
/>
</div>
<FormButton
v-if="false"
class="absolute -right-8 top-4"
color="danger"
size="xs"
rounded
hide-text
:icon-left="XMarkIcon"
@click="onDelete"
></FormButton>
</div>
</template>
<script setup lang="ts">
import { XMarkIcon } from '@heroicons/vue/24/outline'
import { prettyFileSize } from '~~/lib/core/helpers/file'
import {
UploadFileItem,
useFileUploadProgressCore
} from '~~/lib/form/composables/fileUpload'
const emit = defineEmits<{
(e: 'delete', v: { id: string }): void
}>()
const props = defineProps<{
item: UploadFileItem
disabled?: boolean
}>()
const { errorMessage, progressBarClasses, progressBarStyle } =
useFileUploadProgressCore({
item: computed(() => props.item)
})
const onDelete = () => {
if (props.disabled) return
emit('delete', { id: props.item.id })
}
</script>
@@ -0,0 +1,97 @@
<!-- eslint-disable vuejs-accessibility/form-control-has-label -->
<template>
<div ref="fileUploadZone" class="file-upload-zone">
<slot
:is-dragging-files="isOverDropZone"
:open-file-picker="triggerPicker"
:activator-on="{ click: triggerPicker }"
/>
<input
ref="fileInput"
type="file"
class="hidden"
:accept="accept"
:multiple="multiple"
@click.stop
@change="onInputChange"
/>
</div>
</template>
<script setup lang="ts">
/* eslint-disable vue/require-default-prop */
import { Nullable } from '@speckle/shared'
import { useDropZone } from '@vueuse/core'
import {
UploadableFileItem,
usePrepareUploadableFiles
} from '~~/lib/form/composables/fileUpload'
const emit = defineEmits<{
(e: 'files-selected', v: { files: UploadableFileItem[] }): void
}>()
const props = withDefaults(
defineProps<{
/**
* https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept
*/
accept?: string
/**
* https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/multiple
*/
multiple?: boolean
/**
* Max file size in bytes
*/
sizeLimit?: number
/**
* Max file count if 'multiple' is set
*/
countLimit?: number
disabled?: boolean
}>(),
{
sizeLimit: 1024 * 1024 * 100 // 100mb
}
)
const fileUploadZone = ref(null as Nullable<HTMLDivElement>)
const fileInput = ref(null as Nullable<HTMLInputElement>)
const { buildUploadableFiles } = usePrepareUploadableFiles({
sizeLimit: computed(() => props.sizeLimit),
countLimit: computed(() => props.countLimit),
accept: computed(() => props.accept),
multiple: computed(() => props.multiple),
disabled: computed(() => props.disabled)
})
const handleIncomingFiles = (files: File[]) => {
const fileItems = buildUploadableFiles(files)
if (!fileItems?.length) return
emit('files-selected', { files: fileItems })
}
const { isOverDropZone } = useDropZone(fileUploadZone, (files) => {
if (!files?.length) return
handleIncomingFiles(files)
})
const onInputChange = () => {
const input = fileInput.value
if (!input) return
const files = [...(input.files || [])]
input.value = '' // Resetting value
if (!files.length) return
handleIncomingFiles(files)
}
const triggerPicker = () => {
fileInput.value?.click()
}
defineExpose({
triggerPicker
})
</script>
@@ -0,0 +1,266 @@
import { wait } from '@speckle/shared'
import { Meta, StoryObj } from '@storybook/vue3'
import FormSelectBase from '~~/components/form/select/Base.vue'
import { isRequired } from '~~/lib/common/helpers/validation'
type FakeItemType = { id: string; name: string }
type StoryType = StoryObj<
Record<string, unknown> & {
'update:modelValue': (val: FakeItemType) => void
}
>
const fakeItems: FakeItemType[] = [
{
id: '1',
name: 'Rocky Balboa'
},
{
id: '2',
name: 'Bozo the Clown'
},
{
id: '3',
name: `Some jabroni with a super very long name, I mean look at it, it's way too long for a select box!`
},
{
id: '4',
name: 'Miss America 1987'
},
{
id: '5',
name: 'Brad Pitt'
},
{
id: '6',
name: 'Kevin McCallister'
},
{
id: '7',
name: 'Rickety Cricket'
},
{
id: '8',
name: 'Master Chief'
}
]
export default {
component: FormSelectBase,
parameters: {
docs: {
description: {
component: 'Base component for implementing various kinds of select boxes.'
}
}
},
argTypes: {
'update:modelValue': {
type: 'function',
action: 'v-model'
},
'nothing-selected': {
type: 'string',
description: 'Slot for rendering selectbox contents when nothing is selected'
},
'something-selected': {
type: 'string',
description: 'Slot for rendering selectbox contents when something is selected'
},
option: {
type: 'string',
description: 'Slot for rendering each option'
},
filterPredicate: {
type: 'function'
},
getSearchResults: {
type: 'function'
},
disabled: {
type: 'boolean'
},
buttonStyle: {
options: ['base', 'simple'],
control: { type: 'select' }
}
}
} as Meta
export const Default: StoryType = {
render: (args, ctx) => ({
components: { FormSelectBase },
setup: () => {
return { args }
},
template: `
<div class="flex justify-center h-72">
<FormSelectBase v-bind="args" class="max-w-xs w-full" @update:modelValue="onModelUpdate"/>
</div>
`,
methods: {
onModelUpdate(val: FakeItemType) {
args['update:modelValue'](val)
ctx.updateArgs({ ...args, modelValue: val })
}
}
}),
args: {
multiple: false,
items: fakeItems,
modelValue: undefined,
search: false,
filterPredicate: (val: FakeItemType, search: string) =>
val.name.toLowerCase().includes(search.toLowerCase()),
getSearchResults: undefined,
searchPlaceholder: 'Search',
label: 'Choose an item',
showLabel: false,
by: 'name',
name: 'example',
clearable: true,
buttonStyle: 'base',
disabled: false
}
}
export const LimitedWidth: StoryType = {
...Default,
render: (args, ctx) => ({
components: { FormSelectBase },
setup: () => {
return { args }
},
template: `
<div class="flex justify-center h-72 w-44">
<FormSelectBase v-bind="args" class="max-w-xs w-full" @update:modelValue="onModelUpdate"/>
</div>
`,
methods: {
onModelUpdate(val: FakeItemType) {
args['update:modelValue'](val)
ctx.updateArgs({ ...args, modelValue: val })
}
}
})
}
export const WithCustomSlots: StoryType = {
render: (args, ctx) => ({
components: { FormSelectBase },
setup: () => {
return { args }
},
template: `
<div class="flex justify-center h-72">
<FormSelectBase v-bind="args" class="max-w-xs w-full" @update:modelValue="onModelUpdate">
<template #nothing-selected>{{ args['nothing-selected'] }}</template>
<template #something-selected>{{ args['something-selected'] }}</template>
<template #option>{{ args['option'] }}</template>
</FormSelectBase>
</div>
`,
methods: {
onModelUpdate(val: FakeItemType) {
args['update:modelValue'](val)
ctx.updateArgs({ ...args, modelValue: val })
}
}
}),
args: {
...Default.args,
'nothing-selected': 'NOTHING SELECTED SLOT',
'something-selected': 'SOMETHING SELECTED SLOT',
option: 'OPTION SLOT'
},
parameters: {
docs: {
description: {
story: 'Use slots to change how various aspects render'
}
}
}
}
export const Disabled: StoryType = {
...Default,
args: {
...Default.args,
disabled: true
}
}
export const WithValidation: StoryType = {
render: (args, ctx) => ({
components: { FormSelectBase },
setup: () => {
return { args }
},
template: `
<div class="flex justify-center h-72">
<FormSelectBase v-bind="args" class="max-w-xs w-full" @update:modelValue="onModelUpdate" validate-on-mount/>
</div>
`,
methods: {
onModelUpdate(val: FakeItemType) {
args['update:modelValue'](val)
ctx.updateArgs({ ...args, modelValue: val })
}
}
}),
args: {
multiple: false,
items: fakeItems,
modelValue: undefined,
search: false,
filterPredicate: (val: FakeItemType, search: string) =>
val.name.toLowerCase().includes(search.toLowerCase()),
searchPlaceholder: 'Search',
label: 'Item',
showLabel: true,
by: 'name',
rules: [isRequired],
help: 'This is a random help message'
}
}
export const WithFilter: StoryType = {
...Default,
args: {
...Default.args,
search: true
}
}
export const WithAsyncSearch: StoryType = {
...WithFilter,
args: {
...WithFilter.args,
items: undefined,
filterPredicate: undefined,
getSearchResults: async (search: string): Promise<FakeItemType[]> => {
const items = fakeItems.filter((val) =>
val.name.toLowerCase().includes(search.toLowerCase())
)
await wait(2000)
return items
}
}
}
export const Empty: StoryType = {
...WithAsyncSearch,
args: {
...WithAsyncSearch.args,
getSearchResults: () => []
}
}
export const Simple: StoryType = {
...Default,
args: {
...Default.args,
buttonStyle: 'simple'
}
}
@@ -0,0 +1,520 @@
<template>
<div>
<Listbox
v-model="wrappedValue"
:name="name"
:multiple="multiple"
:by="by"
:disabled="isDisabled"
as="div"
>
<ListboxLabel
class="block label text-foreground"
:class="{ 'sr-only': !showLabel }"
>
{{ label }}
</ListboxLabel>
<div :class="buttonsWrapperClasses">
<!-- <div class="relative flex"> -->
<ListboxButton v-slot="{ open }" :class="buttonClasses">
<div class="flex items-center justify-between w-full">
<div class="block truncate grow text-left">
<template
v-if="!wrappedValue || (isArray(wrappedValue) && !wrappedValue.length)"
>
<slot name="nothing-selected">
{{ label }}
</slot>
</template>
<template v-else>
<slot name="something-selected" :value="wrappedValue">
{{ simpleDisplayText(wrappedValue) }}
</slot>
</template>
</div>
<div class="pointer-events-none shrink-0 ml-1 flex items-center">
<ChevronUpIcon
v-if="open"
class="h-4 w-4 text-foreground"
aria-hidden="true"
/>
<ChevronDownIcon
v-else
class="h-4 w-4 text-foreground"
aria-hidden="true"
/>
</div>
</div>
</ListboxButton>
<!-- </div> -->
<!-- Clear Button -->
<button
v-if="renderClearButton"
v-tippy="'Clear'"
:class="clearButtonClasses"
:disabled="disabled"
@click="clearValue()"
>
<XMarkIcon class="w-3 h-3" />
</button>
<Transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute top-[100%] z-10 mt-1 w-full rounded-md bg-foundation-2 py-1 label label--light outline outline-2 outline-primary-muted focus:outline-none shadow"
@focus="searchInput?.focus()"
>
<label v-if="hasSearch" class="flex flex-col mx-1 mb-1">
<span class="sr-only label text-foreground">Search</span>
<div class="relative">
<div
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-2"
>
<MagnifyingGlassIcon class="h-5 w-5 text-foreground" />
</div>
<input
ref="searchInput"
v-model="searchValue"
type="text"
class="pl-9 w-full border-0 bg-foundation-page rounded placeholder:font-normal normal placeholder:text-foreground-2 focus:outline-none focus:ring-1 focus:border-outline-1 focus:ring-outline-1"
:placeholder="searchPlaceholder"
@change="triggerSearch"
@keydown.stop
/>
</div>
</label>
<div
class="overflow-auto simple-scrollbar"
:class="[hasSearch ? 'max-h-52' : 'max-h-60']"
>
<div v-if="isAsyncSearchMode && isAsyncLoading" class="px-1">
<CommonLoadingBar :loading="true" />
</div>
<div v-else-if="isAsyncSearchMode && !currentItems.length">
<slot name="nothing-found">
<div class="text-foreground-2 text-center">Nothing found 🤷</div>
</slot>
</div>
<template v-if="!isAsyncSearchMode || !isAsyncLoading">
<ListboxOption
v-for="item in finalItems"
:key="itemKey(item)"
v-slot="{ active, selected }: { active: boolean, selected: boolean }"
:value="item"
>
<li
:class="[
active ? 'text-primary' : 'text-foreground',
'relative transition cursor-pointer select-none py-1.5 pl-3',
!hideCheckmarks ? 'pr-9' : ''
]"
>
<span :class="['block truncate']">
<slot
name="option"
:item="item"
:active="active"
:selected="selected"
>
{{ simpleDisplayText(item) }}
</slot>
</span>
<span
v-if="!hideCheckmarks && selected"
:class="[
active ? 'text-primary' : 'text-foreground',
'absolute inset-y-0 right-0 flex items-center pr-4'
]"
>
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</template>
</div>
</ListboxOptions>
</Transition>
</div>
</Listbox>
<p
v-if="helpTipId"
:id="helpTipId"
class="mt-2 ml-3 text-sm"
:class="helpTipClasses"
>
{{ helpTip }}
</p>
</div>
</template>
<script setup lang="ts">
// Vue components don't support generic props, so having to rely on any
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import {
Listbox,
ListboxButton,
ListboxOption,
ListboxOptions,
ListboxLabel
} from '@headlessui/vue'
import {
ChevronDownIcon,
CheckIcon,
ChevronUpIcon,
MagnifyingGlassIcon,
XMarkIcon
} from '@heroicons/vue/24/solid'
import { debounce, isArray } from 'lodash-es'
import { PropType } from 'vue'
import { MaybeAsync, Nullable, Optional } from '@speckle/shared'
import { RuleExpression, useField } from 'vee-validate'
import { nanoid } from 'nanoid'
type ButtonStyle = 'base' | 'simple'
type SingleItem = any
type ValueType = SingleItem | SingleItem[] | undefined
defineEmits<{
(e: 'update:modelValue', v: ValueType): void
}>()
const props = defineProps({
multiple: {
type: Boolean,
default: false
},
items: {
type: Array as PropType<SingleItem[]>,
default: () => []
},
modelValue: {
type: [Object, Array, String] as PropType<ValueType>,
default: undefined
},
/**
* Whether to enable the search bar. You must also set one of the following:
* * filterPredicate - to allow filtering passed in `items` based on search bar
* * getSearchResults - to allow asynchronously loading items from server (props.items no longer required in this case,
* but can be used to prefill initial values)
*/
search: {
type: Boolean,
default: false
},
/**
* If search=true and this is set, you can use this to filter passed in items based on whatever
* the user enters in the search bar
*/
filterPredicate: {
type: Function as PropType<
Optional<(item: SingleItem, searchString: string) => boolean>
>,
default: undefined
},
/**
* If search=true and this is set, you can use this to load data asynchronously depending
* on the search query
*/
getSearchResults: {
type: Function as PropType<
Optional<(searchString: string) => MaybeAsync<SingleItem[]>>
>,
default: undefined
},
searchPlaceholder: {
type: String,
default: 'Search'
},
/**
* Label is required at the very least for screen-readers
*/
label: {
type: String,
required: true
},
/**
* Whether to show the label visually
*/
showLabel: {
type: Boolean,
default: false
},
name: {
type: String,
required: true
},
/**
* Objects will be compared by the values in the specified prop
*/
by: {
type: String,
required: false
},
disabled: {
type: Boolean as PropType<Optional<boolean>>,
default: false
},
buttonStyle: {
type: String as PropType<Optional<ButtonStyle>>,
default: 'base'
},
hideCheckmarks: {
type: Boolean as PropType<Optional<boolean>>,
default: false
},
allowUnset: {
type: Boolean as PropType<Optional<boolean>>,
default: true
},
clearable: {
type: Boolean,
default: false
},
/**
* Validation stuff
*/
rules: {
type: [String, Object, Function, Array] as PropType<RuleExpression<string>>,
default: undefined
},
/**
* vee-validate validation() on component mount
*/
validateOnMount: {
type: Boolean,
default: false
},
/**
* Whether to trigger validation whenever the value changes
*/
validateOnValueUpdate: {
type: Boolean,
default: false
},
/**
* Will replace the generic "Value" text with the name of the input in error messages
*/
useLabelInErrors: {
type: Boolean,
default: true
},
/**
* Optional help text
*/
help: {
type: String as PropType<Optional<string>>,
default: undefined
},
fixedHeight: {
type: Boolean,
default: false
}
})
const { value, errorMessage: error } = useField<ValueType>(props.name, props.rules, {
validateOnMount: props.validateOnMount,
validateOnValueUpdate: props.validateOnValueUpdate,
initialValue: props.modelValue
})
const searchInput = ref(null as Nullable<HTMLInputElement>)
const searchValue = ref('')
const currentItems = ref([] as SingleItem[])
const isAsyncLoading = ref(false)
const internalHelpTipId = ref(nanoid())
const title = computed(() => unref(props.label) || unref(props.name))
const errorMessage = computed(() => {
const base = error.value
if (!base || !unref(props.useLabelInErrors)) return base
return base.replace('Value', title.value)
})
const helpTip = computed(() => errorMessage.value || unref(props.help))
const hasHelpTip = computed(() => !!helpTip.value)
const helpTipId = computed(() =>
hasHelpTip.value ? `${unref(props.name)}-${internalHelpTipId.value}` : undefined
)
const helpTipClasses = computed((): string =>
error.value ? 'text-danger' : 'text-foreground-2'
)
const renderClearButton = computed(
() => props.buttonStyle !== 'simple' && props.clearable && !props.disabled
)
const buttonsWrapperClasses = computed(() => {
const classParts: string[] = ['relative flex group', props.showLabel ? 'mt-1' : '']
if (props.buttonStyle !== 'simple') {
classParts.push('hover:shadow rounded-md')
classParts.push('outline outline-2 outline-primary-muted')
}
if (props.fixedHeight) {
classParts.push('h-8')
}
return classParts.join(' ')
})
const commonButtonClasses = computed(() => {
const classParts: string[] = []
if (props.buttonStyle !== 'simple') {
// classParts.push('group-hover:shadow')
// classParts.push('outline outline-2 outline-primary-muted ')
classParts.push(
isDisabled.value ? 'bg-foundation-disabled text-foreground-disabled' : ''
)
}
if (isDisabled.value) classParts.push('cursor-not-allowed')
return classParts.join(' ')
})
const clearButtonClasses = computed(() => {
const classParts = [
'relative z-[1]',
'flex items-center justify-center text-center shrink-0',
'rounded-r-md overflow-hidden transition-all',
hasValueSelected.value ? `w-6 ${commonButtonClasses.value}` : 'w-0'
]
if (!isDisabled.value) {
classParts.push(
'bg-primary-muted hover:bg-primary hover:text-foreground-on-primary'
)
}
return classParts.join(' ')
})
const buttonClasses = computed(() => {
const classParts = [
'relative z-[2]',
'normal rounded-md cursor-pointer transition truncate flex-1',
'flex items-center',
commonButtonClasses.value
]
if (props.buttonStyle !== 'simple') {
classParts.push('py-2 px-3')
if (!isDisabled.value) {
classParts.push('bg-foundation text-foreground')
}
}
if (renderClearButton.value && hasValueSelected.value) {
classParts.push('rounded-r-none')
}
return classParts.join(' ')
})
const hasSearch = computed(
() => !!(props.search && (props.filterPredicate || props.getSearchResults))
)
const isAsyncSearchMode = computed(() => hasSearch.value && props.getSearchResults)
const isDisabled = computed(
() => props.disabled || (!props.items.length && !isAsyncSearchMode.value)
)
const wrappedValue = computed({
get: () => {
const currentValue = value.value
if (props.multiple) {
return isArray(currentValue) ? currentValue : []
} else {
return isArray(currentValue) ? undefined : currentValue
}
},
set: (newVal) => {
if (props.multiple && !isArray(newVal)) {
console.warn('Attempting to set non-array value in selector w/ multiple=true')
return
} else if (!props.multiple && isArray(newVal)) {
console.warn('Attempting to set array value in selector w/ multiple=false')
return
}
if (props.multiple) {
value.value = newVal || []
} else {
const currentVal = value.value
const isUnset =
props.allowUnset &&
currentVal &&
newVal &&
itemKey(currentVal as SingleItem) === itemKey(newVal as SingleItem)
value.value = isUnset ? undefined : newVal
}
}
})
const hasValueSelected = computed(() => {
if (props.multiple) return wrappedValue.value.length !== 0
else return !!wrappedValue.value
})
const clearValue = () => {
if (props.multiple) wrappedValue.value = []
else wrappedValue.value = undefined
}
const finalItems = computed(() => {
const searchVal = searchValue.value
if (!hasSearch.value || !searchVal?.length) return currentItems.value
if (props.filterPredicate) {
return currentItems.value.filter(
(i) => props.filterPredicate?.(i, searchVal) || false
)
}
return currentItems.value
})
const simpleDisplayText = (v: ValueType) => JSON.stringify(v)
const itemKey = (v: SingleItem): string | number =>
props.by ? (v[props.by] as string) : v
const triggerSearch = async () => {
if (!isAsyncSearchMode.value || !props.getSearchResults) return
isAsyncLoading.value = true
try {
currentItems.value = await props.getSearchResults(searchValue.value)
} finally {
isAsyncLoading.value = false
}
}
const debouncedSearch = debounce(triggerSearch, 1000)
watch(
() => props.items,
(newItems) => {
currentItems.value = newItems.slice()
},
{ immediate: true }
)
watch(searchValue, () => {
if (!isAsyncSearchMode.value) return
debouncedSearch()
})
onMounted(() => {
if (isAsyncSearchMode.value && !props.items.length) {
triggerSearch()
}
})
</script>
@@ -0,0 +1,71 @@
<template>
<FormSelectBase
v-model="selectedValue"
:items="Object.values(Roles.Stream)"
:multiple="multiple"
clearable
name="projectRoles"
label="Project roles"
class="min-w-[150px]"
>
<template #nothing-selected>
{{ multiple ? 'Select roles' : 'Select role' }}
</template>
<template #something-selected="{ value }">
<template v-if="isMultiItemArrayValue(value)">
<div ref="elementToWatchForChanges" class="flex items-center space-x-0.5">
<div
ref="itemContainer"
class="flex flex-wrap overflow-hidden space-x-0.5 h-6"
>
<div v-for="(item, i) in value" :key="item" class="text-foreground">
{{ roleDisplayName(item) + (i < value.length - 1 ? ', ' : '') }}
</div>
</div>
<div v-if="hiddenSelectedItemCount > 0" class="text-foreground-2 normal">
+{{ hiddenSelectedItemCount }}
</div>
</div>
</template>
<template v-else>
<div class="truncate text-foreground">
{{ roleDisplayName(isArrayValue(value) ? value[0] : value) }}
</div>
</template>
</template>
<template #option="{ item }">
<div class="flex items-center">
<span class="truncate">{{ roleDisplayName(item) }}</span>
</div>
</template>
</FormSelectBase>
</template>
<script setup lang="ts">
import { StreamRoles, Roles, Nullable } from '@speckle/shared'
import { capitalize } from 'lodash-es'
import { useFormSelectChildInternals } from '~~/lib/form/composables/select'
type ValueType = StreamRoles | StreamRoles[] | undefined
const emit = defineEmits<{
(e: 'update:modelValue', v: ValueType): void
}>()
const props = defineProps<{
multiple?: boolean
modelValue?: ValueType
}>()
const elementToWatchForChanges = ref(null as Nullable<HTMLElement>)
const itemContainer = ref(null as Nullable<HTMLElement>)
const { selectedValue, isArrayValue, isMultiItemArrayValue, hiddenSelectedItemCount } =
useFormSelectChildInternals<StreamRoles>({
props: toRefs(props),
emit,
dynamicVisibility: { elementToWatchForChanges, itemContainer }
})
const roleDisplayName = (role: StreamRoles) =>
capitalize(Object.entries(Roles.Stream).find(([, val]) => val === role)?.[0] || role)
</script>
@@ -0,0 +1,152 @@
<template>
<FormSelectBase
v-model="selectedValue"
:multiple="multiple"
:search="true"
:search-placeholder="searchPlaceholder"
:get-search-results="invokeSearch"
:label="label"
:show-label="showLabel"
:name="name || 'projects'"
by="id"
>
<template #nothing-selected>
<template v-if="selectorPlaceholder">
{{ selectorPlaceholder }}
</template>
<template v-else>
{{ multiple ? 'Select projects' : 'Select a project' }}
</template>
</template>
<template #something-selected="{ value }">
<template v-if="isMultiItemArrayValue(value)">
<div ref="elementToWatchForChanges" class="flex items-center space-x-0.5">
<div
ref="itemContainer"
class="flex flex-wrap overflow-hidden space-x-0.5 h-6"
>
<div v-for="(item, i) in value" :key="item.id" class="text-foreground">
{{ item.name + (i < value.length - 1 ? ', ' : '') }}
</div>
</div>
<div v-if="hiddenSelectedItemCount > 0" class="text-foreground-2 normal">
+{{ hiddenSelectedItemCount }}
</div>
</div>
</template>
<template v-else>
<div class="flex items-center">
<span class="truncate text-foreground">
{{ (isArrayValue(value) ? value[0] : value).name }}
</span>
</div>
</template>
</template>
<template #option="{ item }">
<div class="flex items-center">
<span class="truncate">{{ item.name }}</span>
</div>
</template>
</FormSelectBase>
</template>
<script setup lang="ts">
import { PropType } from 'vue'
import { Nullable, Optional, Roles } from '@speckle/shared'
import { graphql } from '~~/lib/common/generated/gql'
import { FormSelectProjects_ProjectFragment } from '~~/lib/common/generated/gql/graphql'
import { useFormSelectChildInternals } from '~~/lib/form/composables/select'
import { useApolloClient } from '@vue/apollo-composable'
import { searchProjectsQuery } from '~~/lib/form/graphql/queries'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
type ValueType =
| FormSelectProjects_ProjectFragment
| FormSelectProjects_ProjectFragment[]
| undefined
graphql(`
fragment FormSelectProjects_Project on Project {
id
name
}
`)
const emit = defineEmits<{
(e: 'update:modelValue', v: ValueType): void
}>()
const props = defineProps({
/**
* Whether to allow selecting multiple items
*/
multiple: {
type: Boolean,
default: false
},
modelValue: {
type: [Object, Array] as PropType<ValueType>,
default: undefined
},
/**
* Search placeholder text
*/
searchPlaceholder: {
type: String,
default: 'Search projects'
},
selectorPlaceholder: {
type: String as PropType<Optional<string>>,
default: ''
},
/**
* Label is required at the very least for screen-readers
*/
label: {
type: String,
required: true
},
/**
* Whether to show the label visually
*/
showLabel: {
type: Boolean,
default: false
},
name: {
type: String as PropType<Optional<string>>,
default: undefined
},
/**
* Whether to only return owned streams from server
*/
ownedOnly: {
type: Boolean,
default: false
}
})
const elementToWatchForChanges = ref(null as Nullable<HTMLElement>)
const itemContainer = ref(null as Nullable<HTMLElement>)
const { selectedValue, hiddenSelectedItemCount, isArrayValue, isMultiItemArrayValue } =
useFormSelectChildInternals<FormSelectProjects_ProjectFragment>({
props: toRefs(props),
emit,
dynamicVisibility: { elementToWatchForChanges, itemContainer }
})
const apollo = useApolloClient().client
const { isLoggedIn } = useActiveUser()
const invokeSearch = async (search: string) => {
if (!isLoggedIn.value) return []
const results = await apollo.query({
query: searchProjectsQuery,
variables: {
search: search.trim().length ? search : null,
onlyWithRoles: props.ownedOnly ? [Roles.Stream.Owner] : null
}
})
return results.data.activeUser?.projects.items || []
}
</script>
@@ -0,0 +1,75 @@
import { Meta, StoryObj } from '@storybook/vue3'
import FormSelectSourceApps from '~~/components/form/select/SourceApps.vue'
type StoryType = StoryObj<
Record<string, unknown> & {
'update:modelValue': (val: unknown) => void
}
>
export default {
component: FormSelectSourceApps,
argTypes: {
'update:modelValue': {
action: 'update:modelValue',
type: 'function'
},
'nothing-selected': {
type: 'string',
description:
'When nothing has been selected, you can use the slot to render the contents'
}
}
} as Meta
export const Default: StoryType = {
render: (args, ctx) => ({
components: { FormSelectSourceApps },
setup: () => {
return { args }
},
template: `
<div class="flex justify-center h-72">
<FormSelectSourceApps v-bind="args" @update:modelValue="onModelUpdate" class="max-w-[217px] w-full"/>
</div>
`,
methods: {
onModelUpdate(val: unknown) {
args['update:modelValue'](val)
ctx.updateArgs({ ...args, modelValue: val })
}
}
}),
args: {
search: false,
multiple: false,
modelValue: undefined,
label: 'Choose an app',
showLabel: false,
'nothing-selected': undefined
}
}
export const Multiple: StoryType = {
...Default,
args: {
...Default.args,
multiple: true
}
}
export const WithSearch: StoryType = {
...Default,
args: {
...Default.args,
search: true
}
}
export const Disabled: StoryType = {
...Default,
args: {
...Default.args,
disabled: true
}
}
@@ -0,0 +1,137 @@
<template>
<FormSelectBase
v-model="selectedValue"
:multiple="multiple"
:items="items ?? SourceApps"
:search="search"
:search-placeholder="searchPlaceholder"
:label="label"
:show-label="showLabel"
:name="name || 'sourceApps'"
:filter-predicate="searchFilterPredicate"
by="name"
>
<template #nothing-selected>
<template v-if="selectorPlaceholder">
{{ selectorPlaceholder }}
</template>
<template v-else>
{{ multiple ? 'Select apps' : 'Select an app' }}
</template>
</template>
<template #something-selected="{ value }">
<template v-if="isMultiItemArrayValue(value)">
<div ref="elementToWatchForChanges" class="flex items-center space-x-0.5 h-5">
<div
ref="itemContainer"
class="flex flex-wrap overflow-hidden space-x-0.5 h-5"
>
<SourceAppBadge v-for="item in value" :key="item.name" :source-app="item" />
</div>
<div v-if="hiddenSelectedItemCount > 0" class="text-foreground-2 normal">
+{{ hiddenSelectedItemCount }}
</div>
</div>
</template>
<template v-else>
<div class="flex items-center">
<div
class="h-2 w-2 rounded-full mr-2"
:style="{ backgroundColor: firstItem(value).bgColor }"
/>
<span class="truncate">{{ firstItem(value).name }}</span>
</div>
</template>
</template>
<template #option="{ item }">
<div class="flex items-center">
<div
class="h-2 w-2 rounded-full mr-2"
:style="{ backgroundColor: item.bgColor }"
/>
<span class="truncate">{{ item.name }}</span>
</div>
</template>
</FormSelectBase>
</template>
<script setup lang="ts">
import { Nullable, Optional, SourceAppDefinition, SourceApps } from '@speckle/shared'
import { PropType } from 'vue'
import { useFormSelectChildInternals } from '~~/lib/form/composables/select'
type ValueType = SourceAppDefinition | SourceAppDefinition[] | undefined
const emit = defineEmits<{
(e: 'update:modelValue', v: ValueType): void
}>()
const props = defineProps({
/**
* Whether to allow selecting multiple source apps
*/
multiple: {
type: Boolean,
default: false
},
modelValue: {
type: [Object, Array] as PropType<ValueType>,
default: undefined
},
/**
* Whether to allow filtering source apps through a search box
*/
search: {
type: Boolean,
default: false
},
/**
* Search placeholder text
*/
searchPlaceholder: {
type: String,
default: 'Search apps'
},
selectorPlaceholder: {
type: String as PropType<Optional<string>>,
default: undefined
},
/**
* Label is required at the very least for screen-readers
*/
label: {
type: String,
required: true
},
/**
* Whether to show the label visually
*/
showLabel: {
type: Boolean,
default: false
},
name: {
type: String as PropType<Optional<string>>,
default: undefined
},
/**
* Control source apps to show. If left undefined, will show all available options.
*/
items: {
type: Array as PropType<Optional<SourceAppDefinition[]>>,
default: undefined
}
})
const elementToWatchForChanges = ref(null as Nullable<HTMLElement>)
const itemContainer = ref(null as Nullable<HTMLElement>)
const { selectedValue, hiddenSelectedItemCount, isMultiItemArrayValue, firstItem } =
useFormSelectChildInternals<SourceAppDefinition>({
props: toRefs(props),
emit,
dynamicVisibility: { elementToWatchForChanges, itemContainer }
})
const searchFilterPredicate = (i: SourceAppDefinition, search: string) =>
i.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())
</script>
@@ -0,0 +1,144 @@
import { Meta, StoryObj } from '@storybook/vue3'
import FormSelectUsers from '~~/components/form/select/Users.vue'
import { FormUsersSelectItemFragment } from '~~/lib/common/generated/gql/graphql'
import { ApolloMockData } from '~~/lib/common/helpers/storybook'
type StoryType = StoryObj<
Record<string, unknown> & {
'update:modelValue': (val: FormUsersSelectItemFragment) => void
}
>
export const fakeUsers: ApolloMockData<FormUsersSelectItemFragment[]> = [
{
__typename: 'LimitedUser',
id: '1',
name: 'Rocky Balboa',
avatar:
'https://www.gannett-cdn.com/-mm-/ea8a07dc617309ca168e259d006a72abae509118/c=0-35-650-402/local/-/media/2016/02/25/SiouxFalls/SiouxFalls/635919633708862216-rocky.jpg?width=1200&disable=upscale&format=pjpg&auto=webp'
},
{
__typename: 'LimitedUser',
id: '2',
name: 'Bozo the Clown',
avatar:
'https://cdn.vox-cdn.com/thumbor/Z2b-41HMCuVhqtEAkgor1w5iy-E=/1400x1050/filters:format(jpeg)/cdn.vox-cdn.com/uploads/chorus_asset/file/10483479/bozo_RIP_getty_ringer.jpg'
},
{
__typename: 'LimitedUser',
id: '3',
name: `Some jabroni with a super very long name, I mean look at it, it's way too long for a select box!`,
avatar: null
},
{
__typename: 'LimitedUser',
id: '4',
name: 'Miss America 1987',
avatar: null
},
{
__typename: 'LimitedUser',
id: '5',
name: 'Brad Pitt',
avatar:
'https://media1.popsugar-assets.com/files/thumbor/4UYUg9UKWqqhaFfElFDU9bKMRgQ/356x1145:1857x2646/fit-in/500x500/filters:format_auto-!!-:strip_icc-!!-/2019/09/04/970/n/1922398/cc3fa7b15d70381d55bd82.88203803_/i/Brad-Pitt.jpg'
},
{
__typename: 'LimitedUser',
id: '6',
name: 'Kevin McCallister',
avatar:
'https://m.media-amazon.com/images/M/MV5BZjg2ODUwZTgtODRkMS00N2U1LTg2Y2EtNDVhMjRmMDNkNDk3XkEyXkFqcGdeQWFybm8@._V1_.jpg'
},
{
__typename: 'LimitedUser',
id: '7',
name: 'Rickety Cricket',
avatar: 'https://cdn3.whatculture.com/images/2022/08/4ce6e7d99a9761f6-1200x675.jpg'
},
{
__typename: 'LimitedUser',
id: '8',
name: 'Master Chief',
avatar:
'https://cdn1.dotesports.com/wp-content/uploads/2021/08/09111246/MasterChief.jpg'
},
{
__typename: 'LimitedUser',
id: '9',
name: 'Mario',
avatar:
'https://play-lh.googleusercontent.com/5LIMaa7WTNy34bzdFhBETa2MRj7mFJZWb8gCn_uyxQkUvFx_uOFCeQjcK16c6WpBA3E'
}
]
export default {
component: FormSelectUsers,
argTypes: {
'update:modelValue': {
action: 'update:modelValue',
type: 'function'
},
'nothing-selected': {
type: 'string',
description:
'When nothing has been selected, you can use the slot to render the contents'
}
},
excludeStories: ['fakeUsers']
} as Meta
export const Default: StoryType = {
render: (args, ctx) => ({
components: { FormSelectUsers },
setup: () => {
const selectedUser = ref(undefined)
return { args, selectedUser }
},
template: `
<div class="flex justify-center h-72">
<FormSelectUsers v-bind="args" @update:modelValue="onModelUpdate" class="max-w-[217px] w-full"/>
</div>
`,
methods: {
onModelUpdate(val: FormUsersSelectItemFragment) {
args['update:modelValue'](val)
ctx.updateArgs({ ...args, modelValue: val })
}
}
}),
args: {
search: false,
multiple: false,
users: fakeUsers,
modelValue: undefined,
label: 'Choose a user',
showLabel: false,
'nothing-selected': undefined
}
}
export const Multiple: StoryType = {
...Default,
args: {
...Default.args,
multiple: true,
'nothing-selected': 'Choose multiple users'
}
}
export const WithSearch: StoryType = {
...Default,
args: {
...Default.args,
search: true
}
}
export const Disabled: StoryType = {
...Default,
args: {
...Default.args,
disabled: true
}
}
@@ -0,0 +1,151 @@
<template>
<FormSelectBase
v-model="selectedValue"
:multiple="multiple"
:items="users"
:search="search"
:filter-predicate="searchFilterPredicate"
:search-placeholder="searchPlaceholder"
:label="label"
:show-label="showLabel"
:name="name || 'users'"
by="id"
>
<template #nothing-selected>
<template v-if="selectorPlaceholder">
{{ selectorPlaceholder }}
</template>
<template v-else>
{{ multiple ? 'Select users' : 'Select a user' }}
</template>
</template>
<template #something-selected="{ value }">
<template v-if="isMultiItemArrayValue(value)">
<div ref="elementToWatchForChanges" class="flex items-center space-x-0.5">
<div
ref="itemContainer"
class="flex flex-wrap overflow-hidden space-x-0.5 h-6"
>
<UserAvatar
v-for="user in value"
:key="user.id"
:user="user"
no-border
size="sm"
/>
</div>
<div v-if="hiddenSelectedItemCount > 0" class="text-foreground-2 normal">
+{{ hiddenSelectedItemCount }}
</div>
</div>
</template>
<template v-else>
<div class="flex items-center overflow-hidden">
<UserAvatar
:user="isArrayValue(value) ? value[0] : value"
no-border
class="mr-2"
size="sm"
/>
<span class="truncate label label--light min-w-0">
{{ (isArrayValue(value) ? value[0] : value).name }}
</span>
</div>
</template>
</template>
<template #option="{ item }">
<div class="flex items-center">
<UserAvatar :user="item" no-border class="mr-2" size="sm" />
<span class="truncate">{{ item.name }}</span>
</div>
</template>
</FormSelectBase>
</template>
<script setup lang="ts">
import { PropType } from 'vue'
import { Nullable, Optional } from '@speckle/shared'
import { graphql } from '~~/lib/common/generated/gql'
import { FormUsersSelectItemFragment } from '~~/lib/common/generated/gql/graphql'
import { useFormSelectChildInternals } from '~~/lib/form/composables/select'
type ValueType = FormUsersSelectItemFragment | FormUsersSelectItemFragment[] | undefined
graphql(`
fragment FormUsersSelectItem on LimitedUser {
id
name
avatar
}
`)
const emit = defineEmits<{
(e: 'update:modelValue', v: ValueType): void
}>()
const props = defineProps({
/**
* Whether to allow selecting multiple users
*/
multiple: {
type: Boolean,
default: false
},
users: {
type: Array as PropType<FormUsersSelectItemFragment[]>,
required: true
},
modelValue: {
type: [Object, Array] as PropType<ValueType>,
default: undefined
},
/**
* Whether to allow filtering users through a search box
*/
search: {
type: Boolean,
default: false
},
/**
* Search placeholder text
*/
searchPlaceholder: {
type: String,
default: 'Search people'
},
selectorPlaceholder: {
type: String as PropType<Optional<string>>,
default: ''
},
/**
* Label is required at the very least for screen-readers
*/
label: {
type: String,
required: true
},
/**
* Whether to show the label visually
*/
showLabel: {
type: Boolean,
default: false
},
name: {
type: String as PropType<Optional<string>>,
default: undefined
}
})
const elementToWatchForChanges = ref(null as Nullable<HTMLElement>)
const itemContainer = ref(null as Nullable<HTMLElement>)
const { selectedValue, hiddenSelectedItemCount, isArrayValue, isMultiItemArrayValue } =
useFormSelectChildInternals<FormUsersSelectItemFragment>({
props: toRefs(props),
emit,
dynamicVisibility: { elementToWatchForChanges, itemContainer }
})
const searchFilterPredicate = (i: FormUsersSelectItemFragment, search: string) =>
i.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())
</script>
@@ -0,0 +1,89 @@
<template>
<div class="flex items-center">
<div class="flex -space-x-2">
<!-- <img
v-for="(user, i) in avatars.slice(0, numAvatars)"
:key="i + 'avatar'"
class="inline-block h-8 w-8 rounded-full ring-4 ring-white dark:ring-black"
:src="user.avatar"
alt=""
/> -->
<template v-for="(user, i) in avatars.slice(0, numAvatars)" :key="i + 'avatar'">
<img
v-if="i % 2 == 0"
:alt="user.name"
:src="user.avatar"
class="inline-block h-8 w-8 rounded-full shadow-md"
/>
<div
v-else
class="h-8 w-8 bg-primary rounded-full shadow-md flex items-center justify-center text-sm font-bold tracking-tighter text-white"
>
{{ user.name.split(' ')[0][0] }}{{ user.name.split(' ')[1][0] }}
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
defineProps({
numAvatars: {
type: Number,
default: 5
}
})
const avatars = [
{
name: 'Steve Ballmer',
avatar:
'https://images.unsplash.com/photo-1491528323818-fdd1faba62cc?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80'
},
{
name: 'Bob Cadify',
avatar:
'https://images.unsplash.com/photo-1550525811-e5869dd03032?ixlib=rb-1.2.1&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80'
},
{
name: 'Brep Master',
avatar:
'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2.25&w=256&h=256&q=80'
},
{
name: 'Spartacus Author',
avatar:
'https://images.unsplash.com/photo-1491528323818-fdd1faba62cc?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80'
},
{
name: 'Jane Austen',
avatar:
'https://images.unsplash.com/photo-1550525811-e5869dd03032?ixlib=rb-1.2.1&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80'
},
{
name: 'Bruno Latour',
avatar:
'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2.25&w=256&h=256&q=80'
},
{
name: 'Margaret Sussman',
avatar:
'https://images.unsplash.com/photo-1491528323818-fdd1faba62cc?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80'
},
{
name: 'Donella Abelson',
avatar:
'https://images.unsplash.com/photo-1550525811-e5869dd03032?ixlib=rb-1.2.1&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80'
},
{
name: 'Foo Bar',
avatar:
'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2.25&w=256&h=256&q=80'
},
{
name: 'Baz Qux',
avatar:
'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80'
}
]
</script>
@@ -0,0 +1,31 @@
<template>
<span
v-tippy="{ content: text, trigger: triggerValue }"
class="border-foreground border-b border-dashed cursor-help"
>
<slot />
</span>
</template>
<script setup lang="ts">
type TriggerType = 'hover' | 'click'
const props = withDefaults(
defineProps<{
text: string
trigger?: TriggerType
}>(),
{
trigger: 'click'
}
)
const triggerValue = computed(() => {
switch (props.trigger) {
case 'click':
return 'click'
case 'hover':
default:
return 'mouseenter focus'
}
})
</script>
@@ -0,0 +1,70 @@
<template>
<div ref="wrapper">
<InternalInfiniteLoading
v-if="initializeLoader"
v-bind="$props.settings || {}"
@infinite="$emit('infinite', $event)"
>
<template #spinner>
<CommonLoadingBar :loading="true" class="my-2" />
</template>
<template #complete>
<div class="w-full flex flex-col items-center my-2 space-y-2 mt-4">
<div class="inline-flex items-center space-x-1">
<CheckIcon class="w-5 h-5 text-success" />
<span class="text-foreground-2">That's it, you've loaded everything!</span>
</div>
</div>
</template>
<template #error="{ retry }">
<div class="w-full flex flex-col items-center my-2 space-y-2 mt-4">
<div class="inline-flex items-center space-x-1">
<ExclamationTriangleIcon class="w-5 h-5 text-danger" />
<span class="text-foreground-2">An error occurred while loading</span>
</div>
<FormButton v-if="allowRetry" @click="retry">Retry</FormButton>
</div>
</template>
</InternalInfiniteLoading>
</div>
</template>
<script setup lang="ts">
import InternalInfiniteLoading from 'v3-infinite-loading'
import { ExclamationTriangleIcon, CheckIcon } from '@heroicons/vue/24/outline'
import { InfiniteLoaderState } from '~~/lib/global/helpers/components'
import { Nullable } from '@speckle/shared'
defineEmits<{
(e: 'infinite', $state: InfiniteLoaderState): void
}>()
defineProps<{
/**
* v3-infinite-loading props, see docs or type definitions
*/
settings?: {
target?: string
distance?: number
top?: boolean
// eslint-disable-next-line @typescript-eslint/no-explicit-any
identifier?: any
firstload?: boolean
}
allowRetry?: boolean
}>()
const wrapper = ref(null as Nullable<HTMLElement>)
const initializeLoader = ref(false)
// This hack is necessary cause sometimes v3-infinite-loading initializes too early and doesnt trigger
if (process.client) {
onMounted(() => {
const int = setInterval(() => {
if (wrapper.value?.isConnected) {
initializeLoader.value = true
clearInterval(int)
}
}, 200)
})
}
</script>
@@ -0,0 +1,16 @@
<template>
<CommonBadge
color-classes="text-foreground-on-primary"
rounded
:style="{ backgroundColor: sourceApp.bgColor }"
>
{{ sourceApp.short }}
</CommonBadge>
</template>
<script setup lang="ts">
import { SourceAppDefinition } from '@speckle/shared'
defineProps<{
sourceApp: SourceAppDefinition
}>()
</script>
@@ -0,0 +1,5 @@
<template>
<svg width="18" height="19" viewBox="0 0 18 19" xmlns="http://www.w3.org/2000/svg">
<path d="M4.33333 17L1 1L16 9.25806L8.5 10.5L4.33333 17Z" stroke="#2563eb" />
</svg>
</template>
@@ -0,0 +1,14 @@
<template>
<svg
width="18"
height="16"
viewBox="0 0 18 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
>
<path
d="M11.4998 3.75H12.2498V3V2.16667C12.2498 1.66177 12.6582 1.25 13.1665 1.25H15.6665C16.1714 1.25 16.5832 1.65832 16.5832 2.16667V5.5C16.5832 6.0049 16.1749 6.41667 15.6665 6.41667H13.1665C12.6641 6.41667 12.2498 6.00245 12.2498 5.5V4.66667V3.91667H11.4998H9.83317H9.08317V4.66667V10.5083C9.08317 11.3725 9.79396 12.0833 10.6582 12.0833H11.4998H12.2498V11.3333V10.5C12.2498 9.9951 12.6582 9.58333 13.1665 9.58333H15.6665C16.1714 9.58333 16.5832 9.99165 16.5832 10.5V13.8333C16.5832 14.3382 16.1749 14.75 15.6665 14.75H13.1665C12.6616 14.75 12.2498 14.3417 12.2498 13.8333V13V12.25H11.4998H10.6582C9.69738 12.25 8.9165 11.4691 8.9165 10.5083V4.66667V3.91667H8.1665H6.49984H5.74984V4.66667V5.5C5.74984 6.0049 5.34152 6.41667 4.83317 6.41667H2.33317C1.82827 6.41667 1.4165 6.00835 1.4165 5.5V2.16667C1.4165 1.66421 1.83072 1.25 2.33317 1.25H4.8415C5.3464 1.25 5.75817 1.65832 5.75817 2.16667V3V3.75H6.50817H11.4998Z"
/>
</svg>
</template>
@@ -0,0 +1,13 @@
<template>
<svg
width="16"
height="17"
viewBox="0 0 16 17"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.648 8.90476L13.784 15.381H2.224L4.352 8.90476H11.648ZM4 0L0.8 3.2381L4 6.47619V4.04762H7.2V2.42857H4V0ZM12 0V2.42857H8.8V4.04762H12V6.47619L15.2 3.2381L12 0ZM12.8 7.28571H3.2L0 17H16L12.8 7.28571Z"
/>
</svg>
</template>
@@ -0,0 +1,13 @@
<template>
<svg
width="16"
height="17"
viewBox="0 0 16 17"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.648 8.90476L13.784 15.381H2.224L4.352 8.90476H11.648ZM12 0L8.8 3.2381L12 6.47619V4.04762H15.2V2.42857H12V0ZM4 0V2.42857H0.8V4.04762H4V6.47619L7.2 3.2381L4 0ZM12.8 7.28571H3.2L0 17H16L12.8 7.28571Z"
/>
</svg>
</template>
@@ -0,0 +1,9 @@
<template>
<NuxtLink to="/">
<img
src="~/assets/images/speckle_text_logo_white.svg"
alt="Speckle Logo"
width="192"
/>
</NuxtLink>
</template>
@@ -0,0 +1,28 @@
<template>
<NuxtLink class="flex items-center" to="/">
<img
class="block h-6 w-6"
:class="{ 'mr-2': !minimal, grayscale: active }"
src="~~/assets/images/speckle_logo_big.png"
alt="Speckle"
/>
<div
v-if="!minimal"
class="text-primary h6 mt-0 hidden font-bold leading-7 md:flex"
>
Speckle
</div>
</NuxtLink>
</template>
<script setup lang="ts">
defineProps({
minimal: {
type: Boolean,
default: false
},
active: {
type: Boolean,
default: true
}
})
</script>

Some files were not shown because too many files have changed in this diff Show More