Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 46 additions & 18 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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.
Expand All @@ -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
Expand Down
72 changes: 72 additions & 0 deletions components/InPlaceTagEditor.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<script lang="ts" setup>
import { ref } from 'vue';
import type { PropType } from 'vue';
import TagPicker from '@/components/TagPicker.vue';

const props = defineProps({
existingTags: {
type: Array as PropType<string[]>,
required: true,
},
loading: {
type: Boolean,
default: false,
},
error: {
type: String,
default: '',
},
});

const emit = defineEmits(['save', 'cancel']);

const selectedTags = ref<string[]>([...props.existingTags]);

const saveTags = () => {
emit('save', selectedTags.value);
};

const cancelEditing = () => {
emit('cancel');
};

const updateSelectedTags = (tags: string[]) => {
selectedTags.value = tags;
};
</script>

<template>
<div class="mt-2">
<TagPicker
:selected-tags="selectedTags"
description=""
@set-selected-tags="updateSelectedTags"
/>

<!-- Error Message -->
<div
v-if="error"
class="mt-2 text-sm text-red-500 dark:text-red-400"
>
Failed to save tags: {{ error }}
</div>

<!-- Action Buttons -->
<div class="mt-3 flex gap-2">
<button
class="rounded-md bg-orange-500 px-3 py-1 text-sm font-medium text-white transition-colors hover:bg-orange-600 disabled:opacity-50"
:disabled="loading"
@click="saveTags"
>
{{ loading ? 'Saving...' : 'Save' }}
</button>
<button
class="rounded-md border border-gray-300 px-3 py-1 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
:disabled="loading"
@click="cancelEditing"
>
Cancel
</button>
</div>
</div>
</template>
39 changes: 23 additions & 16 deletions components/MultiSelect.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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));
});
</script>

Expand All @@ -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"
>
Expand All @@ -207,9 +211,9 @@ const selectedOptions = computed(() => {
:src="option.avatar"
:alt="option.label"
class="mr-1 h-4 w-4 rounded-full"
>
/>
<!-- Icon if provided -->
<i v-else-if="option.icon" :class="[option.icon, 'mr-1']"/>
<i v-else-if="option.icon" :class="[option.icon, 'mr-1']" />

<span>{{ option.label }}</span>
<span
Expand All @@ -232,7 +236,7 @@ const selectedOptions = computed(() => {
:src="selectedOptions[0]?.avatar"
:alt="selectedOptions[0]?.label"
class="mr-2 h-6 w-6 flex-shrink-0 rounded-full"
>
/>
<i
v-else-if="selectedOptions.length === 1 && selectedOptions[0]?.icon"
:class="[selectedOptions[0]?.icon, 'mr-2 flex-shrink-0']"
Expand Down Expand Up @@ -262,7 +266,7 @@ const selectedOptions = computed(() => {
:title="'Clear selection'"
@click.stop="clearSelection"
>
<i class="fa-solid fa-times"/>
<i class="fa-solid fa-times" />
</button>

<!-- Dropdown arrow -->
Expand Down Expand Up @@ -305,7 +309,7 @@ const selectedOptions = computed(() => {
@click.stop
@focus.stop
@blur.stop
>
/>
</div>

<!-- Loading state -->
Expand Down Expand Up @@ -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
/>
</div>

<!-- Avatar -->
Expand All @@ -348,10 +352,13 @@ const selectedOptions = computed(() => {
:src="option.avatar"
:alt="option.label"
class="mr-3 h-6 w-6 rounded-full"
>
/>

<!-- Icon -->
<i v-else-if="option.icon" :class="[option.icon, 'mr-3']"/>
<i
v-else-if="option.icon"
:class="[option.icon, 'mr-3 dark:text-white']"
/>

<!-- Label -->
<span class="flex-1 text-sm text-gray-900 dark:text-white">
Expand Down
9 changes: 0 additions & 9 deletions components/TagPicker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
})),
}));

Expand All @@ -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
Expand Down
33 changes: 5 additions & 28 deletions components/TagPicker.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
<script lang="ts" setup>
import { ref, computed } from 'vue';
import type { PropType } from 'vue';
import { useQuery, useMutation } from '@vue/apollo-composable';
import { useQuery } from '@vue/apollo-composable';
import { GET_TAGS } from '@/graphQLData/tag/queries';
import { CREATE_TAG } from '@/graphQLData/tag/mutations';
import MultiSelect from '@/components/MultiSelect.vue';
import type { MultiSelectOption } from '@/components/MultiSelect.vue';
import type { Tag } from '@/__generated__/graphql';
Expand All @@ -24,13 +23,9 @@ const emit = defineEmits(['setSelectedTags']);

const searchQuery = ref('');

const { mutate: createTag, loading: createTagLoading } =
useMutation(CREATE_TAG);

const {
loading: tagsLoading,
result: tagsResult,
refetch: refetchTags,
} = useQuery(
GET_TAGS,
computed(() => ({
Expand All @@ -39,7 +34,7 @@ const {
},
})),
{
fetchPolicy: 'cache-first',
fetchPolicy: 'cache-and-network',
}
);

Expand Down Expand Up @@ -67,26 +62,8 @@ const tagOptions = computed<MultiSelectOption[]>(() => {
return options;
});

const handleUpdateTags = async (newTags: string[]) => {
// Check if we need to create any new tags
const existingTags =
tagsResult.value?.tags?.map((tag: Tag) => tag.text) || [];
const tagsToCreate = newTags.filter((tag) => !existingTags.includes(tag));

// Create new tags
for (const tagText of tagsToCreate) {
try {
await createTag({ input: [{ text: tagText }] });
} catch (error) {
console.error('Error creating tag:', error);
}
}

// Refetch tags to update the list
if (tagsToCreate.length > 0) {
await refetchTags();
}

const handleUpdateTags = (newTags: string[]) => {
// Just emit the selected tags - parent component will handle creation via connectOrCreate
emit('setSelectedTags', newTags);
};

Expand All @@ -100,7 +77,7 @@ const handleSearch = (query: string) => {
:model-value="selectedTags"
:options="tagOptions"
:description="description"
:loading="tagsLoading || createTagLoading"
:loading="tagsLoading"
placeholder="Select tags..."
search-placeholder="Type to search or create tags..."
test-id="tag-picker"
Expand Down
Loading
Loading