feat(package/ui): actionButton + labledButton (#9470)

This commit is contained in:
Ulf Gebhardt 2026-03-30 02:16:21 +02:00 committed by GitHub
parent 8849db6cbf
commit e144170cf0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
75 changed files with 1529 additions and 1531 deletions

View File

@ -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)
}

View File

@ -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)
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -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'

View File

@ -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)
})
})
})

View File

@ -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,
},
}

View File

@ -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)
})
})

View File

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1 @@
export { default as OsActionButton } from './OsActionButton.vue'

View File

@ -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)
})
})
})

View File

@ -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>
`,
}),
}

View File

@ -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)
})
})

View File

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -0,0 +1 @@
export { default as OsLabeledButton } from './OsLabeledButton.vue'

View File

@ -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'

View File

@ -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,

View File

@ -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};
}

View File

@ -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()
})
})
})

View File

@ -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>

View File

@ -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 }])
})
})
})
})

View File

@ -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>

View File

@ -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)
}
},
},

View File

@ -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
},

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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)
})
})
})

View File

@ -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>

View File

@ -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]])
})
})
})

View File

@ -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>

View File

@ -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()
})
})
})

View File

@ -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>

View File

@ -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>
`;

View File

@ -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>
`;

View File

@ -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>
`;

View File

@ -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"
/>
`,
}))

View File

@ -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>

View File

@ -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>

View 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 }
}

View 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 })
})
})

View 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 }
}

View 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 })
})
})
})

View 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 }
}

View 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 })
})
})

View 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 }
}

View 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
View 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)
}
`

View File

@ -18,7 +18,7 @@ module.exports = {
],
coverageThreshold: {
global: {
lines: 83,
lines: 84,
},
},
coverageProvider: 'v8',

View File

@ -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>

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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": "Вернуться на страницу входа",

View File

@ -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",

View File

@ -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": "Повернутися на сторінку входу",

View File

@ -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({

View File

@ -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,
}

View File

@ -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',
},

View File

@ -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>

View File

@ -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

View File

@ -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
},