diff --git a/api/.env.docker b/api/.env.docker index 9c4e66d5..db728ef6 100644 --- a/api/.env.docker +++ b/api/.env.docker @@ -34,18 +34,7 @@ AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 AWS_BUCKET= -PUSHER_APP_ID= -PUSHER_APP_KEY= -PUSHER_APP_SECRET= -PUSHER_APP_CLUSTER=mt1 - -MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}" -MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" - JWT_TTL=1440 JWT_SECRET= -MUX_WORKSPACE_ID= -MUX_API_TOKEN= - OPEN_AI_API_KEY= \ No newline at end of file diff --git a/api/.env.example b/api/.env.example index 41365481..61448dee 100644 --- a/api/.env.example +++ b/api/.env.example @@ -76,9 +76,6 @@ H_CAPTCHA_SECRET_KEY= RE_CAPTCHA_SITE_KEY= RE_CAPTCHA_SECRET_KEY= -MUX_WORKSPACE_ID= -MUX_API_TOKEN= - ADMIN_EMAILS= TEMPLATE_EDITOR_EMAILS= diff --git a/api/app/Models/Forms/Form.php b/api/app/Models/Forms/Form.php index 42b6f2ec..afecd4b2 100644 --- a/api/app/Models/Forms/Form.php +++ b/api/app/Models/Forms/Form.php @@ -223,7 +223,7 @@ class Form extends Model implements CachableAttributes public function getIsClosedAttribute() { - return $this->closes_at && now()->gt($this->closes_at); + return $this->visibility === 'closed' || ($this->closes_at && now()->gt($this->closes_at)); } public function getFormPendingSubmissionKeyAttribute() diff --git a/api/app/Open/MentionParser.php b/api/app/Open/MentionParser.php index b679e8e3..5e254bda 100644 --- a/api/app/Open/MentionParser.php +++ b/api/app/Open/MentionParser.php @@ -4,6 +4,7 @@ namespace App\Open; use DOMDocument; use DOMXPath; +use DOMElement; class MentionParser { @@ -39,21 +40,24 @@ class MentionParser libxml_use_internal_errors($internalErrors); $xpath = new DOMXPath($doc); - $mentionElements = $xpath->query("//span[@mention]"); + + $mentionElements = $xpath->query("//span[@mention or @mention='true']"); foreach ($mentionElements as $element) { - $fieldId = $element->getAttribute('mention-field-id'); - $fallback = $element->getAttribute('mention-fallback'); - $value = $this->getData($fieldId); + if ($element instanceof DOMElement) { + $fieldId = $element->getAttribute('mention-field-id'); + $fallback = $element->getAttribute('mention-fallback'); + $value = $this->getData($fieldId); - if ($value !== null) { - $textNode = $doc->createTextNode(is_array($value) ? implode($this->urlFriendly ? ',+' : ', ', $value) : $value); - $element->parentNode->replaceChild($textNode, $element); - } elseif ($fallback) { - $textNode = $doc->createTextNode($fallback); - $element->parentNode->replaceChild($textNode, $element); - } else { - $element->parentNode->removeChild($element); + if ($value !== null) { + $textNode = $doc->createTextNode(is_array($value) ? implode($this->urlFriendly ? ',+' : ', ', $value) : $value); + $element->parentNode->replaceChild($textNode, $element); + } elseif ($fallback) { + $textNode = $doc->createTextNode($fallback); + $element->parentNode->replaceChild($textNode, $element); + } else { + $element->parentNode->removeChild($element); + } } } diff --git a/api/tests/Unit/Service/Forms/MentionParserTest.php b/api/tests/Unit/Service/Forms/MentionParserTest.php index 0607e17b..61bdbb58 100644 --- a/api/tests/Unit/Service/Forms/MentionParserTest.php +++ b/api/tests/Unit/Service/Forms/MentionParserTest.php @@ -35,6 +35,18 @@ describe('MentionParser', function () { expect($result)->toBe('
Hello !
'); }); + it('supports mention="true" syntax', function () { + $content = '

Hello Full Name

'; + $data = [ + ['id' => '123', 'value' => 'John Doe'] + ]; + + $parser = new MentionParser($content, $data); + $result = $parser->parse(); + + expect($result)->toBe('

Hello John Doe

'); + }); + describe('parseAsText', function () { it('converts HTML to plain text with proper line breaks', function () { $content = '
First line
Second line
'; @@ -144,6 +156,29 @@ describe('MentionParser', function () { expect($result)->toBe('

Tags: PHP, Laravel, Testing

'); }); + test('it supports mention="true" attributes', function () { + $content = '

Hello Full Name

'; + $data = [['id' => '123', 'value' => 'John Doe']]; + + $parser = new MentionParser($content, $data); + $result = $parser->parse(); + + expect($result)->toBe('

Hello John Doe

'); + }); + + test('it handles multiple mentions with mention="true" syntax', function () { + $content = '

Name and Title

'; + $data = [ + ['id' => '123', 'value' => 'John Doe'], + ['id' => '456', 'value' => 'Developer'], + ]; + + $parser = new MentionParser($content, $data); + $result = $parser->parse(); + + expect($result)->toBe('

John Doe and Developer

'); + }); + test('it preserves HTML structure', function () { $content = '

Hello Placeholder

How are you?

'; $data = [['id' => '123', 'value' => 'World']]; @@ -174,6 +209,53 @@ describe('MentionParser', function () { expect($result)->toBe('some text replaced text dewde'); }); + test('it handles URL-encoded field IDs', function () { + $content = '

Hello Full Name

'; + $data = [['id' => '%3ARGE', 'value' => 'John Doe']]; + + $parser = new MentionParser($content, $data); + $result = $parser->parse(); + + expect($result)->toBe('

Hello John Doe

'); + }); + + test('it handles URL-encoded field IDs with mention="true" syntax', function () { + $content = '

Hello Full Name

'; + $data = [['id' => '%3ARGE', 'value' => 'John Doe']]; + + $parser = new MentionParser($content, $data); + $result = $parser->parse(); + + expect($result)->toBe('

Hello John Doe

'); + }); + + test('it handles multiple mentions with URL-encoded IDs and mention="true" syntax', function () { + $content = '

Full Name and Phone

'; + $data = [ + ['id' => '%3ARGE', 'value' => 'John Doe'], + ['id' => 'V%7D%40S', 'value' => '123-456-7890'], + ]; + + $parser = new MentionParser($content, $data); + $result = $parser->parse(); + + expect($result)->toBe('

John Doe and 123-456-7890

'); + }); + + test('it recreates real-world example with URL-encoded IDs', function () { + $content = '

Hello there 👋

This is a confirmation that your submission was successfully saved.

Full NameContact FormPhone Number

'; + $data = [ + ['id' => '%3ARGE', 'value' => 'jujujujuju'], + ['id' => 'title', 'value' => 'jujuuj'], + ['id' => 'V%7D%40S', 'value' => '555-1234'], + ]; + + $parser = new MentionParser($content, $data); + $result = $parser->parse(); + + expect($result)->toBe('

Hello there 👋

This is a confirmation that your submission was successfully saved.

jujujujujujujuuj555-1234

'); + }); + describe('urlFriendlyOutput', function () { test('it encodes special characters in values', function () { $content = '

Test: Placeholder

'; diff --git a/client/components/forms/MentionInput.vue b/client/components/forms/MentionInput.vue index 287b4708..f14ad612 100644 --- a/client/components/forms/MentionInput.vue +++ b/client/components/forms/MentionInput.vue @@ -3,12 +3,12 @@ - + - +
- + - + - - - - \ No newline at end of file +}) + +defineExpose({ + editableDiv, + compVal, + mentionState, + openMentionDropdown, + onInput, +}) + + + diff --git a/client/components/forms/RichTextAreaInput.client.vue b/client/components/forms/RichTextAreaInput.client.vue index 57aad7ae..be16f401 100644 --- a/client/components/forms/RichTextAreaInput.client.vue +++ b/client/components/forms/RichTextAreaInput.client.vue @@ -31,6 +31,7 @@ :options="quillOptions" :disabled="disabled" :style="inputStyle" + @ready="onEditorReady" />
@@ -59,7 +60,7 @@ @@ -103,12 +104,23 @@ watch(compVal, (val) => { } }, { immediate: true }) -// Move the mention extension registration to onMounted - -if (props.enableMentions && !Quill.imports['blots/mention']) { +// Initialize mention extension +if (props.enableMentions) { + // Register the mention extension with Quill mentionState.value = registerMentionExtension(Quill) } +// Handle editor ready event +const onEditorReady = (quillInstance) => { + // If we have a mention module, get its state + if (props.enableMentions && quillInstance) { + const mentionModule = quillInstance.getModule('mention') + if (mentionModule && mentionModule.state) { + mentionState.value = mentionModule.state + } + } +} + const quillOptions = computed(() => { const defaultOptions = { placeholder: props.placeholder || '', diff --git a/client/components/forms/components/MentionDropdown.vue b/client/components/forms/components/MentionDropdown.vue index 7ce580b5..8909c4e6 100644 --- a/client/components/forms/components/MentionDropdown.vue +++ b/client/components/forms/components/MentionDropdown.vue @@ -1,7 +1,7 @@ - - \ No newline at end of file +} + +watch(() => props.mentionState.open, (newValue) => { + if (newValue) { + selectedField.value = null + fallbackValue.value = '' + } +}) + +const insertMention = () => { + if (selectedField.value && props.mentionState.onInsert) { + props.mentionState.onInsert({ + field: selectedField.value, + fallback: fallbackValue.value + }) + } +} + +const cancel = () => { + if (props.mentionState.onCancel) { + props.mentionState.onCancel() + } +} + \ No newline at end of file diff --git a/client/components/forms/components/QuillyEditor.vue b/client/components/forms/components/QuillyEditor.vue index 3d0e0b40..1d0a43f7 100644 --- a/client/components/forms/components/QuillyEditor.vue +++ b/client/components/forms/components/QuillyEditor.vue @@ -8,6 +8,7 @@ - diff --git a/client/components/global/Settings/SettingsSection.vue b/client/components/global/Settings/SettingsSection.vue index 47eb4d65..990d44d3 100644 --- a/client/components/global/Settings/SettingsSection.vue +++ b/client/components/global/Settings/SettingsSection.vue @@ -1,6 +1,6 @@ diff --git a/client/plugins/0.error-handler.client.js b/client/plugins/0.error-handler.client.js new file mode 100644 index 00000000..29025e51 --- /dev/null +++ b/client/plugins/0.error-handler.client.js @@ -0,0 +1,21 @@ +export default defineNuxtPlugin((nuxtApp) => { +const router = useRouter() + + router.onError((error) => { + if ( + error.message.includes('Failed to fetch dynamically imported module') || + error.message.includes('Failed to load resource') + ) { + window.location.reload() + } + }) + + nuxtApp.hook('app:error', (error) => { + if ( + error.message.includes('Loading chunk') || + error.message.includes('Failed to load resource') + ) { + window.location.reload() + } + }) +}) \ No newline at end of file diff --git a/client/plugins/crisp.client.js b/client/plugins/crisp.client.js index a7d8cbf3..327f9e4d 100644 --- a/client/plugins/crisp.client.js +++ b/client/plugins/crisp.client.js @@ -3,7 +3,9 @@ import { Crisp } from "crisp-sdk-web" export default defineNuxtPlugin(() => { const isIframe = useIsIframe() const crispWebsiteId = useRuntimeConfig().public.crispWebsiteId - if (crispWebsiteId && !isIframe) { + const isPublicFormPage = useRoute().name === 'forms-slug' + + if (crispWebsiteId && !isIframe && !isPublicFormPage) { Crisp.configure(crispWebsiteId) window.Crisp = Crisp } diff --git a/client/plugins/gtm.client.js b/client/plugins/gtm.client.js new file mode 100644 index 00000000..d2c8c338 --- /dev/null +++ b/client/plugins/gtm.client.js @@ -0,0 +1,31 @@ +import gtmConfig from '../gtm' + +export default defineNuxtPlugin(() => { + const route = useRoute() + const isIframe = useIsIframe() + const isPublicFormPage = route.name === 'forms-slug' + + // Only enable GTM if not in a form page (for respondents) and not in an iframe + if (!isPublicFormPage && !isIframe && process.env.NUXT_PUBLIC_GTM_CODE) { + // Initialize GTM manually only when needed + const gtm = useGtm() + + // Override the enabled setting to true for this session + gtmConfig.enabled = true + + // Watch for route changes to track page views + watch(() => route.fullPath, () => { + if (!route.name || route.name !== 'forms-slug') { + gtm.trackView(route.name, route.fullPath) + } + }, { immediate: true }) + + return { + provide: { + gtm + } + } + } + + return {} +}) \ No newline at end of file diff --git a/client/sentry.client.config.ts b/client/sentry.client.config.ts index 150cce0b..4ace9217 100644 --- a/client/sentry.client.config.ts +++ b/client/sentry.client.config.ts @@ -24,23 +24,37 @@ Sentry.init({ beforeSend (event) { if (event.exception?.values?.length) { + const errorType = event.exception.values[0]?.type || ''; + const errorValue = event.exception.values[0]?.value || ''; + // Don't send validation exceptions to Sentry if ( - event.exception.values[0]?.type === 'FetchError' - && (event.exception.values[0]?.value?.includes('422') - || event.exception.values[0]?.value?.includes('401')) - ) + errorType === 'FetchError' && + (errorValue.includes('422') || errorValue.includes('401')) + ) { return null + } + + // Filter out chunk loading errors + if ( + errorValue.includes('Failed to fetch dynamically imported module') || + errorValue.includes('Loading chunk') || + errorValue.includes('Failed to load resource') + ) { + return null + } } + const authStore = useAuthStore() if (authStore.check) { + const user = authStore.user as { id?: string; email?: string } | null Sentry.setUser({ - id: authStore.user?.id, - email: authStore.user?.email + id: user?.id, + email: user?.email }) event.user = { - id: authStore.user?.id, - email: authStore.user?.email + id: user?.id, + email: user?.email } } return event diff --git a/client/stores/notion_cms.js b/client/stores/notion_cms.js new file mode 100644 index 00000000..6d0b9538 --- /dev/null +++ b/client/stores/notion_cms.js @@ -0,0 +1,127 @@ +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' +import opnformConfig from "~/opnform.config.js" + +function notionApiFetch (entityId, type = 'table') { + const apiUrl = opnformConfig.notion.worker + return useFetch(`${apiUrl}/${type}/${entityId}`) +} + +function fetchNotionDatabasePages (databaseId) { + return notionApiFetch(databaseId) +} + +function fetchNotionPageContent (pageId) { + return notionApiFetch(pageId, 'page') +} + +export const useNotionCmsStore = defineStore('notion_cms', () => { + + // State + const databases = ref({}) + const pages = ref({}) + const pageContents = ref({}) + const slugToIdMap = ref({}) + + const loading = ref(false) + + // Actions + const loadDatabase = (databaseId) => { + return new Promise((resolve, reject) => { + if (databases.value[databaseId]) return resolve() + + loading.value = true + return fetchNotionDatabasePages(databaseId).then((response) => { + databases.value[databaseId] = response.data.value.map(page => formatId(page.id)) + response.data.value.forEach(page => { + pages.value[formatId(page.id)] = { + ...page, + id: formatId(page.id) + } + const slug = page.Slug ?? page.slug ?? null + if (slug) { + setSlugToIdMap(slug, page.id) + } + }) + loading.value = false + resolve() + }).catch((error) => { + loading.value = false + console.error(error) + reject(error) + }) + }) + } + const loadPage = async (pageId) => { + return new Promise((resolve, reject) => { + if (pageContents.value[pageId]) return resolve('in already here') + loading.value = true + return fetchNotionPageContent(pageId).then((response) => { + pageContents.value[formatId(pageId)] = response.data.value + loading.value = false + return resolve('in finishg') + }).catch((error) => { + console.error(error) + loading.value = false + return reject(error) + }) + }) + } + + const loadPageBySlug = (slug) => { + if (!slugToIdMap.value[slug.toLowerCase()]) return + loadPage(slugToIdMap.value[slug.toLowerCase()]) + } + + const formatId = (id) => id.replaceAll('-', '') + + const getPage = (pageId) => { + return { + ...pages.value[pageId], + blocks: getPageBody(pageId) + } + } + + const getPageBody = (pageId) => { + return pageContents.value[pageId] + } + + const setSlugToIdMap = (slug, pageId) => { + if (!slug) return + slugToIdMap.value[slug.toLowerCase()] = formatId(pageId) + } + + const getPageBySlug = (slug) => { + if (!slug) return + const pageId = slugToIdMap.value[slug.toLowerCase()] + return getPage(pageId) + } + +// Getters + const databasePages = (databaseId) => computed(() => databases.value[databaseId]?.map(id => pages.value[id]) || []) + const pageContent = (pageId) => computed(() => pageContents.value[pageId]) + const pageBySlug = (slug) => computed(() => getPageBySlug(slug)) + + return { + // state + loading, + databases, + pages, + pageContents, + slugToIdMap, + + // actions + loadDatabase, + loadPage, + loadPageBySlug, + getPage, + getPageBody, + setSlugToIdMap, + getPageBySlug, + + // getters + databasePages, + pageContent, + pageBySlug + } +}) diff --git a/client/stores/notion_pages.js b/client/stores/notion_pages.js deleted file mode 100644 index 608f78c9..00000000 --- a/client/stores/notion_pages.js +++ /dev/null @@ -1,26 +0,0 @@ -import { defineStore } from "pinia" -import { useContentStore } from "~/composables/stores/useContentStore.js" -import opnformConfig from "~/opnform.config.js" -export const useNotionPagesStore = defineStore("notion_pages", () => { - const contentStore = useContentStore() - - const load = (pageId) => { - contentStore.startLoading() - - const apiUrl = opnformConfig.notion.worker - return useFetch(`${apiUrl}/page/${pageId}`) - .then(({ data }) => { - const val = data.value - val["id"] = pageId - contentStore.save(val) - }) - .finally(() => { - contentStore.stopLoading() - }) - } - - return { - ...contentStore, - load, - } -}) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 36084769..7b85cbef 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -4,12 +4,6 @@ services: api: image: jhumanj/opnform-api:dev container_name: opnform-api - build: - context: . - dockerfile: docker/Dockerfile.api - args: - APP_ENV: local - COMPOSER_FLAGS: "" volumes: - ./api:/usr/share/nginx/html:delegated - /usr/share/nginx/html/vendor # Exclude vendor directory from the mount @@ -44,9 +38,6 @@ services: ui: image: jhumanj/opnform-client:dev container_name: opnform-client - build: - context: . - dockerfile: docker/Dockerfile.client command: sh -c "npm install && NITRO_HOST=0.0.0.0 NITRO_PORT=3000 npm run dev" volumes: - ./client:/app:delegated diff --git a/docker-compose.yml b/docker-compose.yml index 78cc3d33..22402e96 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,12 +3,6 @@ services: api: &api-environment image: jhumanj/opnform-api:latest container_name: opnform-api - build: &api-build - context: . - dockerfile: docker/Dockerfile.api - args: - APP_ENV: production - COMPOSER_FLAGS: --no-dev volumes: &api-environment-volumes - ./api/storage:/usr/share/nginx/html/storage:rw environment: &api-env @@ -35,7 +29,6 @@ services: api-worker: <<: *api-environment container_name: opnform-api-worker - build: *api-build command: ["php", "artisan", "queue:work"] volumes: *api-environment-volumes environment: @@ -48,7 +41,6 @@ services: api-scheduler: <<: *api-environment container_name: opnform-api-scheduler - build: *api-build command: ["php", "artisan", "schedule:work"] volumes: *api-environment-volumes environment: @@ -65,9 +57,6 @@ services: ui: image: jhumanj/opnform-client:latest container_name: opnform-client - build: - context: . - dockerfile: docker/Dockerfile.client env_file: - ./client/.env diff --git a/docker/Dockerfile.client b/docker/Dockerfile.client index 846ee368..eac0d785 100644 --- a/docker/Dockerfile.client +++ b/docker/Dockerfile.client @@ -20,7 +20,8 @@ RUN npm cache clean --force && \ # RUN npm install esbuild@0.21.5 ADD ./client/ /app/ -RUN npm run build +# Increase Node memory limit to prevent out of memory error during build +RUN NODE_OPTIONS="--max-old-space-size=4096" npm run build FROM node:20-alpine WORKDIR /app