diff --git a/packages/ui/scripts/check-completeness.ts b/packages/ui/scripts/check-completeness.ts index 4f5e7bb8f..179717a2e 100644 --- a/packages/ui/scripts/check-completeness.ts +++ b/packages/ui/scripts/check-completeness.ts @@ -87,7 +87,6 @@ function checkStoryCoverage( } const results: CheckResult[] = [] -let hasErrors = false // Find all Vue components (excluding index files) const components = [ @@ -169,18 +168,19 @@ for (const componentPath of components) { if (result.errors.length > 0 || result.warnings.length > 0) { results.push(result) } - - if (result.errors.length > 0) { - hasErrors = true - } } -// --- Ocelot stories (no .vue files, story-driven checks) --- +// --- Ocelot stories without .vue files (e.g. icon stories) --- const ocelotStories = await glob('src/ocelot/**/*.stories.ts') +const componentDirs = new Set(components.map((c) => dirname(c))) for (const storyPath of ocelotStories) { const storyName = basename(storyPath, '.stories.ts') const storyDir = dirname(storyPath) + + // Skip stories that already have a .vue component (checked in the component loop above) + if (componentDirs.has(storyDir)) continue + const visualTestPath = join(storyDir, `${storyName}.visual.spec.ts`) const unitTestPath = join(storyDir, 'index.spec.ts') @@ -210,10 +210,6 @@ for (const storyPath of ocelotStories) { if (result.errors.length > 0 || result.warnings.length > 0) { results.push(result) } - - if (result.errors.length > 0) { - hasErrors = true - } } // Output results @@ -230,16 +226,12 @@ if (results.length === 0) { } for (const warning of result.warnings) { - console.log(` ⚠ ${warning}`) + console.log(` ✗ ${warning}`) } console.log('') } - if (hasErrors) { - console.log('Completeness check failed with errors.') - process.exit(1) - } else { - console.log('Completeness check passed with warnings.') - } + console.log('Completeness check failed.') + process.exit(1) } diff --git a/packages/ui/src/components/OsMenu/OsMenu.visual.spec.ts b/packages/ui/src/components/OsMenu/OsMenu.visual.spec.ts index a52ef321d..65d377d97 100644 --- a/packages/ui/src/components/OsMenu/OsMenu.visual.spec.ts +++ b/packages/ui/src/components/OsMenu/OsMenu.visual.spec.ts @@ -82,4 +82,15 @@ test.describe('OsMenu visual regression', () => { await checkA11y(page) }) + + test('custom menu item', async ({ page }) => { + await page.goto(`${STORY_URL}--custom-menu-item&viewMode=story`) + const root = page.locator(STORY_ROOT) + await root.waitFor() + await waitForFonts(page) + + await expect(root).toHaveScreenshot('custom-menu-item.png') + + await checkA11y(page) + }) }) diff --git a/packages/ui/src/components/OsMenu/__screenshots__/chromium/custom-menu-item.png b/packages/ui/src/components/OsMenu/__screenshots__/chromium/custom-menu-item.png new file mode 100644 index 000000000..ef8a22ab8 Binary files /dev/null and b/packages/ui/src/components/OsMenu/__screenshots__/chromium/custom-menu-item.png differ diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index d626404c1..2625a045d 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -10,6 +10,9 @@ // Re-export all components export * from './components' +// Re-export Ocelot composite components +export * from './ocelot' + // Export Vue plugin for global registration export { default as OcelotUI } from './plugin' diff --git a/packages/ui/src/ocelot/components/OsActionButton/OsActionButton.spec.ts b/packages/ui/src/ocelot/components/OsActionButton/OsActionButton.spec.ts new file mode 100644 index 000000000..d0c5600ab --- /dev/null +++ b/packages/ui/src/ocelot/components/OsActionButton/OsActionButton.spec.ts @@ -0,0 +1,120 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { markRaw } from 'vue-demi' + +import { IconCheck } from '#src/components/OsIcon' + +import OsActionButton from './OsActionButton.vue' + +const icon = markRaw(IconCheck) + +describe('osActionButton', () => { + const defaultProps = { count: 5, ariaLabel: 'Like', icon } + + it('renders the count badge', () => { + const wrapper = mount(OsActionButton, { props: defaultProps }) + + expect(wrapper.find('.os-action-button__count').text()).toBe('5') + }) + + it('renders with wrapper class', () => { + const wrapper = mount(OsActionButton, { props: defaultProps }) + + expect(wrapper.classes()).toContain('os-action-button') + }) + + it('passes aria-label to the button', () => { + const wrapper = mount(OsActionButton, { props: defaultProps }) + + expect(wrapper.find('button').attributes('aria-label')).toBe('Like') + }) + + it('renders a circular OsButton', () => { + const wrapper = mount(OsActionButton, { props: defaultProps }) + + expect(wrapper.find('button').classes()).toContain('rounded-full') + }) + + describe('filled prop', () => { + it('renders outline appearance by default', () => { + const wrapper = mount(OsActionButton, { props: defaultProps }) + + expect(wrapper.find('button').attributes('data-appearance')).toBe('outline') + }) + + it('renders filled appearance when filled is true', () => { + const wrapper = mount(OsActionButton, { + props: { ...defaultProps, filled: true }, + }) + + expect(wrapper.find('button').attributes('data-appearance')).toBe('filled') + }) + }) + + describe('disabled prop', () => { + it('is not disabled by default', () => { + const wrapper = mount(OsActionButton, { props: defaultProps }) + + expect(wrapper.find('button').attributes('disabled')).toBeUndefined() + }) + + it('disables the button when disabled is true', () => { + const wrapper = mount(OsActionButton, { + props: { ...defaultProps, disabled: true }, + }) + + expect(wrapper.find('button').attributes('disabled')).toBeDefined() + }) + }) + + describe('loading prop', () => { + it('is not loading by default', () => { + const wrapper = mount(OsActionButton, { props: defaultProps }) + + expect(wrapper.find('button').attributes('aria-busy')).toBeUndefined() + }) + + it('shows loading state when loading is true', () => { + const wrapper = mount(OsActionButton, { + props: { ...defaultProps, loading: true }, + }) + + expect(wrapper.find('button').attributes('aria-busy')).toBe('true') + }) + }) + + describe('click event', () => { + it('emits click when button is clicked', async () => { + const wrapper = mount(OsActionButton, { props: defaultProps }) + + await wrapper.find('button').trigger('click') + + expect(wrapper.emitted('click')).toHaveLength(1) + }) + }) + + describe('keyboard accessibility', () => { + it('renders a native button element (inherits keyboard support)', () => { + const wrapper = mount(OsActionButton, { props: defaultProps }) + + expect(wrapper.find('button').exists()).toBe(true) + }) + + it('button is not excluded from tab order', () => { + const wrapper = mount(OsActionButton, { props: defaultProps }) + + expect(wrapper.find('button').attributes('tabindex')).not.toBe('-1') + }) + }) + + describe('icon slot', () => { + it('renders custom icon slot content', () => { + const wrapper = mount(OsActionButton, { + props: defaultProps, + slots: { icon: '' }, + }) + + expect(wrapper.find('.custom-icon').exists()).toBe(true) + }) + }) +}) diff --git a/packages/ui/src/ocelot/components/OsActionButton/OsActionButton.stories.ts b/packages/ui/src/ocelot/components/OsActionButton/OsActionButton.stories.ts new file mode 100644 index 000000000..4dd4d0f7d --- /dev/null +++ b/packages/ui/src/ocelot/components/OsActionButton/OsActionButton.stories.ts @@ -0,0 +1,62 @@ +import { ocelotIcons } from '#src/ocelot/icons' + +import OsActionButton from './OsActionButton.vue' + +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +const iconMap = ocelotIcons +const iconNames = Object.keys(iconMap) + +const meta: Meta = { + title: 'Ocelot/ActionButton', + component: OsActionButton, + tags: ['autodocs'], + argTypes: { + icon: { + control: 'select', + options: iconNames, + mapping: iconMap, + }, + }, +} + +export default meta +type Story = StoryObj + +export const Playground: Story = { + args: { + count: 5, + ariaLabel: 'Like', + icon: iconMap.heartO, + filled: false, + disabled: false, + loading: false, + }, +} + +export const Filled: Story = { + args: { + count: 12, + ariaLabel: 'Liked', + icon: iconMap.heartO, + filled: true, + }, +} + +export const Loading: Story = { + args: { + count: 3, + ariaLabel: 'Loading', + icon: iconMap.heartO, + loading: true, + }, +} + +export const Disabled: Story = { + args: { + count: 0, + ariaLabel: 'Disabled', + icon: iconMap.heartO, + disabled: true, + }, +} diff --git a/packages/ui/src/ocelot/components/OsActionButton/OsActionButton.visual.spec.ts b/packages/ui/src/ocelot/components/OsActionButton/OsActionButton.visual.spec.ts new file mode 100644 index 000000000..ad98c2290 --- /dev/null +++ b/packages/ui/src/ocelot/components/OsActionButton/OsActionButton.visual.spec.ts @@ -0,0 +1,82 @@ +import { AxeBuilder } from '@axe-core/playwright' +import { expect, test } from '@playwright/test' + +import type { Page } from '@playwright/test' + +const STORY_URL = '/iframe.html?id=ocelot-actionbutton' +const STORY_ROOT = '#storybook-root' + +async function waitForFonts(page: Page) { + await page.evaluate(async () => document.fonts.ready) +} + +async function checkA11y(page: Page) { + const results = await new AxeBuilder({ page }).include(STORY_ROOT).analyze() + + expect(results.violations).toEqual([]) +} + +test.describe('OsActionButton keyboard accessibility', () => { + test('button shows visible focus indicator', async ({ page }) => { + await page.goto(`${STORY_URL}--playground&viewMode=story`) + const root = page.locator(STORY_ROOT) + await root.waitFor() + + await page.keyboard.press('Tab') + const button = root.locator('button').first() + const outline = await button.evaluate((el) => getComputedStyle(el).outlineStyle) + + expect(outline, 'Button must have visible focus outline via Tab').not.toBe('none') + }) +}) + +test.describe('OsActionButton visual regression', () => { + test('playground', async ({ page }) => { + await page.goto(`${STORY_URL}--playground&viewMode=story`) + const root = page.locator(STORY_ROOT) + await root.waitFor() + await waitForFonts(page) + + await expect(root).toHaveScreenshot('playground.png') + + await checkA11y(page) + }) + + test('filled', async ({ page }) => { + await page.goto(`${STORY_URL}--filled&viewMode=story`) + const root = page.locator(STORY_ROOT) + await root.waitFor() + await waitForFonts(page) + + await expect(root).toHaveScreenshot('filled.png') + + await checkA11y(page) + }) + + test('loading', async ({ page }) => { + await page.goto(`${STORY_URL}--loading&viewMode=story`) + const root = page.locator(STORY_ROOT) + await root.waitFor() + await waitForFonts(page) + await page.evaluate(() => { + document.querySelectorAll('.os-button__spinner, .os-button__spinner circle').forEach((el) => { + ;(el as HTMLElement).style.animationPlayState = 'paused' + }) + }) + + await expect(root).toHaveScreenshot('loading.png') + + await checkA11y(page) + }) + + test('disabled', async ({ page }) => { + await page.goto(`${STORY_URL}--disabled&viewMode=story`) + const root = page.locator(STORY_ROOT) + await root.waitFor() + await waitForFonts(page) + + await expect(root).toHaveScreenshot('disabled.png') + + await checkA11y(page) + }) +}) diff --git a/packages/ui/src/ocelot/components/OsActionButton/OsActionButton.vue b/packages/ui/src/ocelot/components/OsActionButton/OsActionButton.vue new file mode 100644 index 000000000..53b6f8c04 --- /dev/null +++ b/packages/ui/src/ocelot/components/OsActionButton/OsActionButton.vue @@ -0,0 +1,110 @@ + + + diff --git a/packages/ui/src/ocelot/components/OsActionButton/__screenshots__/chromium/disabled.png b/packages/ui/src/ocelot/components/OsActionButton/__screenshots__/chromium/disabled.png new file mode 100644 index 000000000..d71f0ee4c Binary files /dev/null and b/packages/ui/src/ocelot/components/OsActionButton/__screenshots__/chromium/disabled.png differ diff --git a/packages/ui/src/ocelot/components/OsActionButton/__screenshots__/chromium/filled.png b/packages/ui/src/ocelot/components/OsActionButton/__screenshots__/chromium/filled.png new file mode 100644 index 000000000..0abd5d9cf Binary files /dev/null and b/packages/ui/src/ocelot/components/OsActionButton/__screenshots__/chromium/filled.png differ diff --git a/packages/ui/src/ocelot/components/OsActionButton/__screenshots__/chromium/loading.png b/packages/ui/src/ocelot/components/OsActionButton/__screenshots__/chromium/loading.png new file mode 100644 index 000000000..80a04fe85 Binary files /dev/null and b/packages/ui/src/ocelot/components/OsActionButton/__screenshots__/chromium/loading.png differ diff --git a/packages/ui/src/ocelot/components/OsActionButton/__screenshots__/chromium/playground.png b/packages/ui/src/ocelot/components/OsActionButton/__screenshots__/chromium/playground.png new file mode 100644 index 000000000..b56aec888 Binary files /dev/null and b/packages/ui/src/ocelot/components/OsActionButton/__screenshots__/chromium/playground.png differ diff --git a/packages/ui/src/ocelot/components/OsActionButton/index.ts b/packages/ui/src/ocelot/components/OsActionButton/index.ts new file mode 100644 index 000000000..00fa70c7b --- /dev/null +++ b/packages/ui/src/ocelot/components/OsActionButton/index.ts @@ -0,0 +1 @@ +export { default as OsActionButton } from './OsActionButton.vue' diff --git a/packages/ui/src/ocelot/components/OsLabeledButton/OsLabeledButton.spec.ts b/packages/ui/src/ocelot/components/OsLabeledButton/OsLabeledButton.spec.ts new file mode 100644 index 000000000..ddae24534 --- /dev/null +++ b/packages/ui/src/ocelot/components/OsLabeledButton/OsLabeledButton.spec.ts @@ -0,0 +1,94 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { markRaw } from 'vue-demi' + +import { IconCheck } from '#src/components/OsIcon' + +import OsLabeledButton from './OsLabeledButton.vue' + +const icon = markRaw(IconCheck) + +describe('osLabeledButton', () => { + const defaultProps = { icon, label: 'Filter' } + + it('renders the label', () => { + const wrapper = mount(OsLabeledButton, { props: defaultProps }) + + expect(wrapper.find('.os-labeled-button__label').text()).toBe('Filter') + }) + + it('passes label as aria-label to the button', () => { + const wrapper = mount(OsLabeledButton, { props: defaultProps }) + + expect(wrapper.find('button').attributes('aria-label')).toBe('Filter') + }) + + it('renders with wrapper class', () => { + const wrapper = mount(OsLabeledButton, { props: defaultProps }) + + expect(wrapper.classes()).toContain('os-labeled-button') + }) + + it('renders a circular OsButton', () => { + const wrapper = mount(OsLabeledButton, { props: defaultProps }) + + expect(wrapper.find('button').classes()).toContain('rounded-full') + }) + + describe('filled prop', () => { + it('renders outline appearance by default', () => { + const wrapper = mount(OsLabeledButton, { props: defaultProps }) + + expect(wrapper.find('button').attributes('data-appearance')).toBe('outline') + }) + + it('renders filled appearance when filled is true', () => { + const wrapper = mount(OsLabeledButton, { + props: { ...defaultProps, filled: true }, + }) + + expect(wrapper.find('button').attributes('data-appearance')).toBe('filled') + }) + }) + + describe('click event', () => { + it('emits click when button is clicked', async () => { + const wrapper = mount(OsLabeledButton, { props: defaultProps }) + + await wrapper.find('button').trigger('click') + + expect(wrapper.emitted('click')).toHaveLength(1) + }) + }) + + describe('keyboard accessibility', () => { + it('renders a native button element (inherits keyboard support)', () => { + const wrapper = mount(OsLabeledButton, { props: defaultProps }) + + expect(wrapper.find('button').exists()).toBe(true) + }) + + it('button is not excluded from tab order', () => { + const wrapper = mount(OsLabeledButton, { props: defaultProps }) + + expect(wrapper.find('button').attributes('tabindex')).not.toBe('-1') + }) + + it('has an accessible name via aria-label', () => { + const wrapper = mount(OsLabeledButton, { props: defaultProps }) + + expect(wrapper.find('button').attributes('aria-label')).toBe('Filter') + }) + }) + + describe('icon slot', () => { + it('renders custom icon slot content', () => { + const wrapper = mount(OsLabeledButton, { + props: defaultProps, + slots: { icon: '' }, + }) + + expect(wrapper.find('.custom-icon').exists()).toBe(true) + }) + }) +}) diff --git a/packages/ui/src/ocelot/components/OsLabeledButton/OsLabeledButton.stories.ts b/packages/ui/src/ocelot/components/OsLabeledButton/OsLabeledButton.stories.ts new file mode 100644 index 000000000..b0941890b --- /dev/null +++ b/packages/ui/src/ocelot/components/OsLabeledButton/OsLabeledButton.stories.ts @@ -0,0 +1,56 @@ +import { ocelotIcons } from '#src/ocelot/icons' + +import OsLabeledButton from './OsLabeledButton.vue' + +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +const iconMap = ocelotIcons +const iconNames = Object.keys(iconMap) + +const meta: Meta = { + title: 'Ocelot/LabeledButton', + component: OsLabeledButton, + tags: ['autodocs'], + argTypes: { + icon: { + control: 'select', + options: iconNames, + mapping: iconMap, + }, + }, +} + +export default meta +type Story = StoryObj + +export const Playground: Story = { + args: { + icon: iconMap.plus, + label: 'Add item', + filled: false, + }, +} + +export const Filled: Story = { + args: { + icon: iconMap.check, + label: 'Selected', + filled: true, + }, +} + +export const MultipleButtons: Story = { + render: () => ({ + components: { OsLabeledButton }, + setup() { + return { icons: ocelotIcons } + }, + template: ` +
+ + + +
+ `, + }), +} diff --git a/packages/ui/src/ocelot/components/OsLabeledButton/OsLabeledButton.visual.spec.ts b/packages/ui/src/ocelot/components/OsLabeledButton/OsLabeledButton.visual.spec.ts new file mode 100644 index 000000000..6ce9c48a9 --- /dev/null +++ b/packages/ui/src/ocelot/components/OsLabeledButton/OsLabeledButton.visual.spec.ts @@ -0,0 +1,66 @@ +import { AxeBuilder } from '@axe-core/playwright' +import { expect, test } from '@playwright/test' + +import type { Page } from '@playwright/test' + +const STORY_URL = '/iframe.html?id=ocelot-labeledbutton' +const STORY_ROOT = '#storybook-root' + +async function waitForFonts(page: Page) { + await page.evaluate(async () => document.fonts.ready) +} + +async function checkA11y(page: Page) { + const results = await new AxeBuilder({ page }).include(STORY_ROOT).analyze() + + expect(results.violations).toEqual([]) +} + +test.describe('OsLabeledButton keyboard accessibility', () => { + test('button shows visible focus indicator', async ({ page }) => { + await page.goto(`${STORY_URL}--playground&viewMode=story`) + const root = page.locator(STORY_ROOT) + await root.waitFor() + + await page.keyboard.press('Tab') + const button = root.locator('button').first() + const outline = await button.evaluate((el) => getComputedStyle(el).outlineStyle) + + expect(outline, 'Button must have visible focus outline via Tab').not.toBe('none') + }) +}) + +test.describe('OsLabeledButton visual regression', () => { + test('playground', async ({ page }) => { + await page.goto(`${STORY_URL}--playground&viewMode=story`) + const root = page.locator(STORY_ROOT) + await root.waitFor() + await waitForFonts(page) + + await expect(root).toHaveScreenshot('playground.png') + + await checkA11y(page) + }) + + test('filled', async ({ page }) => { + await page.goto(`${STORY_URL}--filled&viewMode=story`) + const root = page.locator(STORY_ROOT) + await root.waitFor() + await waitForFonts(page) + + await expect(root).toHaveScreenshot('filled.png') + + await checkA11y(page) + }) + + test('multiple buttons', async ({ page }) => { + await page.goto(`${STORY_URL}--multiple-buttons&viewMode=story`) + const root = page.locator(STORY_ROOT) + await root.waitFor() + await waitForFonts(page) + + await expect(root).toHaveScreenshot('multiple-buttons.png') + + await checkA11y(page) + }) +}) diff --git a/packages/ui/src/ocelot/components/OsLabeledButton/OsLabeledButton.vue b/packages/ui/src/ocelot/components/OsLabeledButton/OsLabeledButton.vue new file mode 100644 index 000000000..421de37aa --- /dev/null +++ b/packages/ui/src/ocelot/components/OsLabeledButton/OsLabeledButton.vue @@ -0,0 +1,84 @@ + + + diff --git a/packages/ui/src/ocelot/components/OsLabeledButton/__screenshots__/chromium/filled.png b/packages/ui/src/ocelot/components/OsLabeledButton/__screenshots__/chromium/filled.png new file mode 100644 index 000000000..a74aad6bd Binary files /dev/null and b/packages/ui/src/ocelot/components/OsLabeledButton/__screenshots__/chromium/filled.png differ diff --git a/packages/ui/src/ocelot/components/OsLabeledButton/__screenshots__/chromium/multiple-buttons.png b/packages/ui/src/ocelot/components/OsLabeledButton/__screenshots__/chromium/multiple-buttons.png new file mode 100644 index 000000000..a30288b72 Binary files /dev/null and b/packages/ui/src/ocelot/components/OsLabeledButton/__screenshots__/chromium/multiple-buttons.png differ diff --git a/packages/ui/src/ocelot/components/OsLabeledButton/__screenshots__/chromium/playground.png b/packages/ui/src/ocelot/components/OsLabeledButton/__screenshots__/chromium/playground.png new file mode 100644 index 000000000..6db20c134 Binary files /dev/null and b/packages/ui/src/ocelot/components/OsLabeledButton/__screenshots__/chromium/playground.png differ diff --git a/packages/ui/src/ocelot/components/OsLabeledButton/index.ts b/packages/ui/src/ocelot/components/OsLabeledButton/index.ts new file mode 100644 index 000000000..12fd50ce5 --- /dev/null +++ b/packages/ui/src/ocelot/components/OsLabeledButton/index.ts @@ -0,0 +1 @@ +export { default as OsLabeledButton } from './OsLabeledButton.vue' diff --git a/packages/ui/src/ocelot/index.ts b/packages/ui/src/ocelot/index.ts index 81cf076ed..0c0ece095 100644 --- a/packages/ui/src/ocelot/index.ts +++ b/packages/ui/src/ocelot/index.ts @@ -1,2 +1,6 @@ // Ocelot migration icons — temporary, will be removed after Vue 3 migration export { ocelotIcons } from './icons' + +// Ocelot composite components — built from Os* primitives +export { OsActionButton } from './components/OsActionButton' +export { OsLabeledButton } from './components/OsLabeledButton' diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts index 14d0610cc..7ab7fac9c 100644 --- a/packages/ui/vite.config.ts +++ b/packages/ui/vite.config.ts @@ -49,18 +49,25 @@ export default defineConfig({ 'tailwind.preset': resolve(__dirname, 'src/tailwind.preset.ts'), ocelot: resolve(__dirname, 'src/ocelot/index.ts'), }, - formats: ['es', 'cjs'], - fileName: (format, entryName) => `${entryName}.${format === 'es' ? 'mjs' : 'cjs'}`, }, rollupOptions: { external: ['vue', 'vue-demi', 'tailwindcss'], - output: { - globals: { - vue: 'Vue', - 'vue-demi': 'VueDemi', + output: [ + { + format: 'es', + entryFileNames: '[name].mjs', + chunkFileNames: '[name]-[hash].mjs', + assetFileNames: '[name].[ext]', + globals: { vue: 'Vue', 'vue-demi': 'VueDemi' }, }, - assetFileNames: '[name].[ext]', - }, + { + format: 'cjs', + entryFileNames: '[name].cjs', + chunkFileNames: '[name]-[hash].cjs', + assetFileNames: '[name].[ext]', + globals: { vue: 'Vue', 'vue-demi': 'VueDemi' }, + }, + ], }, cssCodeSplit: false, sourcemap: true, diff --git a/webapp/assets/_new/styles/ocelot-ui-variables.scss b/webapp/assets/_new/styles/ocelot-ui-variables.scss index aef21c472..fdc4ff531 100644 --- a/webapp/assets/_new/styles/ocelot-ui-variables.scss +++ b/webapp/assets/_new/styles/ocelot-ui-variables.scss @@ -57,4 +57,8 @@ // Text --color-text-soft: #{$text-color-soft}; // rgb(112, 103, 126) + + // Ocelot ActionButton badge + --os-action-button-color: #{$color-primary-dark}; + --os-action-button-bg: #{$color-secondary-inverse}; } diff --git a/webapp/components/ActionButton.spec.js b/webapp/components/ActionButton.spec.js deleted file mode 100644 index 9da1ae6ea..000000000 --- a/webapp/components/ActionButton.spec.js +++ /dev/null @@ -1,66 +0,0 @@ -import { render, screen, fireEvent } from '@testing-library/vue' -import '@testing-library/jest-dom' -import ActionButton from './ActionButton.vue' -import { ocelotIcons } from '@ocelot-social/ui/ocelot' - -const localVue = global.localVue - -describe('ActionButton.vue', () => { - let mocks - - beforeEach(() => { - mocks = { - $t: jest.fn((t) => t), - } - }) - - let wrapper - const Wrapper = ({ isDisabled = false } = {}) => { - return render(ActionButton, { - mocks, - localVue, - propsData: { - icon: ocelotIcons.heartO, - text: 'Click me', - count: 7, - disabled: isDisabled, - }, - }) - } - - describe('when not disabled', () => { - beforeEach(() => { - wrapper = Wrapper() - }) - - it('renders', () => { - expect(wrapper.container).toMatchSnapshot() - }) - - it('shows count', () => { - const count = screen.getByText('7') - expect(count).toBeInTheDocument() - }) - - it('button emits click event', async () => { - const button = screen.getByRole('button') - await fireEvent.click(button) - expect(wrapper.emitted().click).toEqual([[]]) - }) - }) - - describe('when disabled', () => { - beforeEach(() => { - wrapper = Wrapper({ isDisabled: true }) - }) - - it('renders', () => { - expect(wrapper.container).toMatchSnapshot() - }) - - it('button is disabled', () => { - const button = screen.getByRole('button') - expect(button).toBeDisabled() - }) - }) -}) diff --git a/webapp/components/ActionButton.vue b/webapp/components/ActionButton.vue deleted file mode 100644 index 827538cf5..000000000 --- a/webapp/components/ActionButton.vue +++ /dev/null @@ -1,68 +0,0 @@ - - - - - diff --git a/webapp/components/Button/FollowButton.spec.js b/webapp/components/Button/FollowButton.spec.js deleted file mode 100644 index ab58ee5e2..000000000 --- a/webapp/components/Button/FollowButton.spec.js +++ /dev/null @@ -1,96 +0,0 @@ -import { mount } from '@vue/test-utils' -import FollowButton from './FollowButton.vue' - -const localVue = global.localVue - -describe('FollowButton.vue', () => { - let mocks - let propsData - - beforeEach(() => { - mocks = { - $t: jest.fn(), - $apollo: { - mutate: jest.fn(), - }, - } - propsData = {} - }) - - describe('mount', () => { - let wrapper - const Wrapper = () => { - return mount(FollowButton, { mocks, propsData, localVue }) - } - - beforeEach(() => { - wrapper = Wrapper() - }) - - it('renders button and text', () => { - expect(mocks.$t).toHaveBeenCalledWith('followButton.follow') - expect(wrapper.findAll('[data-test="follow-btn"]')).toHaveLength(1) - }) - - it('renders button and text when followed', () => { - propsData.isFollowed = true - wrapper = Wrapper() - expect(mocks.$t).toHaveBeenCalledWith('followButton.following') - expect(wrapper.findAll('[data-test="follow-btn"]')).toHaveLength(1) - }) - - describe('clicking the follow button', () => { - beforeEach(() => { - propsData = { followId: 'u1' } - mocks.$apollo.mutate.mockResolvedValue({ - data: { followUser: { id: 'u1', followedByCurrentUser: true } }, - }) - wrapper = Wrapper() - }) - - it('emits optimistic result', async () => { - await wrapper.vm.toggle() - expect(wrapper.emitted('optimistic')[0]).toEqual([{ followedByCurrentUser: true }]) - }) - - it('calls followUser mutation', async () => { - await wrapper.vm.toggle() - expect(mocks.$apollo.mutate).toHaveBeenCalledWith( - expect.objectContaining({ variables: { id: 'u1' } }), - ) - }) - - it('emits update with server response', async () => { - await wrapper.vm.toggle() - expect(wrapper.emitted('update')[0]).toEqual([{ id: 'u1', followedByCurrentUser: true }]) - }) - }) - - describe('clicking the unfollow button', () => { - beforeEach(() => { - propsData = { followId: 'u1', isFollowed: true } - mocks.$apollo.mutate.mockResolvedValue({ - data: { unfollowUser: { id: 'u1', followedByCurrentUser: false } }, - }) - wrapper = Wrapper() - }) - - it('emits optimistic result', async () => { - await wrapper.vm.toggle() - expect(wrapper.emitted('optimistic')[0]).toEqual([{ followedByCurrentUser: false }]) - }) - - it('calls unfollowUser mutation', async () => { - await wrapper.vm.toggle() - expect(mocks.$apollo.mutate).toHaveBeenCalledWith( - expect.objectContaining({ variables: { id: 'u1' } }), - ) - }) - - it('emits update with server response', async () => { - await wrapper.vm.toggle() - expect(wrapper.emitted('update')[0]).toEqual([{ id: 'u1', followedByCurrentUser: false }]) - }) - }) - }) -}) diff --git a/webapp/components/Button/FollowButton.vue b/webapp/components/Button/FollowButton.vue deleted file mode 100644 index 4e65fc1c1..000000000 --- a/webapp/components/Button/FollowButton.vue +++ /dev/null @@ -1,93 +0,0 @@ - - - diff --git a/webapp/components/Button/JoinLeaveButton.vue b/webapp/components/Button/JoinLeaveButton.vue index 1e2a22874..7370fea31 100644 --- a/webapp/components/Button/JoinLeaveButton.vue +++ b/webapp/components/Button/JoinLeaveButton.vue @@ -29,7 +29,7 @@ import { OsButton, OsIcon } from '@ocelot-social/ui' import { iconRegistry } from '~/utils/iconRegistry' import ConfirmModal from '~/components/Modal/ConfirmModal' -import { joinGroupMutation, leaveGroupMutation } from '~/graphql/groups' +import { useJoinLeaveGroup } from '~/composables/useJoinLeaveGroup' export default { name: 'JoinLeaveButton', @@ -115,6 +115,11 @@ export default { }, created() { this.icons = iconRegistry + const { joinLeaveGroup } = useJoinLeaveGroup({ + apollo: this.$apollo, + toast: this.$toast, + }) + this._joinLeaveGroup = joinLeaveGroup }, methods: { onHover() { @@ -124,30 +129,21 @@ export default { }, toggle() { if (this.isMember) { - this.openLeaveModal() + this.showConfirmModal = true } else { this.joinLeave() } }, - openLeaveModal() { - this.showConfirmModal = true - }, async joinLeave() { - const join = !this.isMember - const mutation = join ? joinGroupMutation() : leaveGroupMutation() - this.hovered = false - this.$emit('prepare', join) - - try { - const { data } = await this.$apollo.mutate({ - mutation, - variables: { groupId: this.group.id, userId: this.userId }, - }) - const joinedLeftGroupResult = join ? data.JoinGroup : data.LeaveGroup - this.$emit('update', joinedLeftGroupResult) - } catch (error) { - this.$toast.error(error.message) + this.$emit('prepare', !this.isMember) + const { success, data } = await this._joinLeaveGroup({ + groupId: this.group.id, + userId: this.userId, + isMember: this.isMember, + }) + if (success) { + this.$emit('update', data) } }, }, diff --git a/webapp/components/CommentCard/CommentCard.vue b/webapp/components/CommentCard/CommentCard.vue index 64d87e3b2..a9025464d 100644 --- a/webapp/components/CommentCard/CommentCard.vue +++ b/webapp/components/CommentCard/CommentCard.vue @@ -46,13 +46,15 @@
- - - diff --git a/webapp/components/FilterMenu/FilterMenuComponent.vue b/webapp/components/FilterMenu/FilterMenuComponent.vue index 81261aa0f..8b446ecf5 100644 --- a/webapp/components/FilterMenu/FilterMenuComponent.vue +++ b/webapp/components/FilterMenu/FilterMenuComponent.vue @@ -4,7 +4,7 @@

{{ $t('filter-menu.filter-by') }}

-