diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..3ae16ae3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,26 @@ +# Repository Guidelines + +## Project Structure & Module Organization +The Nuxt 4 app lives at the repo root. Vue route files sit in `pages/`, shared components in `components/`, and cross-cutting logic in `composables/` and `stores/`. Domain-specific GraphQL documents are under `graphQLData/`, while reusable utilities and types live in `utils/` and `types/`. Cypress specs reside in `cypress/e2e`, unit tests in `tests/unit`, and static assets in `public/` and `assets/`. Reference `docs/` and `CLAUDE.md` for deeper architectural notes. + +## Build, Test, and Development Commands +- `npm run dev`: Launch the Nuxt dev server on `127.0.0.1`. +- `npm run build`: Produce the production bundle. +- `npm run preview`: Inspect the production build locally. +- `npm run test:unit` / `npm run test:unit -- --run path/to/spec`: Run Vitest suites. +- `npm run coverage`: Generate HTML and text coverage reports in `coverage/`. +- `npm run test` or `npx cypress run --spec cypress/e2e/...`: Execute Cypress interactively or headless. +- `npm run lint` / `npm run lint:a11y`: Apply ESLint rules, including Vue accessibility checks. +- `npm run verify`: Type-check then run unit tests; mirrors the pre-commit hook. + +## Coding Style & Naming Conventions +TypeScript and Vue single-file components use two-space indentation. Vue components and composables follow `PascalCase` and `useThing` naming; Pinia stores live in `stores/` with `useXStore`. Prefer type-only imports (`import type`) as enforced by ESLint. Run `npx eslint --fix file.vue` for quick fixes. Tailwind classes should stay sorted via the Prettier Tailwind plugin. + +## Testing Guidelines +Vitest runs in a `happy-dom` environment with setup in `tests/setup.ts`; keep assertions focused on observable behavior. Maintain ≥80% coverage across lines, branches, functions, and statements as enforced by `vitest.config.ts`. End-to-end specs use the `.spec.cy.ts` suffix; rely on helpers like `setupTestData()` and `loginUser()` from Cypress support, and alias GraphQL operations to guard for status 200 responses. + +## Commit & Pull Request Guidelines +Commit history favors concise, imperative subjects (e.g., “Add pinning feature…”). Keep commits scoped and allow the `verify` hook to pass. Pull requests should describe scope, list critical commands run, and link related issues; include screenshots or recordings for UI changes. Confirm you ran `npm run lint` plus relevant tests before requesting review, and surface follow-up tasks in the PR description. + +## Agent Collaboration Notes +Read `CLAUDE.md` for nuanced component, testing, and permission guidance. When collaborating asynchronously, cite sections from that guide, call out remaining TODOs explicitly, and prefer incremental patches so automation hooks stay fast. diff --git a/CLAUDE.md b/CLAUDE.md index dd2cde5b..874fe5e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,18 +19,6 @@ ### Cypress Test Optimizations -- **Replace arbitrary timeouts with network waits**: - - ```javascript - // Before: Fixed timeout that might be too short or too long - cy.get('button').contains('Save').click().wait(3000); - - // After: Wait for the actual network request to complete - cy.intercept('POST', '**/graphql').as('graphqlRequest'); - cy.get('button').contains('Save').click(); - cy.wait('@graphqlRequest').its('response.statusCode').should('eq', 200); - ``` - - **Validate network responses**: Add status code checks to ensure operations completed successfully ```javascript @@ -87,6 +75,52 @@ - Test files: Run the specific test that changed - To skip pre-commit hooks temporarily: `git commit --no-verify` +## Component Architecture Preferences: + +1. Separation of Concerns + +- Child components are ignorant of parent state/layout - they never manage showEditor, + hideEditor, or similar display state +- Each component has a single, clear responsibility + +1. Reusable UI Components (like InPlaceTagEditor) + +- Pure UI only - no mutations, no business logic +- Edit mode only - view mode belongs in parent components +- Emit generic, simple events: save, cancel +- Accept minimal props: data needed for editing, loading/error states +- Use props as ref defaults: ref([...props.existingTags]) not onMounted + +1. Wrapper Components (like ChannelTagEditor, DiscussionTagEditor) + +- Handle domain-specific mutations (UPDATE_CHANNEL, UPDATE_DISCUSSION) +- Translate between child events and mutation lifecycle +- Emit domain-specific events: + - done on successful save (via onDone hook) + - cancel passed through from child + - refetch for data updates +- Still don't manage display state - that's the parent's job + +1. Parent Components (like ChannelSidebar, DiscussionBody) + +- Manage display state with refs (showTagEditor) +- Render both view mode (tags + edit button) and edit mode (wrapper component) +- Listen to events from wrappers (@done, @cancel) to close editor +- Control the entire UX flow + +1. Event Naming + +- Be clear and specific - no misleading names +- done = successful completion +- cancel = user cancelled +- Don't call cancel "done" or vice versa + +1. Avoid Unnecessary Complexity + +- Use onDone hooks, not watchers +- Use props as ref defaults, not onMounted +- Keep it simple and direct + ## Code Style Guidelines - **TypeScript**: Use strict typing whenever possible, proper interfaces in `types/` directory @@ -276,12 +310,10 @@ The application has two separate but related permission systems: ### User Permission Levels 1. **Standard Users**: - - Use the DefaultChannelRole for the channel (or DefaultServerRole as fallback) - Have permissions like createDiscussion, createComment, upvoteContent, etc. 2. **Channel Admins/Owners**: - - Users in the `Channel.Admins` list - Have all user and moderator permissions automatically @@ -292,14 +324,12 @@ The application has two separate but related permission systems: ### Moderator Permission Levels 1. **Standard/Normal Moderators**: - - All authenticated users are considered standard moderators by default - Not explicitly included in `Channel.Moderators` list, not in `Channel.SuspendedMods` - Can perform basic moderation actions (report, give feedback) based on DefaultModRole - These permissions are controlled by the DefaultModRole configuration 2. **Elevated Moderators**: - - Explicitly included in the `Channel.Moderators` list - Have additional permissions beyond standard moderators - Can typically archive content, manage other moderators, etc. @@ -320,9 +350,7 @@ The application has two separate but related permission systems: - Channel owners/admins bypass all permission checks (both user and mod) - Suspended status overrides all other status for that permission type - **Fallback Chain**: - - Channel-specific roles -> Server default roles -> Deny access - - **User vs. Mod Actions**: - Some UI actions require BOTH user and mod permissions - For example, to archive content: need canHideDiscussion (mod) AND be an elevated mod or admin diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 22ffd783..5801c8f5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -149,7 +149,7 @@ describe('Feature workflow', () => { setupTestData(); // Authenticate before each test - loginUser('loginWithCreateEventButton'); + loginUser('loginProgrammatically'); it('completes a user workflow successfully', () => { // Set up request interception for GraphQL calls @@ -157,6 +157,7 @@ describe('Feature workflow', () => { // Navigate to the starting point cy.visit('/forums/cats'); + cy.syncAuthState(); // Interact with the UI cy.get('[data-testid="create-discussion-button"]').click(); @@ -253,7 +254,7 @@ import { DISCUSSION_CREATION_FORM } from '../constants'; describe('Discussion CRUD operations', () => { setupTestData(); - loginUser('loginWithCreateEventButton'); + loginUser('loginProgrammatically'); it('creates, edits, and deletes a discussion', () => { // Set up GraphQL interception diff --git a/__generated__/graphql.ts b/__generated__/graphql.ts index 6a253492..099e521d 100644 --- a/__generated__/graphql.ts +++ b/__generated__/graphql.ts @@ -30,6 +30,7 @@ export type Activity = { __typename?: 'Activity'; Comments: Array; Discussions: Array; + Downloads: Array; Events: Array; description: Scalars['String']['output']; id: Scalars['String']['output']; @@ -1234,6 +1235,12 @@ export type Channel = { PendingOwnerInvites: Array; PendingOwnerInvitesAggregate?: Maybe; PendingOwnerInvitesConnection: ChannelPendingOwnerInvitesConnection; + PinnedDiscussionChannels: Array; + PinnedDiscussionChannelsAggregate?: Maybe; + PinnedDiscussionChannelsConnection: ChannelPinnedDiscussionChannelsConnection; + PinnedWikiPages: Array; + PinnedWikiPagesAggregate?: Maybe; + PinnedWikiPagesConnection: ChannelPinnedWikiPagesConnection; RelatedChannels: Array; RelatedChannelsAggregate?: Maybe; RelatedChannelsConnection: ChannelRelatedChannelsConnection; @@ -1589,6 +1596,50 @@ export type ChannelPendingOwnerInvitesConnectionArgs = { }; +export type ChannelPinnedDiscussionChannelsArgs = { + directed?: InputMaybe; + options?: InputMaybe; + where?: InputMaybe; +}; + + +export type ChannelPinnedDiscussionChannelsAggregateArgs = { + directed?: InputMaybe; + where?: InputMaybe; +}; + + +export type ChannelPinnedDiscussionChannelsConnectionArgs = { + after?: InputMaybe; + directed?: InputMaybe; + first?: InputMaybe; + sort?: InputMaybe>; + where?: InputMaybe; +}; + + +export type ChannelPinnedWikiPagesArgs = { + directed?: InputMaybe; + options?: InputMaybe; + where?: InputMaybe; +}; + + +export type ChannelPinnedWikiPagesAggregateArgs = { + directed?: InputMaybe; + where?: InputMaybe; +}; + + +export type ChannelPinnedWikiPagesConnectionArgs = { + after?: InputMaybe; + directed?: InputMaybe; + first?: InputMaybe; + sort?: InputMaybe>; + where?: InputMaybe; +}; + + export type ChannelRelatedChannelsArgs = { directed?: InputMaybe; options?: InputMaybe; @@ -2303,6 +2354,8 @@ export type ChannelConnectInput = { Moderators?: InputMaybe>; PendingModInvites?: InputMaybe>; PendingOwnerInvites?: InputMaybe>; + PinnedDiscussionChannels?: InputMaybe>; + PinnedWikiPages?: InputMaybe>; RelatedChannels?: InputMaybe>; SuspendedModRole?: InputMaybe; SuspendedMods?: InputMaybe>; @@ -2350,6 +2403,8 @@ export type ChannelConnectedRelationships = { Moderators?: Maybe; PendingModInvites?: Maybe; PendingOwnerInvites?: Maybe; + PinnedDiscussionChannels?: Maybe; + PinnedWikiPages?: Maybe; RelatedChannels?: Maybe; SuspendedModRole?: Maybe; SuspendedMods?: Maybe; @@ -2374,6 +2429,8 @@ export type ChannelCreateInput = { Moderators?: InputMaybe; PendingModInvites?: InputMaybe; PendingOwnerInvites?: InputMaybe; + PinnedDiscussionChannels?: InputMaybe; + PinnedWikiPages?: InputMaybe; RelatedChannels?: InputMaybe; SuspendedModRole?: InputMaybe; SuspendedMods?: InputMaybe; @@ -2713,6 +2770,8 @@ export type ChannelDeleteInput = { Moderators?: InputMaybe>; PendingModInvites?: InputMaybe>; PendingOwnerInvites?: InputMaybe>; + PinnedDiscussionChannels?: InputMaybe>; + PinnedWikiPages?: InputMaybe>; RelatedChannels?: InputMaybe>; SuspendedModRole?: InputMaybe; SuspendedMods?: InputMaybe>; @@ -2744,6 +2803,8 @@ export type ChannelDisconnectInput = { Moderators?: InputMaybe>; PendingModInvites?: InputMaybe>; PendingOwnerInvites?: InputMaybe>; + PinnedDiscussionChannels?: InputMaybe>; + PinnedWikiPages?: InputMaybe>; RelatedChannels?: InputMaybe>; SuspendedModRole?: InputMaybe; SuspendedMods?: InputMaybe>; @@ -2768,6 +2829,21 @@ export type ChannelDiscussionChannelDiscussionChannelsNodeAggregateSelection = { weightedVotesCount: FloatAggregateSelection; }; +export type ChannelDiscussionChannelPinnedDiscussionChannelsAggregationSelection = { + __typename?: 'ChannelDiscussionChannelPinnedDiscussionChannelsAggregationSelection'; + count: Scalars['Int']['output']; + node?: Maybe; +}; + +export type ChannelDiscussionChannelPinnedDiscussionChannelsNodeAggregateSelection = { + __typename?: 'ChannelDiscussionChannelPinnedDiscussionChannelsNodeAggregateSelection'; + channelUniqueName: StringAggregateSelection; + createdAt: DateTimeAggregateSelection; + discussionId: IdAggregateSelection; + id: IdAggregateSelection; + weightedVotesCount: FloatAggregateSelection; +}; + export type ChannelDiscussionChannelsAggregateInput = { AND?: InputMaybe>; NOT?: InputMaybe; @@ -4888,6 +4964,311 @@ export type ChannelPendingOwnerInvitesUpdateFieldInput = { where?: InputMaybe; }; +export type ChannelPinnedDiscussionChannelsAggregateInput = { + AND?: InputMaybe>; + NOT?: InputMaybe; + OR?: InputMaybe>; + count?: InputMaybe; + count_GT?: InputMaybe; + count_GTE?: InputMaybe; + count_LT?: InputMaybe; + count_LTE?: InputMaybe; + node?: InputMaybe; +}; + +export type ChannelPinnedDiscussionChannelsConnectFieldInput = { + connect?: InputMaybe>; + /** Whether or not to overwrite any matching relationship with the new properties. */ + overwrite?: Scalars['Boolean']['input']; + where?: InputMaybe; +}; + +export type ChannelPinnedDiscussionChannelsConnectedRelationship = { + __typename?: 'ChannelPinnedDiscussionChannelsConnectedRelationship'; + node: DiscussionChannelEventPayload; +}; + +export type ChannelPinnedDiscussionChannelsConnection = { + __typename?: 'ChannelPinnedDiscussionChannelsConnection'; + edges: Array; + pageInfo: PageInfo; + totalCount: Scalars['Int']['output']; +}; + +export type ChannelPinnedDiscussionChannelsConnectionSort = { + node?: InputMaybe; +}; + +export type ChannelPinnedDiscussionChannelsConnectionWhere = { + AND?: InputMaybe>; + NOT?: InputMaybe; + OR?: InputMaybe>; + node?: InputMaybe; +}; + +export type ChannelPinnedDiscussionChannelsCreateFieldInput = { + node: DiscussionChannelCreateInput; +}; + +export type ChannelPinnedDiscussionChannelsDeleteFieldInput = { + delete?: InputMaybe; + where?: InputMaybe; +}; + +export type ChannelPinnedDiscussionChannelsDisconnectFieldInput = { + disconnect?: InputMaybe; + where?: InputMaybe; +}; + +export type ChannelPinnedDiscussionChannelsFieldInput = { + connect?: InputMaybe>; + create?: InputMaybe>; +}; + +export type ChannelPinnedDiscussionChannelsNodeAggregationWhereInput = { + AND?: InputMaybe>; + NOT?: InputMaybe; + OR?: InputMaybe>; + channelUniqueName_AVERAGE_LENGTH_EQUAL?: InputMaybe; + channelUniqueName_AVERAGE_LENGTH_GT?: InputMaybe; + channelUniqueName_AVERAGE_LENGTH_GTE?: InputMaybe; + channelUniqueName_AVERAGE_LENGTH_LT?: InputMaybe; + channelUniqueName_AVERAGE_LENGTH_LTE?: InputMaybe; + channelUniqueName_LONGEST_LENGTH_EQUAL?: InputMaybe; + channelUniqueName_LONGEST_LENGTH_GT?: InputMaybe; + channelUniqueName_LONGEST_LENGTH_GTE?: InputMaybe; + channelUniqueName_LONGEST_LENGTH_LT?: InputMaybe; + channelUniqueName_LONGEST_LENGTH_LTE?: InputMaybe; + channelUniqueName_SHORTEST_LENGTH_EQUAL?: InputMaybe; + channelUniqueName_SHORTEST_LENGTH_GT?: InputMaybe; + channelUniqueName_SHORTEST_LENGTH_GTE?: InputMaybe; + channelUniqueName_SHORTEST_LENGTH_LT?: InputMaybe; + channelUniqueName_SHORTEST_LENGTH_LTE?: InputMaybe; + createdAt_MAX_EQUAL?: InputMaybe; + createdAt_MAX_GT?: InputMaybe; + createdAt_MAX_GTE?: InputMaybe; + createdAt_MAX_LT?: InputMaybe; + createdAt_MAX_LTE?: InputMaybe; + createdAt_MIN_EQUAL?: InputMaybe; + createdAt_MIN_GT?: InputMaybe; + createdAt_MIN_GTE?: InputMaybe; + createdAt_MIN_LT?: InputMaybe; + createdAt_MIN_LTE?: InputMaybe; + weightedVotesCount_AVERAGE_EQUAL?: InputMaybe; + weightedVotesCount_AVERAGE_GT?: InputMaybe; + weightedVotesCount_AVERAGE_GTE?: InputMaybe; + weightedVotesCount_AVERAGE_LT?: InputMaybe; + weightedVotesCount_AVERAGE_LTE?: InputMaybe; + weightedVotesCount_MAX_EQUAL?: InputMaybe; + weightedVotesCount_MAX_GT?: InputMaybe; + weightedVotesCount_MAX_GTE?: InputMaybe; + weightedVotesCount_MAX_LT?: InputMaybe; + weightedVotesCount_MAX_LTE?: InputMaybe; + weightedVotesCount_MIN_EQUAL?: InputMaybe; + weightedVotesCount_MIN_GT?: InputMaybe; + weightedVotesCount_MIN_GTE?: InputMaybe; + weightedVotesCount_MIN_LT?: InputMaybe; + weightedVotesCount_MIN_LTE?: InputMaybe; + weightedVotesCount_SUM_EQUAL?: InputMaybe; + weightedVotesCount_SUM_GT?: InputMaybe; + weightedVotesCount_SUM_GTE?: InputMaybe; + weightedVotesCount_SUM_LT?: InputMaybe; + weightedVotesCount_SUM_LTE?: InputMaybe; +}; + +export type ChannelPinnedDiscussionChannelsRelationship = { + __typename?: 'ChannelPinnedDiscussionChannelsRelationship'; + cursor: Scalars['String']['output']; + node: DiscussionChannel; +}; + +export type ChannelPinnedDiscussionChannelsRelationshipSubscriptionWhere = { + node?: InputMaybe; +}; + +export type ChannelPinnedDiscussionChannelsUpdateConnectionInput = { + node?: InputMaybe; +}; + +export type ChannelPinnedDiscussionChannelsUpdateFieldInput = { + connect?: InputMaybe>; + create?: InputMaybe>; + delete?: InputMaybe>; + disconnect?: InputMaybe>; + update?: InputMaybe; + where?: InputMaybe; +}; + +export type ChannelPinnedWikiPagesAggregateInput = { + AND?: InputMaybe>; + NOT?: InputMaybe; + OR?: InputMaybe>; + count?: InputMaybe; + count_GT?: InputMaybe; + count_GTE?: InputMaybe; + count_LT?: InputMaybe; + count_LTE?: InputMaybe; + node?: InputMaybe; +}; + +export type ChannelPinnedWikiPagesConnectFieldInput = { + connect?: InputMaybe>; + /** Whether or not to overwrite any matching relationship with the new properties. */ + overwrite?: Scalars['Boolean']['input']; + where?: InputMaybe; +}; + +export type ChannelPinnedWikiPagesConnectedRelationship = { + __typename?: 'ChannelPinnedWikiPagesConnectedRelationship'; + node: WikiPageEventPayload; +}; + +export type ChannelPinnedWikiPagesConnection = { + __typename?: 'ChannelPinnedWikiPagesConnection'; + edges: Array; + pageInfo: PageInfo; + totalCount: Scalars['Int']['output']; +}; + +export type ChannelPinnedWikiPagesConnectionSort = { + node?: InputMaybe; +}; + +export type ChannelPinnedWikiPagesConnectionWhere = { + AND?: InputMaybe>; + NOT?: InputMaybe; + OR?: InputMaybe>; + node?: InputMaybe; +}; + +export type ChannelPinnedWikiPagesCreateFieldInput = { + node: WikiPageCreateInput; +}; + +export type ChannelPinnedWikiPagesDeleteFieldInput = { + delete?: InputMaybe; + where?: InputMaybe; +}; + +export type ChannelPinnedWikiPagesDisconnectFieldInput = { + disconnect?: InputMaybe; + where?: InputMaybe; +}; + +export type ChannelPinnedWikiPagesFieldInput = { + connect?: InputMaybe>; + create?: InputMaybe>; +}; + +export type ChannelPinnedWikiPagesNodeAggregationWhereInput = { + AND?: InputMaybe>; + NOT?: InputMaybe; + OR?: InputMaybe>; + body_AVERAGE_LENGTH_EQUAL?: InputMaybe; + body_AVERAGE_LENGTH_GT?: InputMaybe; + body_AVERAGE_LENGTH_GTE?: InputMaybe; + body_AVERAGE_LENGTH_LT?: InputMaybe; + body_AVERAGE_LENGTH_LTE?: InputMaybe; + body_LONGEST_LENGTH_EQUAL?: InputMaybe; + body_LONGEST_LENGTH_GT?: InputMaybe; + body_LONGEST_LENGTH_GTE?: InputMaybe; + body_LONGEST_LENGTH_LT?: InputMaybe; + body_LONGEST_LENGTH_LTE?: InputMaybe; + body_SHORTEST_LENGTH_EQUAL?: InputMaybe; + body_SHORTEST_LENGTH_GT?: InputMaybe; + body_SHORTEST_LENGTH_GTE?: InputMaybe; + body_SHORTEST_LENGTH_LT?: InputMaybe; + body_SHORTEST_LENGTH_LTE?: InputMaybe; + channelUniqueName_AVERAGE_LENGTH_EQUAL?: InputMaybe; + channelUniqueName_AVERAGE_LENGTH_GT?: InputMaybe; + channelUniqueName_AVERAGE_LENGTH_GTE?: InputMaybe; + channelUniqueName_AVERAGE_LENGTH_LT?: InputMaybe; + channelUniqueName_AVERAGE_LENGTH_LTE?: InputMaybe; + channelUniqueName_LONGEST_LENGTH_EQUAL?: InputMaybe; + channelUniqueName_LONGEST_LENGTH_GT?: InputMaybe; + channelUniqueName_LONGEST_LENGTH_GTE?: InputMaybe; + channelUniqueName_LONGEST_LENGTH_LT?: InputMaybe; + channelUniqueName_LONGEST_LENGTH_LTE?: InputMaybe; + channelUniqueName_SHORTEST_LENGTH_EQUAL?: InputMaybe; + channelUniqueName_SHORTEST_LENGTH_GT?: InputMaybe; + channelUniqueName_SHORTEST_LENGTH_GTE?: InputMaybe; + channelUniqueName_SHORTEST_LENGTH_LT?: InputMaybe; + channelUniqueName_SHORTEST_LENGTH_LTE?: InputMaybe; + createdAt_MAX_EQUAL?: InputMaybe; + createdAt_MAX_GT?: InputMaybe; + createdAt_MAX_GTE?: InputMaybe; + createdAt_MAX_LT?: InputMaybe; + createdAt_MAX_LTE?: InputMaybe; + createdAt_MIN_EQUAL?: InputMaybe; + createdAt_MIN_GT?: InputMaybe; + createdAt_MIN_GTE?: InputMaybe; + createdAt_MIN_LT?: InputMaybe; + createdAt_MIN_LTE?: InputMaybe; + slug_AVERAGE_LENGTH_EQUAL?: InputMaybe; + slug_AVERAGE_LENGTH_GT?: InputMaybe; + slug_AVERAGE_LENGTH_GTE?: InputMaybe; + slug_AVERAGE_LENGTH_LT?: InputMaybe; + slug_AVERAGE_LENGTH_LTE?: InputMaybe; + slug_LONGEST_LENGTH_EQUAL?: InputMaybe; + slug_LONGEST_LENGTH_GT?: InputMaybe; + slug_LONGEST_LENGTH_GTE?: InputMaybe; + slug_LONGEST_LENGTH_LT?: InputMaybe; + slug_LONGEST_LENGTH_LTE?: InputMaybe; + slug_SHORTEST_LENGTH_EQUAL?: InputMaybe; + slug_SHORTEST_LENGTH_GT?: InputMaybe; + slug_SHORTEST_LENGTH_GTE?: InputMaybe; + slug_SHORTEST_LENGTH_LT?: InputMaybe; + slug_SHORTEST_LENGTH_LTE?: InputMaybe; + title_AVERAGE_LENGTH_EQUAL?: InputMaybe; + title_AVERAGE_LENGTH_GT?: InputMaybe; + title_AVERAGE_LENGTH_GTE?: InputMaybe; + title_AVERAGE_LENGTH_LT?: InputMaybe; + title_AVERAGE_LENGTH_LTE?: InputMaybe; + title_LONGEST_LENGTH_EQUAL?: InputMaybe; + title_LONGEST_LENGTH_GT?: InputMaybe; + title_LONGEST_LENGTH_GTE?: InputMaybe; + title_LONGEST_LENGTH_LT?: InputMaybe; + title_LONGEST_LENGTH_LTE?: InputMaybe; + title_SHORTEST_LENGTH_EQUAL?: InputMaybe; + title_SHORTEST_LENGTH_GT?: InputMaybe; + title_SHORTEST_LENGTH_GTE?: InputMaybe; + title_SHORTEST_LENGTH_LT?: InputMaybe; + title_SHORTEST_LENGTH_LTE?: InputMaybe; + updatedAt_MAX_EQUAL?: InputMaybe; + updatedAt_MAX_GT?: InputMaybe; + updatedAt_MAX_GTE?: InputMaybe; + updatedAt_MAX_LT?: InputMaybe; + updatedAt_MAX_LTE?: InputMaybe; + updatedAt_MIN_EQUAL?: InputMaybe; + updatedAt_MIN_GT?: InputMaybe; + updatedAt_MIN_GTE?: InputMaybe; + updatedAt_MIN_LT?: InputMaybe; + updatedAt_MIN_LTE?: InputMaybe; +}; + +export type ChannelPinnedWikiPagesRelationship = { + __typename?: 'ChannelPinnedWikiPagesRelationship'; + cursor: Scalars['String']['output']; + node: WikiPage; +}; + +export type ChannelPinnedWikiPagesRelationshipSubscriptionWhere = { + node?: InputMaybe; +}; + +export type ChannelPinnedWikiPagesUpdateConnectionInput = { + node?: InputMaybe; +}; + +export type ChannelPinnedWikiPagesUpdateFieldInput = { + connect?: InputMaybe>; + create?: InputMaybe>; + delete?: InputMaybe>; + disconnect?: InputMaybe>; + update?: InputMaybe; + where?: InputMaybe; +}; + /** * The edge properties for the following fields: * * Channel.EnabledPlugins @@ -5168,6 +5549,8 @@ export type ChannelRelationInput = { Moderators?: InputMaybe>; PendingModInvites?: InputMaybe>; PendingOwnerInvites?: InputMaybe>; + PinnedDiscussionChannels?: InputMaybe>; + PinnedWikiPages?: InputMaybe>; RelatedChannels?: InputMaybe>; SuspendedModRole?: InputMaybe; SuspendedMods?: InputMaybe>; @@ -5226,6 +5609,8 @@ export type ChannelRelationshipsSubscriptionWhere = { Moderators?: InputMaybe; PendingModInvites?: InputMaybe; PendingOwnerInvites?: InputMaybe; + PinnedDiscussionChannels?: InputMaybe; + PinnedWikiPages?: InputMaybe; RelatedChannels?: InputMaybe; SuspendedModRole?: InputMaybe; SuspendedMods?: InputMaybe; @@ -6314,6 +6699,8 @@ export type ChannelUpdateInput = { Moderators?: InputMaybe>; PendingModInvites?: InputMaybe>; PendingOwnerInvites?: InputMaybe>; + PinnedDiscussionChannels?: InputMaybe>; + PinnedWikiPages?: InputMaybe>; RelatedChannels?: InputMaybe>; SuspendedModRole?: InputMaybe; SuspendedMods?: InputMaybe>; @@ -6636,6 +7023,40 @@ export type ChannelWhere = { PendingOwnerInvites_SINGLE?: InputMaybe; /** Return Channels where some of the related Users match this filter */ PendingOwnerInvites_SOME?: InputMaybe; + PinnedDiscussionChannelsAggregate?: InputMaybe; + /** Return Channels where all of the related ChannelPinnedDiscussionChannelsConnections match this filter */ + PinnedDiscussionChannelsConnection_ALL?: InputMaybe; + /** Return Channels where none of the related ChannelPinnedDiscussionChannelsConnections match this filter */ + PinnedDiscussionChannelsConnection_NONE?: InputMaybe; + /** Return Channels where one of the related ChannelPinnedDiscussionChannelsConnections match this filter */ + PinnedDiscussionChannelsConnection_SINGLE?: InputMaybe; + /** Return Channels where some of the related ChannelPinnedDiscussionChannelsConnections match this filter */ + PinnedDiscussionChannelsConnection_SOME?: InputMaybe; + /** Return Channels where all of the related DiscussionChannels match this filter */ + PinnedDiscussionChannels_ALL?: InputMaybe; + /** Return Channels where none of the related DiscussionChannels match this filter */ + PinnedDiscussionChannels_NONE?: InputMaybe; + /** Return Channels where one of the related DiscussionChannels match this filter */ + PinnedDiscussionChannels_SINGLE?: InputMaybe; + /** Return Channels where some of the related DiscussionChannels match this filter */ + PinnedDiscussionChannels_SOME?: InputMaybe; + PinnedWikiPagesAggregate?: InputMaybe; + /** Return Channels where all of the related ChannelPinnedWikiPagesConnections match this filter */ + PinnedWikiPagesConnection_ALL?: InputMaybe; + /** Return Channels where none of the related ChannelPinnedWikiPagesConnections match this filter */ + PinnedWikiPagesConnection_NONE?: InputMaybe; + /** Return Channels where one of the related ChannelPinnedWikiPagesConnections match this filter */ + PinnedWikiPagesConnection_SINGLE?: InputMaybe; + /** Return Channels where some of the related ChannelPinnedWikiPagesConnections match this filter */ + PinnedWikiPagesConnection_SOME?: InputMaybe; + /** Return Channels where all of the related WikiPages match this filter */ + PinnedWikiPages_ALL?: InputMaybe; + /** Return Channels where none of the related WikiPages match this filter */ + PinnedWikiPages_NONE?: InputMaybe; + /** Return Channels where one of the related WikiPages match this filter */ + PinnedWikiPages_SINGLE?: InputMaybe; + /** Return Channels where some of the related WikiPages match this filter */ + PinnedWikiPages_SOME?: InputMaybe; RelatedChannelsAggregate?: InputMaybe; /** Return Channels where all of the related ChannelRelatedChannelsConnections match this filter */ RelatedChannelsConnection_ALL?: InputMaybe; @@ -6951,6 +7372,23 @@ export type ChannelWikiHomePageUpdateFieldInput = { where?: InputMaybe; }; +export type ChannelWikiPagePinnedWikiPagesAggregationSelection = { + __typename?: 'ChannelWikiPagePinnedWikiPagesAggregationSelection'; + count: Scalars['Int']['output']; + node?: Maybe; +}; + +export type ChannelWikiPagePinnedWikiPagesNodeAggregateSelection = { + __typename?: 'ChannelWikiPagePinnedWikiPagesNodeAggregateSelection'; + body: StringAggregateSelection; + channelUniqueName: StringAggregateSelection; + createdAt: DateTimeAggregateSelection; + id: IdAggregateSelection; + slug: StringAggregateSelection; + title: StringAggregateSelection; + updatedAt: DateTimeAggregateSelection; +}; + export type ChannelWikiPageWikiHomePageAggregationSelection = { __typename?: 'ChannelWikiPageWikiHomePageAggregationSelection'; count: Scalars['Int']['output']; diff --git a/components/InPlaceTagEditor.vue b/components/InPlaceTagEditor.vue new file mode 100644 index 00000000..34037a36 --- /dev/null +++ b/components/InPlaceTagEditor.vue @@ -0,0 +1,72 @@ + + + diff --git a/components/MultiSelect.vue b/components/MultiSelect.vue index 303058d8..08df439f 100644 --- a/components/MultiSelect.vue +++ b/components/MultiSelect.vue @@ -151,8 +151,12 @@ const filteredOptions = computed(() => { }); // Get option by value -const getOptionByValue = (value: any): MultiSelectOption | undefined => { - return props.options.find((option) => option.value === value); +const getOptionByValue = (value: any): MultiSelectOption => { + // Try to find in current options + const option = props.options.find((option) => option.value === value); + + // If not found (e.g., filtered out by search), return a simple fallback option + return option || { value, label: String(value) }; }; // Watch for external changes to modelValue @@ -164,11 +168,9 @@ watch( { deep: true } ); -// Selected options for display +// Selected options for display (independent of search filtering) const selectedOptions = computed(() => { - return selected.value - .map((value) => getOptionByValue(value)) - .filter(Boolean) as MultiSelectOption[]; + return selected.value.map((value) => getOptionByValue(value)); }); @@ -187,7 +189,9 @@ const selectedOptions = computed(() => { :data-testid="testId" :class="[ 'flex w-full cursor-pointer rounded-lg border px-4 text-left dark:border-gray-700 dark:bg-gray-700', - showChips ? 'min-h-10 flex-wrap items-center' : 'min-h-12 items-start py-2', + showChips + ? 'min-h-10 flex-wrap items-center' + : 'min-h-12 items-start py-2', ]" @click="toggleDropdown" > @@ -207,9 +211,9 @@ const selectedOptions = computed(() => { :src="option.avatar" :alt="option.label" class="mr-1 h-4 w-4 rounded-full" - > + /> - + {{ option.label }} { :src="selectedOptions[0]?.avatar" :alt="selectedOptions[0]?.label" class="mr-2 h-6 w-6 flex-shrink-0 rounded-full" - > + /> { :title="'Clear selection'" @click.stop="clearSelection" > - + @@ -305,7 +309,7 @@ const selectedOptions = computed(() => { @click.stop @focus.stop @blur.stop - > + /> @@ -338,8 +342,8 @@ const selectedOptions = computed(() => { :checked="selected.includes(option.value)" :disabled="option.disabled" class="h-4 w-4 rounded border border-gray-400 text-orange-600 checked:border-orange-600 checked:bg-orange-600 checked:text-white focus:ring-orange-500 dark:border-gray-500 dark:bg-gray-700" - @click.stop - > + readonly + /> @@ -348,10 +352,13 @@ const selectedOptions = computed(() => { :src="option.avatar" :alt="option.label" class="mr-3 h-6 w-6 rounded-full" - > + /> - + diff --git a/components/TagPicker.spec.ts b/components/TagPicker.spec.ts index 1a40c79d..739e92eb 100644 --- a/components/TagPicker.spec.ts +++ b/components/TagPicker.spec.ts @@ -8,11 +8,6 @@ vi.mock('@vue/apollo-composable', () => ({ useQuery: vi.fn(() => ({ loading: ref(false), result: ref({ tags: [] }), - refetch: vi.fn(), - })), - useMutation: vi.fn(() => ({ - mutate: vi.fn(), - loading: ref(false), })), })); @@ -21,10 +16,6 @@ vi.mock('@/graphQLData/tag/queries', () => ({ GET_TAGS: {}, })); -vi.mock('@/graphQLData/tag/mutations', () => ({ - CREATE_TAG: {}, -})); - // Use real MultiSelect component // Mock v-click-outside directive diff --git a/components/TagPicker.vue b/components/TagPicker.vue index fa64f941..977a9d34 100644 --- a/components/TagPicker.vue +++ b/components/TagPicker.vue @@ -1,9 +1,8 @@