Skip to content

Commit c5a14eb

Browse files
plun1331pre-commit-ci[bot]SoheabCopilotPaillat-dev
authored
feat: FileUpload in Modal (#2938)
Signed-off-by: plun1331 <[email protected]> Signed-off-by: Soheab <[email protected]> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Soheab <[email protected]> Co-authored-by: Copilot <[email protected]> Co-authored-by: Paillat <[email protected]> Co-authored-by: Lulalaby <[email protected]>
1 parent 1d67b64 commit c5a14eb

File tree

13 files changed

+360
-14
lines changed

13 files changed

+360
-14
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ These changes are available on the `master` branch, but have not yet been releas
2828
- Adds pre-typed and pre-constructed with select_type `ui.Select` aliases for the
2929
different select types: `ui.StringSelect`, `ui.UserSelect`, `ui.RoleSelect`,
3030
`ui.MentionableSelect`, and `ui.ChannelSelect`.
31+
- Added `ui.FileUpload` for modals and the `FileUpload` component.
32+
([#2938](https://github.com/Pycord-Development/pycord/pull/2938))
3133

3234
### Changed
3335

@@ -47,6 +49,11 @@ These changes are available on the `master` branch, but have not yet been releas
4749
([#2925](https://github.com/Pycord-Development/pycord/pull/2925))
4850
- Fixed a `TypeError` when typing `ui.Select` without providing optional type arguments.
4951
([#2943](https://github.com/Pycord-Development/pycord/pull/2943))
52+
- Fixed modal input values being misordered when using the `row` parameter and inserting
53+
items out of row order.
54+
([#2938](https://github.com/Pycord-Development/pycord/pull/2938))
55+
- Fixed a KeyError when a text input is left blank in a modal.
56+
([#2938](https://github.com/Pycord-Development/pycord/pull/2938))
5057

5158
### Removed
5259

discord/components.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
from .types.components import Component as ComponentPayload
5151
from .types.components import ContainerComponent as ContainerComponentPayload
5252
from .types.components import FileComponent as FileComponentPayload
53+
from .types.components import FileUploadComponent as FileUploadComponentPayload
5354
from .types.components import InputText as InputTextComponentPayload
5455
from .types.components import LabelComponent as LabelComponentPayload
5556
from .types.components import MediaGalleryComponent as MediaGalleryComponentPayload
@@ -81,6 +82,7 @@
8182
"Container",
8283
"Label",
8384
"SelectDefaultValue",
85+
"FileUpload",
8486
)
8587

8688
C = TypeVar("C", bound="Component")
@@ -938,7 +940,6 @@ def url(self, value: str) -> None:
938940

939941
@classmethod
940942
def from_dict(cls, data: UnfurledMediaItemPayload, state=None) -> UnfurledMediaItem:
941-
942943
r = cls(data.get("url"))
943944
r.proxy_url = data.get("proxy_url")
944945
r.height = data.get("height")
@@ -1347,6 +1348,71 @@ def walk_components(self) -> Iterator[Component]:
13471348
yield from [self.component]
13481349

13491350

1351+
class FileUpload(Component):
1352+
"""Represents an File Upload component from the Discord Bot UI Kit.
1353+
1354+
This inherits from :class:`Component`.
1355+
1356+
.. note::
1357+
1358+
This class is not useable by end-users; see :class:`discord.ui.FileUpload` instead.
1359+
1360+
.. versionadded:: 2.7
1361+
1362+
Attributes
1363+
----------
1364+
custom_id: Optional[:class:`str`]
1365+
The custom ID of the file upload field that gets received during an interaction.
1366+
min_values: Optional[:class:`int`]
1367+
The minimum number of files that must be uploaded.
1368+
max_values: Optional[:class:`int`]
1369+
The maximum number of files that can be uploaded.
1370+
required: Optional[:class:`bool`]
1371+
Whether the file upload field is required or not. Defaults to `True`.
1372+
id: Optional[:class:`int`]
1373+
The file upload's ID.
1374+
"""
1375+
1376+
__slots__: tuple[str, ...] = (
1377+
"type",
1378+
"custom_id",
1379+
"min_values",
1380+
"max_values",
1381+
"required",
1382+
"id",
1383+
)
1384+
1385+
__repr_info__: ClassVar[tuple[str, ...]] = __slots__
1386+
versions: tuple[int, ...] = (1, 2)
1387+
1388+
def __init__(self, data: FileUploadComponentPayload):
1389+
self.type = ComponentType.file_upload
1390+
self.id: int | None = data.get("id")
1391+
self.custom_id = data["custom_id"]
1392+
self.min_values: int | None = data.get("min_values", None)
1393+
self.max_values: int | None = data.get("max_values", None)
1394+
self.required: bool = data.get("required", True)
1395+
1396+
def to_dict(self) -> FileUploadComponentPayload:
1397+
payload = {
1398+
"type": 19,
1399+
"custom_id": self.custom_id,
1400+
}
1401+
if self.id is not None:
1402+
payload["id"] = self.id
1403+
1404+
if self.min_values is not None:
1405+
payload["min_values"] = self.min_values
1406+
1407+
if self.max_values is not None:
1408+
payload["max_values"] = self.max_values
1409+
1410+
if not self.required:
1411+
payload["required"] = self.required
1412+
1413+
return payload # type: ignore
1414+
1415+
13501416
COMPONENT_MAPPINGS = {
13511417
1: ActionRow,
13521418
2: Button,
@@ -1364,6 +1430,7 @@ def walk_components(self) -> Iterator[Component]:
13641430
14: Separator,
13651431
17: Container,
13661432
18: Label,
1433+
19: FileUpload,
13671434
}
13681435

13691436
STATE_COMPONENTS = (Section, Container, Thumbnail, MediaGallery, FileComponent)

discord/enums.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -734,6 +734,8 @@ class ComponentType(Enum):
734734
separator = 14
735735
content_inventory_entry = 16
736736
container = 17
737+
label = 18
738+
file_upload = 19
737739

738740
def __int__(self):
739741
return self.value

discord/types/components.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
from .emoji import PartialEmoji
3434
from .snowflake import Snowflake
3535

36-
ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 18]
36+
ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 18, 19]
3737
ButtonStyle = Literal[1, 2, 3, 4, 5, 6]
3838
InputTextStyle = Literal[1, 2]
3939
SeparatorSpacingSize = Literal[1, 2]
@@ -163,10 +163,20 @@ class LabelComponent(BaseComponent):
163163
type: Literal[18]
164164
label: str
165165
description: NotRequired[str]
166-
component: SelectMenu | InputText
166+
component: SelectMenu | InputText | FileUploadComponent
167167

168168

169-
Component = Union[ActionRow, ButtonComponent, SelectMenu, InputText]
169+
class FileUploadComponent(BaseComponent):
170+
type: Literal[19]
171+
custom_id: str
172+
max_values: NotRequired[int]
173+
min_values: NotRequired[int]
174+
required: NotRequired[bool]
175+
176+
177+
Component = Union[
178+
ActionRow, ButtonComponent, SelectMenu, InputText, FileUploadComponent
179+
]
170180

171181

172182
AllowedContainerComponents = Union[

discord/types/message.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ class Attachment(TypedDict):
8181
waveform: NotRequired[str]
8282
flags: NotRequired[int]
8383
title: NotRequired[str]
84+
ephemeral: NotRequired[bool]
8485

8586

8687
MessageActivityType = Literal[1, 2, 3, 5]

discord/ui/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from .button import *
1212
from .container import *
1313
from .file import *
14+
from .file_upload import *
1415
from .input_text import *
1516
from .item import *
1617
from .media_gallery import *

discord/ui/file_upload.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
from __future__ import annotations
2+
3+
import os
4+
from typing import TYPE_CHECKING
5+
6+
from ..components import FileUpload as FileUploadComponent
7+
from ..enums import ComponentType
8+
from ..message import Attachment
9+
10+
__all__ = ("FileUpload",)
11+
12+
if TYPE_CHECKING:
13+
from ..interactions import Interaction
14+
from ..types.components import FileUploadComponent as FileUploadComponentPayload
15+
16+
17+
class FileUpload:
18+
"""Represents a UI File Upload component.
19+
20+
.. versionadded:: 2.7
21+
22+
Parameters
23+
----------
24+
label: :class:`str`
25+
The label for this component
26+
Must be 45 characters or fewer.
27+
custom_id: Optional[:class:`str`]
28+
The ID of the input text field that gets received during an interaction.
29+
description: Optional[:class:`str`]
30+
The description for the file upload field.
31+
Must be 100 characters or fewer.
32+
min_values: Optional[:class:`int`]
33+
The minimum number of files that must be uploaded.
34+
Defaults to 0 and must be between 0 and 10, inclusive.
35+
max_values: Optional[:class:`int`]
36+
The maximum number of files that can be uploaded.
37+
Must be between 1 and 10, inclusive.
38+
required: Optional[:class:`bool`]
39+
Whether the file upload field is required or not. Defaults to ``True``.
40+
row: Optional[:class:`int`]
41+
The relative row this file upload field belongs to. A modal dialog can only have 5
42+
rows. By default, items are arranged automatically into those 5 rows. If you'd
43+
like to control the relative positioning of the row then passing an index is advised.
44+
For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic
45+
ordering. The row number must be between 0 and 4 (i.e. zero indexed).
46+
"""
47+
48+
__item_repr_attributes__: tuple[str, ...] = (
49+
"label",
50+
"required",
51+
"min_values",
52+
"max_values",
53+
"custom_id",
54+
"id",
55+
"description",
56+
)
57+
58+
def __init__(
59+
self,
60+
*,
61+
label: str,
62+
custom_id: str | None = None,
63+
min_values: int | None = None,
64+
max_values: int | None = None,
65+
required: bool = True,
66+
row: int | None = None,
67+
id: int | None = None,
68+
description: str | None = None,
69+
):
70+
super().__init__()
71+
if len(str(label)) > 45:
72+
raise ValueError("label must be 45 characters or fewer")
73+
if description and len(description) > 100:
74+
raise ValueError("description must be 100 characters or fewer")
75+
if min_values and (min_values < 0 or min_values > 10):
76+
raise ValueError("min_values must be between 0 and 10")
77+
if max_values and (max_values < 1 or max_values > 10):
78+
raise ValueError("max_length must be between 1 and 10")
79+
if custom_id is not None and not isinstance(custom_id, str):
80+
raise TypeError(
81+
f"expected custom_id to be str, not {custom_id.__class__.__name__}"
82+
)
83+
if not isinstance(required, bool):
84+
raise TypeError(f"required must be bool not {required.__class__.__name__}") # type: ignore
85+
custom_id = os.urandom(16).hex() if custom_id is None else custom_id
86+
self.label: str = str(label)
87+
self.description: str | None = description
88+
89+
self._underlying: FileUploadComponent = FileUploadComponent._raw_construct(
90+
type=ComponentType.file_upload,
91+
custom_id=custom_id,
92+
min_values=min_values,
93+
max_values=max_values,
94+
required=required,
95+
id=id,
96+
)
97+
self._attachments: list[Attachment] | None = None
98+
self.row = row
99+
self._rendered_row: int | None = None
100+
101+
def __repr__(self) -> str:
102+
attrs = " ".join(
103+
f"{key}={getattr(self, key)!r}" for key in self.__item_repr_attributes__
104+
)
105+
return f"<{self.__class__.__name__} {attrs}>"
106+
107+
@property
108+
def type(self) -> ComponentType:
109+
return self._underlying.type
110+
111+
@property
112+
def id(self) -> int | None:
113+
"""The ID of this component. If not provided by the user, it is set sequentially by Discord."""
114+
return self._underlying.id
115+
116+
@property
117+
def custom_id(self) -> str:
118+
"""The custom id that gets received during an interaction."""
119+
return self._underlying.custom_id
120+
121+
@custom_id.setter
122+
def custom_id(self, value: str):
123+
if not isinstance(value, str):
124+
raise TypeError(
125+
f"custom_id must be None or str not {value.__class__.__name__}"
126+
)
127+
self._underlying.custom_id = value
128+
129+
@property
130+
def min_values(self) -> int | None:
131+
"""The minimum number of files that must be uploaded. Defaults to 0."""
132+
return self._underlying.min_values
133+
134+
@min_values.setter
135+
def min_values(self, value: int | None):
136+
if value and not isinstance(value, int):
137+
raise TypeError(f"min_values must be None or int not {value.__class__.__name__}") # type: ignore
138+
if value and (value < 0 or value > 10):
139+
raise ValueError("min_values must be between 0 and 10")
140+
self._underlying.min_values = value
141+
142+
@property
143+
def max_values(self) -> int | None:
144+
"""The maximum number of files that can be uploaded."""
145+
return self._underlying.max_values
146+
147+
@max_values.setter
148+
def max_values(self, value: int | None):
149+
if value and not isinstance(value, int):
150+
raise TypeError(f"max_values must be None or int not {value.__class__.__name__}") # type: ignore
151+
if value and (value < 1 or value > 10):
152+
raise ValueError("max_values must be between 1 and 10")
153+
self._underlying.max_values = value
154+
155+
@property
156+
def required(self) -> bool:
157+
"""Whether the input file upload is required or not. Defaults to ``True``."""
158+
return self._underlying.required
159+
160+
@required.setter
161+
def required(self, value: bool):
162+
if not isinstance(value, bool):
163+
raise TypeError(f"required must be bool not {value.__class__.__name__}") # type: ignore
164+
self._underlying.required = bool(value)
165+
166+
@property
167+
def values(self) -> list[Attachment] | None:
168+
"""The files that were uploaded to the field."""
169+
return self._attachments
170+
171+
@property
172+
def width(self) -> int:
173+
return 5
174+
175+
def to_component_dict(self) -> FileUploadComponentPayload:
176+
return self._underlying.to_dict()
177+
178+
def refresh_from_modal(self, interaction: Interaction, data: dict) -> None:
179+
values = data.get("values", [])
180+
self._attachments = [
181+
Attachment(
182+
state=interaction._state,
183+
data=interaction.data["resolved"]["attachments"][attachment_id],
184+
)
185+
for attachment_id in values
186+
]
187+
188+
@staticmethod
189+
def uses_label() -> bool:
190+
return True

discord/ui/input_text.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ def to_component_dict(self) -> InputTextComponentPayload:
246246
return self._underlying.to_dict()
247247

248248
def refresh_state(self, data) -> None:
249-
self._input_value = data["value"]
249+
self._input_value = data.get("value", None)
250250

251251
def refresh_from_modal(self, interaction: Interaction, data: dict) -> None:
252252
return self.refresh_state(data)

0 commit comments

Comments
 (0)