FEATURE: Simplify category creation, hide settings (#36998)

This commit introduces a new simplified category creation and editing
form, hidden behind a `enable_simplified_category_creation` upcoming
change in the "conceptual" status
right now.

The simplified form allows users to toggle between simple + advanced
mode:

<img width="1106" height="641" alt="image"
src="https://github.com/user-attachments/assets/2ff70db7-280b-4edf-a119-bbb6554a1408"
/>

<img width="1116" height="694" alt="image"
src="https://github.com/user-attachments/assets/79e7e7d7-f7a8-49ee-80de-06f4dcb8a93f"
/>



The aim here is to make it faster for admins to create new categories,
which can be a fairly convoluted process right now. Our future aim
is to have different "types" of category creation flows.

In addition, all other forms in the other category tabs have been
converted
to use FormKit.

---------

Co-authored-by: Martin Brennan <martin@discourse.org>
Co-authored-by: Régis Hanol <regis@hanol.fr>
This commit is contained in:
Kris
2026-01-27 14:51:30 +10:00
committed by GitHub
co-authored by Martin Brennan Régis Hanol
parent 6c0afb4a54
commit af3e686989
33 changed files with 2606 additions and 170 deletions
@@ -17,6 +17,7 @@
@import "directory";
@import "discourse";
@import "edit-category";
@import "uc-simplified-category-setup-edit-categories";
@import "edit-topic-timer-modal";
@import "ember-select";
@import "emoji";
@@ -405,7 +405,6 @@ input {
&::-webkit-color-swatch {
border: none;
border-radius: 0;
}
}
@@ -120,6 +120,10 @@ div.edit-category {
.form-kit__container-content {
flex-wrap: wrap;
}
.category-permission-hint {
font-size: var(--font-down-1);
}
}
#list-area & h2 {
@@ -146,6 +150,19 @@ div.edit-category {
.category-chooser {
width: min(340px, 90%);
}
.select-kit-header {
padding-right: 0.65em;
&:hover {
border: 1px solid var(--tertiary);
}
.caret-icon {
color: var(--primary-medium);
width: 0.85em;
}
}
}
.edit-category-tab-security {
@@ -162,6 +162,16 @@
color: var(--d-sidebar-link-icon-color);
}
}
.select-kit.single-select.dropdown-select-box .select-kit-row {
.icons .d-icon {
font-size: var(--font-0);
}
.texts .name {
font-weight: normal;
}
}
}
}
@@ -0,0 +1,98 @@
.uc-enable-simplified-category-creation {
.select-kit.multi-select.group-chooser {
width: var(--form-kit-large-input);
}
div.edit-category {
display: block;
.add-required-tag-group {
align-self: start;
}
.form-kit__control-icon {
.select-kit-row .d-icon,
.select-kit-header .d-icon,
.select-kit-header-wrapper .d-icon:first-of-type {
color: var(--icon-color, currentColor);
}
.select-kit-header-wrapper {
gap: var(--space-2);
}
}
.form-kit__control-icon .formatted-selection {
display: flex !important; // overrides another important
flex: 0 1 auto;
}
#control-emoji {
&.form-kit__field .form-kit__container-content {
width: auto;
}
}
.edit-category-title-bar {
box-sizing: border-box;
width: 100%;
flex-wrap: wrap;
}
.edit-category-title {
flex: 1 1 auto;
h2 {
margin-top: 0;
}
.btn-back {
margin-top: -0.5em;
margin-left: -0.5em;
margin-bottom: 0.5em;
}
}
.edit-category-nav {
border-bottom: 1px solid var(--content-border-color);
}
.edit-category-content {
margin-top: 2em;
grid-area: unset;
}
}
.edit-category-tab-security {
.form-kit__container-content {
width: 100%;
}
}
.edit-category-title-text {
display: flex;
flex-direction: column;
margin: 0;
.edit-category-static-title {
transition: font-size 0.2s ease-in-out;
}
&:has(.badge-category__wrapper) {
.edit-category-static-title {
position: absolute;
font-size: var(--font-down-3);
}
}
.badge-category__wrapper {
font-weight: normal;
font-size: var(--font-up-2);
margin-top: var(--space-5);
}
}
.category-permissions-table {
width: 100%;
}
}
@@ -4,6 +4,7 @@ $mobile-breakpoint: 700px;
.d-page-subheader {
&__title-row {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: stretch;
margin-bottom: var(--space-2);
@@ -74,6 +75,12 @@ $mobile-breakpoint: 700px;
}
}
}
&__drawer {
display: flex;
align-items: center;
margin-bottom: var(--space-2);
}
}
.d-page-subheader {
@@ -1,5 +1,83 @@
.form-kit__conditional-display {
.form-kit__inline-radio {
padding-bottom: 0.25rem;
display: flex;
flex-direction: column;
gap: var(--space-3);
input[disabled] + span {
color: var(--primary-medium);
cursor: not-allowed;
}
&-content {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
}
.--radio-cards {
width: var(--form-kit-large-input);
.form-kit__conditional-display,
.form-kit__container-content {
width: 100%;
}
.form-kit__inline-radio {
gap: var(--space-2);
width: 100%;
label:hover {
cursor: pointer;
border: 1px solid var(--tertiary);
}
> * {
width: 50%;
}
.fk-d-tooltip__trigger-container {
width: 100%;
}
}
.form-kit__control-radio-label {
flex: 1 1 50%;
padding: var(--space-2);
border: 1px solid var(--primary-low-mid);
border-radius: var(--d-input-border-radius);
justify-content: center;
position: relative;
.d-icon {
display: none;
}
input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
&:has(input[disabled]) {
background: var(--primary-very-low);
.d-icon {
color: currentcolor;
display: inline-block;
}
}
&:has(input:checked) {
color: var(--tertiary);
border-color: var(--tertiary);
background: var(--tertiary-very-low);
.d-icon {
color: currentcolor;
display: inline-block;
}
}
}
}
@@ -8,9 +8,15 @@
}
.form-kit__control-color-input {
--color-button-height: var(--space-8);
--color-button-width: var(--space-8);
display: flex;
align-items: center;
gap: 0.25em;
@include viewport.until(sm) {
--color-button-height: var(--space-9);
}
}
.form-kit__control-color-input-prefix {
@@ -24,10 +30,6 @@
align-items: center;
margin-right: -0.25em;
border-radius: var(--d-input-border-radius) 0 0 var(--d-input-border-radius);
@include viewport.until(sm) {
height: 2.25em;
}
}
.form-kit__control-color-input-hex {
@@ -50,18 +52,36 @@
}
}
.form-kit__control-color-picker-wrapper {
position: relative;
display: inline-flex;
.d-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
font-size: var(--font-down-1);
}
&.--is-light .d-icon {
color: black;
}
&.--is-dark .d-icon {
color: white;
}
}
.form-kit__control-color-input-picker {
width: 2em;
height: 2em;
width: var(--color-button-width);
height: var(--color-button-height);
padding: 0;
border: 1px solid var(--primary-low-mid);
border-radius: var(--d-input-border-radius);
cursor: pointer;
@include viewport.until(sm) {
height: 2.25em;
}
&::-webkit-color-swatch-wrapper {
padding: 2px;
}
@@ -91,10 +111,20 @@
}
}
.form-kit__control-color-swatches-btn {
--d-button-default-border: 1px solid var(--d-button-default-bg-color); // match input height
width: var(--color-button-width);
height: var(--color-button-height);
}
.form-kit__control-color-swatches {
display: flex;
flex-wrap: wrap;
gap: 0.25em;
.color-swatches-menu-content & {
padding: var(--space-2);
}
}
.form-kit__control-color-swatch {
@@ -107,7 +137,6 @@
display: flex;
align-items: center;
justify-content: center;
color: white;
.d-icon {
display: none;
@@ -116,7 +145,14 @@
&.is-used .d-icon {
display: inline-flex;
opacity: 0.8;
}
&.--is-light .d-icon {
color: black;
}
&.--is-dark .d-icon {
color: white;
}
&:hover {
+25 -4
View File
@@ -4525,8 +4525,11 @@ en:
edit_dialog_title: "Edit: %{categoryName}"
view: "View Topics in Category"
back: "Back to category"
single_page: "Single Page"
general: "General"
settings: "Settings"
advanced: "Advanced"
show_advanced: "Advanced settings"
topic_template: "Template"
tags: "Tags"
localizations: "Localizations"
@@ -4557,6 +4560,7 @@ en:
create_long: "Create a new category"
save: "Save category"
slug: "Category slug"
parent: "Parent category"
slug_placeholder: "(Optional) dashed-words for url"
creation_error: There has been an error during the creation of the category.
save_error: There was an error saving the category.
@@ -4570,7 +4574,10 @@ en:
logo_description: "Recommended 1:1 aspect ratio with 200px minimum size. If left blank no image will be shown."
background_image: "Category background image"
background_image_dark: "Dark category background image"
style: "Styles"
style: "Style"
icon: "Icon"
emoji: "Emoji"
select_emoji: "Select emoji"
background_color: "Color"
foreground_color: "Text color"
color_validations:
@@ -4581,13 +4588,14 @@ en:
type: "Style"
icon: "Icon"
emoji: "Emoji"
square: "Square"
options:
square: "Square"
icon: "Icon"
emoji: "Emoji"
color_used: "Color in use"
predefined_colors: "Predefined color options"
name_placeholder: "One or two words maximum"
name_placeholder: "One or two words recommended"
color_placeholder: "Any web color"
delete_confirm: "Are you sure you want to delete this category?"
delete_error: "There was an error deleting the category."
@@ -4595,6 +4603,7 @@ en:
no_description: "Please add a description for this category."
change_in_category_topic: "Edit description"
already_used: "This color has been used by another category"
color_palette: "Color palette"
security: "Security"
security_add_group: "Add a group"
permissions:
@@ -4604,6 +4613,7 @@ en:
create: "Create"
no_groups_selected: "No groups have been granted access; this category will only be visible to staff."
everyone_has_access: 'This category is public, everyone can see, reply and create posts. To restrict permissions, remove one or more of the permissions granted to the "everyone" group.'
specific_groups_have_access: "This category is private, only the selected groups can see, reply and create posts."
toggle_reply: "Toggle Reply permission"
toggle_full: "Toggle Create permission"
inherited: 'This permission is inherited from "everyone"'
@@ -4611,7 +4621,7 @@ en:
uncategorized_security_warning: "This category is special. It is intended as holding area for topics that have no category; it cannot have security settings."
uncategorized_general_warning: 'This category is special. It is used as the default category for new topics that do not have a category selected. If you want to prevent this behavior and force category selection, <a href="%{settingLink}">please disable the setting here</a>. If you want to change the name or description, go to <a href="%{customizeLink}">Customize / Text Content</a>.'
pending_permission_change_alert: "You haven't added %{group} to this category; click this button to add them."
images: "Images"
images: "Appearance"
email_in: "Custom incoming email address:"
email_in_tooltip: "You can separate multiple email addresses with the | character."
email_in_allow_strangers: "Accept emails from anonymous users with no accounts"
@@ -4640,7 +4650,18 @@ en:
position_disabled: "Categories will be displayed in order of activity. To control the order of categories in lists, enable the <a href='%{url}'>'fixed category positions'</a> setting."
minimum_required_tags: "Minimum number of tags required in a topic:"
default_slow_mode: 'Enable "Slow mode" for new topics in this category.'
parent: "Parent category"
subcategory_of: "Subcategory of..."
none_subcategory_text: "None"
subcategory_permissions_warning: "A subcategory of a private category can not be public."
visibility:
title: "Access"
public: "Public"
all_members: "All members"
group_restricted: "Private"
who_can_see: "Who can see this category"
who_can_post: "Who can post in this category"
more_options_hint: "More permissions available in"
more_options_hint_link: "advanced settings."
num_auto_bump_daily: "Number of open topics to automatically bump daily:"
auto_bump_cooldown_days: "Minimum days before bumping the same topic again:"
navigate_to_first_post_after_read: "Navigate to first post after topics are read"
+1
View File
@@ -2752,6 +2752,7 @@ en:
fake_upcoming_change: "This is a fake upcoming change for testing purposes. No need to translate this string."
floating_dismiss_topics_on_mobile: "Shows a floating 'Dismiss...' button on mobile for the topic list for easier access and to free up space in the top of the topic list"
experimental_rename_faq_to_guidelines: "This change renames the FAQ page to Guidelines. The FAQ page will still be available at /faq. The 'faq url' setting works as before."
enable_simplified_category_creation: "Enables a simplified category creation flow with fewer options and a cleaner interface."
always_include_topic_excerpts: "Includes topic excerpts in topic lists like Latest and so on. This excerpt may not be visible unless a theme component like 'Topic List Excerpts' is enabled."
errors:
+7
View File
@@ -4226,3 +4226,10 @@ experimental:
upcoming_change:
status: "experimental"
impact: "feature,all_members"
enable_simplified_category_creation:
default: false
hidden: true
client: true
upcoming_change:
status: "conceptual"
impact: "feature,staff"
@@ -0,0 +1,678 @@
import Component from "@glimmer/component";
import { cached, tracked } from "@glimmer/tracking";
import { concat, hash } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import EmojiPicker from "discourse/components/emoji-picker";
import DTooltip from "discourse/float-kit/components/d-tooltip";
import icon from "discourse/helpers/d-icon";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import { AUTO_GROUPS, CATEGORY_TEXT_COLORS } from "discourse/lib/constants";
import discourseDebounce from "discourse/lib/debounce";
import { INPUT_DELAY } from "discourse/lib/environment";
import getURL from "discourse/lib/get-url";
import Category from "discourse/models/category";
import PermissionType from "discourse/models/permission-type";
import CategoryChooser from "discourse/select-kit/components/category-chooser";
import GroupChooser from "discourse/select-kit/components/group-chooser";
import IconPicker from "discourse/select-kit/components/icon-picker";
import { or } from "discourse/truth-helpers";
import { i18n } from "discourse-i18n";
export default class EditCategoryGeneralV2 extends Component {
@service site;
@service siteSettings;
@tracked categoryVisibilityState = null;
@tracked userModifiedPermissions = false;
uncategorizedSiteSettingLink = getURL(
"/admin/site_settings/category/all_results?filter=allow_uncategorized_topics"
);
customizeTextContentLink = getURL(
"/admin/customize/site_texts?q=uncategorized"
);
get parentIsRestricted() {
const parentId = this.args.transientData.parent_category_id;
if (!parentId) {
return false;
}
const parentCategory = Category.findById(parentId);
if (!parentCategory?.permissions?.length) {
return false;
}
const onlyEveryone =
parentCategory.permissions.length === 1 &&
(parentCategory.permissions[0].group_id === AUTO_GROUPS.everyone.id ||
parentCategory.permissions[0].group_name === "everyone");
return !onlyEveryone;
}
get isPrivateCategory() {
const permissions = this.args.category.permissions;
if (!permissions || permissions.length === 0) {
return true;
}
const onlyEveryone =
permissions.length === 1 &&
(permissions[0].group_id === AUTO_GROUPS.everyone.id ||
permissions[0].group_name === "everyone");
return !onlyEveryone;
}
set isPrivateCategory(value) {
if (value) {
this.args.category.set("permissions", []);
} else {
const site = this.args.category.site;
const everyoneGroup = site.groups.find(
(g) => g.id === AUTO_GROUPS.everyone.id
);
this.args.category.set("permissions", [
{
group_name: everyoneGroup?.name || "everyone",
group_id: AUTO_GROUPS.everyone.id,
permission_type: PermissionType.FULL,
},
]);
}
}
get visibilityGroups() {
const permissions = this.args.category.permissions || [];
return permissions
.filter((p) => p.permission_type <= PermissionType.READONLY)
.map((p) => p.group_id);
}
get postingGroups() {
const permissions = this.args.category.permissions || [];
return permissions
.filter((p) => p.permission_type <= PermissionType.CREATE_POST)
.map((p) => p.group_id);
}
@action
onChangeVisibilityGroups(groupIds) {
const site = this.args.category.site;
const currentPermissions = this.args.category.permissions || [];
const postingGroupPermissions = new Map(
currentPermissions
.filter((p) => p.permission_type <= PermissionType.CREATE_POST)
.map((p) => [p.group_id, p.permission_type])
);
const newPermissions = [];
groupIds.forEach((groupId) => {
const group = site.groups.find((g) => g.id === groupId);
const existingPermission = postingGroupPermissions.get(groupId);
newPermissions.push({
group_name: group?.name,
group_id: groupId,
permission_type: existingPermission ?? PermissionType.READONLY,
});
});
this.userModifiedPermissions = true;
this.args.category.set("permissions", newPermissions);
this.args.updatePreview?.({});
}
@action
onChangePostingGroups(groupIds) {
// Posting groups automatically get visibility (if they can post, they can see)
const site = this.args.category.site;
const currentPermissions = this.args.category.permissions || [];
const currentVisibilityGroupIds = currentPermissions.map((p) => p.group_id);
const newPermissions = [];
groupIds.forEach((groupId) => {
const group = site.groups.find((g) => g.id === groupId);
newPermissions.push({
group_name: group?.name,
group_id: groupId,
permission_type: PermissionType.FULL,
});
});
currentVisibilityGroupIds.forEach((groupId) => {
if (!groupIds.includes(groupId)) {
const group = site.groups.find((g) => g.id === groupId);
newPermissions.push({
group_name: group?.name,
group_id: groupId,
permission_type: PermissionType.READONLY,
});
}
});
this.userModifiedPermissions = true;
this.args.category.set("permissions", newPermissions);
this.args.updatePreview?.({});
}
get allowSubCategoriesAsParent() {
// If max_category_nesting is 2, only top-level categories can be parents
// If max_category_nesting is 3, subcategories can also be parents
return this.siteSettings.max_category_nesting > 2;
}
@cached
get backgroundColors() {
const categories = this.site.get("categoriesList");
return uniqueItemsFromArray(
this.siteSettings.category_colors
.split("|")
.filter(Boolean)
.map((i) => i.toUpperCase())
.concat(categories.map((c) => c.color.toUpperCase()))
);
}
@cached
get usedBackgroundColors() {
const categories = this.site.get("categoriesList");
const categoryId = this.args.category.id;
const categoryColor = this.args.category.color;
return categories
.map((c) => {
return categoryId &&
categoryColor?.toUpperCase() === c.color.toUpperCase()
? null
: c.color.toUpperCase();
})
.filter((item) => item != null);
}
get categoryVisibility() {
if (this.categoryVisibilityState) {
return this.categoryVisibilityState;
}
if (this.parentIsRestricted) {
return "group_restricted";
}
return this.isPrivateCategory ? "group_restricted" : "public";
}
get publicVisibilityLabel() {
return this.siteSettings.login_required
? "category.visibility.all_members"
: "category.visibility.public";
}
@action
onVisibilityChange(value) {
this.categoryVisibilityState = value;
this.userModifiedPermissions = true;
if (value === "public") {
this.isPrivateCategory = false;
} else if (value === "group_restricted") {
this.isPrivateCategory = true;
}
// Trigger preview update to show restricted status
this.args.updatePreview?.({});
}
@action
async onParentCategoryChange(parentCategoryId) {
if (!parentCategoryId) {
if (!this.userModifiedPermissions) {
const site = this.args.category.site;
const everyoneGroup = site.groups.find(
(g) => g.id === AUTO_GROUPS.everyone.id
);
this.args.category.set("permissions", [
{
group_name: everyoneGroup?.name || "everyone",
group_id: AUTO_GROUPS.everyone.id,
permission_type: PermissionType.FULL,
},
]);
}
return;
}
const result = await Category.reloadById(parentCategoryId);
const site = this.args.category.site;
const parentCategory = site.updateCategory(result.category);
parentCategory.setupGroupsAndPermissions();
if (parentCategory?.permissions?.length > 0) {
const onlyEveryone =
parentCategory.permissions.length === 1 &&
(parentCategory.permissions[0].group_id === AUTO_GROUPS.everyone.id ||
parentCategory.permissions[0].group_name === "everyone");
if (!onlyEveryone) {
const newPermissions = parentCategory.permissions.map((p) => ({
group_name: p.group_name,
group_id: p.group_id,
permission_type: p.permission_type,
}));
this.args.category.set("permissions", newPermissions);
} else {
const everyoneGroup = site.groups.find(
(g) => g.id === AUTO_GROUPS.everyone.id
);
this.args.category.set("permissions", [
{
group_name: everyoneGroup?.name || "everyone",
group_id: AUTO_GROUPS.everyone.id,
permission_type: PermissionType.FULL,
},
]);
}
}
}
@action
async onParentCategorySet(value, { set, name, index }) {
await set(name, value, { index });
await this.onParentCategoryChange(value);
this.args.updatePreview?.({ parent_category_id: value });
}
@action
togglePrivateCategory() {
this.isPrivateCategory = !this.isPrivateCategory;
}
get showWarning() {
return this.args.category.isUncategorizedCategory;
}
@cached
get subCategories() {
if (this.args.category.isNew) {
return null;
}
return Category.list().filter(
(category) => category.get("parent_category_id") === this.args.category.id
);
}
@cached
get showDescription() {
const category = this.args.category;
return (
!category.isUncategorizedCategory && category.id && category.topic_url
);
}
@action
showCategoryTopic() {
window.open(this.args.category.get("topic_url"), "_blank").focus();
}
@action
async onBackgroundColorSet(value, { set }) {
const color = value?.replace(/^#/, "") ?? "";
await set("color", color);
const whiteDiff = this.colorDifference(color, CATEGORY_TEXT_COLORS[0]);
const blackDiff = this.colorDifference(color, CATEGORY_TEXT_COLORS[1]);
const colorIndex = whiteDiff > blackDiff ? 0 : 1;
this.args.form.set("text_color", CATEGORY_TEXT_COLORS[colorIndex]);
this.args.updatePreview?.({
color,
text_color: CATEGORY_TEXT_COLORS[colorIndex],
});
}
@action
async onNameChange(value, { set, name, index }) {
await set(name, value, { index });
discourseDebounce(this, this._updateNamePreview, value, INPUT_DELAY);
}
_updateNamePreview(value) {
this.args.updatePreview?.({ name: value });
}
@action
async onIconSet(value, { set, name, index }) {
await set(name, value, { index });
this.args.updatePreview?.({ icon: value, style_type: "icon" });
}
@action
async onEmojiSet(value, { set, name, index }) {
await set(name, value, { index });
this.args.updatePreview?.({ emoji: value, style_type: "emoji" });
}
@action
onStyleTypeChange(value) {
this.args.form.setProperties({ style_type: value });
const updateData = { style_type: value };
// Use transientData (current form values) if available, otherwise fall back to category model
const currentIcon =
this.args.transientData?.icon ?? this.args.category.icon ?? null;
const currentEmoji =
this.args.transientData?.emoji ?? this.args.category.emoji ?? null;
if (value === "icon" && currentIcon) {
updateData.icon = currentIcon;
} else if (value === "emoji" && currentEmoji) {
updateData.emoji = currentEmoji;
}
this.args.updatePreview?.(updateData);
}
colorDifference(color1, color2) {
const r1 = parseInt(color1.substr(0, 2), 16);
const g1 = parseInt(color1.substr(2, 2), 16);
const b1 = parseInt(color1.substr(4, 2), 16);
const r2 = parseInt(color2.substr(0, 2), 16);
const g2 = parseInt(color2.substr(2, 2), 16);
const b2 = parseInt(color2.substr(4, 2), 16);
const rDiff = Math.max(r1, r2) - Math.min(r1, r2);
const gDiff = Math.max(g1, g2) - Math.min(g1, g2);
const bDiff = Math.max(b1, b2) - Math.min(b1, b2);
return rDiff + gDiff + bDiff;
}
@action
validateColor(name, color, { addError }) {
color = color.trim();
let title;
if (name === "color") {
title = i18n("category.background_color");
} else if (name === "text_color") {
title = i18n("category.foreground_color");
} else {
throw new Error(`unknown title for category attribute ${name}`);
}
if (!color) {
addError(name, {
title,
message: i18n("category.color_validations.cant_be_empty"),
});
}
if (color.length !== 3 && color.length !== 6) {
addError(name, {
title,
message: i18n("category.color_validations.incorrect_length"),
});
}
if (!/^[0-9A-Fa-f]+$/.test(color)) {
addError(name, {
title,
message: i18n("category.color_validations.non_hexdecimal"),
});
}
}
get categoryDescription() {
if (this.args.category.description) {
return htmlSafe(this.args.category.description);
}
return htmlSafe(i18n("category.no_description"));
}
@action
goToSecurityTab(event) {
event.preventDefault();
this.args.setSelectedTab?.("security");
}
get canSelectParentCategory() {
return !this.args.category.isUncategorizedCategory;
}
get panelClass() {
const isActive = this.args.selectedTab === "general" ? "active" : "";
return `edit-category-tab edit-category-tab-general ${isActive}`;
}
<template>
<div class={{this.panelClass}}>
{{#if this.showWarning}}
<@form.Alert @type="warning" @icon="triangle-exclamation">
{{htmlSafe
(i18n
"category.uncategorized_general_warning"
settingLink=this.uncategorizedSiteSettingLink
customizeLink=this.customizeTextContentLink
)
}}
</@form.Alert>
{{/if}}
{{#unless @category.isUncategorizedCategory}}
<@form.Field
@name="name"
@title={{i18n "category.name"}}
@format="large"
@validation="required"
@onSet={{this.onNameChange}}
as |field|
>
<field.Input
placeholder={{i18n "category.name_placeholder"}}
@maxlength="50"
class="category-name"
data-1p-ignore
/>
</@form.Field>
{{/unless}}
<@form.Field
@name="color"
@title={{i18n "category.background_color"}}
@format="large"
@validation="required"
@onSet={{this.onBackgroundColorSet}}
as |field|
>
<field.Color
@colors={{this.backgroundColors}}
@usedColors={{this.usedBackgroundColors}}
@collapseSwatches={{true}}
@collapseSwatchesLabel={{i18n "category.color_palette"}}
/>
</@form.Field>
<@form.Container @title={{i18n "category.style"}}>
<@form.ConditionalContent
@activeName={{or @category.styleType "square"}}
@onChange={{this.onStyleTypeChange}}
as |cc|
>
<cc.Conditions as |Condition|>
<Condition @name="icon">
{{i18n "category.styles.icon"}}
</Condition>
<Condition @name="emoji">
{{i18n "category.styles.emoji"}}
</Condition>
<Condition @name="square">
{{i18n "category.styles.square"}}
</Condition>
</cc.Conditions>
<cc.Contents as |Content|>
<Content @name="icon">
<@form.Field
@name="icon"
@title={{i18n "category.icon"}}
@showTitle={{false}}
@format="large"
@onSet={{this.onIconSet}}
as |field|
>
<field.Custom>
<IconPicker
@value={{readonly field.value}}
@onlyAvailable={{true}}
@options={{hash
maximum=1
disabled=field.disabled
caretDownIcon="angle-down"
caretUpIcon="angle-up"
icons=field.value
}}
@onChange={{field.set}}
class="form-kit__control-icon"
style={{htmlSafe
(concat "--icon-color: #" @transientData.color ";")
}}
/>
</field.Custom>
</@form.Field>
</Content>
<Content @name="emoji">
<@form.Field
@name="emoji"
@title={{i18n "category.emoji"}}
@showTitle={{false}}
@format="large"
@onSet={{this.onEmojiSet}}
as |field|
>
<field.Custom>
<EmojiPicker
@emoji={{field.value}}
@didSelectEmoji={{field.set}}
@modalForMobile={{false}}
@btnClass="btn-default btn-emoji"
@label={{unless field.value (i18n "category.select_emoji")}}
/>
</field.Custom>
</@form.Field>
</Content>
</cc.Contents>
</@form.ConditionalContent>
</@form.Container>
{{#unless @category.isUncategorizedCategory}}
<@form.Field
@name="parent_category_id"
@title={{i18n "category.subcategory_of"}}
@format="large"
@onSet={{this.onParentCategorySet}}
as |field|
>
<field.Custom>
<CategoryChooser
@value={{@transientData.parent_category_id}}
@onChange={{field.set}}
@options={{hash
allowSubCategories=this.allowSubCategoriesAsParent
allowRestrictedCategories=true
allowUncategorized=false
excludeCategoryId=@category.id
none="category.none_subcategory_text"
clearable=true
caretUpIcon="chevron-up"
caretDownIcon="chevron-down"
}}
/>
</field.Custom>
</@form.Field>
{{/unless}}
<@form.Container
@title={{i18n "category.visibility.title"}}
class="--radio-cards"
>
<@form.ConditionalContent
@activeName={{this.categoryVisibility}}
@onChange={{this.onVisibilityChange}}
as |cc|
>
<cc.Conditions as |Condition|>
{{#if this.parentIsRestricted}}
<DTooltip
@content={{i18n "category.subcategory_permissions_warning"}}
>
<:trigger>
<Condition @name="public" @disabled={{true}}>
{{icon "ban"}}
{{i18n this.publicVisibilityLabel}}
</Condition>
</:trigger>
</DTooltip>
{{else}}
<Condition @name="public">
{{icon "check"}}
{{i18n this.publicVisibilityLabel}}
</Condition>
{{/if}}
<Condition @name="group_restricted">
{{icon "check"}}
{{i18n "category.visibility.group_restricted"}}
</Condition>
</cc.Conditions>
<cc.Contents as |Content|>
<Content @name="group_restricted">
<@form.Container
@title={{i18n "category.visibility.who_can_see"}}
>
<GroupChooser
@content={{@category.site.groups}}
@value={{this.visibilityGroups}}
@onChange={{this.onChangeVisibilityGroups}}
/>
</@form.Container>
<@form.Container
@title={{i18n "category.visibility.who_can_post"}}
>
<GroupChooser
@content={{@category.site.groups}}
@value={{this.postingGroups}}
@onChange={{this.onChangePostingGroups}}
/>
</@form.Container>
<span class="category-permission-hint">
{{i18n "category.visibility.more_options_hint"}}
<a href {{on "click" this.goToSecurityTab}}>
{{i18n "category.visibility.more_options_hint_link"}}
</a>
</span>
</Content>
</cc.Contents>
</@form.ConditionalContent>
</@form.Container>
</div>
</template>
}
@@ -0,0 +1,390 @@
import { fn, hash } from "@ember/helper";
import EmberObject, { action } from "@ember/object";
import { and, empty } from "@ember/object/computed";
import { buildCategoryPanel } from "discourse/admin/components/edit-category-panel";
import PluginOutlet from "discourse/components/plugin-outlet";
import UppyImageUploader from "discourse/components/uppy-image-uploader";
import lazyHash from "discourse/helpers/lazy-hash";
import { CATEGORY_TEXT_COLORS } from "discourse/lib/constants";
import discourseComputed from "discourse/lib/decorators";
import { applyMutableValueTransformer } from "discourse/lib/transformer";
import ComboBox from "discourse/select-kit/components/combo-box";
import { i18n } from "discourse-i18n";
export default class EditCategoryImagesV2 extends buildCategoryPanel("images") {
@and("category.show_subcategory_list", "isParentCategory")
showSubcategoryListStyle;
@empty("category.sort_order") isDefaultSortOrder;
@discourseComputed("category.uploaded_background.url")
backgroundImageUrl(uploadedBackgroundUrl) {
return uploadedBackgroundUrl || "";
}
@discourseComputed("category.uploaded_background_dark.url")
backgroundDarkImageUrl(uploadedBackgroundDarkUrl) {
return uploadedBackgroundDarkUrl || "";
}
@discourseComputed("category.uploaded_logo.url")
logoImageUrl(uploadedLogoUrl) {
return uploadedLogoUrl || "";
}
@discourseComputed("category.uploaded_logo_dark.url")
logoImageDarkUrl(uploadedLogoDarkUrl) {
return uploadedLogoDarkUrl || "";
}
@action
logoUploadDone(upload) {
this._setFromUpload("category.uploaded_logo", upload);
}
@action
logoUploadDeleted() {
this._deleteUpload("category.uploaded_logo");
}
@action
logoDarkUploadDone(upload) {
this._setFromUpload("category.uploaded_logo_dark", upload);
}
@action
logoDarkUploadDeleted() {
this._deleteUpload("category.uploaded_logo_dark");
}
@action
backgroundUploadDone(upload) {
this._setFromUpload("category.uploaded_background", upload);
}
@action
backgroundUploadDeleted() {
this._deleteUpload("category.uploaded_background");
}
@action
backgroundDarkUploadDone(upload) {
this._setFromUpload("category.uploaded_background_dark", upload);
}
@action
backgroundDarkUploadDeleted() {
this._deleteUpload("category.uploaded_background_dark");
}
_deleteUpload(path) {
this.set(
path,
EmberObject.create({
id: null,
url: null,
})
);
}
_setFromUpload(path, upload) {
this.set(
path,
EmberObject.create({
url: upload.url,
id: upload.id,
})
);
}
@discourseComputed(
"category.isParent",
"category.parent_category_id",
"transientData.parent_category_id"
)
isParentCategory(isParent, parentCategoryId, transientParentCategoryId) {
return isParent || !(parentCategoryId || transientParentCategoryId);
}
@discourseComputed
availableSubcategoryListStyles() {
return [
{ name: i18n("category.subcategory_list_styles.rows"), value: "rows" },
{
name: i18n(
"category.subcategory_list_styles.rows_with_featured_topics"
),
value: "rows_with_featured_topics",
},
{
name: i18n("category.subcategory_list_styles.boxes"),
value: "boxes",
},
{
name: i18n(
"category.subcategory_list_styles.boxes_with_featured_topics"
),
value: "boxes_with_featured_topics",
},
];
}
@discourseComputed("category.id", "category.custom_fields")
availableViews(categoryId, customFields) {
const views = [
{ name: i18n("filters.latest.title"), value: "latest" },
{ name: i18n("filters.top.title"), value: "top" },
];
const context = {
categoryId,
customFields,
};
return applyMutableValueTransformer(
"category-available-views",
views,
context
);
}
@discourseComputed
availableTopPeriods() {
return ["all", "yearly", "quarterly", "monthly", "weekly", "daily"].map(
(p) => {
return { name: i18n(`filters.top.${p}.title`), value: p };
}
);
}
@discourseComputed
availableListFilters() {
return ["all", "none"].map((p) => {
return { name: i18n(`category.list_filters.${p}`), value: p };
});
}
@discourseComputed
availableSorts() {
return applyMutableValueTransformer("category-sort-orders", [
"likes",
"op_likes",
"views",
"posts",
"activity",
"posters",
"category",
"created",
])
.map((s) => ({ name: i18n("category.sort_options." + s), value: s }))
.sort((a, b) => a.name.localeCompare(b.name));
}
@discourseComputed("category.sort_ascending")
sortAscendingOption(sortAscending) {
if (sortAscending === "false") {
return false;
}
if (sortAscending === "true") {
return true;
}
return sortAscending;
}
@discourseComputed
sortAscendingOptions() {
return [
{ name: i18n("category.sort_ascending"), value: true },
{ name: i18n("category.sort_descending"), value: false },
];
}
<template>
<@form.Container
@title={{i18n "category.logo"}}
@subtitle={{i18n "category.logo_description"}}
>
<UppyImageUploader
@imageUrl={{this.logoImageUrl}}
@onUploadDone={{this.logoUploadDone}}
@onUploadDeleted={{this.logoUploadDeleted}}
@type="category_logo"
@id="category-logo-uploader"
class="no-repeat contain-image"
/>
</@form.Container>
<@form.Container
@title={{i18n "category.logo_dark"}}
@subtitle={{i18n "category.logo_description"}}
>
<UppyImageUploader
@imageUrl={{this.logoImageDarkUrl}}
@onUploadDone={{this.logoDarkUploadDone}}
@onUploadDeleted={{this.logoDarkUploadDeleted}}
@type="category_logo_dark"
@id="category-dark-logo-uploader"
class="no-repeat contain-image"
/>
</@form.Container>
<@form.Container @title={{i18n "category.background_image"}}>
<UppyImageUploader
@imageUrl={{this.backgroundImageUrl}}
@onUploadDone={{this.backgroundUploadDone}}
@onUploadDeleted={{this.backgroundUploadDeleted}}
@type="category_background"
@id="category-background-uploader"
/>
</@form.Container>
<@form.Container @title={{i18n "category.background_image_dark"}}>
<UppyImageUploader
@imageUrl={{this.backgroundDarkImageUrl}}
@onUploadDone={{this.backgroundDarkUploadDone}}
@onUploadDeleted={{this.backgroundDarkUploadDeleted}}
@type="category_background_dark"
@id="category-dark-background-uploader"
/>
</@form.Container>
{{! This field is removed from edit-category-general when the UC is active }}
{{#if this.siteSettings.enable_simplified_category_creation}}
<@form.Field
@name="text_color"
@title={{i18n "category.foreground_color"}}
@format="large"
as |field|
>
<field.Color @colors={{CATEGORY_TEXT_COLORS}} />
</@form.Field>
{{/if}}
<@form.Field
@name="default_view"
@title={{i18n "category.default_view"}}
@format="large"
as |field|
>
<field.Custom>
<ComboBox
@valueProperty="value"
@id="category-default-view"
@content={{this.availableViews}}
@value={{field.value}}
@onChange={{field.set}}
@options={{hash placementStrategy="absolute"}}
/>
</field.Custom>
</@form.Field>
<@form.Field
@name="default_top_period"
@title={{i18n "category.default_top_period"}}
@format="large"
as |field|
>
<field.Custom>
<ComboBox
@valueProperty="value"
@id="category-default-period"
@content={{this.availableTopPeriods}}
@value={{field.value}}
@onChange={{field.set}}
@options={{hash placementStrategy="absolute"}}
/>
</field.Custom>
</@form.Field>
<@form.Field
@name="sort_order"
@title={{i18n "category.sort_order"}}
@format="large"
as |field|
>
<field.Custom>
<ComboBox
@valueProperty="value"
@content={{this.availableSorts}}
@value={{field.value}}
@options={{hash none="category.sort_options.default"}}
@onChange={{field.set}}
/>
{{#unless this.isDefaultSortOrder}}
<ComboBox
@valueProperty="value"
@content={{this.sortAscendingOptions}}
@value={{this.sortAscendingOption}}
@options={{hash
none="category.sort_options.default"
placementStrategy="absolute"
}}
@onChange={{fn (mut @category.sort_ascending)}}
/>
{{/unless}}
</field.Custom>
</@form.Field>
<@form.Field
@name="default_list_filter"
@title={{i18n "category.default_list_filter"}}
@format="large"
as |field|
>
<field.Custom>
<ComboBox
@id="category-default-filter"
@valueProperty="value"
@content={{this.availableListFilters}}
@value={{field.value}}
@onChange={{field.set}}
/>
</field.Custom>
</@form.Field>
{{#if this.isParentCategory}}
<@form.Field
@name="show_subcategory_list"
@title={{i18n "category.show_subcategory_list"}}
@format="large"
as |field|
>
<field.Checkbox />
</@form.Field>
{{#if @transientData.show_subcategory_list}}
<@form.Field
@name="subcategory_list_style"
@title={{i18n "category.subcategory_list_style"}}
@format="large"
as |field|
>
<field.Custom>
<ComboBox
@valueProperty="value"
@id="subcategory-list-style"
@content={{this.availableSubcategoryListStyles}}
@value={{field.value}}
@onChange={{field.set}}
@options={{hash placementStrategy="absolute"}}
/>
</field.Custom>
</@form.Field>
{{/if}}
{{/if}}
<@form.Field
@name="read_only_banner"
@title={{i18n "category.read_only_banner"}}
@format="large"
as |field|
>
<field.Input @maxlength="255" />
</@form.Field>
<PluginOutlet
@name="category-custom-images"
@outletArgs={{lazyHash category=this.category}}
/>
</template>
}
@@ -0,0 +1,156 @@
import { hash } from "@ember/helper";
import { action } from "@ember/object";
import { not } from "@ember/object/computed";
import { service } from "@ember/service";
import { buildCategoryPanel } from "discourse/admin/components/edit-category-panel";
import CategoryPermissionRow from "discourse/components/category-permission-row";
import PluginOutlet from "discourse/components/plugin-outlet";
import lazyHash from "discourse/helpers/lazy-hash";
import { AUTO_GROUPS } from "discourse/lib/constants";
import PermissionType from "discourse/models/permission-type";
import ComboBox from "discourse/select-kit/components/combo-box";
import { i18n } from "discourse-i18n";
export default class EditCategorySecurityV2 extends buildCategoryPanel(
"security"
) {
@service site;
selectedGroup = null;
@not("selectedGroup") noGroupSelected;
get everyonePermission() {
return this.category.permissions.find(
(p) => p.group_id === AUTO_GROUPS.everyone.id
);
}
get everyoneGrantedFull() {
return (
this.everyonePermission &&
this.everyonePermission.permission_type === PermissionType.FULL
);
}
get minimumPermission() {
return this.everyonePermission
? this.everyonePermission.permission_type
: PermissionType.READONLY;
}
@action
onSelectGroup(group_name) {
const group = this.site.groups.find((g) => g.name === group_name);
this.category.addPermission({
group_name,
group_id: group?.id,
permission_type: this.minimumPermission,
});
}
@action
onChangeEveryonePermission(everyonePermissionType) {
this.category.permissions.forEach((permission, idx) => {
if (permission.group_id === AUTO_GROUPS.everyone.id) {
return;
}
if (everyonePermissionType < permission.permission_type) {
this.category.set(
`permissions.${idx}.permission_type`,
everyonePermissionType
);
}
});
}
<template>
{{#if this.category.is_special}}
{{#if this.category.isUncategorizedCategory}}
<@form.Alert @type="warning">
{{i18n "category.uncategorized_security_warning"}}
</@form.Alert>
{{else}}
<@form.Alert @type="warning">
{{i18n "category.special_warning"}}
</@form.Alert>
{{/if}}
{{/if}}
{{#unless this.category.is_special}}
<@form.Container>
<div class="category-permissions-table">
<div class="permission-row row-header">
<span class="group-name">{{i18n
"category.permissions.group"
}}</span>
<span class="options">
<span class="cell">{{i18n "category.permissions.see"}}</span>
<span class="cell">{{i18n "category.permissions.reply"}}</span>
<span class="cell">{{i18n "category.permissions.create"}}</span>
<span class="cell"></span>
</span>
</div>
{{#each this.category.permissions as |p|}}
<CategoryPermissionRow
@groupId={{p.group_id}}
@groupName={{p.group_name}}
@type={{p.permission_type}}
@category={{this.category}}
@everyonePermission={{this.everyonePermission}}
@onChangeEveryonePermission={{this.onChangeEveryonePermission}}
/>
{{/each}}
{{#unless this.category.permissions}}
<div class="permission-row row-empty">
{{i18n "category.permissions.no_groups_selected"}}
</div>
{{/unless}}
{{#if this.category.availableGroups}}
<PluginOutlet
@name="category-security-permissions-add-group"
@outletArgs={{lazyHash
category=this.category
availableGroups=this.category.availableGroups
onSelectGroup=this.onSelectGroup
}}
@defaultGlimmer={{true}}
>
<div class="add-group">
<span class="group-name">
<ComboBox
@content={{this.category.availableGroups}}
@onChange={{this.onSelectGroup}}
@value={{null}}
@valueProperty={{null}}
@nameProperty={{null}}
@options={{hash none="category.security_add_group"}}
class="available-groups"
/>
</span>
</div>
</PluginOutlet>
{{/if}}
</div>
{{#if this.everyoneGrantedFull}}
<@form.Alert @type="warning">
{{i18n "category.permissions.everyone_has_access"}}
</@form.Alert>
{{else}}
<@form.Alert @type="warning">
{{i18n "category.permissions.specific_groups_have_access"}}
</@form.Alert>
{{/if}}
</@form.Container>
{{/unless}}
<PluginOutlet
@name="category-custom-security"
@outletArgs={{lazyHash category=this.category}}
/>
</template>
}
@@ -134,9 +134,13 @@ export default class EditCategorySecurity extends buildCategoryPanel(
{{/if}}
{{#if this.everyoneGrantedFull}}
<p class="warning everyone-has-access-warning">{{i18n
"category.permissions.everyone_has_access"
}}</p>
<@form.Alert @type="warning">
{{i18n "category.permissions.everyone_has_access"}}
</@form.Alert>
{{else}}
<@form.Alert @type="warning">
{{i18n "category.permissions.specific_groups_have_access"}}
</@form.Alert>
{{/if}}
</PluginOutlet>
{{/unless}}
@@ -0,0 +1,418 @@
import { Input } from "@ember/component";
import { fn, hash } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { and, empty } from "@ember/object/computed";
import { htmlSafe } from "@ember/template";
import { buildCategoryPanel } from "discourse/admin/components/edit-category-panel";
import PluginOutlet from "discourse/components/plugin-outlet";
import RelativeTimePicker from "discourse/components/relative-time-picker";
import lazyHash from "discourse/helpers/lazy-hash";
import withEventValue from "discourse/helpers/with-event-value";
import { setting } from "discourse/lib/computed";
import { SEARCH_PRIORITIES } from "discourse/lib/constants";
import discourseComputed from "discourse/lib/decorators";
import getUrl from "discourse/lib/get-url";
import { applyMutableValueTransformer } from "discourse/lib/transformer";
import ComboBox from "discourse/select-kit/components/combo-box";
import GroupChooser from "discourse/select-kit/components/group-chooser";
import { i18n } from "discourse-i18n";
export default class EditCategorySettingsV2 extends buildCategoryPanel(
"settings"
) {
@setting("email_in") emailInEnabled;
@setting("fixed_category_positions") showPositionInput;
@and("category.show_subcategory_list", "isParentCategory")
showSubcategoryListStyle;
@empty("category.sort_order") isDefaultSortOrder;
@discourseComputed(
"category.isParent",
"category.parent_category_id",
"transientData.parent_category_id"
)
isParentCategory(isParent, parentCategoryId, transientParentCategoryId) {
return isParent || !(parentCategoryId || transientParentCategoryId);
}
@discourseComputed
availableSubcategoryListStyles() {
return [
{ name: i18n("category.subcategory_list_styles.rows"), value: "rows" },
{
name: i18n(
"category.subcategory_list_styles.rows_with_featured_topics"
),
value: "rows_with_featured_topics",
},
{
name: i18n("category.subcategory_list_styles.boxes"),
value: "boxes",
},
{
name: i18n(
"category.subcategory_list_styles.boxes_with_featured_topics"
),
value: "boxes_with_featured_topics",
},
];
}
@discourseComputed("category.id", "category.custom_fields")
availableViews(categoryId, customFields) {
const views = [
{ name: i18n("filters.hot.title"), value: "hot" },
{ name: i18n("filters.latest.title"), value: "latest" },
{ name: i18n("filters.top.title"), value: "top" },
];
const context = {
categoryId,
customFields,
};
return applyMutableValueTransformer(
"category-available-views",
views,
context
);
}
@discourseComputed
availableTopPeriods() {
return ["all", "yearly", "quarterly", "monthly", "weekly", "daily"].map(
(p) => {
return { name: i18n(`filters.top.${p}.title`), value: p };
}
);
}
@discourseComputed
availableListFilters() {
return ["all", "none"].map((p) => {
return { name: i18n(`category.list_filters.${p}`), value: p };
});
}
@discourseComputed
searchPrioritiesOptions() {
const options = [];
Object.entries(SEARCH_PRIORITIES).forEach((entry) => {
const [name, value] = entry;
options.push({
name: i18n(`category.search_priority.options.${name}`),
value,
});
});
return options;
}
@discourseComputed
availableSorts() {
return applyMutableValueTransformer("category-sort-orders", [
"likes",
"op_likes",
"views",
"posts",
"activity",
"posters",
"category",
"created",
])
.map((s) => ({ name: i18n("category.sort_options." + s), value: s }))
.sort((a, b) => a.name.localeCompare(b.name));
}
@discourseComputed("category.sort_ascending")
sortAscendingOption(sortAscending) {
if (sortAscending === "false") {
return false;
}
if (sortAscending === "true") {
return true;
}
return sortAscending;
}
@discourseComputed
sortAscendingOptions() {
return [
{ name: i18n("category.sort_ascending"), value: true },
{ name: i18n("category.sort_descending"), value: false },
];
}
@discourseComputed
hiddenRelativeIntervals() {
return ["mins"];
}
@action
onAutoCloseDurationChange(minutes) {
let hours = minutes ? minutes / 60 : null;
this.set("category.auto_close_hours", hours);
}
@action
onDefaultSlowModeDurationChange(minutes) {
let seconds = minutes ? minutes * 60 : null;
this.set("category.default_slow_mode_seconds", seconds);
}
@action
onCategoryModeratingGroupsChange(groupIds) {
this.set("category.moderating_group_ids", groupIds);
}
<template>
{{! This field is removed from edit-category-general when the UC is active }}
{{#if this.siteSettings.enable_simplified_category_creation}}
<@form.Field
@name="slug"
@title={{i18n "category.slug"}}
@format="large"
as |field|
>
<field.Input
placeholder={{i18n "category.slug_placeholder"}}
@maxlength="255"
/>
</@form.Field>
{{/if}}
{{#if this.showPositionInput}}
<@form.Field
@name="position"
@title={{i18n "category.position"}}
@format="large"
as |field|
>
<field.Input type="number" min="0" />
</@form.Field>
{{/if}}
<@form.Field
@name="num_featured_topics"
@title={{if
@category.parent_category_id
(i18n "category.subcategory_num_featured_topics")
(i18n "category.num_featured_topics")
}}
@format="large"
as |field|
>
<field.Input type="number" min="1" />
</@form.Field>
<@form.Field
@name="search_priority"
@title={{i18n "category.search_priority.label"}}
@format="large"
as |field|
>
<field.Custom>
<ComboBox
@valueProperty="value"
@id="category-search-priority"
@content={{this.searchPrioritiesOptions}}
@value={{field.value}}
@onChange={{field.set}}
@options={{hash placementStrategy="absolute"}}
/>
</field.Custom>
</@form.Field>
{{#if this.siteSettings.enable_badges}}
<@form.Field
@name="allow_badges"
@title={{i18n "category.allow_badges_label"}}
@format="large"
as |field|
>
<field.Checkbox />
</@form.Field>
{{/if}}
{{#if this.siteSettings.topic_featured_link_enabled}}
<@form.Field
@name="topic_featured_link_allowed"
@title={{i18n "category.topic_featured_link_allowed"}}
@format="large"
as |field|
>
<field.Checkbox />
</@form.Field>
{{/if}}
<@form.Field
@name="navigate_to_first_post_after_read"
@title={{i18n "category.navigate_to_first_post_after_read"}}
@format="large"
as |field|
>
<field.Checkbox />
</@form.Field>
<@form.Field
@name="all_topics_wiki"
@title={{i18n "category.all_topics_wiki"}}
@format="large"
as |field|
>
<field.Checkbox />
</@form.Field>
<@form.Field
@name="allow_unlimited_owner_edits_on_first_post"
@title={{i18n "category.allow_unlimited_owner_edits_on_first_post"}}
@format="large"
as |field|
>
<field.Checkbox />
</@form.Field>
<@form.Section @title={{i18n "category.settings_sections.moderation"}}>
{{#if this.siteSettings.enable_category_group_moderation}}
<@form.Container @title={{i18n "category.reviewable_by_group"}}>
<GroupChooser
@content={{this.site.groups}}
@value={{@category.moderating_group_ids}}
@onChange={{this.onCategoryModeratingGroupsChange}}
/>
</@form.Container>
{{/if}}
<@form.Container>
<label class="checkbox-label">
<Input
@type="checkbox"
@checked={{@category.category_setting.require_topic_approval}}
/>
{{i18n "category.require_topic_approval"}}
</label>
</@form.Container>
<@form.Container>
<label class="checkbox-label">
<Input
@type="checkbox"
@checked={{@category.category_setting.require_reply_approval}}
/>
{{i18n "category.require_reply_approval"}}
</label>
</@form.Container>
<@form.Container @title={{i18n "category.default_slow_mode"}}>
<RelativeTimePicker
@id="category-default-slow-mode"
@durationMinutes={{@category.defaultSlowModeMinutes}}
@onChange={{this.onDefaultSlowModeDurationChange}}
/>
</@form.Container>
<@form.Container @title={{i18n "topic.auto_close.label"}}>
<RelativeTimePicker
@id="topic-auto-close"
@durationHours={{@category.auto_close_hours}}
@hiddenIntervals={{this.hiddenRelativeIntervals}}
@onChange={{this.onAutoCloseDurationChange}}
/>
<label class="checkbox-label">
<Input
@type="checkbox"
@checked={{@category.auto_close_based_on_last_post}}
/>
{{i18n "topic.auto_close.based_on_last_post"}}
</label>
</@form.Container>
<@form.Container @title={{i18n "category.num_auto_bump_daily"}}>
<input
{{on
"input"
(withEventValue
(fn (mut @category.category_setting.num_auto_bump_daily))
)
}}
value={{@category.category_setting.num_auto_bump_daily}}
type="number"
min="0"
id="category-number-daily-bump"
/>
</@form.Container>
<@form.Container @title={{i18n "category.auto_bump_cooldown_days"}}>
<input
{{on
"input"
(withEventValue
(fn (mut @category.category_setting.auto_bump_cooldown_days))
)
}}
value={{@category.category_setting.auto_bump_cooldown_days}}
type="number"
min="0"
id="category-auto-bump-cooldown-days"
/>
</@form.Container>
</@form.Section>
<@form.Section @title={{i18n "category.settings_sections.email"}}>
{{#if this.emailInEnabled}}
<@form.Field
@name="email_in"
@title={{i18n "category.email_in"}}
@format="large"
as |field|
>
<field.Input @maxlength="255" />
</@form.Field>
<@form.Field
@name="email_in_allow_strangers"
@title={{i18n "category.email_in_allow_strangers"}}
@format="large"
as |field|
>
<field.Checkbox />
</@form.Field>
<@form.Field
@name="mailinglist_mirror"
@title={{i18n "category.mailinglist_mirror"}}
@format="large"
as |field|
>
<field.Checkbox />
</@form.Field>
<PluginOutlet
@name="category-email-in"
@connectorTagName="div"
@outletArgs={{lazyHash category=@category}}
/>
{{else}}
<@form.Alert @type="info">
{{htmlSafe
(i18n
"category.email_in_disabled"
setting_url=(getUrl
"/admin/site_settings/category/all_results?filter=email_in"
)
)
}}
</@form.Alert>
{{/if}}
</@form.Section>
<PluginOutlet
@name="category-custom-settings"
@outletArgs={{lazyHash category=@category}}
/>
</template>
}
@@ -34,15 +34,6 @@ export default class EditCategoryTab extends Component {
scheduleOnce("afterRender", this, this._addToCollection);
}
willDestroyElement() {
super.willDestroyElement(...arguments);
this.setProperties({
selectedTab: "general",
params: {},
});
}
_addToCollection() {
addUniqueValueToArray(this.panels, this.tabClassName);
}
@@ -0,0 +1,133 @@
import { array, fn, hash } from "@ember/helper";
import { on } from "@ember/modifier";
import { action, set } from "@ember/object";
import { and, empty } from "@ember/object/computed";
import { LinkTo } from "@ember/routing";
import { buildCategoryPanel } from "discourse/admin/components/edit-category-panel";
import DButton from "discourse/components/d-button";
import withEventValue from "discourse/helpers/with-event-value";
import { removeValueFromArray } from "discourse/lib/array-tools";
import TagChooser from "discourse/select-kit/components/tag-chooser";
import TagGroupChooser from "discourse/select-kit/components/tag-group-chooser";
import { i18n } from "discourse-i18n";
export default class EditCategoryTagsV2 extends buildCategoryPanel("tags") {
@empty("category.allowed_tags") allowedTagsEmpty;
@empty("category.allowed_tag_groups") allowedTagGroupsEmpty;
@and("allowedTagsEmpty", "allowedTagGroupsEmpty") disableAllowGlobalTags;
@action
onTagGroupChange(rtg, valueArray) {
// A little strange, but we're using a multi-select component
// to select a single tag group. This action takes the array
// and extracts the first value in it.
set(rtg, "name", valueArray[0]);
}
@action
addRequiredTagGroup() {
this.category.required_tag_groups.push({
min_count: 1,
});
}
@action
deleteRequiredTagGroup(rtg) {
removeValueFromArray(this.category.required_tag_groups, rtg);
}
<template>
<@form.Field
@name="minimum_required_tags"
@title={{i18n "category.minimum_required_tags"}}
@format="large"
as |field|
>
<field.Input type="number" min="0" id="category-minimum-tags" />
</@form.Field>
<@form.Container
@title={{if
this.category.id
(i18n "category.tags_allowed_tags" categoryName=this.category.name)
(i18n "category.tags_allowed_tags_new_category")
}}
>
<TagChooser
@id="category-allowed-tags"
@tags={{this.category.allowed_tags}}
@everyTag={{true}}
@excludeSynonyms={{true}}
@unlimitedTagCount={{true}}
@onChange={{fn (mut this.category.allowed_tags)}}
@options={{hash filterPlaceholder="category.tags_placeholder"}}
/>
</@form.Container>
<@form.Container
@title={{if
this.category.id
(i18n
"category.tags_allowed_tag_groups" categoryName=this.category.name
)
(i18n "category.tags_allowed_tag_groups_new_category")
}}
>
<TagGroupChooser
@id="category-allowed-tag-groups"
@tagGroups={{this.category.allowed_tag_groups}}
@onChange={{fn (mut this.category.allowed_tag_groups)}}
/>
<LinkTo @route="tagGroups" class="manage-tag-groups">{{i18n
"category.manage_tag_groups_link"
}}</LinkTo>
</@form.Container>
<@form.Field
@name="allow_global_tags"
@title={{i18n "category.allow_global_tags_label"}}
@format="large"
@disabled={{this.disableAllowGlobalTags}}
as |field|
>
<field.Checkbox />
</@form.Field>
<@form.Alert @type="info">
{{i18n "category.tags_tab_description"}}
</@form.Alert>
<@form.Section @title={{i18n "category.required_tag_group.description"}}>
{{#each this.category.required_tag_groups as |rtg|}}
<div class="required-tag-group-row">
<input
type="number"
min="1"
value={{rtg.min_count}}
{{on "input" (withEventValue (fn (mut rtg.min_count)))}}
/>
<TagGroupChooser
@tagGroups={{if rtg.name (array rtg.name) (array)}}
@onChange={{fn this.onTagGroupChange rtg}}
@options={{hash
maximum=1
filterPlaceholder="category.required_tag_group.placeholder"
}}
/>
<DButton
@label="category.required_tag_group.delete"
@action={{fn this.deleteRequiredTagGroup rtg}}
@icon="trash-can"
class="delete-required-tag-group"
/>
</div>
{{/each}}
<DButton
@label="category.required_tag_group.add"
@action={{this.addRequiredTagGroup}}
@icon="plus"
class="btn-default add-required-tag-group"
/>
</@form.Section>
</template>
}
@@ -2,8 +2,9 @@ import { tracked } from "@glimmer/tracking";
import Controller from "@ember/controller";
import { action, getProperties } from "@ember/object";
import { and } from "@ember/object/computed";
import { next } from "@ember/runloop";
import { service } from "@ember/service";
import { underscore } from "@ember/string";
import { TrackedObject } from "@ember-compat/tracked-built-ins";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { AUTO_GROUPS } from "discourse/lib/constants";
import discourseComputed from "discourse/lib/decorators";
@@ -24,18 +25,66 @@ const FIELD_LIST = [
"emoji",
"icon",
"localizations",
"position",
"num_featured_topics",
"search_priority",
"allow_badges",
"topic_featured_link_allowed",
"navigate_to_first_post_after_read",
"all_topics_wiki",
"allow_unlimited_owner_edits_on_first_post",
"moderating_group_ids",
"auto_close_hours",
"auto_close_based_on_last_post",
"default_view",
"default_top_period",
"sort_order",
"sort_ascending",
"default_list_filter",
"show_subcategory_list",
"subcategory_list_style",
"read_only_banner",
"email_in",
"email_in_allow_strangers",
"mailinglist_mirror",
];
const PREVIEW_FIELD_MAP = {
name: "previewName",
color: "previewColor",
text_color: "previewTextColor",
style_type: "previewStyleType",
emoji: "previewEmoji",
icon: "previewIcon",
parent_category_id: "previewParentCategoryId",
};
const PREVIEW_DEFAULTS = {
previewName: "",
previewColor: "",
previewTextColor: "",
previewStyleType: "",
previewEmoji: "",
previewIcon: "",
previewParentCategoryId: null,
};
const SHOW_ADVANCED_TABS_KEY = "category_edit_show_advanced_tabs";
export default class EditCategoryTabsController extends Controller {
@service currentUser;
@service dialog;
@service site;
@service router;
@service keyValueStore;
@tracked breadcrumbCategories = this.site.get("categoriesList");
@tracked
showAdvancedTabs =
this.keyValueStore.getItem(SHOW_ADVANCED_TABS_KEY) === "true";
@tracked selectedTab = "general";
@tracked previewData = new TrackedObject(PREVIEW_DEFAULTS);
@trackedArray panels = [];
selectedTab = "general";
saving = false;
deleting = false;
showTooltip = false;
@@ -51,7 +100,7 @@ export default class EditCategoryTabsController extends Controller {
const data = getProperties(this.model, ...FIELD_LIST);
if (!this.model.styleType) {
data.style_type = "square";
data.style_type = "icon";
}
return data;
@@ -63,6 +112,10 @@ export default class EditCategoryTabsController extends Controller {
return false;
}
if (transientData.style_type === "emoji" && !transientData.emoji) {
return false;
}
if (this.saving || this.deleting) {
return true;
}
@@ -89,18 +142,36 @@ export default class EditCategoryTabsController extends Controller {
return id ? "category.save" : "category.create";
}
@discourseComputed("model.id", "model.name")
title(id, name) {
return id
? i18n("category.edit_dialog_title", {
categoryName: name,
})
: i18n("category.create");
get baseTitle() {
if (this.model.id) {
return i18n("category.edit_dialog_title", {
categoryName: this.model.name,
});
}
return i18n("category.create");
}
@discourseComputed("selectedTab")
selectedTabTitle(tab) {
return i18n(`category.${underscore(tab)}`);
@action
updatePreview(data) {
Object.entries(PREVIEW_FIELD_MAP).forEach(([key, previewField]) => {
if (data[key] !== undefined) {
this.previewData[previewField] = data[key];
}
});
}
@action
resetPreview() {
Object.entries(PREVIEW_DEFAULTS).forEach(([key, value]) => {
this.previewData[key] = value;
});
}
@action
setSelectedTab(tab) {
this.selectedTab = tab;
this.showAdvancedTabs = this.showAdvancedTabs || tab !== "general";
}
@action
@@ -139,6 +210,11 @@ export default class EditCategoryTabsController extends Controller {
this.model.setProperties(data);
// If permissions is empty or not set, ensure it's an empty array (public category)
if (!this.model.permissions || this.model.permissions.length === 0) {
this.model.set("permissions", []);
}
const lostAccess = this._wouldLoseAccess();
if (lostAccess) {
@@ -214,4 +290,20 @@ export default class EditCategoryTabsController extends Controller {
goBack() {
DiscourseURL.routeTo(this.model.url);
}
@action
toggleAdvancedTabs() {
this.showAdvancedTabs = !this.showAdvancedTabs;
// Save preference to localStorage
this.keyValueStore.setItem(
SHOW_ADVANCED_TABS_KEY,
this.showAdvancedTabs.toString()
);
// Always ensure we're on general tab after toggling
next(() => {
this.selectedTab = "general";
});
}
}
@@ -9,11 +9,15 @@ export default class EditCategoryTabs extends DiscourseRoute {
super.setupController(...arguments);
const parentParams = this.paramsFor("editCategory");
const selectedTab = transition.to.params.tab;
controller.setProperties({
parentParams,
selectedTab: transition.to.params.tab,
showTooltip: false,
});
controller.setSelectedTab(selectedTab);
controller.resetPreview();
}
}
@@ -49,6 +49,8 @@ export default class NewCategory extends DiscourseRoute {
return this.store.createRecord("category", {
color: backgroundColor,
text_color: textColor,
style_type: "icon",
icon: "square-full",
group_permissions,
available_groups: this.site.groups.map((g) => g.name),
allow_badges: true,
@@ -63,6 +65,13 @@ export default class NewCategory extends DiscourseRoute {
});
}
setupController(controller) {
super.setupController(...arguments);
controller.resetPreview();
controller.selectedTab = "general";
}
titleToken() {
return i18n("category.create");
}
@@ -0,0 +1,97 @@
import { concat } from "@ember/helper";
import { on } from "@ember/modifier";
import EditCategoryTab from "discourse/admin/components/edit-category-tab";
import BreadCrumbs from "discourse/components/bread-crumbs";
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
import DPageHeader from "discourse/components/d-page-header";
import DToggleSwitch from "discourse/components/d-toggle-switch";
import { and } from "discourse/truth-helpers";
import { i18n } from "discourse-i18n";
const EditCategoryTabsHorizontalTemplate = <template>
<DPageHeader
@titleLabel={{@controller.baseTitle}}
@showDrawer={{true}}
@collapseActionsOnMobile={{false}}
>
<:breadcrumbs>
{{#if (and @controller.site.desktopView @controller.model.id)}}
<DBreadcrumbsItem
@path={{concat "/c/" @controller.model.slug}}
@label={{i18n "category.back"}}
/>
{{/if}}
</:breadcrumbs>
<:actions>
<DToggleSwitch
@label="category.show_advanced"
@state={{@controller.showAdvancedTabs}}
{{on "click" @controller.toggleAdvancedTabs}}
/>
</:actions>
<:tabs>
{{#if @controller.showAdvancedTabs}}
<EditCategoryTab
@panels={{@controller.panels}}
@selectedTab={{@controller.selectedTab}}
@params={{@controller.parentParams}}
@tab="general"
/>
<EditCategoryTab
@panels={{@controller.panels}}
@selectedTab={{@controller.selectedTab}}
@params={{@controller.parentParams}}
@tab="security"
/>
<EditCategoryTab
@panels={{@controller.panels}}
@selectedTab={{@controller.selectedTab}}
@params={{@controller.parentParams}}
@tab="settings"
/>
<EditCategoryTab
@panels={{@controller.panels}}
@selectedTab={{@controller.selectedTab}}
@params={{@controller.parentParams}}
@tab="images"
/>
<EditCategoryTab
@panels={{@controller.panels}}
@selectedTab={{@controller.selectedTab}}
@params={{@controller.parentParams}}
@tab="topic-template"
/>
{{#if @controller.siteSettings.tagging_enabled}}
<EditCategoryTab
@panels={{@controller.panels}}
@selectedTab={{@controller.selectedTab}}
@params={{@controller.parentParams}}
@tab="tags"
/>
{{/if}}
{{#if @controller.siteSettings.content_localization_enabled}}
<EditCategoryTab
@panels={{@controller.panels}}
@selectedTab={{@controller.selectedTab}}
@params={{@controller.parentParams}}
@tab="localizations"
/>
{{/if}}
{{/if}}
</:tabs>
<:drawer>
{{#if @controller.model.id}}
<BreadCrumbs
@categories={{@controller.breadcrumbCategories}}
@category={{@controller.model}}
@noSubcategories={{@controller.model.noSubcategories}}
@editingCategory={{true}}
@editingCategoryTab={{@controller.selectedTab}}
/>
{{/if}}
</:drawer>
</DPageHeader>
</template>;
export default EditCategoryTabsHorizontalTemplate;
@@ -0,0 +1,92 @@
import EditCategoryTab from "discourse/admin/components/edit-category-tab";
import BreadCrumbs from "discourse/components/bread-crumbs";
import DButton from "discourse/components/d-button";
import { and } from "discourse/truth-helpers";
const EditCategoryTabsVerticalTemplate = <template>
<div class="edit-category-title-bar">
<div class="edit-category-title">
<h2 class="edit-category-title-text">
<span
class="edit-category-static-title"
>{{@controller.baseTitle}}</span>
<span class="edit-category-preview-badge">
{{#if @controller.showPreviewBadge}}
{{@controller.previewBadge}}
{{/if}}
</span>
</h2>
{{#if @controller.model.id}}
<BreadCrumbs
@categories={{@controller.breadcrumbCategories}}
@category={{@controller.model}}
@noSubcategories={{@controller.model.noSubcategories}}
@editingCategory={{true}}
@editingCategoryTab={{@controller.selectedTab}}
/>
{{/if}}
</div>
{{#if (and @controller.site.desktopView @controller.model.id)}}
<DButton
@action={{@controller.goBack}}
@label="category.back"
@icon="caret-left"
class="category-back"
/>
{{/if}}
</div>
<div class="edit-category-nav">
<ul class="nav nav-stacked">
<EditCategoryTab
@panels={{@controller.panels}}
@selectedTab={{@controller.selectedTab}}
@params={{@controller.parentParams}}
@tab="general"
/>
<EditCategoryTab
@panels={{@controller.panels}}
@selectedTab={{@controller.selectedTab}}
@params={{@controller.parentParams}}
@tab="security"
/>
<EditCategoryTab
@panels={{@controller.panels}}
@selectedTab={{@controller.selectedTab}}
@params={{@controller.parentParams}}
@tab="settings"
/>
<EditCategoryTab
@panels={{@controller.panels}}
@selectedTab={{@controller.selectedTab}}
@params={{@controller.parentParams}}
@tab="images"
/>
<EditCategoryTab
@panels={{@controller.panels}}
@selectedTab={{@controller.selectedTab}}
@params={{@controller.parentParams}}
@tab="topic-template"
/>
{{#if @controller.siteSettings.tagging_enabled}}
<EditCategoryTab
@panels={{@controller.panels}}
@selectedTab={{@controller.selectedTab}}
@params={{@controller.parentParams}}
@tab="tags"
/>
{{/if}}
{{#if @controller.siteSettings.content_localization_enabled}}
<EditCategoryTab
@panels={{@controller.panels}}
@selectedTab={{@controller.selectedTab}}
@params={{@controller.parentParams}}
@tab="localizations"
/>
{{/if}}
</ul>
</div>
</template>;
export default EditCategoryTabsVerticalTemplate;
@@ -1,16 +1,21 @@
import { concat } from "@ember/helper";
import { htmlSafe } from "@ember/template";
import EditCategoryGeneral from "discourse/admin/components/edit-category-general";
import EditCategoryGeneralV2 from "discourse/admin/components/edit-category-general-v2";
import EditCategoryImages from "discourse/admin/components/edit-category-images";
import EditCategoryImagesV2 from "discourse/admin/components/edit-category-images-v2";
import EditCategoryLocalizations from "discourse/admin/components/edit-category-localizations";
import EditCategorySecurity from "discourse/admin/components/edit-category-security";
import EditCategorySecurityV2 from "discourse/admin/components/edit-category-security-v2";
import EditCategorySettings from "discourse/admin/components/edit-category-settings";
import EditCategoryTab from "discourse/admin/components/edit-category-tab";
import EditCategorySettingsV2 from "discourse/admin/components/edit-category-settings-v2";
import EditCategoryTags from "discourse/admin/components/edit-category-tags";
import EditCategoryTagsV2 from "discourse/admin/components/edit-category-tags-v2";
import EditCategoryTopicTemplate from "discourse/admin/components/edit-category-topic-template";
import BreadCrumbs from "discourse/components/bread-crumbs";
import DButton from "discourse/components/d-button";
import EditCategoryTabsHorizontal from "discourse/admin/templates/edit-category/tabs-horizontal";
import EditCategoryTabsVertical from "discourse/admin/templates/edit-category/tabs-vertical";
import Form from "discourse/components/form";
import { and, not } from "discourse/truth-helpers";
import { not } from "discourse/truth-helpers";
const TAB_COMPONENTS = {
general: EditCategoryGeneral,
@@ -22,91 +27,33 @@ const TAB_COMPONENTS = {
localizations: EditCategoryLocalizations,
};
function componentFor(name) {
name = name.replace("edit-category-", "");
const TAB_COMPONENTS_V2 = {
general: EditCategoryGeneralV2,
security: EditCategorySecurityV2,
settings: EditCategorySettingsV2,
images: EditCategoryImagesV2,
"topic-template": EditCategoryTopicTemplate,
tags: EditCategoryTagsV2,
localizations: EditCategoryLocalizations,
};
if (!TAB_COMPONENTS[name]) {
function componentFor(name, useSimplified) {
name = name.replace("edit-category-", "");
const components = useSimplified ? TAB_COMPONENTS_V2 : TAB_COMPONENTS;
if (!components[name]) {
throw new Error(`No category-tab component found for tab name: ${name}`);
}
return TAB_COMPONENTS[name];
return components[name];
}
export default <template>
<div class="edit-category {{if @controller.expandedMenu 'expanded-menu'}}">
<div class="edit-category-title-bar">
<div class="edit-category-title">
<h2>{{@controller.title}}</h2>
{{#if @controller.model.id}}
<BreadCrumbs
@categories={{@controller.breadcrumbCategories}}
@category={{@controller.model}}
@noSubcategories={{@controller.model.noSubcategories}}
@editingCategory={{true}}
@editingCategoryTab={{@controller.selectedTab}}
/>
{{/if}}
</div>
{{#if (and @controller.site.desktopView @controller.model.id)}}
<DButton
@action={{@controller.goBack}}
@label="category.back"
@icon="caret-left"
class="category-back"
/>
{{/if}}
</div>
<div class="edit-category-nav">
<ul class="nav nav-stacked">
<EditCategoryTab
@panels={{@controller.panels}}
@selectedTab={{@controller.selectedTab}}
@params={{@controller.parentParams}}
@tab="general"
/>
<EditCategoryTab
@panels={{@controller.panels}}
@selectedTab={{@controller.selectedTab}}
@params={{@controller.parentParams}}
@tab="security"
/>
<EditCategoryTab
@panels={{@controller.panels}}
@selectedTab={{@controller.selectedTab}}
@params={{@controller.parentParams}}
@tab="settings"
/>
<EditCategoryTab
@panels={{@controller.panels}}
@selectedTab={{@controller.selectedTab}}
@params={{@controller.parentParams}}
@tab="images"
/>
<EditCategoryTab
@panels={{@controller.panels}}
@selectedTab={{@controller.selectedTab}}
@params={{@controller.parentParams}}
@tab="topic-template"
/>
{{#if @controller.siteSettings.tagging_enabled}}
<EditCategoryTab
@panels={{@controller.panels}}
@selectedTab={{@controller.selectedTab}}
@params={{@controller.parentParams}}
@tab="tags"
/>
{{/if}}
{{#if @controller.siteSettings.content_localization_enabled}}
<EditCategoryTab
@panels={{@controller.panels}}
@selectedTab={{@controller.selectedTab}}
@params={{@controller.parentParams}}
@tab="localizations"
/>
{{/if}}
</ul>
</div>
{{#if @controller.siteSettings.enable_simplified_category_creation}}
<EditCategoryTabsHorizontal @controller={{@controller}} />
{{else}}
<EditCategoryTabsVertical @controller={{@controller}} />
{{/if}}
<Form
@data={{@controller.formData}}
@@ -115,20 +62,46 @@ export default <template>
as |form transientData|
>
<form.Section
@title={{@controller.selectedTabTitle}}
class="edit-category-content"
class="edit-category-content edit-category-tab-{{@controller.selectedTab}}"
>
{{#each @controller.panels as |tabName|}}
{{#let (componentFor tabName) as |Tab|}}
{{#if @controller.siteSettings.enable_simplified_category_creation}}
{{#let
(componentFor
(concat "edit-category-" @controller.selectedTab)
@controller.siteSettings.enable_simplified_category_creation
)
as |Tab|
}}
<Tab
@selectedTab={{@controller.selectedTab}}
@category={{@controller.model}}
@registerValidator={{@controller.registerValidator}}
@transientData={{transientData}}
@form={{form}}
@updatePreview={{@controller.updatePreview}}
@setSelectedTab={{@controller.setSelectedTab}}
/>
{{/let}}
{{/each}}
{{else}}
{{#each @controller.panels as |tabName|}}
{{#let
(componentFor
tabName
@controller.siteSettings.enable_simplified_category_creation
)
as |Tab|
}}
<Tab
@selectedTab={{@controller.selectedTab}}
@category={{@controller.model}}
@registerValidator={{@controller.registerValidator}}
@transientData={{transientData}}
@form={{form}}
/>
{{/let}}
{{/each}}
{{/if}}
</form.Section>
{{#if @controller.showDeleteReason}}
@@ -59,6 +59,10 @@ export default class DPageHeader extends Component {
!HEADLESS_ACTIONS.find((segment) => pathSegments.includes(segment));
}
get shouldCollapseActionsOnMobile() {
return this.site.mobileView && this.args.collapseActionsOnMobile !== false;
}
<template>
{{#if this.shouldDisplay}}
<div class="d-page-header">
@@ -77,7 +81,7 @@ export default class DPageHeader extends Component {
{{#if (or (has-block "actions") @headerActionComponent)}}
<div class="d-page-header__actions">
{{#if this.site.mobileView}}
{{#if this.shouldCollapseActionsOnMobile}}
<DMenu
@identifier="d-page-header-mobile-actions"
@title={{i18n "more_options"}}
@@ -137,6 +141,12 @@ export default class DPageHeader extends Component {
</p>
{{/if}}
{{#if @showDrawer}}
<div class="d-page-header__drawer">
{{yield to="drawer"}}
</div>
{{/if}}
{{#unless @hideTabs}}
<div class="d-nav-submenu">
<HorizontalOverflowNav class="d-nav-submenu__tabs">
@@ -111,6 +111,8 @@ export default class FKControlWrapper extends Component {
@colors={{@colors}}
@usedColors={{@usedColors}}
@allowNamedColors={{@allowNamedColors}}
@collapseSwatches={{@collapseSwatches}}
@collapseSwatchesLabel={{@collapseSwatchesLabel}}
@onControlWidthChange={{fn (mut this.controlWidth)}}
id={{@field.id}}
name={{@field.name}}
@@ -3,6 +3,7 @@ import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { htmlSafe } from "@ember/template";
import DMenu from "discourse/float-kit/components/d-menu";
import concatClass from "discourse/helpers/concat-class";
import icon from "discourse/helpers/d-icon";
import {
@@ -10,6 +11,7 @@ import {
normalizeHex,
resolveColor,
} from "discourse/lib/color-transformations";
import { and } from "discourse/truth-helpers";
import { i18n } from "discourse-i18n";
function isColorUsed(usedColors, color) {
@@ -28,6 +30,34 @@ function colorLabel(usedColors, color) {
return `#${color}${usedText}`;
}
function calculateLuminance(hex) {
if (!hex || hex.length < 3) {
return 1;
}
let normalized = hex.toUpperCase();
if (normalized.length === 3) {
normalized =
normalized[0] +
normalized[0] +
normalized[1] +
normalized[1] +
normalized[2] +
normalized[2];
}
const r = parseInt(normalized.slice(0, 2), 16);
const g = parseInt(normalized.slice(2, 4), 16);
const b = parseInt(normalized.slice(4, 6), 16);
return (0.299 * r + 0.587 * g + 0.114 * b) / 255;
}
function colorLuminanceClass(color) {
return calculateLuminance(color) > 0.5 ? "--is-light" : "--is-dark";
}
export default class FKControlColor extends Component {
static controlType = "color";
@@ -56,6 +86,16 @@ export default class FKControlColor extends Component {
return "#000000";
}
get pickerIconClass() {
const value = this.args.field.value;
if (!value || !isValidHex(value)) {
return "--is-light";
}
const hex = normalizeHex(value);
return calculateLuminance(hex) > 0.5 ? "--is-light" : "--is-dark";
}
@action
handleTextInput(event) {
this.args.field.set(event.target.value);
@@ -74,8 +114,11 @@ export default class FKControlColor extends Component {
}
@action
selectColor(color) {
selectColor(color, closeMenu) {
this.args.field.set(color);
if (typeof closeMenu === "function") {
closeMenu();
}
}
<template>
@@ -94,40 +137,87 @@ export default class FKControlColor extends Component {
{{on "paste" this.handlePaste}}
...attributes
/>
<input
type="color"
value={{this.normalizedValueForPicker}}
class="form-kit__control-color-input-picker"
disabled={{@field.disabled}}
{{on "input" this.handlePickerInput}}
/>
<span
class={{concatClass
"form-kit__control-color-picker-wrapper"
this.pickerIconClass
}}
>
<input
type="color"
value={{this.normalizedValueForPicker}}
class="form-kit__control-color-input-picker"
disabled={{@field.disabled}}
{{on "input" this.handlePickerInput}}
/>
{{icon "eye-dropper"}}
</span>
{{#if (and @colors @collapseSwatches)}}
<DMenu
@identifier="color-swatches-menu"
@icon="palette"
@title={{@collapseSwatchesLabel}}
@modalForMobile={{true}}
class="btn-default form-kit__control-color-swatches-btn"
>
<:content as |args|>
<div class="form-kit__control-color-swatches" role="group">
{{#each @colors as |color|}}
<button
type="button"
style={{colorStyle color}}
class={{concatClass
"form-kit__control-color-swatch"
(if (isColorUsed @usedColors color) "is-used")
(colorLuminanceClass color)
}}
title={{if
(isColorUsed @usedColors color)
(i18n "category.already_used")
}}
aria-label={{colorLabel @usedColors color}}
data-color={{color}}
{{on "click" (fn this.selectColor color args.close)}}
>
{{#if (isColorUsed @usedColors color)}}
{{icon "check"}}
{{/if}}
</button>
{{/each}}
</div>
</:content>
</DMenu>
{{/if}}
</div>
{{#if @colors}}
<div class="form-kit__control-color-swatches" role="group">
{{#each @colors as |color|}}
<button
type="button"
style={{colorStyle color}}
class={{concatClass
"form-kit__control-color-swatch"
(if (isColorUsed @usedColors color) "is-used")
}}
title={{if
(isColorUsed @usedColors color)
(i18n "category.already_used")
}}
aria-label={{colorLabel @usedColors color}}
data-color={{color}}
disabled={{@field.disabled}}
{{on "click" (fn this.selectColor color)}}
>
{{#if (isColorUsed @usedColors color)}}
{{icon "check"}}
{{/if}}
</button>
{{/each}}
</div>
{{#unless @collapseSwatches}}
<div class="form-kit__control-color-swatches" role="group">
{{#each @colors as |color|}}
<button
type="button"
style={{colorStyle color}}
class={{concatClass
"form-kit__control-color-swatch"
(if (isColorUsed @usedColors color) "is-used")
(colorLuminanceClass color)
}}
title={{if
(isColorUsed @usedColors color)
(i18n "category.already_used")
}}
aria-label={{colorLabel @usedColors color}}
data-color={{color}}
disabled={{@field.disabled}}
{{on "click" (fn this.selectColor color)}}
>
{{#if (isColorUsed @usedColors color)}}
{{icon "check"}}
{{/if}}
</button>
{{/each}}
</div>
{{/unless}}
{{/if}}
</div>
</template>
@@ -24,11 +24,16 @@ const Contents = <template>
</template>;
export default class FKControlConditionalContent extends Component {
@tracked activeName = this.args.activeName;
@tracked manuallySetName = null;
get activeName() {
return this.manuallySetName ?? this.args.activeName;
}
@action
setCondition(name) {
this.activeName = name;
this.manuallySetName = name;
this.args.onChange?.(name);
}
<template>
@@ -12,6 +12,7 @@ const FKControlConditionalContentOption = <template>
id={{uuid}}
value={{@name}}
checked={{eq @name @activeName}}
disabled={{@disabled}}
class="form-kit__control-radio"
{{on "change" (fn @setCondition @name)}}
/>
+16 -1
View File
@@ -24,6 +24,7 @@ import Site from "discourse/models/site";
import Topic from "./topic";
const CATEGORY_ASYNC_SEARCH_CACHE = {};
const CATEGORY_ASYNC_HIERARCHICAL_SEARCH_CACHE = {};
const pluginSaveProperties = new Set();
let _uncategorized;
@@ -417,13 +418,27 @@ export default class Category extends RestModel {
page: opts.page,
};
const result = await ajax("/categories/hierarchical_search", { data });
const cacheKey = JSON.stringify(data);
CATEGORY_ASYNC_HIERARCHICAL_SEARCH_CACHE[cacheKey] ||= await ajax(
"/categories/hierarchical_search",
{
method: "GET",
data,
}
);
const result = CATEGORY_ASYNC_HIERARCHICAL_SEARCH_CACHE[cacheKey];
return result["categories"].map((category) =>
Site.current().updateCategory(category)
);
}
static clearAsyncHierarchicalSearchCache() {
Object.keys(CATEGORY_ASYNC_HIERARCHICAL_SEARCH_CACHE).forEach((key) => {
delete CATEGORY_ASYNC_HIERARCHICAL_SEARCH_CACHE[key];
});
}
static async asyncSearch(term, opts) {
opts ||= {};
@@ -38,7 +38,7 @@ export default class ComboBoxHeader extends SingleSelectHeaderComponent {
@icon="xmark"
@action={{this.selectKit.onClearSelection}}
@ariaLabel="clear_input"
class="btn-clear"
class="btn-clear btn-transparent"
/>
{{/if}}
+1
View File
@@ -108,6 +108,7 @@ module SvgSprite
ellipsis-vertical
envelope
eye
eye-dropper
fab-android
fab-apple
fab-chrome
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB