feat: Frontend 2.0 MVP
@@ -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:
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,10 +23,13 @@ const config = {
|
||||
ignorePatterns: [
|
||||
'node_modules',
|
||||
'dist',
|
||||
'dist-*',
|
||||
'public',
|
||||
'events.json',
|
||||
'.*.{ts,js,vue,tsx,jsx}',
|
||||
'generated/**/*'
|
||||
'generated/**/*',
|
||||
'.nuxt',
|
||||
'.output'
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -58,3 +58,5 @@ packages/server/.vscode/*.log
|
||||
|
||||
# GitGuardian
|
||||
.cache_ggshield
|
||||
|
||||
storybook-static
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
schema: 'http://localhost:3000/graphql'
|
||||
extensions:
|
||||
languageService:
|
||||
# Cause it's busted
|
||||
enableValidation: false
|
||||
require:
|
||||
- ts-node/register
|
||||
- tsconfig-paths/register
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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(' ')
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
*.log*
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
.output
|
||||
.env
|
||||
dist
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 545 KiB |
|
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 |
@@ -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? </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">
|
||||
|
||||
<!-- 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>
|
||||