Migrate front-end to Nuxt app (#284)

* wip

* Managed to load a page

* Stuck at changing routes

* Fixed the router, and editable div

* WIP

* Fix app loader

* WIP

* Fix check-auth middleware

* Started to refactor input components

* WIP

* Added select input, v-click-outside for vselect

* update vselect & phone input

* Fixed the mixin

* input component updates

* Fix signature input import

* input component updates in vue3

* image input in vue3

* small fixes

* fix useFormInput watcher

* scale input in vue3

* Vue3: migrating from vuex to Pinia (#249)

* Vue3: migrating from vuex to Pinia

* toggle input fixes

* update configureCompat

---------

Co-authored-by: Forms Dev <chirag+new@notionforms.io>

* support vue3 query builder

* Refactor inpus

* fix: Vue3 Query Builder - Logic Editor (#251)

* support vue3 query builder

* upgrade

* remove local from middleware

* Submission table pagination & migrate chart to vue3 (#254)

* Submission table Pagination in background

* migrate chart to vue3

* Form submissions pagination

* Form submissions

* Fix form starts

* Fix openSelect key issue

---------

Co-authored-by: Forms Dev <chirag+new@notionforms.io>
Co-authored-by: Julien Nahum <julien@nahum.net>

* Vue 3 better animation (#257)

* vue-3-better-animation

* Working on migration to vueuse/motion

* Form sidebar animations

* Clean code

* Added animations for modal

* Finished implementing better animations

---------

Co-authored-by: Forms Dev <chirag+new@notionforms.io>

* Work in progress

* Migrating amplitude and crisp plugin/composable

* Started to refactor pages

* WIP

* vue3-scroll-shadow-fixes (#260)

* WIP

* WIP

* WIP

* Figured out auth & middlewares

* WI

* Refactoring stores and templates pages to comp. api

* Finishing the templates pages

* fix collapsible

* Finish reworking most templates pages

* Reworked workspaces store

* Working on home page and modal

* Fix dropdown

* Fix modal

* Fixed form creation

* Fixed most of the form/show pages

* Updated cors dependency

* fix custom domain warning

* NuxtLink migration (#262)

Co-authored-by: Forms Dev <chirag+new@notionforms.io>

* Tiny fixes + start pre-rendering

* migrate-to-nuxt-useappconfig (#263)

* migrate-to-nuxt-useappconfig

* defineAppConfig

---------

Co-authored-by: Forms Dev <chirag+new@notionforms.io>

* Working on form/show and editor

* Globally import form inputs to fix resolve

* Remove vform - working on form public page

* Remove initform mixin

* Work in progress for form create guess user

* Nuxt Migration notifications (#265)

* Nuxt Migration notifications

* @input to @update:model-value

* change field type fixes

* @update:model-value

* Enable form-block-logic-editor

* vue-confetti migration

* PR request changes

* useAlert in setup

* Migrate to nuxt settings page AND remove axios (#266)

* Settings pages migration

* remove axios and use opnFetch

* Make created form reactive (#267)

* Remove verify pages and axios lib

---------

Co-authored-by: Julien Nahum <julien@nahum.net>

* Fix alert styling + bug fixes and cleaning

* Refactor notifications + add shadow

* Fix vselect issue

* Working on page pre-rendering

* Created NotionPages store

* Added sitemap on nuxt side

* Sitemap done, working on aws amplify

* Adding missing module

* Remove axios and commit backend changes to sitemap

* Fix notifications

* fix guestpage editor (#269)

Co-authored-by: Julien Nahum <julien@nahum.net>

* Remove appconfig in favor of runtimeconfig

* Fixed amplitude bugs, and added staging environment

* Added amplify file

* Change basdirectory amplify

* Fix loading bar position

* Fix custom redirect (#273)

* Dirty form handling - nuxt migration (#272)

* SEO meta nuxt migration (#274)

* SEO meta nuxt migration

* Polish seo metas, add defaults for OG and twitter

---------

Co-authored-by: Julien Nahum <julien@nahum.net>

* migrate to nuxt useClipboard (#268)

* Set middleware on pages (#278)

* Se middleware on pages

* Se middleware on account page

* add robots.txt (#276)

* 404 page migration (#277)

* Templates pages migration (#275)

* NuxtImg Migration (#279)

Co-authored-by: Julien Nahum <julien@nahum.net>

* Update package json

* Fix build script

* Add loglevel param

* Disable page pre-rendering

* Attempt to allow svgs

* Fix SVGs with NuxtImage

* Add .env file at AWS build time

* tRGIGGER deploy

* Fix issue

* ANother attrempt

* Fix typo

* Fix env?

* Attempt to simplify build

* Enable swr caching instead of prerenderign

* Better image compression

* Last attempt at nuxt images efficiency

* Improve image optimization again

* Remove NuxtImg for non asset files

* Restore templates pages cache

* Remove useless images + fix templates show page

* image optimization caching + fix hydratation issue form template page

* URL generation (front&back) + fixed authJWT for SSR

* Fix composable issue

* Fix form share page

* Embeddable form as a nuxt middleware

* Fix URL for embeddable middleware

* Debugging embeddable on amplify

* Add custom domain support

* No follow for non-production env

* Fix sentry nuxt and custom domain redirect

* remove api prefix from routes (#280)

* remove api prefix from routes

* PR changes

---------

Co-authored-by: Julien Nahum <julien@nahum.net>

* nuxt migration -file upload - WIP (#271)

Co-authored-by: Julien Nahum <julien@nahum.net>

* Fix local file upload

* Fix file submissions preview

* API redirect to back-end from nuxt

* API redirect to back-end from nuxt

* Remove old JS app, update deploy script

* Fix tests, added gh action nuxt step

* Updated package-lock.json

* Setup node in GH Nuxt action

* Setup client directory for GH workflow

---------

Co-authored-by: Forms Dev <chirag+new@notionforms.io>
Co-authored-by: Chirag Chhatrala <60499540+chiragchhatrala@users.noreply.github.com>
Co-authored-by: Rishi Raj Jain <rishi18304@iiitd.ac.in>
Co-authored-by: formsdev <136701234+formsdev@users.noreply.github.com>
This commit is contained in:
Julien Nahum
2024-01-15 12:14:47 +01:00
committed by GitHub
parent c01f566ba9
commit 0adce5a2ff
478 changed files with 27676 additions and 34120 deletions

54
client/composables/forms/initForm.js vendored Normal file
View File

@@ -0,0 +1,54 @@
export const initForm = (defaultValue = {}) => {
return useForm({
title: 'My Form',
description: null,
visibility: 'public',
workspace_id: null,
properties: [],
notifies: false,
slack_notifies: false,
send_submission_confirmation: false,
webhook_url: null,
notification_settings: {},
// Customization
theme: 'default',
width: 'centered',
dark_mode: 'auto',
color: '#3B82F6',
hide_title: false,
no_branding: false,
uppercase_labels: true,
transparent_background: false,
closes_at: null,
closed_text: 'This form has now been closed by its owner and does not accept submissions anymore.',
auto_save: true,
// Submission
submit_button_text: 'Submit',
re_fillable: false,
re_fill_button_text: 'Fill Again',
submitted_text: 'Amazing, we saved your answers. Thank you for your time and have a great day!',
notification_sender: 'OpnForm',
notification_subject: 'We saved your answers',
notification_body: 'Hello there 👋 <br>This is a confirmation that your submission was successfully saved.',
notifications_include_submission: true,
use_captcha: false,
is_rating: false,
rating_max_value: 5,
max_submissions_count: null,
max_submissions_reached_text: 'This form has now reached the maximum number of allowed submissions and is now closed.',
editable_submissions_button_text: 'Edit submission',
confetti_on_submission: false,
// Security & Privacy
can_be_indexed: true,
// Custom SEO
seo_meta: {},
...defaultValue
})
}

View File

@@ -0,0 +1,34 @@
import {hash} from "~/lib/utils.js"
import {useStorage} from "@vueuse/core"
export const pendingSubmission = (form) => {
const formPendingSubmissionKey = computed(() => {
return (form) ? form.form_pending_submission_key + '-' + hash(window.location.href) : ''
})
const enabled = computed(() => {
return form?.auto_save ?? false
})
const set = (value) => {
if (process.server || !enabled.value) return
useStorage(formPendingSubmissionKey.value).value = JSON.stringify(value)
}
const remove = () => {
return set(null)
}
const get = (defaultValue = {}) => {
if (process.server || !enabled.value) return
const pendingSubmission = useStorage(formPendingSubmissionKey.value).value
return pendingSubmission ? JSON.parse(pendingSubmission) : defaultValue
}
return {
enabled,
set,
get
}
}

View File

@@ -0,0 +1,14 @@
import FormPropertyLogicRule from "~/lib/forms/FormPropertyLogicRule.js";
export const validatePropertiesLogic = (properties) => {
properties.forEach((field) => {
const isValid = (new FormPropertyLogicRule(field)).isValid()
if (!isValid) {
field.logic = {
conditions: null,
actions: []
}
}
})
return properties
}

75
client/composables/lib/vForm/Errors.js vendored Normal file
View File

@@ -0,0 +1,75 @@
function arrayWrap(value) {
return Array.isArray(value) ? value : [value];
}
export default class Errors {
constructor() {
this.errors = {};
}
set(field, messages = undefined) {
if (typeof field === 'object') {
this.errors = field;
} else {
this.set({ ...this.errors, [field]: arrayWrap(messages) });
}
}
all() {
return this.errors;
}
has(field) {
return Object.prototype.hasOwnProperty.call(this.errors, field);
}
hasAny(...fields) {
return fields.some(field => this.has(field));
}
any() {
return Object.keys(this.errors).length > 0;
}
get(field) {
if (this.has(field)) {
return this.getAll(field)[0];
}
}
getAll(field) {
return arrayWrap(this.errors[field] || []);
}
only(...fields) {
const messages = [];
fields.forEach((field) => {
const message = this.get(field);
if (message) {
messages.push(message);
}
});
return messages;
}
flatten() {
return Object.values(this.errors).reduce((a, b) => a.concat(b), []);
}
clear(field = undefined) {
const errors = {};
if (field) {
Object.keys(this.errors).forEach((key) => {
if (key !== field) {
errors[key] = this.errors[key];
}
});
}
this.set(errors);
}
}

170
client/composables/lib/vForm/Form.js vendored Normal file
View File

@@ -0,0 +1,170 @@
import {serialize} from 'object-to-formdata';
import Errors from './Errors';
import cloneDeep from 'clone-deep';
import {opnFetch} from "~/composables/useOpnApi.js";
function hasFiles(data) {
return data instanceof File ||
data instanceof Blob ||
data instanceof FileList ||
(typeof data === 'object' && data !== null && Object.values(data).find(value => hasFiles(value)) !== undefined);
}
class Form {
constructor(data = {}) {
this.originalData = {};
this.busy = false;
this.successful = false;
this.recentlySuccessful = false;
this.recentlySuccessfulTimeoutId = undefined;
this.errors = new Errors();
this.update(data);
}
static errorMessage = 'Something went wrong. Please try again.';
static recentlySuccessfulTimeout = 2000;
static ignore = ['busy', 'successful', 'errors', 'originalData', 'recentlySuccessful', 'recentlySuccessfulTimeoutId'];
static make(augment) {
return new this(augment);
}
update(data) {
this.originalData = Object.assign({}, this.originalData, cloneDeep(data));
Object.assign(this, data);
}
fill(data = {}) {
this.keys().forEach((key) => {
this[key] = data[key];
});
}
data() {
return this.keys().reduce((data, key) => (
{...data, [key]: this[key]}
), {});
}
keys() {
return Object.keys(this).filter(key => !Form.ignore.includes(key));
}
startProcessing() {
this.errors.clear();
this.busy = true;
this.successful = false;
this.recentlySuccessful = false;
clearTimeout(this.recentlySuccessfulTimeoutId);
}
finishProcessing() {
this.busy = false;
this.successful = true;
this.recentlySuccessful = true;
this.recentlySuccessfulTimeoutId = setTimeout(() => {
this.recentlySuccessful = false;
}, Form.recentlySuccessfulTimeout);
}
clear() {
this.errors.clear();
this.successful = false;
this.recentlySuccessful = false;
clearTimeout(this.recentlySuccessfulTimeoutId);
}
reset() {
Object.keys(this)
.filter(key => !Form.ignore.includes(key))
.forEach((key) => {
this[key] = JSON.parse(JSON.stringify(this.originalData[key]));
});
}
get(url, config = {}) {
return this.submit('get', url, config);
}
post(url, config = {}) {
return this.submit('post', url, config);
}
patch(url, config = {}) {
return this.submit('patch', url, config);
}
put(url, config = {}) {
return this.submit('put', url, config);
}
delete(url, config = {}) {
return this.submit('delete', url, config);
}
submit(method, url, config = {}) {
this.startProcessing();
config = {
body: {},
params: {},
url: url,
method: method,
...config
};
if (method.toLowerCase() === 'get') {
config.params = {...this.data(), ...config.params};
} else {
config.body = {...this.data(), ...config.data};
if (hasFiles(config.data) && !config.transformRequest) {
config.transformRequest = [data => serialize(data)];
}
}
return new Promise((resolve, reject) => {
opnFetch(config.url, config)
.then((data) => {
this.finishProcessing();
resolve(data);
}).catch((error) => {
this.handleErrors(error);
reject(error)
})
});
}
handleErrors(error) {
this.busy = false;
if (error) {
this.errors.set(this.extractErrors(error.data));
}
}
extractErrors(data) {
if (!data || typeof data !== 'object') {
return {error: Form.errorMessage};
}
if (data.errors) {
return {...data.errors};
}
if (data.message) {
return {error: data.message};
}
return {...data};
}
onKeydown(event) {
const target = event.target;
if (target.name) {
this.errors.clear(target.name);
}
}
}
export default Form;

View File

@@ -0,0 +1,65 @@
// Composable with all the logic to encapsulate a default content store
export const useContentStore = (mapKey = 'id') => {
const content = ref(new Map())
const loading = ref(false)
// Computed
const getAll = computed(() => {
return [...content.value.values()]
})
const getByKey = (key) => {
if (Array.isArray(key)) {
return key.map((k) => content.value.get(k)).filter((item) => item !== undefined)
}
return content.value.get(key)
}
const length = computed(() => content.value.size)
// Actions
function set(items) {
content.value = new Map
save(items)
}
function save(items) {
if (!items) return
if (!Array.isArray(items)) items = [items]
items.forEach((item) => {
content.value.set(item[mapKey], item)
})
}
function remove(item) {
content.value.delete( typeof item === 'object' ? item[mapKey] : item)
}
function startLoading() {
loading.value = true
}
function stopLoading() {
loading.value = false
}
function resetState() {
set([])
stopLoading()
}
return {
content,
loading,
getAll,
getByKey,
length,
set,
save,
remove,
startLoading,
stopLoading,
resetState
}
}

51
client/composables/useAlert.js vendored Normal file
View File

@@ -0,0 +1,51 @@
const { notify } = useNotification()
export const useAlert = () => {
function success(message, autoClose = 10000) {
notify({
title: 'Success',
text: message,
type: 'success',
duration: autoClose
})
}
function error(message, autoClose = 10000) {
notify({
title: 'Error',
text: message,
type: 'error',
duration: autoClose
})
}
function warning(message, autoClose = 10000) {
notify({
title: 'Warning',
text: message,
type: 'warning',
duration: autoClose
})
}
function confirm(message, success, failure = ()=>{}, autoClose = 10000) {
notify({
title: 'Confirm',
text: message,
type: 'confirm',
duration: autoClose,
data: {
success,
failure
}
})
}
return {
success,
error,
warning,
confirm
}
}

40
client/composables/useAmplitude.js vendored Normal file
View File

@@ -0,0 +1,40 @@
import amplitude from 'amplitude-js'
export const useAmplitude = () => {
const config = useRuntimeConfig()
const amplitudeCode = config.public.amplitudeCode
const amplitudeClient = amplitudeCode ? amplitude.getInstance() : null;
if (amplitudeClient) {
amplitudeClient.init(amplitudeCode)
}
const logEvent = function (eventName, eventData) {
if (!config.public.env === 'production' || !amplitudeClient) {
console.log('[DEBUG] Amplitude logged event:', eventName, eventData)
return
}
if (eventData && typeof eventData !== 'object') {
throw new Error('Amplitude event value must be an object.')
}
amplitudeClient.logEvent(eventName, eventData)
}
const setUser = function (user) {
if (!amplitudeClient) return
amplitudeClient.setUserId(user.id)
amplitudeClient.setUserProperties({
email: user.email,
subscribed: user.is_subscribed,
enterprise_subscription: user.has_enterprise_subscription
})
}
return {
logEvent,
setUser,
amplitude: amplitudeClient
}
}

22
client/composables/useConfetti.js vendored Normal file
View File

@@ -0,0 +1,22 @@
import { ref, onUnmounted } from 'vue'
export const useConfetti = () => {
let timeoutId = ref(null)
const nuxtApp = useNuxtApp()
const $confetti = nuxtApp.vueApp.config.globalProperties.$confetti
function play(duration=3000) {
$confetti.start({ defaultSize: 6 })
timeoutId = setTimeout(() => {
$confetti.stop()
}, duration)
}
onUnmounted(() => {
if (timeoutId) clearTimeout(timeoutId)
})
return {
play
}
}

88
client/composables/useCrisp.js vendored Normal file
View File

@@ -0,0 +1,88 @@
export const useCrisp = () => {
let crisp = process.client ? window.Crisp : null
function openChat() {
if (!crisp) return
showChat()
crisp.chat.open()
}
function showChat() {
if (!crisp) return
crisp.chat.show()
}
function hideChat() {
if (!crisp) return
crisp.chat.hide()
}
function closeChat() {
if (!crisp) return
crisp.chat.close()
}
function openAndShowChat(message = null) {
if (!crisp) return
showChat()
openChat()
if (message) sendTextMessage(message)
}
function openHelpdesk() {
if (!crisp) return
openChat()
crisp.chat.setHelpdeskView()
}
function openHelpdeskArticle(articleSlug, locale = 'en') {
if (!crisp) return
crisp.chat.openHelpdeskArticle(locale, articleSlug);
}
function sendTextMessage(message) {
if (!crisp) return
crisp.message.send('text', message)
}
function setUser(user) {
if (!crisp) return
crisp.user.setEmail(user.email);
crisp.user.setNickname(user.name);
crisp.session.setData({
user_id: user.id,
'pro-subscription': user?.is_subscribed ?? false,
'stripe-id': user?.stripe_id ?? '',
'subscription': user?.has_enterprise_subscription ? 'enterprise' : 'pro'
});
if (user?.is_subscribed ?? false) {
setSegments(['subscribed', user?.has_enterprise_subscription ? 'enterprise' : 'pro'])
}
}
function pushEvent(event, data = {}) {
if (!crisp) return
crisp.pushEvent(event, data)
}
function setSegments(segments, overwrite = false) {
if (!crisp) return
crisp.session.setSegments(segments, overwrite)
}
return {
crisp,
openChat,
showChat,
hideChat,
closeChat,
openAndShowChat,
openHelpdesk,
openHelpdeskArticle,
sendTextMessage,
pushEvent,
setUser
}
}

5
client/composables/useForm.js vendored Normal file
View File

@@ -0,0 +1,5 @@
import Form from "~/composables/lib/vForm/Form.js"
export const useForm = (formData = {}) => {
return reactive(new Form(formData))
}

6
client/composables/useIsIframe.js vendored Normal file
View File

@@ -0,0 +1,6 @@
export const useIsIframe = () => {
if (process.client) {
return window.location !== window.parent.location || window.frameElement
}
return false
}

80
client/composables/useOpnApi.js vendored Normal file
View File

@@ -0,0 +1,80 @@
import {getDomain, getHost, customDomainUsed} from "~/lib/utils.js";
function addAuthHeader(request, options) {
const authStore = useAuthStore()
if (authStore.token) {
options.headers = {Authorization: `Bearer ${authStore.token}`, ...options.headers}
}
}
function addPasswordToFormRequest(request, options) {
const url = request.url
if (!url || !url.startsWith('/forms/')) return
const slug = url.split('/')[3]
const passwordCookie = useCookie('password-' + slug, {maxAge: 60 * 60 * 24 * 30}) // 30 days
if (slug !== undefined && slug !== '' && passwordCookie.value !== undefined) {
options.headers['form-password'] = passwordCookie.value
}
}
/**
* Add custom domain header if custom domain is used
*/
function addCustomDomainHeader(request, options) {
if (!customDomainUsed()) return
options.headers['x-custom-domain'] = getDomain(getHost())
}
export function getOpnRequestsOptions(request, opts) {
const config = useRuntimeConfig()
if (opts.body && opts.body instanceof FormData) {
opts.headers = {
'charset': 'utf-8',
...opts.headers,
}
}
opts.headers = {accept: 'application/json', ...opts.headers}
// Authenticate requests coming from the server
if (process.server && config.apiSecret) {
opts.headers['x-api-secret'] = config.apiSecret
}
addAuthHeader(request, opts)
addPasswordToFormRequest(request, opts)
addCustomDomainHeader(request, opts)
if (!opts.baseURL) opts.baseURL = config.public.apiBase
return {
async onResponseError({response}) {
const authStore = useAuthStore()
const {status} = response
if (status === 401) {
if (authStore.check) {
console.log("Logging out due to 401")
authStore.logout()
useRouter().push({name: 'login'})
}
} else if (status === 420) {
// If invalid domain, redirect to main domain
window.location.href = config.public.appUrl + '?utm_source=failed_custom_domain_redirect'
} else if (status >= 500) {
console.error('Request error', status)
}
},
...opts
}
}
export const opnFetch = (request, opts = {}) => {
return $fetch(request, getOpnRequestsOptions(request, opts))
}
export const useOpnApi = (request, opts = {}) => {
return useFetch(request, getOpnRequestsOptions(request, opts))
}

16
client/composables/useOpnSeoMeta.js vendored Normal file
View File

@@ -0,0 +1,16 @@
export const useOpnSeoMeta = (meta) => {
return useSeoMeta({
...meta.title ? {
ogTitle: meta.title,
twitterTitle: meta.title,
} : {},
...meta.description ? {
ogDescription: meta.description,
twitterDescription: meta.description,
} : {},
...meta.ogImage ? {
twitterImage: meta.ogImage,
} : {},
...meta,
})
}