diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77bcc3b..41c1152 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,7 +56,7 @@ jobs: run: | curl -sSL https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor | sudo tee /usr/share/keyrings/google.gpg > /dev/null echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google.gpg] http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee /etc/apt/sources.list.d/google-chrome.list - sudo apt-get update + sudo apt-get update --allow-releaseinfo-change-label sudo apt-get install -y google-chrome-stable sudo apt install socat @@ -87,7 +87,7 @@ jobs: - name: Run PHPMD run: vendor/bin/phpmd src/ text vendor/phplist/core/config/PHPMD/rules.xml - name: Run PHP_CodeSniffer - run: vendor/bin/phpcs --standard=vendor/phplist/core/config/PhpCodeSniffer/ src/ tests/ + run: vendor/bin/phpcs --ignore=tests/Unit/assets --standard=vendor/phplist/core/config/PhpCodeSniffer/ src/ tests/ - name: Install Prism run: npm install -g @stoplight/prism-cli @@ -145,6 +145,9 @@ jobs: - name: Run tests with phpunit run: vendor/bin/phpunit tests + - name: Run tests with vitest + run: node node_modules/vitest/vitest.mjs --run + - name: Upload Panther screenshots if: failure() uses: actions/upload-artifact@v4 diff --git a/.husky/pre-commit b/.husky/pre-commit index bb5f8a8..989fba2 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -8,4 +8,7 @@ echo "๐Ÿ“ Running PHPMD..." php vendor/bin/phpmd src/ text vendor/phplist/core/config/PHPMD/rules.xml || exit 1 echo "๐Ÿงน Running PHPCS..." -php vendor/bin/phpcs --standard=vendor/phplist/core/config/PhpCodeSniffer/ src/ tests/ || exit 1 +php vendor/bin/phpcs \ + --standard=vendor/phplist/core/config/PhpCodeSniffer/ \ + --ignore=tests/Unit/assets \ + src/ tests/ || exit 1 diff --git a/README.md b/README.md index 185b65d..0971e85 100644 --- a/README.md +++ b/README.md @@ -67,3 +67,56 @@ To add new Vue components: 1. Create the component in the `assets/vue/` directory 2. Import and mount it in `assets/app.js` 3. Add a mount point in the appropriate template + +## CKEditor 5 Integration + +The rich text editor lives in `assets/editor/` and is exposed to the existing forms through `assets/vue/components/base/CkEditorField.vue`. + +### Architecture + +- `assets/editor/CkEditor.vue` is the reusable Vue 3 editor component. +- `assets/editor/uploadAdapter.ts` provides CKEditor upload support against a Symfony endpoint. +- `assets/editor/assetBrowserPlugin.js` adds a toolbar button that opens the native asset browser. +- `assets/editor/EditorAssetPicker.vue` shows existing uploaded files and inserts them as images or links. +- `assets/editor/plugins.ts` and `assets/editor/toolbar.ts` keep the editor configuration isolated and reusable. +- `src/Service/EditorUploadService.php` stores uploads in the public filesystem and returns a browser URL. +- `src/Controller/EditorUploadController.php` accepts authenticated uploads and lists existing assets. + +### Reused from the legacy plugin + +- upload path conventions +- image validation rules +- public-file URL generation strategy +- file storage separation for editor content +- asset browsing behavior, expressed as a native Vue modal instead of a popup window + +### New behavior + +- Vue component with `v-model` +- configurable toolbar and CKEditor options +- upload adapter with progress support +- cleanup on unmount +- read-only mode support +- existing file browser for reusing uploaded content + +### Build and test + +```bash +yarn encore dev +yarn test:vue +vendor/bin/phpunit +``` + +### Configuration + +The backend uses the existing phpList upload directory parameters: + +- `phplist.upload_images_dir` + +Uploads are stored below `public//ckeditor5/`. + +### Limitations + +- This integration only covers image uploads from CKEditor. +- elFinder is not embedded in the frontend UI; the frontend now provides a native asset browser instead. +- Existing legacy HTML content is preserved as-is, but custom HTML support still depends on CKEditor 5 output rules. diff --git a/assets/editor/CkEditor.vue b/assets/editor/CkEditor.vue new file mode 100644 index 0000000..1439afb --- /dev/null +++ b/assets/editor/CkEditor.vue @@ -0,0 +1,278 @@ + + + + + diff --git a/assets/editor/EditorAssetPicker.vue b/assets/editor/EditorAssetPicker.vue new file mode 100644 index 0000000..ba16dc2 --- /dev/null +++ b/assets/editor/EditorAssetPicker.vue @@ -0,0 +1,218 @@ + + + diff --git a/assets/editor/assetBrowserPlugin.js b/assets/editor/assetBrowserPlugin.js new file mode 100644 index 0000000..1bf752e --- /dev/null +++ b/assets/editor/assetBrowserPlugin.js @@ -0,0 +1,26 @@ +import { ButtonView, Plugin } from 'ckeditor5'; + +export default class AssetBrowserPlugin extends Plugin { + init() { + const editor = this.editor; + + editor.ui.componentFactory.add('assetBrowser', (locale) => { + const view = new ButtonView(locale); + const openAssetPicker = editor.config.get('openAssetPicker'); + + view.set({ + label: 'Browse files', + tooltip: true, + withText: true, + }); + + view.on('execute', () => { + if (typeof openAssetPicker === 'function') { + openAssetPicker(editor); + } + }); + + return view; + }); + } +} diff --git a/assets/editor/index.ts b/assets/editor/index.ts new file mode 100644 index 0000000..a5c67cf --- /dev/null +++ b/assets/editor/index.ts @@ -0,0 +1,6 @@ +export { default as CkEditor } from './CkEditor.vue'; +export { default as EditorUploadAdapter } from './uploadAdapter.ts'; +export { DEFAULT_TOOLBAR, DEFAULT_IMAGE_TOOLBAR } from './toolbar.ts'; +export { DEFAULT_PLUGINS } from './plugins.ts'; +export { default as EditorAssetPicker } from './EditorAssetPicker.vue'; +export { default as AssetBrowserPlugin } from './assetBrowserPlugin.js'; diff --git a/assets/editor/plugins.ts b/assets/editor/plugins.ts new file mode 100644 index 0000000..8809fcf --- /dev/null +++ b/assets/editor/plugins.ts @@ -0,0 +1,118 @@ +import { + Alignment, + AutoImage, + AutoLink, + Autosave, + BlockQuote, + Bold, + Code, + FontBackgroundColor, + FontColor, + FontFamily, + FontSize, + Essentials, + FileRepository, + GeneralHtmlSupport, + Heading, + Highlight, + HorizontalLine, + HtmlEmbed, + Indent, + IndentBlock, + Image, + ImageCaption, + ImageBlock, + ImageInsert, + ImageInsertUI, + ImageInsertViaUrl, + ImageInline, + ImageResize, + ImageStyle, + ImageToolbar, + ImageUpload, + Italic, + Link, + LinkImage, + List, + ListProperties, + MediaEmbed, + Paragraph, + PictureEditing, + PlainTableOutput, + RemoveFormat, + SourceEditing, + Strikethrough, + Subscript, + Superscript, + Table, + TableCaption, + TableCellProperties, + TableColumnResize, + TableLayout, + TableProperties, + TableToolbar, + TextTransformation, + TodoList, + Underline, +} from 'ckeditor5'; + +import AssetBrowserPlugin from './assetBrowserPlugin.js'; + +export const DEFAULT_PLUGINS = [ + Essentials, + Alignment, + AutoLink, + Autosave, + Paragraph, + Bold, + Italic, + Code, + Heading, + FontBackgroundColor, + FontColor, + FontFamily, + FontSize, + Link, + List, + ListProperties, + BlockQuote, + Highlight, + Table, + TableCaption, + TableCellProperties, + TableColumnResize, + TableLayout, + TableProperties, + TableToolbar, + HorizontalLine, + HtmlEmbed, + SourceEditing, + RemoveFormat, + Strikethrough, + Subscript, + Superscript, + Underline, + TextTransformation, + TodoList, + Indent, + IndentBlock, + MediaEmbed, + Image, + ImageToolbar, + ImageCaption, + ImageStyle, + ImageResize, + ImageUpload, + ImageInsert, + ImageInsertUI, + ImageInsertViaUrl, + ImageInline, + ImageBlock, + LinkImage, + AutoImage, + PictureEditing, + PlainTableOutput, + FileRepository, + GeneralHtmlSupport, + AssetBrowserPlugin, +]; diff --git a/assets/editor/toolbar.ts b/assets/editor/toolbar.ts new file mode 100644 index 0000000..b711b54 --- /dev/null +++ b/assets/editor/toolbar.ts @@ -0,0 +1,50 @@ +export const DEFAULT_TOOLBAR = [ + 'undo', + 'redo', + '|', + 'heading', + '|', + 'fontSize', + 'fontFamily', + 'fontColor', + 'fontBackgroundColor', + '|', + 'bold', + 'italic', + 'underline', + 'strikethrough', + 'subscript', + 'superscript', + 'code', + 'removeFormat', + '|', + 'link', + 'highlight', + 'alignment', + '|', + 'bulletedList', + 'numberedList', + 'todoList', + '|', + 'blockQuote', + 'insertTable', + 'insertTableLayout', + 'insertImage', + 'sourceEditing', + 'htmlEmbed', + 'mediaEmbed', + 'assetBrowser', + '|', + 'horizontalLine', + 'outdent', + 'indent', +]; + +export const DEFAULT_IMAGE_TOOLBAR = [ + 'imageStyle:inline', + 'imageStyle:wrapText', + 'imageStyle:breakText', + '|', + 'toggleImageCaption', + 'imageTextAlternative', +]; diff --git a/assets/editor/uploadAdapter.ts b/assets/editor/uploadAdapter.ts new file mode 100644 index 0000000..f54c273 --- /dev/null +++ b/assets/editor/uploadAdapter.ts @@ -0,0 +1,88 @@ +export default class EditorUploadAdapter { + constructor(loader, options = {}) { + this.loader = loader; + this.endpoint = options.endpoint || '/editor/upload'; + this.headers = options.headers || {}; + this.withCredentials = options.withCredentials !== false; + this.xhr = null; + } + + upload() { + return this.loader.file.then((file) => new Promise((resolve, reject) => { + const xhr = this.xhr = new XMLHttpRequest(); + const formData = new FormData(); + + xhr.open('POST', this.endpoint, true); + xhr.responseType = 'json'; + xhr.withCredentials = this.withCredentials; + xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); + + Object.entries(this.headers).forEach(([key, value]) => { + xhr.setRequestHeader(key, value); + }); + + xhr.upload.addEventListener('progress', (event) => { + if (!event.lengthComputable) { + return; + } + + this.loader.uploadTotal = event.total; + this.loader.uploaded = event.loaded; + }); + + xhr.addEventListener('error', () => { + reject('The file could not be uploaded.'); + }); + + xhr.addEventListener('abort', () => { + reject('The upload was aborted.'); + }); + + xhr.addEventListener('load', () => { + const response = xhr.response || parseResponse(xhr.responseText); + + if (!response) { + reject('The upload response was empty.'); + return; + } + + if (response.error?.message) { + reject(response.error.message); + return; + } + + if (typeof response.url === 'string' && response.url.length > 0) { + resolve({ + default: response.url, + }); + return; + } + + reject('The upload response did not include a file URL.'); + }); + + formData.append('upload', file); + formData.append('fileName', file.name); + + xhr.send(formData); + })); + } + + abort() { + if (this.xhr) { + this.xhr.abort(); + } + } +} + +function parseResponse(responseText) { + if (typeof responseText !== 'string' || responseText.trim() === '') { + return null; + } + + try { + return JSON.parse(responseText); + } catch (_error) { + return null; + } +} diff --git a/assets/router/index.js b/assets/router/index.js index f1d66d3..702e426 100644 --- a/assets/router/index.js +++ b/assets/router/index.js @@ -8,6 +8,10 @@ import CampaignEditView from '../vue/views/CampaignEditView.vue' import TemplatesView from '../vue/views/TemplatesView.vue' import TemplateEditView from '../vue/views/TemplateEditView.vue' import BouncesView from '../vue/views/BouncesView.vue' +import AnalyticsView from '../vue/views/AnalyticsView.vue' +import PublicPagesView from '../vue/views/PublicPagesView.vue' +import PublicPageEditView from '../vue/views/PublicPageEditView.vue' +import SettingsView from '../vue/views/SettingsView.vue' export const router = createRouter({ history: createWebHistory(), @@ -23,6 +27,11 @@ export const router = createRouter({ { path: '/campaigns/:campaignId/edit', name: 'campaign-edit', component: CampaignEditView, meta: { title: 'Edit Campaign' } }, { path: '/lists/:listId/subscribers', name: 'list-subscribers', component: ListSubscribersView, meta: { title: 'List Subscribers' } }, { path: '/bounces', name: 'bounces', component: BouncesView, meta: { title: 'Bounces' } }, + { path: '/analytics', name: 'analytics', component: AnalyticsView, meta: { title: 'Analytics' } }, + { path: '/public', name: 'public-pages', component: PublicPagesView, meta: { title: 'Public Pages' } }, + { path: '/public/create', name: 'public-page-create', component: PublicPageEditView, meta: { title: 'Create Public Page' } }, + { path: '/public/:pageId/edit', name: 'public-page-edit', component: PublicPageEditView, meta: { title: 'Edit Public Page' } }, + { path: '/settings', name: 'settings', component: SettingsView, meta: { title: 'Settings' } }, { path: '/:pathMatch(.*)*', redirect: '/' }, ], }); diff --git a/assets/styles/color.css b/assets/styles/color.css new file mode 100644 index 0000000..accbd53 --- /dev/null +++ b/assets/styles/color.css @@ -0,0 +1,33 @@ +:root { + --page-bg: #e5e7eb; + --primary-text: #374151; + + --header-bg: #1e1b4b; + --logo-color: #c7d2fe; + + --topbar-gradient-start: #312e81; + --topbar-gradient-middle: #3730a3; + --topbar-gradient-end: #4338ca; + --topbar-border: #4f46e5; + + --card-bg: #ffffff; + --card-border: #e5e7eb; + + --input-bg: #ffffff; + --input-border: #d1d5db; + + --field-frame-bg: #f9fafb; + --field-frame-border: #e5e7eb; + + --required-color: #dc2626; + + --error-bg: #fef2f2; + --error-border: #fca5a5; + --error-text: #dc2626; + + --success-bg: #f0fdf4; + --success-border: #86efac; + --success-text: #16a34a; + + --footer-border: #e5e7eb; +} diff --git a/assets/styles/subscribe.css b/assets/styles/subscribe.css new file mode 100644 index 0000000..2ca756f --- /dev/null +++ b/assets/styles/subscribe.css @@ -0,0 +1,354 @@ +body { + margin: 0; + background: var(--page-bg); + color: var(--primary-text); + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; +} + +#container { + min-width: 300px; + margin: 0 auto; +} + +#header { + background: var(--primary-text); + box-sizing: border-box; + position: relative; + left: 50%; + transform: translateX(-50%); + width: 100vw; +} + +#logo { + color: var(--logo-color); + margin-top: 0px; + font-size: 48px; + text-align: center; + padding-bottom: 20px; +} + +#logo a { + color: var(--logo-color); + text-decoration: none; + font-size: 2rem; +} + +#wrapper { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +#mainContent { + min-height: 500px; +} + +#footer { + padding: 20px; + border-top: 1px solid var(--footer-border); +} + +.inline-field { + display: flex; + align-items: center; + gap: 12px; +} + +.inline-field .legacy-label { + margin: 0; + white-space: nowrap; + min-width: 80px; /* adjust as needed */ +} + +.inline-field .legacy-input { + flex: 1; +} + +.checkbox-inline { + display: flex; + align-items: center; + gap: 8px; +} + +.checkbox-inline .legacy-label { + margin: 0; +} + +.checkbox-inline input[type="checkbox"] { + margin: 0; + flex-shrink: 0; +} + +.checkbox-field label { + display: inline-flex; + align-items: center; + gap: 6px; + margin-right: 15px; + margin-bottom: 0; +} + +.checkbox-field input[type="checkbox"] { + margin: 0; +} + +.legacy-topbar { + background: linear-gradient(90deg, var(--topbar-gradient-start) 0%, var(--topbar-gradient-middle) 55%, var(--topbar-gradient-end) 100%); + border-bottom: 1px solid var(--topbar-border); + box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.1); + margin: 10px 0 0; + min-height: 68px; +} + +.legacy-topbar-inner { + box-sizing: border-box; + color: #e0e7ff; + font-size: 33px; + font-weight: 700; + line-height: 1; + margin: 0 auto; + max-width: 930px; + padding: 6px 18px; +} + +.legacy-page-shell { + padding: 40px 16px 60px; +} + +.legacy-card { + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: 16px; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1), 0 2px 6px rgba(0, 0, 0, 0.06); + margin: 0 auto; + max-width: 900px; + padding: 52px 30px 36px; +} + +.legacy-form { + margin: 0; +} + +.legacy-html-text { + color: var(--primary-text); + font-size: 13px; + line-height: 1.5; + margin-bottom: 18px; +} + +.legacy-required-note { + color: var(--required-color); + font-size: 12px; + font-weight: 400; + margin: 10px 0 14px; + text-transform: uppercase; +} + +.legacy-field-group { + margin-bottom: 18px; +} + +.legacy-label { + color: var(--primary-text); + display: block; + font-size: 13px; + font-weight: 600; + margin-bottom: 6px; +} + +.legacy-input { + background: var(--input-bg); + border: 1px solid var(--input-border); + border-radius: 6px; + box-sizing: border-box; + color: var(--primary-text); + font-size: 13px; + max-width: none; + padding: 7px 10px; + transition: border-color 0.15s, box-shadow 0.15s; + width: 100%; +} + +.legacy-input:focus { + outline: none; + border-color: #543ff6; + box-shadow: 0 0 0 3px rgba(84, 63, 246, 0.12); +} + +.legacy-field-frame { + background: var(--field-frame-bg); + border: 1px solid var(--field-frame-border); + border-radius: 8px; + margin: 14px 0 12px; + padding: 10px; +} + +.legacy-field-row { + align-items: center; + display: grid; + gap: 20px; + grid-template-columns: 220px minmax(0, 1fr); + margin-bottom: 12px; +} + +.legacy-field-row .legacy-label { + margin: 0; +} + +.legacy-option-label, +.legacy-list-option, +.legacy-check-label { + color: var(--primary-text); + font-size: 13px; +} + +.legacy-lists { + border: 0; + margin: 16px 0; + padding: 0; +} + +.legacy-lists legend { + color: var(--primary-text); + font-size: 14px; + font-weight: 700; + margin-bottom: 8px; + padding: 0; +} + +.legacy-list-options { + display: flex; + flex-direction: column; + gap: 8px; +} + +.legacy-list-option { + align-items: flex-start; + display: flex; + gap: 8px; +} + +.legacy-actions { + align-items: center; + display: flex; + gap: 14px; + margin-top: 10px; +} + +.legacy-button { + background: #543ff6; + border: 1px solid transparent; + border-radius: 6px; + color: #ffffff; + cursor: pointer; + font-size: 14px; + font-weight: 600; + line-height: 1; + padding: 8px 16px; + transition: background-color 0.15s; +} + +.legacy-button:hover { + background: #303F9F; +} + +.adminmessage { + background: var(--page-bg); + border: 1px solid var(--card-border); + border-radius: 8px; + color: var(--primary-text); + font-size: 12px; + margin-bottom: 14px; + padding: 14px 14px 12px; +} + +.adminmessage p { + margin: 0 0 8px; +} + +.adminmessage p:last-child { + margin-bottom: 0; +} + +.adminmessage .button { + background: #543ff6; + border: 1px solid #fff; + border-radius: 6px; + color: #fff; + display: inline-block; + padding: 4px 8px; + text-decoration: none; +} + +.legacy-confirmation { + color: var(--primary-text); + font-size: 13px; + margin: 10px 0 16px; +} + +.legacy-powered-by { + margin-top: 26px; + text-align: center; +} + +.legacy-powered-by img { + display: inline-block; +} + +.legacy-error-box { + background: var(--error-bg); + border: 1px solid var(--error-border); + border-radius: 6px; + color: var(--error-text); + font-size: 13px; + margin-bottom: 12px; + padding: 10px; +} + +.legacy-error-box p { + margin: 0 0 6px; +} + +.legacy-error-box p:last-child { + margin-bottom: 0; +} + +.legacy-success { + background: var(--success-bg); + border: 1px solid var(--success-border); + border-radius: 6px; + color: var(--success-text); + font-size: 13px; + margin-bottom: 12px; + padding: 10px; +} + +.legacy-hidden { + display: none; +} + +input[type="checkbox"] { + accent-color: var(--topbar-gradient-end); +} + +.poweredby { + position: fixed; + bottom: 1rem; + left: 50%; + transform: translateX(-50%); + z-index: 9999; +} + +@media (max-width: 720px) { + .legacy-topbar-inner { + font-size: 34px; + } + + .legacy-card { + padding: 28px 16px; + } + + .legacy-field-row { + gap: 6px; + grid-template-columns: 1fr; + } +} diff --git a/assets/vue/App.vue b/assets/vue/App.vue index e38be10..dacfe52 100644 --- a/assets/vue/App.vue +++ b/assets/vue/App.vue @@ -2,7 +2,7 @@
-
+
diff --git a/assets/vue/api.js b/assets/vue/api.js index 43e2e12..f4e44ba 100644 --- a/assets/vue/api.js +++ b/assets/vue/api.js @@ -1,14 +1,18 @@ import { + AdminClient, CampaignClient, Client, ListMessagesClient, ListClient, StatisticsClient, + SubscribePagesClient, SubscribersClient, SubscriptionClient, SubscriberAttributesClient, TemplatesClient, BouncesClient, + ConfigClient, + AdminAttributeClient, } from '@tatevikgr/rest-api-client'; const AUTHENTICATION_REDIRECT_PATH = '/login'; @@ -27,7 +31,9 @@ const redirectToLogin = () => { return; } isAuthenticationRedirectInProgress = true; - window.location.href = AUTHENTICATION_REDIRECT_PATH; + const redirectTarget = `${window.location.pathname}${window.location.search}`; + const search = new URLSearchParams({ redirect: redirectTarget }).toString(); + window.location.href = `${AUTHENTICATION_REDIRECT_PATH}?${search}`; }; const appElement = document.getElementById('vue-app'); @@ -40,7 +46,6 @@ if (!apiBaseUrl) { const client = new Client(apiBaseUrl || '', { onAuthenticationError: redirectToLogin, - onAuthorizationError: redirectToLogin, }); if (apiToken) { @@ -59,14 +64,19 @@ client.axiosInstance?.interceptors?.response?.use( ); export const subscribersClient = new SubscribersClient(client); +export const adminClient = new AdminClient(client); +export const adminAttributeClient = new AdminAttributeClient(client); export const listClient = new ListClient(client); export const campaignClient = new CampaignClient(client); export const listMessagesClient = new ListMessagesClient(client); export const statisticsClient = new StatisticsClient(client); export const subscriptionClient = new SubscriptionClient(client); +export const subscribePagesClient = new SubscribePagesClient(client); export const subscriberAttributesClient = new SubscriberAttributesClient(client); export const templateClient = new TemplatesClient(client); export const bouncesClient = new BouncesClient(client); +export const configClient = new ConfigClient(client); + export const backendFetch = async (input, init = undefined) => { const response = await fetch(input, init); @@ -100,4 +110,48 @@ export const fetchAllLists = async ({ limit = 100, maxPages = 100 } = {}) => { return lists; }; +export const fetchAllAdmins = async ({ limit = 100, maxPages = 100 } = {}) => { + const admins = []; + let afterId = null; + + for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) { + const response = await adminClient.getAdministrators(afterId, limit); + const items = Array.isArray(response?.items) ? response.items : []; + admins.push(...items); + + const hasMore = response?.pagination?.hasMore === true; + const nextCursor = response?.pagination?.nextCursor; + + if (!hasMore || !Number.isFinite(nextCursor) || nextCursor === afterId) { + break; + } + + afterId = nextCursor; + } + + return admins; +}; + +export const fetchAllAttributeDefinitions = async ({ limit = 100, maxPages = 100 } = {}) => { + const attributes = []; + let afterId = null; + + for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) { + const response = await subscriberAttributesClient.getAttributeDefinitions(afterId, limit); + const items = Array.isArray(response?.items) ? response.items : []; + attributes.push(...items); + + const hasMore = response?.pagination?.hasMore === true; + const nextCursor = response?.pagination?.nextCursor; + + if (!hasMore || !Number.isFinite(nextCursor) || nextCursor === afterId) { + break; + } + + afterId = nextCursor; + } + + return attributes; +}; + export default client; diff --git a/assets/vue/components/base/BaseBadge.spec.js b/assets/vue/components/base/BaseBadge.spec.js deleted file mode 100644 index df0507a..0000000 --- a/assets/vue/components/base/BaseBadge.spec.js +++ /dev/null @@ -1,33 +0,0 @@ -import { mount } from '@vue/test-utils' -import BaseBadge from './BaseBadge.vue' - -describe('BaseBadge', () => { - it('renders neutral variant by default', () => { - const wrapper = mount(BaseBadge, { - slots: { - default: 'All', - }, - }) - - const classes = wrapper.get('span').classes() - expect(wrapper.text()).toContain('All') - expect(classes).toContain('bg-gray-100') - expect(classes).toContain('text-gray-800') - }) - - it('renders counter variant styles', () => { - const wrapper = mount(BaseBadge, { - props: { - variant: 'counter', - }, - slots: { - default: '10', - }, - }) - - const classes = wrapper.get('span').classes() - expect(classes).toContain('bg-indigo-50') - expect(classes).toContain('text-ext-wf3') - expect(wrapper.text()).toContain('10') - }) -}) diff --git a/assets/vue/components/base/BaseCard.vue b/assets/vue/components/base/BaseCard.vue index 2278c74..5a15a04 100644 --- a/assets/vue/components/base/BaseCard.vue +++ b/assets/vue/components/base/BaseCard.vue @@ -1,6 +1,6 @@