mirror of
https://github.com/glitch-soc/mastodon.git
synced 2025-12-14 00:08:46 +00:00
Merge commit 'b10fde673d4e703b53f43691419a6e91672daf9e' into glitch-soc/merge-upstream
Conflicts: - `tsconfig.json`: Upstream added config for storybook files, while glitch-soc had an addition for the glitch flavor on the last line. Added upstream's new config. - `yarn.lock`: Upstream added a new dependency to an `eslint` plugin textually adjacent to a couple glitch-only-dependencies. Added upstream's new dependency.
This commit is contained in:
40
.github/workflows/chromatic.yml
vendored
Normal file
40
.github/workflows/chromatic.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
name: 'Chromatic'
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches-ignore:
|
||||||
|
- renovate/*
|
||||||
|
- stable-*
|
||||||
|
paths:
|
||||||
|
- 'package.json'
|
||||||
|
- 'yarn.lock'
|
||||||
|
- '**/*.js'
|
||||||
|
- '**/*.jsx'
|
||||||
|
- '**/*.ts'
|
||||||
|
- '**/*.tsx'
|
||||||
|
- '**/*.css'
|
||||||
|
- '**/*.scss'
|
||||||
|
- '.github/workflows/chromatic.yml'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
chromatic:
|
||||||
|
name: Run Chromatic
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Set up Javascript environment
|
||||||
|
uses: ./.github/actions/setup-javascript
|
||||||
|
|
||||||
|
- name: Build Storybook
|
||||||
|
run: yarn build-storybook
|
||||||
|
|
||||||
|
- name: Run Chromatic
|
||||||
|
uses: chromaui/action@v12
|
||||||
|
with:
|
||||||
|
# ⚠️ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret
|
||||||
|
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
||||||
|
zip: true
|
||||||
|
storybookBuildDir: 'storybook-static'
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -75,3 +75,6 @@ docker-compose.override.yml
|
|||||||
|
|
||||||
# Ignore local-only rspec configuration
|
# Ignore local-only rspec configuration
|
||||||
.rspec-local
|
.rspec-local
|
||||||
|
|
||||||
|
*storybook.log
|
||||||
|
storybook-static
|
||||||
|
|||||||
16
.storybook/main.ts
Normal file
16
.storybook/main.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { StorybookConfig } from '@storybook/react-vite';
|
||||||
|
|
||||||
|
const config: StorybookConfig = {
|
||||||
|
stories: ['../app/javascript/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||||
|
addons: [
|
||||||
|
'@storybook/addon-docs',
|
||||||
|
'@storybook/addon-a11y',
|
||||||
|
'@storybook/addon-vitest',
|
||||||
|
],
|
||||||
|
framework: {
|
||||||
|
name: '@storybook/react-vite',
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
7
.storybook/manager.ts
Normal file
7
.storybook/manager.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { addons } from 'storybook/manager-api';
|
||||||
|
|
||||||
|
import theme from './storybook-theme';
|
||||||
|
|
||||||
|
addons.setConfig({
|
||||||
|
theme,
|
||||||
|
});
|
||||||
18
.storybook/preview-head.html
Normal file
18
.storybook/preview-head.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<style>
|
||||||
|
/* Increase docs font size */
|
||||||
|
.sbdocs.sbdocs-content :where(p:not(.sb-anchor, .sb-unstyled, .sb-unstyled p)),
|
||||||
|
.sbdocs.sbdocs-content :where(li:not(.sb-anchor, .sb-unstyled, .sb-unstyled li)) {
|
||||||
|
font-size: 1.0666rem; /* 17px */
|
||||||
|
line-height: 1.585; /* 27px */
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbdocs.sbdocs-content :where(p:not(.sb-anchor, .sb-unstyled, .sb-unstyled p)) code,
|
||||||
|
.sbdocs.sbdocs-content :where(li:not(.sb-anchor, .sb-unstyled, .sb-unstyled li)) code {
|
||||||
|
font-size: 0.875rem; /* ~15px */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bring numbers back for ordered lists */
|
||||||
|
ol {
|
||||||
|
list-style: revert !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
29
.storybook/preview.ts
Normal file
29
.storybook/preview.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Preview } from '@storybook/react-vite';
|
||||||
|
|
||||||
|
// If you want to run the dark theme during development,
|
||||||
|
// you can change the below to `/application.scss`
|
||||||
|
import '../app/javascript/styles/mastodon-light.scss';
|
||||||
|
|
||||||
|
const preview: Preview = {
|
||||||
|
// Auto-generate docs: https://storybook.js.org/docs/writing-docs/autodocs
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
|
||||||
|
controls: {
|
||||||
|
matchers: {
|
||||||
|
color: /(background|color)$/i,
|
||||||
|
date: /Date$/i,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
a11y: {
|
||||||
|
// 'todo' - show a11y violations in the test UI only
|
||||||
|
// 'error' - fail CI on a11y violations
|
||||||
|
// 'off' - skip a11y checks entirely
|
||||||
|
test: 'todo',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default preview;
|
||||||
7
.storybook/storybook-addon-vitest.d.ts
vendored
Normal file
7
.storybook/storybook-addon-vitest.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// The addon package.json incorrectly exports types, so we need to override them here.
|
||||||
|
// See: https://github.com/storybookjs/storybook/blob/v9.0.4/code/addons/vitest/package.json#L70-L76
|
||||||
|
declare module '@storybook/addon-vitest/vitest-plugin' {
|
||||||
|
export * from '@storybook/addon-vitest/dist/vitest-plugin/index';
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
7
.storybook/storybook-theme.ts
Normal file
7
.storybook/storybook-theme.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { create } from 'storybook/theming';
|
||||||
|
|
||||||
|
export default create({
|
||||||
|
base: 'light',
|
||||||
|
brandTitle: 'Mastodon Storybook',
|
||||||
|
brandImage: 'https://joinmastodon.org/logos/wordmark-black-text.svg',
|
||||||
|
});
|
||||||
8
.storybook/vitest.setup.ts
Normal file
8
.storybook/vitest.setup.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview';
|
||||||
|
import { setProjectAnnotations } from '@storybook/react-vite';
|
||||||
|
|
||||||
|
import * as projectAnnotations from './preview';
|
||||||
|
|
||||||
|
// This is an important step to apply the right configuration when testing your stories.
|
||||||
|
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
|
||||||
|
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);
|
||||||
@@ -633,7 +633,7 @@ GEM
|
|||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
raabro (1.4.0)
|
raabro (1.4.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (3.1.15)
|
rack (3.1.16)
|
||||||
rack-attack (6.7.0)
|
rack-attack (6.7.0)
|
||||||
rack (>= 1.0, < 4)
|
rack (>= 1.0, < 4)
|
||||||
rack-cors (3.0.0)
|
rack-cors (3.0.0)
|
||||||
|
|||||||
97
app/javascript/mastodon/components/button/button.stories.tsx
Normal file
97
app/javascript/mastodon/components/button/button.stories.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
import { fn, expect } from 'storybook/test';
|
||||||
|
|
||||||
|
import { Button } from '.';
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Components/Button',
|
||||||
|
component: Button,
|
||||||
|
args: {
|
||||||
|
secondary: false,
|
||||||
|
compact: false,
|
||||||
|
dangerous: false,
|
||||||
|
disabled: false,
|
||||||
|
onClick: fn(),
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
text: {
|
||||||
|
control: 'text',
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'Alternative way of specifying the button label. Will override `children` if provided.',
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: 'string',
|
||||||
|
control: 'text',
|
||||||
|
table: {
|
||||||
|
type: { summary: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tags: ['test'],
|
||||||
|
} satisfies Meta<typeof Button>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
const buttonTest: Story['play'] = async ({ args, canvas, userEvent }) => {
|
||||||
|
await userEvent.click(canvas.getByRole('button'));
|
||||||
|
await expect(args.onClick).toHaveBeenCalled();
|
||||||
|
};
|
||||||
|
|
||||||
|
const disabledButtonTest: Story['play'] = async ({
|
||||||
|
args,
|
||||||
|
canvas,
|
||||||
|
userEvent,
|
||||||
|
}) => {
|
||||||
|
await userEvent.click(canvas.getByRole('button'));
|
||||||
|
await expect(args.onClick).not.toHaveBeenCalled();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {
|
||||||
|
children: 'Primary button',
|
||||||
|
},
|
||||||
|
play: buttonTest,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Secondary: Story = {
|
||||||
|
args: {
|
||||||
|
secondary: true,
|
||||||
|
children: 'Secondary button',
|
||||||
|
},
|
||||||
|
play: buttonTest,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Compact: Story = {
|
||||||
|
args: {
|
||||||
|
compact: true,
|
||||||
|
children: 'Compact button',
|
||||||
|
},
|
||||||
|
play: buttonTest,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Dangerous: Story = {
|
||||||
|
args: {
|
||||||
|
dangerous: true,
|
||||||
|
children: 'Dangerous button',
|
||||||
|
},
|
||||||
|
play: buttonTest,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PrimaryDisabled: Story = {
|
||||||
|
args: {
|
||||||
|
...Primary.args,
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
play: disabledButtonTest,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SecondaryDisabled: Story = {
|
||||||
|
args: {
|
||||||
|
...Secondary.args,
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
play: disabledButtonTest,
|
||||||
|
};
|
||||||
@@ -22,6 +22,10 @@ interface PropsWithText extends BaseProps {
|
|||||||
|
|
||||||
type Props = PropsWithText | PropsChildren;
|
type Props = PropsWithText | PropsChildren;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Primary UI component for user interaction that doesn't result in navigation.
|
||||||
|
*/
|
||||||
|
|
||||||
export const Button: React.FC<Props> = ({
|
export const Button: React.FC<Props> = ({
|
||||||
type = 'button',
|
type = 'button',
|
||||||
onClick,
|
onClick,
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import { PureComponent } from 'react';
|
|
||||||
|
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import { is } from 'immutable';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
|
|
||||||
import Textarea from 'react-textarea-autosize';
|
|
||||||
|
|
||||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
placeholder: { id: 'account_note.placeholder', defaultMessage: 'Click to add a note' },
|
|
||||||
});
|
|
||||||
|
|
||||||
class InlineAlert extends PureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
show: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
state = {
|
|
||||||
mountMessage: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
static TRANSITION_DELAY = 200;
|
|
||||||
|
|
||||||
UNSAFE_componentWillReceiveProps (nextProps) {
|
|
||||||
if (!this.props.show && nextProps.show) {
|
|
||||||
this.setState({ mountMessage: true });
|
|
||||||
} else if (this.props.show && !nextProps.show) {
|
|
||||||
setTimeout(() => this.setState({ mountMessage: false }), InlineAlert.TRANSITION_DELAY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { show } = this.props;
|
|
||||||
const { mountMessage } = this.state;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span aria-live='polite' role='status' className='inline-alert' style={{ opacity: show ? 1 : 0 }}>
|
|
||||||
{mountMessage && <FormattedMessage id='generic.saved' defaultMessage='Saved' />}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
class AccountNote extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
accountId: PropTypes.string.isRequired,
|
|
||||||
value: PropTypes.string,
|
|
||||||
onSave: PropTypes.func.isRequired,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
state = {
|
|
||||||
value: null,
|
|
||||||
saving: false,
|
|
||||||
saved: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
UNSAFE_componentWillMount () {
|
|
||||||
this._reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
UNSAFE_componentWillReceiveProps (nextProps) {
|
|
||||||
const accountWillChange = !is(this.props.accountId, nextProps.accountId);
|
|
||||||
const newState = {};
|
|
||||||
|
|
||||||
if (accountWillChange && this._isDirty()) {
|
|
||||||
this._save(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (accountWillChange || nextProps.value === this.state.value) {
|
|
||||||
newState.saving = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.props.value !== nextProps.value) {
|
|
||||||
newState.value = nextProps.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState(newState);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount () {
|
|
||||||
if (this._isDirty()) {
|
|
||||||
this._save(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setTextareaRef = c => {
|
|
||||||
this.textarea = c;
|
|
||||||
};
|
|
||||||
|
|
||||||
handleChange = e => {
|
|
||||||
this.setState({ value: e.target.value, saving: false });
|
|
||||||
};
|
|
||||||
|
|
||||||
handleKeyDown = e => {
|
|
||||||
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (this.textarea) {
|
|
||||||
this.textarea.blur();
|
|
||||||
} else {
|
|
||||||
this._save();
|
|
||||||
}
|
|
||||||
} else if (e.keyCode === 27) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
this._reset(() => {
|
|
||||||
if (this.textarea) {
|
|
||||||
this.textarea.blur();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleBlur = () => {
|
|
||||||
if (this._isDirty()) {
|
|
||||||
this._save();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
_save (showMessage = true) {
|
|
||||||
this.setState({ saving: true }, () => this.props.onSave(this.state.value));
|
|
||||||
|
|
||||||
if (showMessage) {
|
|
||||||
this.setState({ saved: true }, () => setTimeout(() => this.setState({ saved: false }), 2000));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_reset (callback) {
|
|
||||||
this.setState({ value: this.props.value }, callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
_isDirty () {
|
|
||||||
return !this.state.saving && this.props.value !== null && this.state.value !== null && this.state.value !== this.props.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { accountId, intl } = this.props;
|
|
||||||
const { value, saved } = this.state;
|
|
||||||
|
|
||||||
if (!accountId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='account__header__account-note'>
|
|
||||||
<label htmlFor={`account-note-${accountId}`}>
|
|
||||||
<FormattedMessage id='account.account_note_header' defaultMessage='Personal note' /> <InlineAlert show={saved} />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{this.props.value === undefined ? (
|
|
||||||
<div className='account__header__account-note__loading-indicator-wrapper'>
|
|
||||||
<LoadingIndicator />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Textarea
|
|
||||||
id={`account-note-${accountId}`}
|
|
||||||
className='account__header__account-note__content'
|
|
||||||
disabled={value === null}
|
|
||||||
placeholder={intl.formatMessage(messages.placeholder)}
|
|
||||||
value={value || ''}
|
|
||||||
onChange={this.handleChange}
|
|
||||||
onKeyDown={this.handleKeyDown}
|
|
||||||
onBlur={this.handleBlur}
|
|
||||||
ref={this.setTextareaRef}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default injectIntl(AccountNote);
|
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import type { ChangeEventHandler, KeyboardEventHandler } from 'react';
|
||||||
|
import { useState, useRef, useCallback, useId } from 'react';
|
||||||
|
|
||||||
|
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import Textarea from 'react-textarea-autosize';
|
||||||
|
|
||||||
|
import { submitAccountNote } from '@/mastodon/actions/account_notes';
|
||||||
|
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
placeholder: {
|
||||||
|
id: 'account_note.placeholder',
|
||||||
|
defaultMessage: 'Click to add a note',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const AccountNoteUI: React.FC<{
|
||||||
|
initialValue: string | undefined;
|
||||||
|
onSubmit: (newNote: string) => void;
|
||||||
|
wasSaved: boolean;
|
||||||
|
}> = ({ initialValue, onSubmit, wasSaved }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const uniqueId = useId();
|
||||||
|
const [value, setValue] = useState(initialValue ?? '');
|
||||||
|
const isLoading = initialValue === undefined;
|
||||||
|
const canSubmitOnBlurRef = useRef(true);
|
||||||
|
|
||||||
|
const handleChange = useCallback<ChangeEventHandler<HTMLTextAreaElement>>(
|
||||||
|
(e) => {
|
||||||
|
setValue(e.target.value);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback<KeyboardEventHandler<HTMLTextAreaElement>>(
|
||||||
|
(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
setValue(initialValue ?? '');
|
||||||
|
|
||||||
|
canSubmitOnBlurRef.current = false;
|
||||||
|
e.currentTarget.blur();
|
||||||
|
} else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
onSubmit(value);
|
||||||
|
|
||||||
|
canSubmitOnBlurRef.current = false;
|
||||||
|
e.currentTarget.blur();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[initialValue, onSubmit, value],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBlur = useCallback(() => {
|
||||||
|
if (initialValue !== value && canSubmitOnBlurRef.current) {
|
||||||
|
onSubmit(value);
|
||||||
|
}
|
||||||
|
canSubmitOnBlurRef.current = true;
|
||||||
|
}, [initialValue, onSubmit, value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='account__header__account-note'>
|
||||||
|
<label htmlFor={`account-note-${uniqueId}`}>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account.account_note_header'
|
||||||
|
defaultMessage='Personal note'
|
||||||
|
/>{' '}
|
||||||
|
<span
|
||||||
|
aria-live='polite'
|
||||||
|
role='status'
|
||||||
|
className='inline-alert'
|
||||||
|
style={{ opacity: wasSaved ? 1 : 0 }}
|
||||||
|
>
|
||||||
|
{wasSaved && (
|
||||||
|
<FormattedMessage id='generic.saved' defaultMessage='Saved' />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className='account__header__account-note__loading-indicator-wrapper'>
|
||||||
|
<LoadingIndicator />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Textarea
|
||||||
|
id={`account-note-${uniqueId}`}
|
||||||
|
className='account__header__account-note__content'
|
||||||
|
placeholder={intl.formatMessage(messages.placeholder)}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AccountNote: React.FC<{
|
||||||
|
accountId: string;
|
||||||
|
}> = ({ accountId }) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const initialValue = useAppSelector((state) =>
|
||||||
|
state.relationships.get(accountId)?.get('note'),
|
||||||
|
);
|
||||||
|
const [wasSaved, setWasSaved] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(note: string) => {
|
||||||
|
setWasSaved(true);
|
||||||
|
void dispatch(submitAccountNote({ accountId, note }));
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setWasSaved(false);
|
||||||
|
}, 2000);
|
||||||
|
},
|
||||||
|
[dispatch, accountId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccountNoteUI
|
||||||
|
key={`${accountId}-${initialValue}`}
|
||||||
|
initialValue={initialValue}
|
||||||
|
wasSaved={wasSaved}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { submitAccountNote } from 'mastodon/actions/account_notes';
|
|
||||||
|
|
||||||
import AccountNote from '../components/account_note';
|
|
||||||
|
|
||||||
const mapStateToProps = (state, { accountId }) => ({
|
|
||||||
value: state.relationships.getIn([accountId, 'note']),
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch, { accountId }) => ({
|
|
||||||
|
|
||||||
onSave (value) {
|
|
||||||
dispatch(submitAccountNote({ accountId: accountId, note: value }));
|
|
||||||
},
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(AccountNote);
|
|
||||||
@@ -44,8 +44,8 @@ import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
|
|||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import { IconButton } from 'mastodon/components/icon_button';
|
import { IconButton } from 'mastodon/components/icon_button';
|
||||||
import { ShortNumber } from 'mastodon/components/short_number';
|
import { ShortNumber } from 'mastodon/components/short_number';
|
||||||
|
import { AccountNote } from 'mastodon/features/account/components/account_note';
|
||||||
import { DomainPill } from 'mastodon/features/account/components/domain_pill';
|
import { DomainPill } from 'mastodon/features/account/components/domain_pill';
|
||||||
import AccountNoteContainer from 'mastodon/features/account/containers/account_note_container';
|
|
||||||
import FollowRequestNoteContainer from 'mastodon/features/account/containers/follow_request_note_container';
|
import FollowRequestNoteContainer from 'mastodon/features/account/containers/follow_request_note_container';
|
||||||
import { useLinks } from 'mastodon/hooks/useLinks';
|
import { useLinks } from 'mastodon/hooks/useLinks';
|
||||||
import { useIdentity } from 'mastodon/identity_context';
|
import { useIdentity } from 'mastodon/identity_context';
|
||||||
@@ -923,7 +923,7 @@ export const AccountHeader: React.FC<{
|
|||||||
onClickCapture={handleLinkClick}
|
onClickCapture={handleLinkClick}
|
||||||
>
|
>
|
||||||
{account.id !== me && signedIn && (
|
{account.id !== me && signedIn && (
|
||||||
<AccountNoteContainer accountId={accountId} />
|
<AccountNote accountId={accountId} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{account.note.length > 0 && account.note !== '<p></p>' && (
|
{account.note.length > 0 && account.note !== '<p></p>' && (
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import jsxA11Y from 'eslint-plugin-jsx-a11y';
|
|||||||
import promisePlugin from 'eslint-plugin-promise';
|
import promisePlugin from 'eslint-plugin-promise';
|
||||||
import react from 'eslint-plugin-react';
|
import react from 'eslint-plugin-react';
|
||||||
import reactHooks from 'eslint-plugin-react-hooks';
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import storybook from 'eslint-plugin-storybook';
|
||||||
import globals from 'globals';
|
import globals from 'globals';
|
||||||
import tseslint from 'typescript-eslint';
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
@@ -206,6 +207,7 @@ export default tseslint.config([
|
|||||||
importPlugin.flatConfigs.react,
|
importPlugin.flatConfigs.react,
|
||||||
// @ts-expect-error -- For some reason the formatjs package exports an empty object?
|
// @ts-expect-error -- For some reason the formatjs package exports an empty object?
|
||||||
formatjs.configs.strict,
|
formatjs.configs.strict,
|
||||||
|
storybook.configs['flat/recommended'],
|
||||||
{
|
{
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: {
|
globals: {
|
||||||
@@ -271,6 +273,11 @@ export default tseslint.config([
|
|||||||
'app/javascript/mastodon/test_setup.js',
|
'app/javascript/mastodon/test_setup.js',
|
||||||
'app/javascript/mastodon/test_helpers.tsx',
|
'app/javascript/mastodon/test_helpers.tsx',
|
||||||
'app/javascript/**/__tests__/**',
|
'app/javascript/**/__tests__/**',
|
||||||
|
'app/javascript/**/*.stories.ts',
|
||||||
|
'app/javascript/**/*.stories.tsx',
|
||||||
|
'app/javascript/**/*.test.ts',
|
||||||
|
'app/javascript/**/*.test.tsx',
|
||||||
|
'.storybook/**/*.ts',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -422,4 +429,18 @@ export default tseslint.config([
|
|||||||
globals: globals.vitest,
|
globals: globals.vitest,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.stories.ts', '**/*.stories.tsx', '.storybook/**/*.ts'],
|
||||||
|
rules: {
|
||||||
|
'import/no-default-export': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['vitest.shims.d.ts'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-unnecessary-boolean-literal-compare': 'off',
|
||||||
|
'@typescript-eslint/no-unnecessary-condition': 'off',
|
||||||
|
'@typescript-eslint/prefer-nullish-coalescing': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
24
package.json
24
package.json
@@ -26,8 +26,12 @@
|
|||||||
"postinstall": "test -d node_modules/husky && husky || echo \"husky is not installed\"",
|
"postinstall": "test -d node_modules/husky && husky || echo \"husky is not installed\"",
|
||||||
"start": "node ./streaming/index.js",
|
"start": "node ./streaming/index.js",
|
||||||
"test": "yarn lint && yarn run typecheck && yarn test:js run",
|
"test": "yarn lint && yarn run typecheck && yarn test:js run",
|
||||||
"test:js": "vitest",
|
"test:js": "vitest --project=legacy-tests",
|
||||||
"typecheck": "tsc --noEmit"
|
"test:storybook": "vitest --project=storybook",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"storybook": "storybook dev -p 6006",
|
||||||
|
"build-storybook": "VITE_RUBY_PUBLIC_OUTPUT_DIR='.' VITE_RUBY_PUBLIC_DIR='./storybook-static' storybook build",
|
||||||
|
"chromatic": "npx chromatic -d storybook-static"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -125,6 +129,10 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.23.0",
|
"@eslint/js": "^9.23.0",
|
||||||
"@formatjs/cli": "^6.1.1",
|
"@formatjs/cli": "^6.1.1",
|
||||||
|
"@storybook/addon-a11y": "^9.0.4",
|
||||||
|
"@storybook/addon-docs": "^9.0.4",
|
||||||
|
"@storybook/addon-vitest": "^9.0.4",
|
||||||
|
"@storybook/react-vite": "^9.0.4",
|
||||||
"@testing-library/dom": "^10.2.0",
|
"@testing-library/dom": "^10.2.0",
|
||||||
"@testing-library/react": "^16.0.0",
|
"@testing-library/react": "^16.0.0",
|
||||||
"@types/emoji-mart": "3.0.14",
|
"@types/emoji-mart": "3.0.14",
|
||||||
@@ -150,6 +158,10 @@
|
|||||||
"@types/react-toggle": "^4.0.3",
|
"@types/react-toggle": "^4.0.3",
|
||||||
"@types/redux-immutable": "^4.0.3",
|
"@types/redux-immutable": "^4.0.3",
|
||||||
"@types/requestidlecallback": "^0.3.5",
|
"@types/requestidlecallback": "^0.3.5",
|
||||||
|
"@vitest/browser": "^3.2.1",
|
||||||
|
"@vitest/coverage-v8": "^3.2.0",
|
||||||
|
"@vitest/ui": "^3.2.1",
|
||||||
|
"chromatic": "^12.1.0",
|
||||||
"eslint": "^9.23.0",
|
"eslint": "^9.23.0",
|
||||||
"eslint-import-resolver-typescript": "^4.2.5",
|
"eslint-import-resolver-typescript": "^4.2.5",
|
||||||
"eslint-plugin-formatjs": "^5.3.1",
|
"eslint-plugin-formatjs": "^5.3.1",
|
||||||
@@ -159,11 +171,14 @@
|
|||||||
"eslint-plugin-promise": "~7.2.1",
|
"eslint-plugin-promise": "~7.2.1",
|
||||||
"eslint-plugin-react": "^7.37.4",
|
"eslint-plugin-react": "^7.37.4",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-storybook": "^9.0.4",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
"husky": "^9.0.11",
|
"husky": "^9.0.11",
|
||||||
"lint-staged": "^16.0.0",
|
"lint-staged": "^16.0.0",
|
||||||
|
"playwright": "^1.52.0",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"react-test-renderer": "^18.2.0",
|
"react-test-renderer": "^18.2.0",
|
||||||
|
"storybook": "^9.0.4",
|
||||||
"stylelint": "^16.19.1",
|
"stylelint": "^16.19.1",
|
||||||
"stylelint-config-prettier-scss": "^1.0.0",
|
"stylelint-config-prettier-scss": "^1.0.0",
|
||||||
"stylelint-config-standard-scss": "^15.0.1",
|
"stylelint-config-standard-scss": "^15.0.1",
|
||||||
@@ -171,13 +186,14 @@
|
|||||||
"typescript-eslint": "^8.29.1",
|
"typescript-eslint": "^8.29.1",
|
||||||
"vite-plugin-rails": "^0.5.0",
|
"vite-plugin-rails": "^0.5.0",
|
||||||
"vite-plugin-svgr": "^4.2.0",
|
"vite-plugin-svgr": "^4.2.0",
|
||||||
"vitest": "^3.1.3"
|
"vitest": "^3.2.1"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"@types/react": "^18.2.7",
|
"@types/react": "^18.2.7",
|
||||||
"@types/react-dom": "^18.2.4",
|
"@types/react-dom": "^18.2.4",
|
||||||
"kind-of": "^6.0.3",
|
"kind-of": "^6.0.3",
|
||||||
"vite-plugin-ruby": "^5.1.0"
|
"vite-plugin-ruby": "^5.1.0",
|
||||||
|
"vite": "^6.3.5"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"react": {
|
"react": {
|
||||||
|
|||||||
@@ -30,10 +30,13 @@
|
|||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"vite.config.mts",
|
"vite.config.mts",
|
||||||
|
"vitest.config.mts",
|
||||||
"config/vite",
|
"config/vite",
|
||||||
"app/javascript/mastodon",
|
"app/javascript/mastodon",
|
||||||
"app/javascript/entrypoints",
|
"app/javascript/entrypoints",
|
||||||
"app/javascript/types",
|
"app/javascript/types",
|
||||||
"app/javascript/flavours/glitch"
|
"app/javascript/flavours/glitch",
|
||||||
|
".storybook/*.ts",
|
||||||
|
".storybook/*.tsx"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,44 @@
|
|||||||
import { configDefaults, defineConfig } from 'vitest/config';
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import svgr from 'vite-plugin-svgr';
|
||||||
|
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||||
|
import {
|
||||||
|
configDefaults,
|
||||||
|
defineConfig,
|
||||||
|
TestProjectInlineConfiguration,
|
||||||
|
} from 'vitest/config';
|
||||||
|
|
||||||
import { config as viteConfig } from './vite.config.mjs';
|
import { config as viteConfig } from './vite.config.mjs';
|
||||||
|
|
||||||
export default defineConfig(async (context) => {
|
const storybookTests: TestProjectInlineConfiguration = {
|
||||||
return {
|
plugins: [
|
||||||
...(await viteConfig(context)),
|
// See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest
|
||||||
|
storybookTest({
|
||||||
|
configDir: '.storybook',
|
||||||
|
storybookScript: 'yarn run storybook',
|
||||||
|
}),
|
||||||
|
react(),
|
||||||
|
svgr(),
|
||||||
|
tsconfigPaths(),
|
||||||
|
],
|
||||||
test: {
|
test: {
|
||||||
|
name: 'storybook',
|
||||||
|
browser: {
|
||||||
|
enabled: true,
|
||||||
|
headless: true,
|
||||||
|
provider: 'playwright',
|
||||||
|
instances: [{ browser: 'chromium' }],
|
||||||
|
},
|
||||||
|
setupFiles: [resolve(__dirname, '.storybook/vitest.setup.ts')],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const legacyTests: TestProjectInlineConfiguration = {
|
||||||
|
extends: true,
|
||||||
|
test: {
|
||||||
|
name: 'legacy-tests',
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
include: [
|
include: [
|
||||||
...configDefaults.include,
|
...configDefaults.include,
|
||||||
@@ -23,4 +56,14 @@ export default defineConfig(async (context) => {
|
|||||||
globals: true,
|
globals: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default defineConfig(async (context) => {
|
||||||
|
const baseConfig = await viteConfig(context);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseConfig,
|
||||||
|
test: {
|
||||||
|
projects: [legacyTests, storybookTests],
|
||||||
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
1
vitest.shims.d.ts
vendored
Normal file
1
vitest.shims.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="@vitest/browser/providers/playwright" />
|
||||||
Reference in New Issue
Block a user