feat(package/ui): actionButton + labledButton (#9470)
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@ -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'
|
||||
|
||||
|
||||
@ -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: '<span class="custom-icon">★</span>' },
|
||||
})
|
||||
|
||||
expect(wrapper.find('.custom-icon').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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<typeof OsActionButton> = {
|
||||
title: 'Ocelot/ActionButton',
|
||||
component: OsActionButton,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
icon: {
|
||||
control: 'select',
|
||||
options: iconNames,
|
||||
mapping: iconMap,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof OsActionButton>
|
||||
|
||||
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,
|
||||
},
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent, h, isVue2 } from 'vue-demi'
|
||||
|
||||
import OsButton from '#src/components/OsButton/OsButton.vue'
|
||||
import OsIcon from '#src/components/OsIcon/OsIcon.vue'
|
||||
|
||||
import type { Component, PropType } from 'vue-demi'
|
||||
|
||||
/**
|
||||
* Circular icon button with a count badge.
|
||||
* Used for actions like "shout" or "observe" where a count is displayed.
|
||||
*
|
||||
* @slot icon - Custom icon content (overrides the `icon` prop)
|
||||
*/
|
||||
export default defineComponent({
|
||||
name: 'OsActionButton',
|
||||
props: {
|
||||
/** Number displayed in the badge */
|
||||
count: { type: Number, required: true },
|
||||
/** Accessible label for screen readers (icon-only button) */
|
||||
ariaLabel: { type: String, required: true },
|
||||
/** Icon component or render function */
|
||||
icon: { type: [Object, Function] as PropType<Component>, required: true },
|
||||
/** Whether the button appears filled (active state) */
|
||||
filled: { type: Boolean, default: false },
|
||||
/** Disables the button */
|
||||
disabled: { type: Boolean, default: false },
|
||||
/** Shows loading spinner */
|
||||
loading: { type: Boolean, default: false },
|
||||
},
|
||||
emits: ['click'],
|
||||
setup(props, { slots, emit }) {
|
||||
return () => {
|
||||
const iconSlot = slots.icon?.() || [
|
||||
h(
|
||||
OsIcon,
|
||||
/* v8 ignore next -- Vue 2 */ isVue2
|
||||
? { props: { icon: props.icon } }
|
||||
: { icon: props.icon },
|
||||
),
|
||||
]
|
||||
|
||||
const button = h(
|
||||
OsButton,
|
||||
/* v8 ignore start -- Vue 2 branch tested in webapp Jest tests */
|
||||
isVue2
|
||||
? {
|
||||
props: {
|
||||
variant: 'primary',
|
||||
appearance: props.filled ? 'filled' : 'outline',
|
||||
loading: props.loading,
|
||||
disabled: props.disabled,
|
||||
circle: true,
|
||||
},
|
||||
attrs: { 'aria-label': props.ariaLabel },
|
||||
on: { click: () => emit('click') },
|
||||
}
|
||||
: /* v8 ignore stop */ {
|
||||
variant: 'primary',
|
||||
appearance: props.filled ? 'filled' : 'outline',
|
||||
loading: props.loading,
|
||||
disabled: props.disabled,
|
||||
circle: true,
|
||||
'aria-label': props.ariaLabel,
|
||||
onClick: () => emit('click'),
|
||||
},
|
||||
/* v8 ignore next -- Vue 2 */ isVue2 ? iconSlot : { icon: () => iconSlot },
|
||||
)
|
||||
|
||||
const badge = h(
|
||||
'div',
|
||||
{
|
||||
class: 'os-action-button__count',
|
||||
'aria-hidden': 'true',
|
||||
},
|
||||
/* v8 ignore next -- Vue 2 */ isVue2 ? [String(props.count)] : String(props.count),
|
||||
)
|
||||
|
||||
return h('div', { class: 'os-action-button' }, [button, badge])
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.os-action-button {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.os-action-button__count {
|
||||
user-select: none;
|
||||
color: var(--os-action-button-color, var(--color-primary));
|
||||
background-color: var(--os-action-button-bg, var(--color-primary-contrast));
|
||||
border: 1px solid var(--os-action-button-color, var(--color-primary));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
left: calc(100% - 16px);
|
||||
min-width: 25px;
|
||||
height: 25px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
padding-inline: 2px;
|
||||
}
|
||||
</style>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
@ -0,0 +1 @@
|
||||
export { default as OsActionButton } from './OsActionButton.vue'
|
||||
@ -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: '<span class="custom-icon">★</span>' },
|
||||
})
|
||||
|
||||
expect(wrapper.find('.custom-icon').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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<typeof OsLabeledButton> = {
|
||||
title: 'Ocelot/LabeledButton',
|
||||
component: OsLabeledButton,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
icon: {
|
||||
control: 'select',
|
||||
options: iconNames,
|
||||
mapping: iconMap,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof OsLabeledButton>
|
||||
|
||||
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: `
|
||||
<div style="display: flex; gap: 24px;">
|
||||
<OsLabeledButton :icon="icons.book" label="Articles" />
|
||||
<OsLabeledButton :icon="icons.calendar" label="Events" :filled="true" />
|
||||
<OsLabeledButton :icon="icons.users" label="Groups" />
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent, h, isVue2 } from 'vue-demi'
|
||||
|
||||
import OsButton from '#src/components/OsButton/OsButton.vue'
|
||||
import OsIcon from '#src/components/OsIcon/OsIcon.vue'
|
||||
|
||||
import type { Component, PropType } from 'vue-demi'
|
||||
|
||||
/**
|
||||
* Circular button with a label below it.
|
||||
* Used for filter toggles and categorized actions.
|
||||
*
|
||||
* @slot icon - Custom icon content (overrides the `icon` prop)
|
||||
*/
|
||||
export default defineComponent({
|
||||
name: 'OsLabeledButton',
|
||||
props: {
|
||||
/** Icon component or render function */
|
||||
icon: { type: [Object, Function] as PropType<Component>, required: true },
|
||||
/** Text label displayed below the button */
|
||||
label: { type: String, required: true },
|
||||
/** Whether the button appears filled (active state) */
|
||||
filled: { type: Boolean, default: false },
|
||||
},
|
||||
emits: ['click'],
|
||||
setup(props, { slots, emit }) {
|
||||
return () => {
|
||||
const iconSlot = slots.icon?.() || [
|
||||
h(
|
||||
OsIcon,
|
||||
/* v8 ignore next -- Vue 2 */ isVue2
|
||||
? { props: { icon: props.icon } }
|
||||
: { icon: props.icon },
|
||||
),
|
||||
]
|
||||
|
||||
const button = h(
|
||||
OsButton,
|
||||
/* v8 ignore start -- Vue 2 branch tested in webapp Jest tests */
|
||||
isVue2
|
||||
? {
|
||||
props: {
|
||||
variant: 'primary',
|
||||
appearance: props.filled ? 'filled' : 'outline',
|
||||
circle: true,
|
||||
},
|
||||
attrs: { 'aria-label': props.label },
|
||||
on: { click: (e: Event) => emit('click', e) },
|
||||
}
|
||||
: /* v8 ignore stop */ {
|
||||
variant: 'primary',
|
||||
appearance: props.filled ? 'filled' : 'outline',
|
||||
circle: true,
|
||||
'aria-label': props.label,
|
||||
onClick: (e: Event) => emit('click', e),
|
||||
},
|
||||
/* v8 ignore next -- Vue 2 */ isVue2 ? iconSlot : { icon: () => iconSlot },
|
||||
)
|
||||
|
||||
const label = h(
|
||||
'span',
|
||||
{ class: 'os-labeled-button__label' },
|
||||
/* v8 ignore next -- Vue 2 */ isVue2 ? [props.label] : props.label,
|
||||
)
|
||||
|
||||
return h('div', { class: 'os-labeled-button' }, [button, label])
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.os-labeled-button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.os-labeled-button__label {
|
||||
margin-top: 8px;
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
@ -0,0 +1 @@
|
||||
export { default as OsLabeledButton } from './OsLabeledButton.vue'
|
||||
@ -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'
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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};
|
||||
}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,68 +0,0 @@
|
||||
<template>
|
||||
<div class="action-button">
|
||||
<os-button
|
||||
variant="primary"
|
||||
:appearance="filled ? 'filled' : 'outline'"
|
||||
:loading="loading"
|
||||
:disabled="disabled"
|
||||
:aria-label="text"
|
||||
circle
|
||||
@click="click"
|
||||
>
|
||||
<template #icon>
|
||||
<os-icon :icon="icon" />
|
||||
</template>
|
||||
</os-button>
|
||||
<div class="count">{{ count }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { OsButton, OsIcon } from '@ocelot-social/ui'
|
||||
|
||||
export default {
|
||||
components: { OsButton, OsIcon },
|
||||
props: {
|
||||
count: { type: Number, required: true },
|
||||
text: { type: String, required: true },
|
||||
icon: { type: [Object, Function], required: true },
|
||||
filled: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean },
|
||||
loading: { type: Boolean },
|
||||
},
|
||||
methods: {
|
||||
click() {
|
||||
this.$emit('click')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.action-button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: $space-xx-small;
|
||||
position: relative;
|
||||
--icon-size: calc(var(--circle-button-width, #{$size-button-base}) / 2);
|
||||
}
|
||||
.count {
|
||||
user-select: none;
|
||||
color: $color-primary-dark;
|
||||
background-color: $color-secondary-inverse;
|
||||
border: 1px solid $color-primary-dark;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
left: calc(100% - 16px);
|
||||
--diameter: calc(var(--circle-button-width, #{$size-button-base}) * 0.7);
|
||||
min-width: var(--diameter);
|
||||
height: var(--diameter);
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
padding-inline: 2px;
|
||||
}
|
||||
</style>
|
||||
@ -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 }])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,93 +0,0 @@
|
||||
<template>
|
||||
<os-button
|
||||
data-test="follow-btn"
|
||||
:variant="isFollowed && hovered ? 'danger' : 'primary'"
|
||||
:appearance="isFollowed && !hovered ? 'filled' : 'outline'"
|
||||
:disabled="disabled || !followId"
|
||||
:loading="loading"
|
||||
full-width
|
||||
@mouseenter="onHover"
|
||||
@mouseleave="hovered = false"
|
||||
@click.prevent="toggle"
|
||||
>
|
||||
<template #icon>
|
||||
<os-icon :icon="icon" />
|
||||
</template>
|
||||
{{ label }}
|
||||
</os-button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { OsButton, OsIcon } from '@ocelot-social/ui'
|
||||
import { iconRegistry } from '~/utils/iconRegistry'
|
||||
import { followUserMutation, unfollowUserMutation } from '~/graphql/User'
|
||||
|
||||
export default {
|
||||
name: 'HcFollowButton',
|
||||
components: { OsButton, OsIcon },
|
||||
props: {
|
||||
followId: { type: String, default: null },
|
||||
isFollowed: { type: Boolean, default: false },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
disabled: false,
|
||||
loading: false,
|
||||
hovered: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
icon() {
|
||||
if (this.isFollowed && this.hovered) {
|
||||
return this.icons.close
|
||||
} else {
|
||||
return this.isFollowed ? this.icons.check : this.icons.plus
|
||||
}
|
||||
},
|
||||
label() {
|
||||
if (this.isFollowed) {
|
||||
return this.$t('followButton.following')
|
||||
} else {
|
||||
return this.$t('followButton.follow')
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
isFollowed() {
|
||||
this.loading = false
|
||||
this.hovered = false
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.icons = iconRegistry
|
||||
},
|
||||
methods: {
|
||||
onHover() {
|
||||
if (!this.disabled && !this.loading) {
|
||||
this.hovered = true
|
||||
}
|
||||
},
|
||||
async toggle() {
|
||||
const follow = !this.isFollowed
|
||||
const mutation = follow ? followUserMutation(this.$i18n) : unfollowUserMutation(this.$i18n)
|
||||
|
||||
this.hovered = false
|
||||
const optimisticResult = { followedByCurrentUser: follow }
|
||||
this.$emit('optimistic', optimisticResult)
|
||||
|
||||
try {
|
||||
const { data } = await this.$apollo.mutate({
|
||||
mutation,
|
||||
variables: { id: this.followId },
|
||||
})
|
||||
|
||||
const followedUser = follow ? data.followUser : data.unfollowUser
|
||||
this.$emit('update', followedUser)
|
||||
} catch (err) {
|
||||
optimisticResult.followedByCurrentUser = !follow
|
||||
this.$emit('optimistic', optimisticResult)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@ -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)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@ -46,13 +46,15 @@
|
||||
</os-button>
|
||||
</template>
|
||||
<div class="actions">
|
||||
<shout-button
|
||||
<os-action-button
|
||||
:disabled="isAuthor"
|
||||
:count="comment.shoutedCount"
|
||||
:is-shouted="comment.shoutedByCurrentUser"
|
||||
:node-id="comment.id"
|
||||
:count="shoutedCount"
|
||||
:aria-label="$t('shoutButton.shouted', { count: shoutedCount })"
|
||||
:filled="shouted"
|
||||
:icon="icons.heartO"
|
||||
:loading="shoutLoading"
|
||||
class="shout-button"
|
||||
node-type="Comment"
|
||||
@click="toggleShout"
|
||||
/>
|
||||
<os-button
|
||||
:title="$t('post.comment.reply')"
|
||||
@ -74,7 +76,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { OsButton, OsCard, OsIcon } from '@ocelot-social/ui'
|
||||
import { OsButton, OsCard, OsIcon, OsActionButton } from '@ocelot-social/ui'
|
||||
import { iconRegistry } from '~/utils/iconRegistry'
|
||||
import { mapGetters } from 'vuex'
|
||||
import { COMMENT_MAX_UNTRUNCATED_LENGTH, COMMENT_TRUNCATE_TO_LENGTH } from '~/constants/comment'
|
||||
@ -83,7 +85,7 @@ import ContentMenu from '~/components/ContentMenu/ContentMenu'
|
||||
import ContentViewer from '~/components/Editor/ContentViewer'
|
||||
import CommentForm from '~/components/CommentForm/CommentForm'
|
||||
import CommentMutations from '~/graphql/CommentMutations'
|
||||
import ShoutButton from '~/components/ShoutButton.vue'
|
||||
import { useShout } from '~/composables/useShout'
|
||||
import scrollToAnchor from '~/mixins/scrollToAnchor.js'
|
||||
|
||||
export default {
|
||||
@ -95,7 +97,7 @@ export default {
|
||||
ContentMenu,
|
||||
ContentViewer,
|
||||
CommentForm,
|
||||
ShoutButton,
|
||||
OsActionButton,
|
||||
},
|
||||
mixins: [scrollToAnchor],
|
||||
data() {
|
||||
@ -107,6 +109,9 @@ export default {
|
||||
isTarget,
|
||||
isCollapsed: !isTarget,
|
||||
editingComment: false,
|
||||
shoutedCount: this.comment.shoutedCount || 0,
|
||||
shouted: this.comment.shoutedByCurrentUser || false,
|
||||
shoutLoading: false,
|
||||
}
|
||||
},
|
||||
props: {
|
||||
@ -119,6 +124,14 @@ export default {
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'comment.shoutedCount'(val) {
|
||||
if (!this.shoutLoading) this.shoutedCount = val || 0
|
||||
},
|
||||
'comment.shoutedByCurrentUser'(val) {
|
||||
if (!this.shoutLoading) this.shouted = !!val
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
user: 'auth/user',
|
||||
@ -180,8 +193,27 @@ export default {
|
||||
},
|
||||
created() {
|
||||
this.icons = iconRegistry
|
||||
const { toggleShout } = useShout({ apollo: this.$apollo })
|
||||
this._toggleShout = toggleShout
|
||||
},
|
||||
methods: {
|
||||
async toggleShout() {
|
||||
const newShouted = !this.shouted
|
||||
const backup = { shoutedCount: this.shoutedCount, shouted: this.shouted }
|
||||
this.shouted = newShouted
|
||||
this.shoutedCount += newShouted ? 1 : -1
|
||||
this.shoutLoading = true
|
||||
const { success } = await this._toggleShout({
|
||||
id: this.comment.id,
|
||||
type: 'Comment',
|
||||
isCurrentlyShouted: !newShouted,
|
||||
})
|
||||
if (!success) {
|
||||
this.shoutedCount = backup.shoutedCount
|
||||
this.shouted = backup.shouted
|
||||
}
|
||||
this.shoutLoading = false
|
||||
},
|
||||
checkAnchor(anchor) {
|
||||
return `#${this.anchor}` === anchor
|
||||
},
|
||||
|
||||
@ -1,70 +0,0 @@
|
||||
<template>
|
||||
<div class="emotion-button">
|
||||
<os-button
|
||||
:id="emotion"
|
||||
appearance="ghost"
|
||||
circle
|
||||
:aria-label="$t(`contribution.emotions-label.${emotion}`)"
|
||||
@click="$emit('toggleEmotion', emotion)"
|
||||
>
|
||||
<img class="image" :src="emojiPath" />
|
||||
</os-button>
|
||||
<label class="label" :for="emotion">{{ $t(`contribution.emotions-label.${emotion}`) }}</label>
|
||||
<p v-if="emotionCount !== null" class="count">{{ emotionCount }}x</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { OsButton } from '@ocelot-social/ui'
|
||||
|
||||
export default {
|
||||
components: { OsButton },
|
||||
name: 'EmotionButton',
|
||||
props: {
|
||||
emojiPath: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
emotion: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
emotionCount: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.emotion-button {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
> button {
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
padding: $space-xxx-small;
|
||||
}
|
||||
}
|
||||
|
||||
> .label {
|
||||
margin-top: $space-x-small;
|
||||
font-size: $font-size-small;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
> .count {
|
||||
margin: $space-x-small 0;
|
||||
}
|
||||
|
||||
.image {
|
||||
max-width: $size-button-base;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -4,7 +4,7 @@
|
||||
<div class="filter-header">
|
||||
<h2 class="title">{{ $t('filter-menu.filter-by') }}</h2>
|
||||
<div v-if="categoriesActive" class="item-save-topics">
|
||||
<labeled-button
|
||||
<os-labeled-button
|
||||
filled
|
||||
:label="$t('actions.saveCategories')"
|
||||
:icon="icons.save"
|
||||
@ -35,7 +35,7 @@ import PostTypeFilter from './PostTypeFilter'
|
||||
import FollowingFilter from './FollowingFilter'
|
||||
import OrderByFilter from './OrderByFilter'
|
||||
import CategoriesFilter from './CategoriesFilter'
|
||||
import LabeledButton from '~/components/_new/generic/LabeledButton/LabeledButton'
|
||||
import { OsLabeledButton } from '@ocelot-social/ui'
|
||||
import SaveCategories from '~/graphql/SaveCategories.js'
|
||||
import GetCategories from '~/mixins/getCategoriesMixin.js'
|
||||
|
||||
@ -47,7 +47,7 @@ export default {
|
||||
OrderByFilter,
|
||||
CategoriesFilter,
|
||||
PostTypeFilter,
|
||||
LabeledButton,
|
||||
OsLabeledButton,
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
@ -86,7 +86,7 @@ export default {
|
||||
.filter-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
& .labeled-button {
|
||||
& .os-labeled-button {
|
||||
margin-right: 2em;
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
>
|
||||
<template #filter-follower>
|
||||
<li class="item all-item">
|
||||
<labeled-button
|
||||
<os-labeled-button
|
||||
:icon="icons.check"
|
||||
:label="$t('filter-menu.all')"
|
||||
:filled="filteredPostTypes.length === 0"
|
||||
@ -14,10 +14,10 @@
|
||||
@click="togglePostType(null)"
|
||||
>
|
||||
{{ $t('filter-menu.all') }}
|
||||
</labeled-button>
|
||||
</os-labeled-button>
|
||||
</li>
|
||||
<li class="item article-item">
|
||||
<labeled-button
|
||||
<os-labeled-button
|
||||
:icon="icons.book"
|
||||
:label="$t('filter-menu.article')"
|
||||
:filled="filteredPostTypes.includes('Article')"
|
||||
@ -26,7 +26,7 @@
|
||||
/>
|
||||
</li>
|
||||
<li class="item event-item">
|
||||
<labeled-button
|
||||
<os-labeled-button
|
||||
:icon="icons.calendar"
|
||||
:label="$t('filter-menu.event')"
|
||||
:filled="filteredPostTypes.includes('Event')"
|
||||
@ -42,13 +42,13 @@
|
||||
import { iconRegistry } from '~/utils/iconRegistry'
|
||||
import { mapGetters, mapMutations } from 'vuex'
|
||||
import FilterMenuSection from '~/components/FilterMenu/FilterMenuSection'
|
||||
import LabeledButton from '~/components/_new/generic/LabeledButton/LabeledButton'
|
||||
import { OsLabeledButton } from '@ocelot-social/ui'
|
||||
|
||||
export default {
|
||||
name: 'PostTypeFilter',
|
||||
components: {
|
||||
FilterMenuSection,
|
||||
LabeledButton,
|
||||
OsLabeledButton,
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
@ -68,11 +68,12 @@ export default {
|
||||
|
||||
<style lang="scss">
|
||||
.post-type-filter {
|
||||
& .filter-list {
|
||||
display: flex;
|
||||
flex-basis: 100%;
|
||||
flex-grow: 1;
|
||||
padding-left: $space-base;
|
||||
& .item {
|
||||
min-width: 80px;
|
||||
|
||||
&:first-child {
|
||||
margin-left: calc(-1 * (80px - 36px) / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -70,9 +70,48 @@
|
||||
<notification-menu placement="top" />
|
||||
</client-only>
|
||||
<!-- invite button -->
|
||||
<div v-if="inviteRegistration">
|
||||
<div v-if="inviteRegistration" class="invite-button">
|
||||
<client-only>
|
||||
<invite-button placement="top" />
|
||||
<dropdown ref="inviteDropdown" offset="8" placement="top" noMouseLeaveClosing>
|
||||
<template #default="{ toggleMenu }">
|
||||
<os-button
|
||||
variant="primary"
|
||||
appearance="ghost"
|
||||
circle
|
||||
:aria-label="$t('invite-codes.button.tooltip')"
|
||||
v-tooltip="{
|
||||
content: $t('invite-codes.button.tooltip'),
|
||||
placement: 'bottom-start',
|
||||
}"
|
||||
@click.prevent="toggleMenu"
|
||||
>
|
||||
<template #icon>
|
||||
<os-icon :icon="icons.userPlus" />
|
||||
</template>
|
||||
</os-button>
|
||||
</template>
|
||||
<template #popover>
|
||||
<div class="invite-list">
|
||||
<h2>{{ $t('invite-codes.my-invite-links') }}</h2>
|
||||
<invitation-list
|
||||
@generate-invite-code="generatePersonalInviteCode"
|
||||
@invalidate-invite-code="invalidateInviteCode"
|
||||
@open-delete-modal="openInviteDeleteModal"
|
||||
:inviteCodes="user.inviteCodes"
|
||||
:copy-message="
|
||||
$t('invite-codes.invite-link-message-personal', {
|
||||
network: $env.NETWORK_NAME,
|
||||
})
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</dropdown>
|
||||
<confirm-modal
|
||||
v-if="showInviteConfirmModal"
|
||||
:modalData="inviteModalData"
|
||||
@close="showInviteConfirmModal = false"
|
||||
/>
|
||||
</client-only>
|
||||
</div>
|
||||
<!-- group button -->
|
||||
@ -540,7 +579,10 @@ import CustomButton from '~/components/CustomButton/CustomButton'
|
||||
import FilterMenu from '~/components/FilterMenu/FilterMenu.vue'
|
||||
import FilterMenuComponent from '~/components/FilterMenu/FilterMenuComponent'
|
||||
import headerMenuBranded from '~/constants/headerMenuBranded.js'
|
||||
import InviteButton from '~/components/InviteButton/InviteButton'
|
||||
import ConfirmModal from '~/components/Modal/ConfirmModal'
|
||||
import Dropdown from '~/components/Dropdown'
|
||||
import InvitationList from '~/components/_new/features/Invitations/InvitationList.vue'
|
||||
import { useInviteCode } from '~/composables/useInviteCode'
|
||||
import LocaleSwitch from '~/components/LocaleSwitch/LocaleSwitch'
|
||||
import Logo from '~/components/Logo/Logo'
|
||||
import SearchField from '~/components/features/SearchField/SearchField.vue'
|
||||
@ -561,7 +603,9 @@ export default {
|
||||
CustomButton,
|
||||
FilterMenu,
|
||||
FilterMenuComponent,
|
||||
InviteButton,
|
||||
ConfirmModal,
|
||||
Dropdown,
|
||||
InvitationList,
|
||||
LocaleSwitch,
|
||||
Logo,
|
||||
NotificationMenu,
|
||||
@ -588,6 +632,8 @@ export default {
|
||||
mobileFilterMenuOpen: false,
|
||||
mobileLocaleMenuOpen: false,
|
||||
inviteRegistration: this.$env.INVITE_REGISTRATION === true, // for 'false' in .env INVITE_REGISTRATION is of type undefined and not(!) boolean false, because of internal handling,
|
||||
showInviteConfirmModal: false,
|
||||
inviteModalData: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@ -674,8 +720,27 @@ export default {
|
||||
created() {
|
||||
this.icons = iconRegistry
|
||||
this.throttledMouseMove = throttle(this.handleMouseMove, 100)
|
||||
const { generatePersonalInviteCode, invalidateInviteCode } = useInviteCode({
|
||||
apollo: this.$apollo,
|
||||
toast: this.$toast,
|
||||
t: (key, ...args) => this.$t(key, ...args),
|
||||
store: this.$store,
|
||||
})
|
||||
this._generateInviteCode = generatePersonalInviteCode
|
||||
this._invalidateInviteCode = invalidateInviteCode
|
||||
},
|
||||
methods: {
|
||||
async generatePersonalInviteCode(comment) {
|
||||
await this._generateInviteCode(comment)
|
||||
},
|
||||
async invalidateInviteCode(code) {
|
||||
await this._invalidateInviteCode(code)
|
||||
},
|
||||
openInviteDeleteModal(modalData) {
|
||||
this.$refs.inviteDropdown.isPopoverOpen = false
|
||||
this.inviteModalData = modalData
|
||||
this.showInviteConfirmModal = true
|
||||
},
|
||||
handleScroll() {
|
||||
if (this.toggleMobileMenu) return
|
||||
const currentScrollPos = window.pageYOffset
|
||||
@ -1194,4 +1259,14 @@ export default {
|
||||
margin: -4px;
|
||||
}
|
||||
}
|
||||
|
||||
.invite-list {
|
||||
max-width: min(400px, 90vw);
|
||||
padding: $space-small;
|
||||
margin-top: $space-base;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: $space-small;
|
||||
--invitation-column-max-width: 75%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,147 +0,0 @@
|
||||
<template>
|
||||
<div class="invite-button">
|
||||
<dropdown ref="dropdown" offset="8" :placement="placement" noMouseLeaveClosing>
|
||||
<template #default="{ toggleMenu }">
|
||||
<os-button
|
||||
variant="primary"
|
||||
appearance="ghost"
|
||||
circle
|
||||
:aria-label="$t('invite-codes.button.tooltip')"
|
||||
v-tooltip="{
|
||||
content: $t('invite-codes.button.tooltip'),
|
||||
placement: 'bottom-start',
|
||||
}"
|
||||
@click.prevent="toggleMenu"
|
||||
>
|
||||
<template #icon>
|
||||
<os-icon :icon="icons.userPlus" />
|
||||
</template>
|
||||
</os-button>
|
||||
</template>
|
||||
<template #popover>
|
||||
<div class="invite-list">
|
||||
<h2>{{ $t('invite-codes.my-invite-links') }}</h2>
|
||||
<invitation-list
|
||||
@generate-invite-code="generatePersonalInviteCode"
|
||||
@invalidate-invite-code="invalidateInviteCode"
|
||||
@open-delete-modal="openDeleteModal"
|
||||
:inviteCodes="user.inviteCodes"
|
||||
:copy-message="
|
||||
$t('invite-codes.invite-link-message-personal', {
|
||||
network: $env.NETWORK_NAME,
|
||||
})
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</dropdown>
|
||||
<confirm-modal
|
||||
v-if="showConfirmModal"
|
||||
:modalData="currentModalData"
|
||||
@close="showConfirmModal = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { OsButton, OsIcon } from '@ocelot-social/ui'
|
||||
import { iconRegistry } from '~/utils/iconRegistry'
|
||||
import ConfirmModal from '~/components/Modal/ConfirmModal'
|
||||
import Dropdown from '~/components/Dropdown'
|
||||
import { mapGetters, mapMutations } from 'vuex'
|
||||
import InvitationList from '~/components/_new/features/Invitations/InvitationList.vue'
|
||||
import { generatePersonalInviteCode, invalidateInviteCode } from '~/graphql/InviteCode'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ConfirmModal,
|
||||
OsButton,
|
||||
OsIcon,
|
||||
Dropdown,
|
||||
InvitationList,
|
||||
},
|
||||
props: {
|
||||
placement: { type: String, default: 'top-end' },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showConfirmModal: false,
|
||||
currentModalData: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
user: 'auth/user',
|
||||
}),
|
||||
inviteCode() {
|
||||
return this.user.inviteCodes[0] || null
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.icons = iconRegistry
|
||||
},
|
||||
methods: {
|
||||
...mapMutations({
|
||||
setCurrentUser: 'auth/SET_USER_PARTIAL',
|
||||
}),
|
||||
openDeleteModal(modalData) {
|
||||
this.$refs.dropdown.isPopoverOpen = false
|
||||
this.currentModalData = modalData
|
||||
this.showConfirmModal = true
|
||||
},
|
||||
async generatePersonalInviteCode(comment) {
|
||||
try {
|
||||
await this.$apollo.mutate({
|
||||
mutation: generatePersonalInviteCode(),
|
||||
variables: {
|
||||
comment,
|
||||
},
|
||||
update: (_, { data: { generatePersonalInviteCode } }) => {
|
||||
this.setCurrentUser({
|
||||
...this.user,
|
||||
inviteCodes: [...this.user.inviteCodes, generatePersonalInviteCode],
|
||||
})
|
||||
},
|
||||
})
|
||||
this.$toast.success(this.$t('invite-codes.create-success'))
|
||||
} catch (error) {
|
||||
this.$toast.error(this.$t('invite-codes.create-error', { error: error.message }))
|
||||
}
|
||||
},
|
||||
async invalidateInviteCode(code) {
|
||||
try {
|
||||
await this.$apollo.mutate({
|
||||
mutation: invalidateInviteCode(),
|
||||
variables: {
|
||||
code,
|
||||
},
|
||||
update: (_, { data: { _invalidateInviteCode } }) => {
|
||||
this.setCurrentUser({
|
||||
...this.user,
|
||||
inviteCodes: this.user.inviteCodes.map((inviteCode) => ({
|
||||
...inviteCode,
|
||||
isValid: inviteCode.code === code ? false : inviteCode.isValid,
|
||||
})),
|
||||
})
|
||||
},
|
||||
})
|
||||
this.$toast.success(this.$t('invite-codes.invalidate-success'))
|
||||
} catch (error) {
|
||||
this.$toast.error(this.$t('invite-codes.invalidate-error', { error: error.message }))
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.invite-list {
|
||||
max-width: min(400px, 90vw);
|
||||
padding: $space-small;
|
||||
margin-top: $space-base;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: $space-small;
|
||||
--invitation-column-max-width: 75%;
|
||||
}
|
||||
</style>
|
||||
@ -1,44 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import LoginButton from './LoginButton.vue'
|
||||
|
||||
const stubs = {
|
||||
'v-popover': true,
|
||||
'nuxt-link': true,
|
||||
}
|
||||
|
||||
describe('LoginButton.vue', () => {
|
||||
let wrapper
|
||||
let mocks
|
||||
let propsData
|
||||
|
||||
beforeEach(() => {
|
||||
mocks = {
|
||||
$t: jest.fn(),
|
||||
navigator: {
|
||||
clipboard: {
|
||||
writeText: jest.fn(),
|
||||
},
|
||||
},
|
||||
}
|
||||
propsData = {}
|
||||
})
|
||||
|
||||
describe('mount', () => {
|
||||
const Wrapper = () => {
|
||||
return mount(LoginButton, { mocks, propsData, stubs })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
it('renders', () => {
|
||||
expect(wrapper.find('.login-button').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('open popup', () => {
|
||||
wrapper.find('[data-test="login-btn"]').trigger('click')
|
||||
expect(wrapper.find('.login-button').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,72 +0,0 @@
|
||||
<template>
|
||||
<dropdown class="login-button" offset="8" :placement="placement">
|
||||
<template #default="{ toggleMenu }">
|
||||
<os-button
|
||||
data-test="login-btn"
|
||||
variant="primary"
|
||||
appearance="ghost"
|
||||
circle
|
||||
:aria-label="$t('login.login')"
|
||||
@click.prevent="toggleMenu"
|
||||
>
|
||||
<template #icon>
|
||||
<os-icon :icon="icons.signIn" />
|
||||
</template>
|
||||
</os-button>
|
||||
</template>
|
||||
<template #popover>
|
||||
<div class="login-button-menu-popover">
|
||||
<nuxt-link class="login-link" :to="{ name: 'login' }">
|
||||
<os-icon :icon="icons.signIn" />
|
||||
{{ $t('login.login') }}
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</template>
|
||||
</dropdown>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { OsButton, OsIcon } from '@ocelot-social/ui'
|
||||
import { iconRegistry } from '~/utils/iconRegistry'
|
||||
import Dropdown from '~/components/Dropdown'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
OsButton,
|
||||
OsIcon,
|
||||
Dropdown,
|
||||
},
|
||||
props: {
|
||||
placement: { type: String, default: 'top-end' },
|
||||
},
|
||||
created() {
|
||||
this.icons = iconRegistry
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-button {
|
||||
color: $color-secondary;
|
||||
}
|
||||
|
||||
.login-button-menu-popover {
|
||||
padding-top: $space-x-small;
|
||||
padding-bottom: $space-x-small;
|
||||
hr {
|
||||
color: $color-neutral-90;
|
||||
background-color: $color-neutral-90;
|
||||
}
|
||||
.login-link {
|
||||
color: $text-color-link;
|
||||
padding-top: $space-xx-small;
|
||||
&:hover {
|
||||
color: $text-color-link-active;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.invite-code {
|
||||
left: 50%;
|
||||
}
|
||||
</style>
|
||||
@ -1,56 +0,0 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/vue'
|
||||
import ObserveButton from './ObserveButton.vue'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
describe('ObserveButton', () => {
|
||||
const Wrapper = (count = 1, postId = '123', isObserved = true) => {
|
||||
return render(ObserveButton, {
|
||||
mocks: {
|
||||
$t: jest.fn((t) => t),
|
||||
},
|
||||
localVue,
|
||||
propsData: {
|
||||
count,
|
||||
postId,
|
||||
isObserved,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('observed', () => {
|
||||
let wrapper
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper(1, '123', true)
|
||||
})
|
||||
|
||||
it('renders', () => {
|
||||
expect(wrapper.container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('emits toggleObservePost with false when clicked', async () => {
|
||||
const button = screen.getByRole('button')
|
||||
await fireEvent.click(button)
|
||||
expect(wrapper.emitted().toggleObservePost).toEqual([['123', false]])
|
||||
})
|
||||
})
|
||||
|
||||
describe('unobserved', () => {
|
||||
let wrapper
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper(1, '123', false)
|
||||
})
|
||||
|
||||
it('renders', () => {
|
||||
expect(wrapper.container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('emits toggleObservePost with true when clicked', async () => {
|
||||
const button = screen.getByRole('button')
|
||||
await fireEvent.click(button)
|
||||
expect(wrapper.emitted().toggleObservePost).toEqual([['123', true]])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,35 +0,0 @@
|
||||
<template>
|
||||
<action-button
|
||||
:loading="false"
|
||||
:count="count"
|
||||
:text="$t('observeButton.observed')"
|
||||
:filled="isObserved"
|
||||
:icon="icons.bell"
|
||||
circle
|
||||
@click="toggle"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { iconRegistry } from '~/utils/iconRegistry'
|
||||
import ActionButton from '~/components/ActionButton.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ActionButton,
|
||||
},
|
||||
props: {
|
||||
count: { type: Number, default: 0 },
|
||||
postId: { type: String, default: null },
|
||||
isObserved: { type: Boolean, default: false },
|
||||
},
|
||||
created() {
|
||||
this.icons = iconRegistry
|
||||
},
|
||||
methods: {
|
||||
toggle() {
|
||||
this.$emit('toggleObservePost', this.postId, !this.isObserved)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@ -1,63 +0,0 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/vue'
|
||||
import '@testing-library/jest-dom'
|
||||
import Vue from 'vue'
|
||||
import ShoutButton from './ShoutButton.vue'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
describe('ShoutButton.vue', () => {
|
||||
let mocks
|
||||
|
||||
beforeEach(() => {
|
||||
mocks = {
|
||||
$t: jest.fn((t) => t),
|
||||
$apollo: {
|
||||
mutate: jest.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
let wrapper
|
||||
|
||||
const Wrapper = ({ isShouted = false } = {}) => {
|
||||
return render(ShoutButton, { mocks, localVue, propsData: { isShouted } })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
it('renders button and text', () => {
|
||||
expect(wrapper.container).toMatchSnapshot()
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggle the button', async () => {
|
||||
mocks.$apollo.mutate = jest.fn().mockResolvedValue({ data: { shout: 'WeDoShout' } })
|
||||
const button = screen.getByRole('button')
|
||||
await fireEvent.click(button)
|
||||
expect(wrapper.container).toMatchSnapshot()
|
||||
const shoutedCount = screen.getByText('1')
|
||||
expect(shoutedCount).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggle the button, but backend fails', async () => {
|
||||
mocks.$apollo.mutate = jest.fn().mockRejectedValue({ message: 'Ouch!' })
|
||||
const button = screen.getByRole('button')
|
||||
await fireEvent.click(button)
|
||||
expect(wrapper.container).toMatchSnapshot()
|
||||
let shoutedCount = screen.getByText('1')
|
||||
expect(shoutedCount).toBeInTheDocument()
|
||||
await Vue.nextTick()
|
||||
shoutedCount = screen.getByText('0')
|
||||
expect(shoutedCount).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('when shouted', () => {
|
||||
it('renders', () => {
|
||||
wrapper = Wrapper({ isShouted: true })
|
||||
expect(wrapper.container).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,89 +0,0 @@
|
||||
<template>
|
||||
<action-button
|
||||
:loading="loading"
|
||||
:disabled="disabled"
|
||||
:count="shoutedCount"
|
||||
:text="$t('shoutButton.shouted')"
|
||||
:filled="shouted"
|
||||
:icon="icons.heartO"
|
||||
@click="toggle"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import { iconRegistry } from '~/utils/iconRegistry'
|
||||
|
||||
import ActionButton from '~/components/ActionButton.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ActionButton,
|
||||
},
|
||||
props: {
|
||||
count: { type: Number, default: 0 },
|
||||
nodeType: { type: String },
|
||||
nodeId: { type: String, default: null },
|
||||
isShouted: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
shoutedCount: this.count,
|
||||
shouted: false,
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
isShouted: {
|
||||
immediate: true,
|
||||
handler: function (shouted) {
|
||||
this.shouted = shouted
|
||||
},
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.icons = iconRegistry
|
||||
},
|
||||
methods: {
|
||||
toggle() {
|
||||
const shout = !this.shouted
|
||||
const mutation = shout ? 'shout' : 'unshout'
|
||||
const count = shout ? this.shoutedCount + 1 : this.shoutedCount - 1
|
||||
|
||||
const backup = {
|
||||
shoutedCount: this.shoutedCount,
|
||||
shouted: this.shouted,
|
||||
}
|
||||
|
||||
this.shoutedCount = count
|
||||
this.shouted = shout
|
||||
|
||||
this.$apollo
|
||||
.mutate({
|
||||
mutation: gql`
|
||||
mutation($id: ID!, $type: ShoutTypeEnum!) {
|
||||
${mutation}(id: $id, type: $type)
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
id: this.nodeId,
|
||||
type: this.nodeType,
|
||||
},
|
||||
})
|
||||
.then((res) => {
|
||||
if (res && res.data) {
|
||||
this.$emit('update', shout)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.shoutedCount = backup.shoutedCount
|
||||
this.shouted = backup.shouted
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@ -1,92 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ActionButton.vue when disabled renders 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="action-button"
|
||||
>
|
||||
<button
|
||||
aria-label="Click me"
|
||||
class="os-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] disabled:hover:bg-transparent disabled:hover:text-[var(--color-disabled)] disabled:hover:border-[var(--color-disabled)] disabled:active:bg-transparent disabled:active:text-[var(--color-disabled)] disabled:active:border-[var(--color-disabled)] h-[36px] min-w-[36px] text-[15px] leading-[normal] align-middle border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[36px]"
|
||||
data-appearance="outline"
|
||||
data-variant="primary"
|
||||
disabled="disabled"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center"
|
||||
>
|
||||
<span
|
||||
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 32 32"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M9.5 5c3.433 0 5.645 2.066 6.5 2.938 0.855-0.871 3.067-2.938 6.5-2.938 4.138 0 7.5 3.404 7.5 7.5 0 2.857-2.469 5.031-2.469 5.031l-11.531 11.563-0.719-0.719-10.813-10.844s-0.619-0.573-1.219-1.469-1.25-2.134-1.25-3.563c0-4.096 3.362-7.5 7.5-7.5zM9.5 7c-3.042 0-5.5 2.496-5.5 5.5 0 0.772 0.423 1.716 0.906 2.438s0.969 1.188 0.969 1.188l10.125 10.125 10.125-10.125s1.875-2.080 1.875-3.625c0-3.004-2.458-5.5-5.5-5.5-2.986 0-5.75 2.906-5.75 2.906l-0.75 0.844-0.75-0.844s-2.764-2.906-5.75-2.906z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="count"
|
||||
>
|
||||
7
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`ActionButton.vue when not disabled renders 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="action-button"
|
||||
>
|
||||
<button
|
||||
aria-label="Click me"
|
||||
class="os-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] disabled:hover:bg-transparent disabled:hover:text-[var(--color-disabled)] disabled:hover:border-[var(--color-disabled)] disabled:active:bg-transparent disabled:active:text-[var(--color-disabled)] disabled:active:border-[var(--color-disabled)] h-[36px] min-w-[36px] text-[15px] leading-[normal] align-middle border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[36px]"
|
||||
data-appearance="outline"
|
||||
data-variant="primary"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center"
|
||||
>
|
||||
<span
|
||||
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 32 32"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M9.5 5c3.433 0 5.645 2.066 6.5 2.938 0.855-0.871 3.067-2.938 6.5-2.938 4.138 0 7.5 3.404 7.5 7.5 0 2.857-2.469 5.031-2.469 5.031l-11.531 11.563-0.719-0.719-10.813-10.844s-0.619-0.573-1.219-1.469-1.25-2.134-1.25-3.563c0-4.096 3.362-7.5 7.5-7.5zM9.5 7c-3.042 0-5.5 2.496-5.5 5.5 0 0.772 0.423 1.716 0.906 2.438s0.969 1.188 0.969 1.188l10.125 10.125 10.125-10.125s1.875-2.080 1.875-3.625c0-3.004-2.458-5.5-5.5-5.5-2.986 0-5.75 2.906-5.75 2.906l-0.75 0.844-0.75-0.844s-2.764-2.906-5.75-2.906z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="count"
|
||||
>
|
||||
7
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@ -1,93 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ObserveButton observed renders 1`] = `
|
||||
<div>
|
||||
<div
|
||||
circle=""
|
||||
class="action-button"
|
||||
>
|
||||
<button
|
||||
aria-label="observeButton.observed"
|
||||
class="os-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] shadow-[inset_0_0_0_1px_rgba(0,0,0,0.05)] disabled:bg-[var(--color-disabled)] disabled:text-[var(--color-disabled-contrast)] disabled:border-[var(--color-disabled)] disabled:shadow-[inset_0_0_0_1px_transparent] disabled:hover:bg-[var(--color-disabled)] disabled:hover:text-[var(--color-disabled-contrast)] disabled:hover:border-[var(--color-disabled)] disabled:active:bg-[var(--color-disabled)] disabled:active:text-[var(--color-disabled-contrast)] disabled:active:border-[var(--color-disabled)] h-[36px] min-w-[36px] text-[15px] leading-[normal] align-middle bg-[var(--color-primary)] text-[var(--color-primary-contrast)] border-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] hover:border-[var(--color-primary-hover)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[36px]"
|
||||
data-appearance="filled"
|
||||
data-variant="primary"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center"
|
||||
>
|
||||
<span
|
||||
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 32 32"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M15 3c1.105 0 2 0.895 2 2 0 0.085-0.021 0.168-0.031 0.25 3.521 0.924 6.031 4.273 6.031 8.031v8.719c0 0.565 0.435 1 1 1h1v2h-7.188c0.114 0.316 0.188 0.647 0.188 1 0 1.645-1.355 3-3 3s-3-1.355-3-3c0-0.353 0.073-0.684 0.188-1h-7.188v-2h1c0.565 0 1-0.435 1-1v-9c0-3.726 2.574-6.866 6.031-7.75-0.010-0.082-0.031-0.165-0.031-0.25 0-1.105 0.895-2 2-2zM14.563 7c-3.118 0.226-5.563 2.824-5.563 6v9c0 0.353-0.073 0.684-0.188 1h12.375c-0.114-0.316-0.188-0.647-0.188-1v-8.719c0-3.319-2.546-6.183-5.813-6.281-0.064-0.002-0.124-0-0.188 0-0.148 0-0.292-0.011-0.438 0zM15 25c-0.564 0-1 0.436-1 1s0.436 1 1 1 1-0.436 1-1-0.436-1-1-1z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="count"
|
||||
>
|
||||
1
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`ObserveButton unobserved renders 1`] = `
|
||||
<div>
|
||||
<div
|
||||
circle=""
|
||||
class="action-button"
|
||||
>
|
||||
<button
|
||||
aria-label="observeButton.observed"
|
||||
class="os-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] disabled:hover:bg-transparent disabled:hover:text-[var(--color-disabled)] disabled:hover:border-[var(--color-disabled)] disabled:active:bg-transparent disabled:active:text-[var(--color-disabled)] disabled:active:border-[var(--color-disabled)] h-[36px] min-w-[36px] text-[15px] leading-[normal] align-middle border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[36px]"
|
||||
data-appearance="outline"
|
||||
data-variant="primary"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center"
|
||||
>
|
||||
<span
|
||||
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 32 32"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M15 3c1.105 0 2 0.895 2 2 0 0.085-0.021 0.168-0.031 0.25 3.521 0.924 6.031 4.273 6.031 8.031v8.719c0 0.565 0.435 1 1 1h1v2h-7.188c0.114 0.316 0.188 0.647 0.188 1 0 1.645-1.355 3-3 3s-3-1.355-3-3c0-0.353 0.073-0.684 0.188-1h-7.188v-2h1c0.565 0 1-0.435 1-1v-9c0-3.726 2.574-6.866 6.031-7.75-0.010-0.082-0.031-0.165-0.031-0.25 0-1.105 0.895-2 2-2zM14.563 7c-3.118 0.226-5.563 2.824-5.563 6v9c0 0.353-0.073 0.684-0.188 1h12.375c-0.114-0.316-0.188-0.647-0.188-1v-8.719c0-3.319-2.546-6.183-5.813-6.281-0.064-0.002-0.124-0-0.188 0-0.148 0-0.292-0.011-0.438 0zM15 25c-0.564 0-1 0.436-1 1s0.436 1 1 1 1-0.436 1-1-0.436-1-1-1z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="count"
|
||||
>
|
||||
1
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@ -1,181 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ShoutButton.vue renders button and text 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="action-button"
|
||||
>
|
||||
<button
|
||||
aria-label="shoutButton.shouted"
|
||||
class="os-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] disabled:hover:bg-transparent disabled:hover:text-[var(--color-disabled)] disabled:hover:border-[var(--color-disabled)] disabled:active:bg-transparent disabled:active:text-[var(--color-disabled)] disabled:active:border-[var(--color-disabled)] h-[36px] min-w-[36px] text-[15px] leading-[normal] align-middle border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[36px]"
|
||||
data-appearance="outline"
|
||||
data-variant="primary"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center"
|
||||
>
|
||||
<span
|
||||
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 32 32"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M9.5 5c3.433 0 5.645 2.066 6.5 2.938 0.855-0.871 3.067-2.938 6.5-2.938 4.138 0 7.5 3.404 7.5 7.5 0 2.857-2.469 5.031-2.469 5.031l-11.531 11.563-0.719-0.719-10.813-10.844s-0.619-0.573-1.219-1.469-1.25-2.134-1.25-3.563c0-4.096 3.362-7.5 7.5-7.5zM9.5 7c-3.042 0-5.5 2.496-5.5 5.5 0 0.772 0.423 1.716 0.906 2.438s0.969 1.188 0.969 1.188l10.125 10.125 10.125-10.125s1.875-2.080 1.875-3.625c0-3.004-2.458-5.5-5.5-5.5-2.986 0-5.75 2.906-5.75 2.906l-0.75 0.844-0.75-0.844s-2.764-2.906-5.75-2.906z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="count"
|
||||
>
|
||||
0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`ShoutButton.vue toggle the button 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="action-button"
|
||||
>
|
||||
<button
|
||||
aria-label="shoutButton.shouted"
|
||||
class="os-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] shadow-[inset_0_0_0_1px_rgba(0,0,0,0.05)] disabled:bg-[var(--color-disabled)] disabled:text-[var(--color-disabled-contrast)] disabled:border-[var(--color-disabled)] disabled:shadow-[inset_0_0_0_1px_transparent] disabled:hover:bg-[var(--color-disabled)] disabled:hover:text-[var(--color-disabled-contrast)] disabled:hover:border-[var(--color-disabled)] disabled:active:bg-[var(--color-disabled)] disabled:active:text-[var(--color-disabled-contrast)] disabled:active:border-[var(--color-disabled)] h-[36px] min-w-[36px] text-[15px] leading-[normal] align-middle bg-[var(--color-primary)] text-[var(--color-primary-contrast)] border-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] hover:border-[var(--color-primary-hover)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[36px]"
|
||||
data-appearance="filled"
|
||||
data-variant="primary"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center"
|
||||
>
|
||||
<span
|
||||
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 32 32"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M9.5 5c3.433 0 5.645 2.066 6.5 2.938 0.855-0.871 3.067-2.938 6.5-2.938 4.138 0 7.5 3.404 7.5 7.5 0 2.857-2.469 5.031-2.469 5.031l-11.531 11.563-0.719-0.719-10.813-10.844s-0.619-0.573-1.219-1.469-1.25-2.134-1.25-3.563c0-4.096 3.362-7.5 7.5-7.5zM9.5 7c-3.042 0-5.5 2.496-5.5 5.5 0 0.772 0.423 1.716 0.906 2.438s0.969 1.188 0.969 1.188l10.125 10.125 10.125-10.125s1.875-2.080 1.875-3.625c0-3.004-2.458-5.5-5.5-5.5-2.986 0-5.75 2.906-5.75 2.906l-0.75 0.844-0.75-0.844s-2.764-2.906-5.75-2.906z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="count"
|
||||
>
|
||||
1
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`ShoutButton.vue toggle the button, but backend fails 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="action-button"
|
||||
>
|
||||
<button
|
||||
aria-label="shoutButton.shouted"
|
||||
class="os-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] shadow-[inset_0_0_0_1px_rgba(0,0,0,0.05)] disabled:bg-[var(--color-disabled)] disabled:text-[var(--color-disabled-contrast)] disabled:border-[var(--color-disabled)] disabled:shadow-[inset_0_0_0_1px_transparent] disabled:hover:bg-[var(--color-disabled)] disabled:hover:text-[var(--color-disabled-contrast)] disabled:hover:border-[var(--color-disabled)] disabled:active:bg-[var(--color-disabled)] disabled:active:text-[var(--color-disabled-contrast)] disabled:active:border-[var(--color-disabled)] h-[36px] min-w-[36px] text-[15px] leading-[normal] align-middle bg-[var(--color-primary)] text-[var(--color-primary-contrast)] border-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] hover:border-[var(--color-primary-hover)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[36px]"
|
||||
data-appearance="filled"
|
||||
data-variant="primary"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center"
|
||||
>
|
||||
<span
|
||||
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 32 32"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M9.5 5c3.433 0 5.645 2.066 6.5 2.938 0.855-0.871 3.067-2.938 6.5-2.938 4.138 0 7.5 3.404 7.5 7.5 0 2.857-2.469 5.031-2.469 5.031l-11.531 11.563-0.719-0.719-10.813-10.844s-0.619-0.573-1.219-1.469-1.25-2.134-1.25-3.563c0-4.096 3.362-7.5 7.5-7.5zM9.5 7c-3.042 0-5.5 2.496-5.5 5.5 0 0.772 0.423 1.716 0.906 2.438s0.969 1.188 0.969 1.188l10.125 10.125 10.125-10.125s1.875-2.080 1.875-3.625c0-3.004-2.458-5.5-5.5-5.5-2.986 0-5.75 2.906-5.75 2.906l-0.75 0.844-0.75-0.844s-2.764-2.906-5.75-2.906z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="count"
|
||||
>
|
||||
1
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`ShoutButton.vue when shouted renders 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="action-button"
|
||||
>
|
||||
<button
|
||||
aria-label="shoutButton.shouted"
|
||||
class="os-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] shadow-[inset_0_0_0_1px_rgba(0,0,0,0.05)] disabled:bg-[var(--color-disabled)] disabled:text-[var(--color-disabled-contrast)] disabled:border-[var(--color-disabled)] disabled:shadow-[inset_0_0_0_1px_transparent] disabled:hover:bg-[var(--color-disabled)] disabled:hover:text-[var(--color-disabled-contrast)] disabled:hover:border-[var(--color-disabled)] disabled:active:bg-[var(--color-disabled)] disabled:active:text-[var(--color-disabled-contrast)] disabled:active:border-[var(--color-disabled)] h-[36px] min-w-[36px] text-[15px] leading-[normal] align-middle bg-[var(--color-primary)] text-[var(--color-primary-contrast)] border-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] hover:border-[var(--color-primary-hover)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] rounded-full p-0 w-[36px]"
|
||||
data-appearance="filled"
|
||||
data-variant="primary"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center"
|
||||
>
|
||||
<span
|
||||
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 32 32"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M9.5 5c3.433 0 5.645 2.066 6.5 2.938 0.855-0.871 3.067-2.938 6.5-2.938 4.138 0 7.5 3.404 7.5 7.5 0 2.857-2.469 5.031-2.469 5.031l-11.531 11.563-0.719-0.719-10.813-10.844s-0.619-0.573-1.219-1.469-1.25-2.134-1.25-3.563c0-4.096 3.362-7.5 7.5-7.5zM9.5 7c-3.042 0-5.5 2.496-5.5 5.5 0 0.772 0.423 1.716 0.906 2.438s0.969 1.188 0.969 1.188l10.125 10.125 10.125-10.125s1.875-2.080 1.875-3.625c0-3.004-2.458-5.5-5.5-5.5-2.986 0-5.75 2.906-5.75 2.906l-0.75 0.844-0.75-0.844s-2.764-2.906-5.75-2.906z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="count"
|
||||
>
|
||||
0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@ -1,24 +0,0 @@
|
||||
import { storiesOf } from '@storybook/vue'
|
||||
import { iconRegistry } from '~/utils/iconRegistry'
|
||||
import helpers from '~/storybook/helpers'
|
||||
import LabeledButton from './LabeledButton.vue'
|
||||
|
||||
helpers.init()
|
||||
|
||||
storiesOf('Generic/LabeledButton', module)
|
||||
.addDecorator(helpers.layout)
|
||||
.add('default', () => ({
|
||||
components: { LabeledButton },
|
||||
data: () => ({
|
||||
filled: false,
|
||||
icons: iconRegistry,
|
||||
}),
|
||||
template: `
|
||||
<labeled-button
|
||||
:icon="icons.check"
|
||||
:filled="filled"
|
||||
label="Toggle Me!!"
|
||||
@click="filled = !filled"
|
||||
/>
|
||||
`,
|
||||
}))
|
||||
@ -1,51 +0,0 @@
|
||||
<template>
|
||||
<div class="labeled-button">
|
||||
<os-button
|
||||
variant="primary"
|
||||
circle
|
||||
:appearance="filled ? 'filled' : 'outline'"
|
||||
@click="(event) => $emit('click', event)"
|
||||
>
|
||||
<template #icon>
|
||||
<os-icon :icon="icon" />
|
||||
</template>
|
||||
</os-button>
|
||||
<label class="label">{{ label }}</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { OsButton, OsIcon } from '@ocelot-social/ui'
|
||||
|
||||
export default {
|
||||
components: { OsButton, OsIcon },
|
||||
props: {
|
||||
filled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
icon: {
|
||||
type: [Object, Function],
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.labeled-button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
> .label {
|
||||
margin-top: $space-x-small;
|
||||
font-size: $font-size-small;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -43,34 +43,13 @@ export default {
|
||||
OsIcon,
|
||||
},
|
||||
props: {
|
||||
pageSize: {
|
||||
type: Number,
|
||||
default: 24,
|
||||
},
|
||||
hasNext: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hasPrevious: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
activePage: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
totalResultCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
activeResourceCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
showPageCounter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
pageSize: { type: Number, default: 24 },
|
||||
hasNext: { type: Boolean, default: false },
|
||||
hasPrevious: { type: Boolean, default: false },
|
||||
activePage: { type: Number, default: 0 },
|
||||
totalResultCount: { type: Number, default: 0 },
|
||||
activeResourceCount: { type: Number, default: 0 },
|
||||
showPageCounter: { type: Boolean, default: false },
|
||||
},
|
||||
created() {
|
||||
this.icons = iconRegistry
|
||||
@ -88,7 +67,6 @@ export default {
|
||||
|
||||
.pagination-pageCount {
|
||||
justify-content: space-around;
|
||||
|
||||
margin: 8px auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
20
webapp/composables/useFollowUser.js
Normal file
@ -0,0 +1,20 @@
|
||||
import { followUserMutation, unfollowUserMutation } from '~/graphql/User'
|
||||
|
||||
export function useFollowUser({ apollo, i18n }) {
|
||||
async function toggleFollow({ id, isCurrentlyFollowed }) {
|
||||
const follow = !isCurrentlyFollowed
|
||||
const mutation = follow ? followUserMutation(i18n) : unfollowUserMutation(i18n)
|
||||
try {
|
||||
const { data } = await apollo.mutate({
|
||||
mutation,
|
||||
variables: { id },
|
||||
})
|
||||
const result = follow ? data.followUser : data.unfollowUser
|
||||
return { success: true, data: result }
|
||||
} catch {
|
||||
return { success: false }
|
||||
}
|
||||
}
|
||||
|
||||
return { toggleFollow }
|
||||
}
|
||||
53
webapp/composables/useFollowUser.spec.js
Normal file
@ -0,0 +1,53 @@
|
||||
import { useFollowUser } from './useFollowUser'
|
||||
|
||||
describe('useFollowUser', () => {
|
||||
let apollo, i18n, toggleFollow
|
||||
|
||||
beforeEach(() => {
|
||||
apollo = {
|
||||
mutate: jest.fn().mockResolvedValue({
|
||||
data: { followUser: { id: 'u1', followedByCurrentUser: true } },
|
||||
}),
|
||||
}
|
||||
i18n = { locale: () => 'en' }
|
||||
;({ toggleFollow } = useFollowUser({ apollo, i18n }))
|
||||
})
|
||||
|
||||
it('calls followUser mutation when not followed', async () => {
|
||||
await toggleFollow({ id: 'u1', isCurrentlyFollowed: false })
|
||||
expect(apollo.mutate).toHaveBeenCalledWith(expect.objectContaining({ variables: { id: 'u1' } }))
|
||||
})
|
||||
|
||||
it('returns success and data on follow', async () => {
|
||||
const result = await toggleFollow({ id: 'u1', isCurrentlyFollowed: false })
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: { id: 'u1', followedByCurrentUser: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('calls unfollowUser mutation when followed', async () => {
|
||||
apollo.mutate.mockResolvedValue({
|
||||
data: { unfollowUser: { id: 'u1', followedByCurrentUser: false } },
|
||||
})
|
||||
await toggleFollow({ id: 'u1', isCurrentlyFollowed: true })
|
||||
expect(apollo.mutate).toHaveBeenCalledWith(expect.objectContaining({ variables: { id: 'u1' } }))
|
||||
})
|
||||
|
||||
it('returns success and data on unfollow', async () => {
|
||||
apollo.mutate.mockResolvedValue({
|
||||
data: { unfollowUser: { id: 'u1', followedByCurrentUser: false } },
|
||||
})
|
||||
const result = await toggleFollow({ id: 'u1', isCurrentlyFollowed: true })
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: { id: 'u1', followedByCurrentUser: false },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns success false on error', async () => {
|
||||
apollo.mutate.mockRejectedValue(new Error('Ouch'))
|
||||
const result = await toggleFollow({ id: 'u1', isCurrentlyFollowed: false })
|
||||
expect(result).toEqual({ success: false })
|
||||
})
|
||||
})
|
||||
53
webapp/composables/useInviteCode.js
Normal file
@ -0,0 +1,53 @@
|
||||
import {
|
||||
generatePersonalInviteCode as generateMutation,
|
||||
invalidateInviteCode as invalidateMutation,
|
||||
} from '~/graphql/InviteCode'
|
||||
|
||||
export function useInviteCode({ apollo, toast, t, store }) {
|
||||
async function generatePersonalInviteCode(comment) {
|
||||
try {
|
||||
await apollo.mutate({
|
||||
mutation: generateMutation(),
|
||||
variables: { comment },
|
||||
update: (_, { data: { generatePersonalInviteCode: newCode } }) => {
|
||||
const user = store.getters['auth/user']
|
||||
store.commit('auth/SET_USER_PARTIAL', {
|
||||
...user,
|
||||
inviteCodes: [...user.inviteCodes, newCode],
|
||||
})
|
||||
},
|
||||
})
|
||||
toast.success(t('invite-codes.create-success'))
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
toast.error(t('invite-codes.create-error', { error: error.message }))
|
||||
return { success: false }
|
||||
}
|
||||
}
|
||||
|
||||
async function invalidateInviteCode(code) {
|
||||
try {
|
||||
await apollo.mutate({
|
||||
mutation: invalidateMutation(),
|
||||
variables: { code },
|
||||
update: () => {
|
||||
const user = store.getters['auth/user']
|
||||
store.commit('auth/SET_USER_PARTIAL', {
|
||||
...user,
|
||||
inviteCodes: user.inviteCodes.map((inviteCode) => ({
|
||||
...inviteCode,
|
||||
isValid: inviteCode.code === code ? false : inviteCode.isValid,
|
||||
})),
|
||||
})
|
||||
},
|
||||
})
|
||||
toast.success(t('invite-codes.invalidate-success'))
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
toast.error(t('invite-codes.invalidate-error', { error: error.message }))
|
||||
return { success: false }
|
||||
}
|
||||
}
|
||||
|
||||
return { generatePersonalInviteCode, invalidateInviteCode }
|
||||
}
|
||||
99
webapp/composables/useInviteCode.spec.js
Normal file
@ -0,0 +1,99 @@
|
||||
import { useInviteCode } from './useInviteCode'
|
||||
|
||||
describe('useInviteCode', () => {
|
||||
let apollo, toast, t, store, generatePersonalInviteCode, invalidateInviteCode
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock apollo.mutate to invoke the update callback (simulating Apollo's behavior)
|
||||
apollo = {
|
||||
mutate: jest.fn().mockImplementation(({ update }) => {
|
||||
const data = { generatePersonalInviteCode: { code: 'new123' }, _invalidateInviteCode: true }
|
||||
if (update) update(null, { data })
|
||||
return Promise.resolve({ data })
|
||||
}),
|
||||
}
|
||||
toast = { success: jest.fn(), error: jest.fn() }
|
||||
t = jest.fn((key) => key)
|
||||
store = {
|
||||
getters: { 'auth/user': { inviteCodes: [{ code: 'abc', isValid: true }] } },
|
||||
commit: jest.fn(),
|
||||
}
|
||||
;({ generatePersonalInviteCode, invalidateInviteCode } = useInviteCode({
|
||||
apollo,
|
||||
toast,
|
||||
t,
|
||||
store,
|
||||
}))
|
||||
})
|
||||
|
||||
describe('generatePersonalInviteCode', () => {
|
||||
it('calls mutation with comment', async () => {
|
||||
await generatePersonalInviteCode('Hello')
|
||||
expect(apollo.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ variables: { comment: 'Hello' } }),
|
||||
)
|
||||
})
|
||||
|
||||
it('shows success toast', async () => {
|
||||
await generatePersonalInviteCode('Hello')
|
||||
expect(toast.success).toHaveBeenCalledWith('invite-codes.create-success')
|
||||
})
|
||||
|
||||
it('returns success true', async () => {
|
||||
const result = await generatePersonalInviteCode('Hello')
|
||||
expect(result).toEqual({ success: true })
|
||||
})
|
||||
|
||||
it('updates store with new invite code', async () => {
|
||||
await generatePersonalInviteCode('Hello')
|
||||
expect(store.commit).toHaveBeenCalledWith('auth/SET_USER_PARTIAL', {
|
||||
inviteCodes: [{ code: 'abc', isValid: true }, { code: 'new123' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error toast on failure', async () => {
|
||||
apollo.mutate.mockRejectedValue(new Error('Ouch'))
|
||||
await generatePersonalInviteCode('Hello')
|
||||
expect(toast.error).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns success false on failure', async () => {
|
||||
apollo.mutate.mockRejectedValue(new Error('Ouch'))
|
||||
const result = await generatePersonalInviteCode('Hello')
|
||||
expect(result).toEqual({ success: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalidateInviteCode', () => {
|
||||
it('calls mutation with code', async () => {
|
||||
await invalidateInviteCode('abc')
|
||||
expect(apollo.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ variables: { code: 'abc' } }),
|
||||
)
|
||||
})
|
||||
|
||||
it('shows success toast', async () => {
|
||||
await invalidateInviteCode('abc')
|
||||
expect(toast.success).toHaveBeenCalledWith('invite-codes.invalidate-success')
|
||||
})
|
||||
|
||||
it('updates store to mark code as invalid', async () => {
|
||||
await invalidateInviteCode('abc')
|
||||
expect(store.commit).toHaveBeenCalledWith('auth/SET_USER_PARTIAL', {
|
||||
inviteCodes: [{ code: 'abc', isValid: false }],
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error toast on failure', async () => {
|
||||
apollo.mutate.mockRejectedValue(new Error('Ouch'))
|
||||
await invalidateInviteCode('abc')
|
||||
expect(toast.error).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns success false on failure', async () => {
|
||||
apollo.mutate.mockRejectedValue(new Error('Ouch'))
|
||||
const result = await invalidateInviteCode('abc')
|
||||
expect(result).toEqual({ success: false })
|
||||
})
|
||||
})
|
||||
})
|
||||
21
webapp/composables/useJoinLeaveGroup.js
Normal file
@ -0,0 +1,21 @@
|
||||
import { joinGroupMutation, leaveGroupMutation } from '~/graphql/groups'
|
||||
|
||||
export function useJoinLeaveGroup({ apollo, toast }) {
|
||||
async function joinLeaveGroup({ groupId, userId, isMember }) {
|
||||
const join = !isMember
|
||||
const mutation = join ? joinGroupMutation() : leaveGroupMutation()
|
||||
try {
|
||||
const { data } = await apollo.mutate({
|
||||
mutation,
|
||||
variables: { groupId, userId },
|
||||
})
|
||||
const result = join ? data.JoinGroup : data.LeaveGroup
|
||||
return { success: true, data: result }
|
||||
} catch (error) {
|
||||
toast.error(error.message)
|
||||
return { success: false }
|
||||
}
|
||||
}
|
||||
|
||||
return { joinLeaveGroup }
|
||||
}
|
||||
63
webapp/composables/useJoinLeaveGroup.spec.js
Normal file
@ -0,0 +1,63 @@
|
||||
import { useJoinLeaveGroup } from './useJoinLeaveGroup'
|
||||
|
||||
describe('useJoinLeaveGroup', () => {
|
||||
let apollo, toast, joinLeaveGroup
|
||||
|
||||
beforeEach(() => {
|
||||
apollo = {
|
||||
mutate: jest.fn().mockResolvedValue({
|
||||
data: { JoinGroup: { user: { id: 'u1' }, membership: { role: 'usual' } } },
|
||||
}),
|
||||
}
|
||||
toast = { error: jest.fn() }
|
||||
;({ joinLeaveGroup } = useJoinLeaveGroup({ apollo, toast }))
|
||||
})
|
||||
|
||||
it('calls JoinGroup mutation when not a member', async () => {
|
||||
await joinLeaveGroup({ groupId: 'g1', userId: 'u1', isMember: false })
|
||||
expect(apollo.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ variables: { groupId: 'g1', userId: 'u1' } }),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns success and data on join', async () => {
|
||||
const result = await joinLeaveGroup({ groupId: 'g1', userId: 'u1', isMember: false })
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: { user: { id: 'u1' }, membership: { role: 'usual' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('calls LeaveGroup mutation when a member', async () => {
|
||||
apollo.mutate.mockResolvedValue({
|
||||
data: { LeaveGroup: { user: { id: 'u1' }, membership: { role: 'none' } } },
|
||||
})
|
||||
await joinLeaveGroup({ groupId: 'g1', userId: 'u1', isMember: true })
|
||||
expect(apollo.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ variables: { groupId: 'g1', userId: 'u1' } }),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns success and data on leave', async () => {
|
||||
apollo.mutate.mockResolvedValue({
|
||||
data: { LeaveGroup: { user: { id: 'u1' }, membership: { role: 'none' } } },
|
||||
})
|
||||
const result = await joinLeaveGroup({ groupId: 'g1', userId: 'u1', isMember: true })
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: { user: { id: 'u1' }, membership: { role: 'none' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('shows toast error on failure', async () => {
|
||||
apollo.mutate.mockRejectedValue(new Error('Ouch'))
|
||||
await joinLeaveGroup({ groupId: 'g1', userId: 'u1', isMember: false })
|
||||
expect(toast.error).toHaveBeenCalledWith('Ouch')
|
||||
})
|
||||
|
||||
it('returns success false on error', async () => {
|
||||
apollo.mutate.mockRejectedValue(new Error('Ouch'))
|
||||
const result = await joinLeaveGroup({ groupId: 'g1', userId: 'u1', isMember: false })
|
||||
expect(result).toEqual({ success: false })
|
||||
})
|
||||
})
|
||||
19
webapp/composables/useShout.js
Normal file
@ -0,0 +1,19 @@
|
||||
import { shoutMutation, unshoutMutation } from '~/graphql/Shout'
|
||||
|
||||
export function useShout({ apollo }) {
|
||||
async function toggleShout({ id, type, isCurrentlyShouted }) {
|
||||
const mutation = isCurrentlyShouted ? unshoutMutation : shoutMutation
|
||||
try {
|
||||
const res = await apollo.mutate({
|
||||
mutation,
|
||||
variables: { id, type },
|
||||
})
|
||||
const result = isCurrentlyShouted ? res?.data?.unshout : res?.data?.shout
|
||||
return { success: !!result }
|
||||
} catch {
|
||||
return { success: false }
|
||||
}
|
||||
}
|
||||
|
||||
return { toggleShout }
|
||||
}
|
||||
57
webapp/composables/useShout.spec.js
Normal file
@ -0,0 +1,57 @@
|
||||
import { useShout } from './useShout'
|
||||
import { shoutMutation, unshoutMutation } from '~/graphql/Shout'
|
||||
|
||||
describe('useShout', () => {
|
||||
let apollo, toggleShout
|
||||
|
||||
beforeEach(() => {
|
||||
apollo = { mutate: jest.fn().mockResolvedValue({ data: { shout: true } }) }
|
||||
;({ toggleShout } = useShout({ apollo }))
|
||||
})
|
||||
|
||||
it('calls shout mutation when not currently shouted', async () => {
|
||||
await toggleShout({ id: '1', type: 'Post', isCurrentlyShouted: false })
|
||||
expect(apollo.mutate).toHaveBeenCalledWith({
|
||||
mutation: shoutMutation,
|
||||
variables: { id: '1', type: 'Post' },
|
||||
})
|
||||
})
|
||||
|
||||
it('calls unshout mutation when currently shouted', async () => {
|
||||
apollo.mutate.mockResolvedValue({ data: { unshout: true } })
|
||||
await toggleShout({ id: '1', type: 'Post', isCurrentlyShouted: true })
|
||||
expect(apollo.mutate).toHaveBeenCalledWith({
|
||||
mutation: unshoutMutation,
|
||||
variables: { id: '1', type: 'Post' },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns success true when shout succeeds', async () => {
|
||||
const result = await toggleShout({ id: '1', type: 'Post', isCurrentlyShouted: false })
|
||||
expect(result).toEqual({ success: true })
|
||||
})
|
||||
|
||||
it('returns success true when unshout succeeds', async () => {
|
||||
apollo.mutate.mockResolvedValue({ data: { unshout: true } })
|
||||
const result = await toggleShout({ id: '1', type: 'Post', isCurrentlyShouted: true })
|
||||
expect(result).toEqual({ success: true })
|
||||
})
|
||||
|
||||
it('returns success false when backend returns false', async () => {
|
||||
apollo.mutate.mockResolvedValue({ data: { shout: false } })
|
||||
const result = await toggleShout({ id: '1', type: 'Post', isCurrentlyShouted: false })
|
||||
expect(result).toEqual({ success: false })
|
||||
})
|
||||
|
||||
it('returns success false when resolved without data', async () => {
|
||||
apollo.mutate.mockResolvedValue({})
|
||||
const result = await toggleShout({ id: '1', type: 'Post', isCurrentlyShouted: false })
|
||||
expect(result).toEqual({ success: false })
|
||||
})
|
||||
|
||||
it('returns success false on error', async () => {
|
||||
apollo.mutate.mockRejectedValue(new Error('Ouch'))
|
||||
const result = await toggleShout({ id: '1', type: 'Post', isCurrentlyShouted: false })
|
||||
expect(result).toEqual({ success: false })
|
||||
})
|
||||
})
|
||||
13
webapp/graphql/Shout.js
Normal file
@ -0,0 +1,13 @@
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
export const shoutMutation = gql`
|
||||
mutation ($id: ID!, $type: ShoutTypeEnum!) {
|
||||
shout(id: $id, type: $type)
|
||||
}
|
||||
`
|
||||
|
||||
export const unshoutMutation = gql`
|
||||
mutation ($id: ID!, $type: ShoutTypeEnum!) {
|
||||
unshout(id: $id, type: $type)
|
||||
}
|
||||
`
|
||||
@ -18,7 +18,7 @@ module.exports = {
|
||||
],
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
lines: 83,
|
||||
lines: 84,
|
||||
},
|
||||
},
|
||||
coverageProvider: 'v8',
|
||||
|
||||
@ -17,7 +17,30 @@
|
||||
<locale-switch class="topbar-locale-switch" placement="top" offset="8" />
|
||||
<template v-if="!isLoggedIn">
|
||||
<client-only>
|
||||
<login-button placement="top" />
|
||||
<dropdown class="login-button" offset="8" placement="top">
|
||||
<template #default="{ toggleMenu }">
|
||||
<os-button
|
||||
data-test="login-btn"
|
||||
variant="primary"
|
||||
appearance="ghost"
|
||||
circle
|
||||
:aria-label="$t('login.login')"
|
||||
@click.prevent="toggleMenu"
|
||||
>
|
||||
<template #icon>
|
||||
<os-icon :icon="icons.signIn" />
|
||||
</template>
|
||||
</os-button>
|
||||
</template>
|
||||
<template #popover>
|
||||
<div class="login-button-menu-popover">
|
||||
<nuxt-link class="login-link" :to="{ name: 'login' }">
|
||||
<os-icon :icon="icons.signIn" />
|
||||
{{ $t('login.login') }}
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</template>
|
||||
</dropdown>
|
||||
</client-only>
|
||||
</template>
|
||||
</div>
|
||||
@ -36,21 +59,28 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { OsButton, OsIcon } from '@ocelot-social/ui'
|
||||
import { iconRegistry } from '~/utils/iconRegistry'
|
||||
import { mapGetters } from 'vuex'
|
||||
import seo from '~/mixins/seo'
|
||||
import Logo from '~/components/Logo/Logo'
|
||||
import LocaleSwitch from '~/components/LocaleSwitch/LocaleSwitch'
|
||||
import LoginButton from '~/components/LoginButton/LoginButton'
|
||||
import Dropdown from '~/components/Dropdown'
|
||||
import PageFooter from '~/components/PageFooter/PageFooter'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
OsButton,
|
||||
OsIcon,
|
||||
Logo,
|
||||
LocaleSwitch,
|
||||
LoginButton,
|
||||
Dropdown,
|
||||
PageFooter,
|
||||
},
|
||||
mixins: [seo],
|
||||
created() {
|
||||
this.icons = iconRegistry
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
isLoggedIn: 'auth/isLoggedIn',
|
||||
@ -82,4 +112,21 @@ export default {
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.login-button {
|
||||
color: $color-secondary;
|
||||
}
|
||||
|
||||
.login-button-menu-popover {
|
||||
padding-top: $space-x-small;
|
||||
padding-bottom: $space-x-small;
|
||||
|
||||
.login-link {
|
||||
color: $text-color-link;
|
||||
padding-top: $space-xx-small;
|
||||
&:hover {
|
||||
color: $text-color-link-active;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -529,8 +529,10 @@
|
||||
"startDate": "Anfangszeitpunkt"
|
||||
},
|
||||
"followButton": {
|
||||
"error": "Aktion fehlgeschlagen. Bitte versuche es erneut.",
|
||||
"follow": "Folgen",
|
||||
"following": "Folge Ich"
|
||||
"following": "Folge Ich",
|
||||
"unfollow": "Entfolgen"
|
||||
},
|
||||
"group": {
|
||||
"actionRadii": {
|
||||
@ -859,7 +861,7 @@
|
||||
"user": "Nutzer"
|
||||
},
|
||||
"observeButton": {
|
||||
"observed": "beobachtet"
|
||||
"observed": "{count} beobachtet"
|
||||
},
|
||||
"pagination": {
|
||||
"next": "Weiter",
|
||||
@ -1251,7 +1253,7 @@
|
||||
}
|
||||
},
|
||||
"shoutButton": {
|
||||
"shouted": "empfohlen"
|
||||
"shouted": "{count} empfohlen"
|
||||
},
|
||||
"site": {
|
||||
"back-to-login": "Zurück zur Anmeldung",
|
||||
|
||||
@ -529,8 +529,10 @@
|
||||
"startDate": "Start date"
|
||||
},
|
||||
"followButton": {
|
||||
"error": "Action failed. Please try again.",
|
||||
"follow": "Follow",
|
||||
"following": "Following"
|
||||
"following": "Following",
|
||||
"unfollow": "Unfollow"
|
||||
},
|
||||
"group": {
|
||||
"actionRadii": {
|
||||
@ -859,7 +861,7 @@
|
||||
"user": "User"
|
||||
},
|
||||
"observeButton": {
|
||||
"observed": "observed"
|
||||
"observed": "{count} observed"
|
||||
},
|
||||
"pagination": {
|
||||
"next": "Next",
|
||||
@ -1251,7 +1253,7 @@
|
||||
}
|
||||
},
|
||||
"shoutButton": {
|
||||
"shouted": "shouted"
|
||||
"shouted": "{count} shouted"
|
||||
},
|
||||
"site": {
|
||||
"back-to-login": "Back to login page",
|
||||
|
||||
@ -529,8 +529,10 @@
|
||||
"startDate": "Fecha de inicio"
|
||||
},
|
||||
"followButton": {
|
||||
"error": "Acción fallida. Por favor, inténtalo de nuevo.",
|
||||
"follow": "Seguir",
|
||||
"following": "Siguiendo"
|
||||
"following": "Siguiendo",
|
||||
"unfollow": "Dejar de seguir"
|
||||
},
|
||||
"group": {
|
||||
"actionRadii": {
|
||||
@ -859,7 +861,7 @@
|
||||
"user": "Usuario"
|
||||
},
|
||||
"observeButton": {
|
||||
"observed": "observado"
|
||||
"observed": "{count} observado"
|
||||
},
|
||||
"pagination": {
|
||||
"next": "Siguiente",
|
||||
@ -1251,7 +1253,7 @@
|
||||
}
|
||||
},
|
||||
"shoutButton": {
|
||||
"shouted": "recomendado"
|
||||
"shouted": "{count} recomendado"
|
||||
},
|
||||
"site": {
|
||||
"back-to-login": "Volver a la página de inicio de sesión",
|
||||
|
||||
@ -529,8 +529,10 @@
|
||||
"startDate": "Date de début"
|
||||
},
|
||||
"followButton": {
|
||||
"error": "Action échouée. Veuillez réessayer.",
|
||||
"follow": "Suivre",
|
||||
"following": "Je suis les"
|
||||
"following": "Abonné",
|
||||
"unfollow": "Ne plus suivre"
|
||||
},
|
||||
"group": {
|
||||
"actionRadii": {
|
||||
@ -859,7 +861,7 @@
|
||||
"user": "Utilisateur"
|
||||
},
|
||||
"observeButton": {
|
||||
"observed": "observé"
|
||||
"observed": "{count} observé"
|
||||
},
|
||||
"pagination": {
|
||||
"next": "Suivant",
|
||||
@ -1251,7 +1253,7 @@
|
||||
}
|
||||
},
|
||||
"shoutButton": {
|
||||
"shouted": "recommandé"
|
||||
"shouted": "{count} recommandé"
|
||||
},
|
||||
"site": {
|
||||
"back-to-login": "Retour à la page de connexion",
|
||||
|
||||
@ -529,8 +529,10 @@
|
||||
"startDate": "Data di inizio"
|
||||
},
|
||||
"followButton": {
|
||||
"error": "Azione fallita. Per favore riprova.",
|
||||
"follow": "Segui",
|
||||
"following": "Seguendo"
|
||||
"following": "Seguendo",
|
||||
"unfollow": "Smetti di seguire"
|
||||
},
|
||||
"group": {
|
||||
"actionRadii": {
|
||||
@ -859,7 +861,7 @@
|
||||
"user": "Utente"
|
||||
},
|
||||
"observeButton": {
|
||||
"observed": "osservato"
|
||||
"observed": "{count} osservato"
|
||||
},
|
||||
"pagination": {
|
||||
"next": "Successivo",
|
||||
@ -1251,7 +1253,7 @@
|
||||
}
|
||||
},
|
||||
"shoutButton": {
|
||||
"shouted": "raccomandato"
|
||||
"shouted": "{count} raccomandato"
|
||||
},
|
||||
"site": {
|
||||
"back-to-login": "Torna alla pagina di login",
|
||||
|
||||
@ -529,8 +529,10 @@
|
||||
"startDate": "Startdatum"
|
||||
},
|
||||
"followButton": {
|
||||
"error": "Actie mislukt. Probeer het opnieuw.",
|
||||
"follow": "Volgen",
|
||||
"following": "Volgt"
|
||||
"following": "Volgt",
|
||||
"unfollow": "Ontvolgen"
|
||||
},
|
||||
"group": {
|
||||
"actionRadii": {
|
||||
@ -859,7 +861,7 @@
|
||||
"user": "Gebruiker"
|
||||
},
|
||||
"observeButton": {
|
||||
"observed": "geobserveerd"
|
||||
"observed": "{count} geobserveerd"
|
||||
},
|
||||
"pagination": {
|
||||
"next": "Volgende",
|
||||
@ -1251,7 +1253,7 @@
|
||||
}
|
||||
},
|
||||
"shoutButton": {
|
||||
"shouted": "uitgeroepen"
|
||||
"shouted": "{count} uitgeroepen"
|
||||
},
|
||||
"site": {
|
||||
"back-to-login": "Terug naar de inlogpagina",
|
||||
|
||||
@ -529,8 +529,10 @@
|
||||
"startDate": "Data rozpoczęcia"
|
||||
},
|
||||
"followButton": {
|
||||
"follow": "naśladować",
|
||||
"following": "w skutek"
|
||||
"error": "Akcja nie powiodła się. Spróbuj ponownie.",
|
||||
"follow": "Obserwuj",
|
||||
"following": "Obserwujesz",
|
||||
"unfollow": "Przestań obserwować"
|
||||
},
|
||||
"group": {
|
||||
"actionRadii": {
|
||||
@ -859,7 +861,7 @@
|
||||
"user": "Użytkownik"
|
||||
},
|
||||
"observeButton": {
|
||||
"observed": "obserwowany"
|
||||
"observed": "{count} obserwujących"
|
||||
},
|
||||
"pagination": {
|
||||
"next": "Następna",
|
||||
@ -1251,7 +1253,7 @@
|
||||
}
|
||||
},
|
||||
"shoutButton": {
|
||||
"shouted": "krzyczeć"
|
||||
"shouted": "{count} rekomendacji"
|
||||
},
|
||||
"site": {
|
||||
"back-to-login": "Powrót do strony logowania",
|
||||
|
||||
@ -529,8 +529,10 @@
|
||||
"startDate": "Data de início"
|
||||
},
|
||||
"followButton": {
|
||||
"error": "Ação falhou. Por favor, tente novamente.",
|
||||
"follow": "Seguir",
|
||||
"following": "Seguindo"
|
||||
"following": "Seguindo",
|
||||
"unfollow": "Deixar de seguir"
|
||||
},
|
||||
"group": {
|
||||
"actionRadii": {
|
||||
@ -859,7 +861,7 @@
|
||||
"user": "Usuário"
|
||||
},
|
||||
"observeButton": {
|
||||
"observed": "observado"
|
||||
"observed": "{count} observadores"
|
||||
},
|
||||
"pagination": {
|
||||
"next": "Seguinte",
|
||||
@ -1251,7 +1253,7 @@
|
||||
}
|
||||
},
|
||||
"shoutButton": {
|
||||
"shouted": "aclamou"
|
||||
"shouted": "{count} recomendações"
|
||||
},
|
||||
"site": {
|
||||
"back-to-login": "Voltar à página de login",
|
||||
|
||||
@ -529,8 +529,10 @@
|
||||
"startDate": "Дата начала"
|
||||
},
|
||||
"followButton": {
|
||||
"error": "Действие не удалось. Пожалуйста, попробуйте снова.",
|
||||
"follow": "Подписаться",
|
||||
"following": "Вы подписаны"
|
||||
"following": "Вы подписаны",
|
||||
"unfollow": "Отписаться"
|
||||
},
|
||||
"group": {
|
||||
"actionRadii": {
|
||||
@ -859,7 +861,7 @@
|
||||
"user": "Пользователь"
|
||||
},
|
||||
"observeButton": {
|
||||
"observed": "наблюдаемый"
|
||||
"observed": "{count} наблюдаемый"
|
||||
},
|
||||
"pagination": {
|
||||
"next": "Вперёд",
|
||||
@ -1251,7 +1253,7 @@
|
||||
}
|
||||
},
|
||||
"shoutButton": {
|
||||
"shouted": "выкрикнули"
|
||||
"shouted": "{count} выкрикнули"
|
||||
},
|
||||
"site": {
|
||||
"back-to-login": "Вернуться на страницу входа",
|
||||
|
||||
@ -529,8 +529,10 @@
|
||||
"startDate": "Data e fillimit"
|
||||
},
|
||||
"followButton": {
|
||||
"error": "Veprimi dështoi. Ju lutemi provoni përsëri.",
|
||||
"follow": "Ndiq",
|
||||
"following": "Duke ndjekur"
|
||||
"following": "Duke ndjekur",
|
||||
"unfollow": "Mos ndiq"
|
||||
},
|
||||
"group": {
|
||||
"actionRadii": {
|
||||
@ -859,7 +861,7 @@
|
||||
"user": "Përdorues"
|
||||
},
|
||||
"observeButton": {
|
||||
"observed": "i observuar"
|
||||
"observed": "{count} i observuar"
|
||||
},
|
||||
"pagination": {
|
||||
"next": "Tjetër",
|
||||
@ -1251,7 +1253,7 @@
|
||||
}
|
||||
},
|
||||
"shoutButton": {
|
||||
"shouted": "rekomanduar"
|
||||
"shouted": "{count} rekomanduar"
|
||||
},
|
||||
"site": {
|
||||
"back-to-login": "Kthehu në faqen e hyrjes",
|
||||
|
||||
@ -529,8 +529,10 @@
|
||||
"startDate": "Дата початку"
|
||||
},
|
||||
"followButton": {
|
||||
"error": "Дія не вдалася. Будь ласка, спробуйте ще раз.",
|
||||
"follow": "Підписатися",
|
||||
"following": "Підписано"
|
||||
"following": "Підписано",
|
||||
"unfollow": "Відписатися"
|
||||
},
|
||||
"group": {
|
||||
"actionRadii": {
|
||||
@ -859,7 +861,7 @@
|
||||
"user": "Користувач"
|
||||
},
|
||||
"observeButton": {
|
||||
"observed": "спостерігається"
|
||||
"observed": "{count} спостерігається"
|
||||
},
|
||||
"pagination": {
|
||||
"next": "Далі",
|
||||
@ -1251,7 +1253,7 @@
|
||||
}
|
||||
},
|
||||
"shoutButton": {
|
||||
"shouted": "рекомендовано"
|
||||
"shouted": "{count} рекомендовано"
|
||||
},
|
||||
"site": {
|
||||
"back-to-login": "Повернутися на сторінку входу",
|
||||
|
||||
@ -116,6 +116,8 @@ export default {
|
||||
'~assets/_new/styles/_ds-compat.scss',
|
||||
// UI library component styles (Tailwind utilities + OsMenu CSS)
|
||||
'../packages/ui/dist/style.css',
|
||||
// Ocelot composite component styles (ActionButton, LabeledButton)
|
||||
'../packages/ui/dist/ui.css',
|
||||
],
|
||||
|
||||
/*
|
||||
@ -280,7 +282,7 @@ export default {
|
||||
*/
|
||||
build: {
|
||||
// Transpile ESM modules for SSR compatibility
|
||||
// vue-demi and @ocelot-social/ui must be transpiled to ensure isVue2 is consistent
|
||||
// vue-demi and @ocelot-social/ui must be transpiled to ensure module resolution works
|
||||
transpile: ['vue-demi', '@ocelot-social/ui'],
|
||||
// Invalidate cache between versions
|
||||
// https://www.reddit.com/r/Nuxt/comments/18i8hp2/comment/kdc1wa3/
|
||||
@ -327,6 +329,7 @@ export default {
|
||||
config.resolve.alias['@ocelot-social/ui$'] = path.join(uiLibraryPath, 'index.mjs')
|
||||
config.resolve.alias['@ocelot-social/ui/ocelot$'] = path.join(uiLibraryPath, 'ocelot.mjs')
|
||||
config.resolve.alias['@ocelot-social/ui/style.css$'] = path.join(uiLibraryPath, 'style.css')
|
||||
config.resolve.alias['@ocelot-social/ui/ui.css$'] = path.join(uiLibraryPath, 'ui.css')
|
||||
const svgRule = config.module.rules.find((rule) => rule.test.test('.svg'))
|
||||
svgRule.test = /\.(png|jpe?g|gif|webp)$/
|
||||
config.module.rules.push({
|
||||
|
||||
@ -20,6 +20,10 @@ describe('PostSlug', () => {
|
||||
id: '1',
|
||||
author,
|
||||
postType: ['Article'],
|
||||
shoutedCount: 0,
|
||||
shoutedByCurrentUser: false,
|
||||
observingUsersCount: 0,
|
||||
isObservedByMe: false,
|
||||
comments: [
|
||||
{
|
||||
id: 'comment134',
|
||||
@ -111,8 +115,12 @@ describe('PostSlug', () => {
|
||||
post: {
|
||||
id: '1',
|
||||
author: null,
|
||||
comments: [],
|
||||
postType: ['Article'],
|
||||
shoutedCount: 0,
|
||||
shoutedByCurrentUser: false,
|
||||
observingUsersCount: 0,
|
||||
isObservedByMe: false,
|
||||
comments: [],
|
||||
},
|
||||
ready: true,
|
||||
}
|
||||
|
||||
@ -122,19 +122,23 @@
|
||||
</div>
|
||||
<div class="actions">
|
||||
<!-- Shout Button -->
|
||||
<shout-button
|
||||
<os-action-button
|
||||
:disabled="isAuthor"
|
||||
:count="post.shoutedCount"
|
||||
:is-shouted="post.shoutedByCurrentUser"
|
||||
:node-id="post.id"
|
||||
node-type="Post"
|
||||
:count="shoutedCount"
|
||||
:aria-label="$t('shoutButton.shouted', { count: shoutedCount })"
|
||||
:filled="shouted"
|
||||
:icon="icons.heartO"
|
||||
:loading="shoutLoading"
|
||||
@click="toggleShout"
|
||||
/>
|
||||
<!-- Follow Button -->
|
||||
<observe-button
|
||||
:is-observed="post.isObservedByMe"
|
||||
<os-action-button
|
||||
:count="post.observingUsersCount"
|
||||
:post-id="post.id"
|
||||
@toggleObservePost="toggleObservePost"
|
||||
:aria-label="$t('observeButton.observed', { count: post.observingUsersCount })"
|
||||
:filled="post.isObservedByMe"
|
||||
:icon="icons.bell"
|
||||
:loading="observeLoading"
|
||||
@click="toggleObservePost(post.id, !post.isObservedByMe)"
|
||||
/>
|
||||
</div>
|
||||
<!-- comments -->
|
||||
@ -184,7 +188,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { OsButton, OsCard, OsIcon, OsMenu } from '@ocelot-social/ui'
|
||||
import { OsButton, OsCard, OsIcon, OsMenu, OsActionButton } from '@ocelot-social/ui'
|
||||
import { iconRegistry } from '~/utils/iconRegistry'
|
||||
import ContentViewer from '~/components/Editor/ContentViewer'
|
||||
import CommentForm from '~/components/CommentForm/CommentForm'
|
||||
@ -197,9 +201,8 @@ import HcCategory from '~/components/Category'
|
||||
import HcEmpty from '~/components/Empty/Empty'
|
||||
import HcHashtag from '~/components/Hashtag/Hashtag'
|
||||
import LocationTeaser from '~/components/LocationTeaser/LocationTeaser'
|
||||
import ObserveButton from '~/components/ObserveButton.vue'
|
||||
import ResponsiveImage from '~/components/ResponsiveImage/ResponsiveImage.vue'
|
||||
import ShoutButton from '~/components/ShoutButton.vue'
|
||||
import { useShout } from '~/composables/useShout'
|
||||
import UserTeaser from '~/components/UserTeaser/UserTeaser'
|
||||
import {
|
||||
postMenuModalsData,
|
||||
@ -236,14 +239,15 @@ export default {
|
||||
HcEmpty,
|
||||
HcHashtag,
|
||||
LocationTeaser,
|
||||
ObserveButton,
|
||||
OsActionButton,
|
||||
ResponsiveImage,
|
||||
ShoutButton,
|
||||
UserTeaser,
|
||||
},
|
||||
mixins: [GetCategories, postListActions, SortCategories],
|
||||
created() {
|
||||
this.icons = iconRegistry
|
||||
const { toggleShout } = useShout({ apollo: this.$apollo })
|
||||
this._toggleShout = toggleShout
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
@ -261,6 +265,10 @@ export default {
|
||||
blocked: null,
|
||||
postAuthor: null,
|
||||
group: null,
|
||||
shoutedCount: 0,
|
||||
shouted: false,
|
||||
shoutLoading: false,
|
||||
observeLoading: false,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@ -341,6 +349,23 @@ export default {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async toggleShout() {
|
||||
const newShouted = !this.shouted
|
||||
const backup = { shoutedCount: this.shoutedCount, shouted: this.shouted }
|
||||
this.shouted = newShouted
|
||||
this.shoutedCount += newShouted ? 1 : -1
|
||||
this.shoutLoading = true
|
||||
const { success } = await this._toggleShout({
|
||||
id: this.post.id,
|
||||
type: 'Post',
|
||||
isCurrentlyShouted: !newShouted,
|
||||
})
|
||||
if (!success) {
|
||||
this.shoutedCount = backup.shoutedCount
|
||||
this.shouted = backup.shouted
|
||||
}
|
||||
this.shoutLoading = false
|
||||
},
|
||||
reply(message) {
|
||||
this.$refs.commentForm && this.$refs.commentForm.reply(message)
|
||||
},
|
||||
@ -358,23 +383,23 @@ export default {
|
||||
this.post.isObservedByMe = comment.isPostObservedByMe
|
||||
this.post.observingUsersCount = comment.postObservingUsersCount
|
||||
},
|
||||
toggleObservePost(postId, value) {
|
||||
this.$apollo
|
||||
.mutate({
|
||||
async toggleObservePost(postId, value) {
|
||||
this.observeLoading = true
|
||||
try {
|
||||
await this.$apollo.mutate({
|
||||
mutation: PostMutations().toggleObservePost,
|
||||
variables: {
|
||||
value,
|
||||
id: postId,
|
||||
},
|
||||
variables: { value, id: postId },
|
||||
})
|
||||
.then(() => {
|
||||
const message = this.$t(
|
||||
`post.menu.${value ? 'observedSuccessfully' : 'unobservedSuccessfully'}`,
|
||||
)
|
||||
this.$toast.success(message)
|
||||
this.$apollo.queries.Post.refetch()
|
||||
})
|
||||
.catch((error) => this.$toast.error(error.message))
|
||||
const message = this.$t(
|
||||
`post.menu.${value ? 'observedSuccessfully' : 'unobservedSuccessfully'}`,
|
||||
)
|
||||
this.$toast.success(message)
|
||||
await this.$apollo.queries.Post.refetch()
|
||||
} catch (error) {
|
||||
this.$toast.error(error.message)
|
||||
} finally {
|
||||
this.observeLoading = false
|
||||
}
|
||||
},
|
||||
toggleNewCommentForm(showNewCommentForm) {
|
||||
this.showNewCommentForm = showNewCommentForm
|
||||
@ -400,6 +425,8 @@ export default {
|
||||
const { image } = this.post
|
||||
this.postAuthor = this.post.author
|
||||
this.blurred = image && image.sensitive
|
||||
this.shouted = !!this.post.shoutedByCurrentUser
|
||||
this.shoutedCount = this.post.shoutedCount || 0
|
||||
},
|
||||
fetchPolicy: 'cache-and-network',
|
||||
},
|
||||
|
||||
@ -364,8 +364,8 @@ exports[`ProfileSlug given an authenticated user given another profile user and
|
||||
</span>
|
||||
</span>
|
||||
|
||||
followButton.follow
|
||||
|
||||
followButton.follow
|
||||
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@ -1090,8 +1090,8 @@ exports[`ProfileSlug given an authenticated user given another profile user and
|
||||
</span>
|
||||
</span>
|
||||
|
||||
followButton.follow
|
||||
|
||||
followButton.follow
|
||||
|
||||
</span>
|
||||
</button>
|
||||
|
||||
|
||||
@ -84,13 +84,26 @@
|
||||
>
|
||||
{{ $t('settings.muted-users.unmute') }}
|
||||
</os-button>
|
||||
<hc-follow-button
|
||||
<os-button
|
||||
v-if="!user.isMuted && !user.isBlocked"
|
||||
:follow-id="user.id"
|
||||
:is-followed="user.followedByCurrentUser"
|
||||
@optimistic="optimisticFollow"
|
||||
@update="updateFollow"
|
||||
/>
|
||||
data-test="follow-btn"
|
||||
:variant="user.followedByCurrentUser && followHovered ? 'danger' : 'primary'"
|
||||
:appearance="user.followedByCurrentUser && !followHovered ? 'filled' : 'outline'"
|
||||
:disabled="!user.id"
|
||||
:loading="followLoading"
|
||||
:aria-pressed="user.followedByCurrentUser"
|
||||
full-width
|
||||
@mouseenter="onFollowHover"
|
||||
@mouseleave="followHovered = false"
|
||||
@focus="onFollowHover"
|
||||
@blur="followHovered = false"
|
||||
@click.prevent="toggleFollow"
|
||||
>
|
||||
<template #icon>
|
||||
<os-icon :icon="followIcon" />
|
||||
</template>
|
||||
{{ followLabel }}
|
||||
</os-button>
|
||||
<os-button
|
||||
variant="primary"
|
||||
appearance="outline"
|
||||
@ -266,7 +279,7 @@ import uniqBy from 'lodash/uniqBy'
|
||||
import { mapGetters, mapMutations } from 'vuex'
|
||||
import postListActions from '~/mixins/postListActions'
|
||||
import PostTeaser from '~/components/PostTeaser/PostTeaser.vue'
|
||||
import HcFollowButton from '~/components/Button/FollowButton'
|
||||
import { useFollowUser } from '~/composables/useFollowUser'
|
||||
import HcBadges from '~/components/Badges.vue'
|
||||
import FollowList, { followListVisibleCount } from '~/components/features/ProfileList/FollowList'
|
||||
import HcEmpty from '~/components/Empty/Empty'
|
||||
@ -304,7 +317,6 @@ export default {
|
||||
OsSpinner,
|
||||
SocialMedia,
|
||||
PostTeaser,
|
||||
HcFollowButton,
|
||||
HcBadges,
|
||||
HcEmpty,
|
||||
ProfileAvatar,
|
||||
@ -320,6 +332,8 @@ export default {
|
||||
},
|
||||
created() {
|
||||
this.icons = iconRegistry
|
||||
const { toggleFollow } = useFollowUser({ apollo: this.$apollo, i18n: this.$i18n })
|
||||
this._toggleFollow = toggleFollow
|
||||
},
|
||||
mixins: [postListActions],
|
||||
transition: {
|
||||
@ -347,6 +361,8 @@ export default {
|
||||
showDeleteModal: false,
|
||||
deleteUserData: null,
|
||||
deleteLoading: false,
|
||||
followHovered: false,
|
||||
followLoading: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@ -356,6 +372,18 @@ export default {
|
||||
myProfile() {
|
||||
return this.$route.params.id === this.$store.getters['auth/user'].id
|
||||
},
|
||||
followIcon() {
|
||||
if (this.user.followedByCurrentUser && this.followHovered) return this.icons.close
|
||||
return this.user.followedByCurrentUser ? this.icons.check : this.icons.plus
|
||||
},
|
||||
followLabel() {
|
||||
if (this.user.followedByCurrentUser && this.followHovered) {
|
||||
return this.$t('followButton.unfollow')
|
||||
}
|
||||
return this.user.followedByCurrentUser
|
||||
? this.$t('followButton.following')
|
||||
: this.$t('followButton.follow')
|
||||
},
|
||||
user() {
|
||||
return this.User ? this.User[0] : {}
|
||||
},
|
||||
@ -503,21 +531,47 @@ export default {
|
||||
this.deleteLoading = false
|
||||
}
|
||||
},
|
||||
optimisticFollow({ followedByCurrentUser }) {
|
||||
onFollowHover() {
|
||||
if (!this.followLoading) this.followHovered = true
|
||||
},
|
||||
async toggleFollow() {
|
||||
if (this.followLoading) return
|
||||
const follow = !this.user.followedByCurrentUser
|
||||
this.followHovered = false
|
||||
this.followLoading = true
|
||||
|
||||
// optimistic update
|
||||
const currentUser = this.$store.getters['auth/user']
|
||||
if (followedByCurrentUser) {
|
||||
if (follow) {
|
||||
this.user.followedByCount++
|
||||
this.user.followedBy = [currentUser, ...this.user.followedBy]
|
||||
} else {
|
||||
this.user.followedByCount--
|
||||
this.user.followedBy = this.user.followedBy.filter((user) => user.id !== currentUser.id)
|
||||
this.user.followedBy = this.user.followedBy.filter((u) => u.id !== currentUser.id)
|
||||
}
|
||||
this.user.followedByCurrentUser = followedByCurrentUser
|
||||
},
|
||||
updateFollow({ followedByCurrentUser, followedBy, followedByCount }) {
|
||||
this.user.followedByCount = followedByCount
|
||||
this.user.followedByCurrentUser = followedByCurrentUser
|
||||
this.user.followedBy = followedBy
|
||||
this.user.followedByCurrentUser = follow
|
||||
|
||||
const { success, data } = await this._toggleFollow({
|
||||
id: this.user.id,
|
||||
isCurrentlyFollowed: !follow,
|
||||
})
|
||||
if (success) {
|
||||
this.user.followedByCount = data.followedByCount
|
||||
this.user.followedByCurrentUser = data.followedByCurrentUser
|
||||
this.user.followedBy = data.followedBy
|
||||
} else {
|
||||
// rollback optimistic update
|
||||
this.$toast.error(this.$t('followButton.error'))
|
||||
this.user.followedByCurrentUser = !follow
|
||||
if (follow) {
|
||||
this.user.followedByCount--
|
||||
this.user.followedBy = this.user.followedBy.filter((u) => u.id !== currentUser.id)
|
||||
} else {
|
||||
this.user.followedByCount++
|
||||
this.user.followedBy = [currentUser, ...this.user.followedBy]
|
||||
}
|
||||
}
|
||||
this.followLoading = false
|
||||
},
|
||||
fetchAllConnections(type, count) {
|
||||
if (type === 'following') this.followingCount = count
|
||||
|
||||
@ -22,7 +22,7 @@ export default {
|
||||
apollo: this.$apollo,
|
||||
store: this.$store,
|
||||
toast: this.$toast,
|
||||
t: this.$t,
|
||||
t: (key, ...args) => this.$t(key, ...args),
|
||||
})
|
||||
this._changePassword = changePassword
|
||||
},
|
||||
|
||||